FlawOpen

flawopen.com/path-traversal/Kotlin

● CWE-22 · High
FlawOpen Security Research

Path Traversal in Kotlin (java.io.File, Android)

File(dir, name) keeps ../ segments for the file system to follow, a canonicalPath.startsWith() check accepts a sibling folder such as attachments-old, and Uri.getLastPathSegment() decodes %2F so a content:// URI can smuggle ../ into an Android ContentProvider. How each mistake exposes app-private files such as shared_prefs, and how a canonical-path check with a trailing separator, File(name).name and FileProvider stop it.

💡 Plain English Explainer (ELI5)

Imagine an Android apartment building where each app keeps private files in a locked room. An exported ContentProvider is the building's front desk: it should open only files inside the app's shared room. If the desk joins a caller's file label to that room without checking the final canonical location, ../ in the label means “go back through a door” and can point to another room. Check that the resolved path stays beneath the intended directory, or share fixed locations through Android FileProvider.

Core Concepts & Subsystem Terms

File(parent, child) keeps ..
File(dir, "../shared_prefs/auth.xml") keeps the .. segments and the file system resolves them on open. Unlike Path.resolve, an absolute child is appended rather than replacing the parent, so .. is the attack here.
canonicalPath string prefix
file.canonicalPath.startsWith(dir.canonicalPath) compares characters, so .../attachments-old/secret.txt passes. Append File.separator to the base before comparing, or compare Path objects.
Uri.getLastPathSegment() decodes %2F
getLastPathSegment() returns the decoded segment, so content://com.example.files/..%2F..%2Fshared_prefs%2Fauth.xml yields ../../shared_prefs/auth.xml. Any other app that can call your provider can use it to read your private files.
File(name).name for server-supplied names
A download's file name from a Content-Disposition header or API response can contain ../. File(serverFileName).name keeps only the last component; a generated name is safer still.
FileProvider instead of a custom provider
androidx.core.content.FileProvider maps a fixed set of directories declared in XML to content:// URIs and resolves names inside them. Keep it exported="false" and share single files with FLAG_GRANT_READ_URI_PERMISSION instead of writing your own openFile.

Framework mappings

  • CWE reference for the weakness described in this guide: CWE-22

Step-by-Step Attack Flow

Step 1

An untrusted name reaches File

A file name from a server response, an intent extra, or uri.lastPathSegment inside ContentProvider.openFile is passed to File(dir, name).

Step 2

The name climbs out of the folder

It contains ../, or %2F-encoded slashes that lastPathSegment decodes, or names a sibling folder with the same prefix. The benign test value ../../shared_prefs/auth.xml illustrates the read case.

Step 3

The file system opens the resolved path

FileOutputStream or ParcelFileDescriptor.open hands the path to the kernel, which follows every .. inside the app's data directory.

Step 4

App data leaks or is overwritten

Another app reads session tokens from shared_prefs or a database file, or a malicious download overwrites a cached library or config the app loads later.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// AttachmentStore.kt: each method lets an untrusted name leave the attachments folder
package com.example.files

import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.File
import java.io.InputStream

class AttachmentStore(context: Context) {
    private val dir = File(context.filesDir, "attachments")

    // a server name such as ../shared_prefs/auth.xml overwrites app data
    fun save(serverFileName: String, body: InputStream): File {
        val target = File(dir, serverFileName)
        target.outputStream().use { body.copyTo(it) }
        return target
    }

    // startsWith also accepts attachments-old
    fun open(name: String): File {
        val file = File(dir, name)
        require(file.canonicalPath.startsWith(dir.canonicalPath)) { "outside attachments" }
        return file
    }

    // lastPathSegment decodes %2F, so ../ reaches File
    fun openForProvider(uri: Uri): ParcelFileDescriptor {
        val file = File(dir, uri.lastPathSegment ?: "")
        return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
    }
}
HARDENED SECURE PATCH
// AttachmentStore.kt: canonical path plus separator, reduced names
package com.example.files

import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream

class AttachmentStore(context: Context) {
    private val dir = File(context.filesDir, "attachments").canonicalFile

    // the separator stops sibling folders; canonicalFile removes .. and links
    private fun inside(name: String): File {
        val file = File(dir, name).canonicalFile
        require(file.path.startsWith(dir.path + File.separator)) { "outside attachments" }
        return file
    }

    // only the last component of the server name is used
    fun save(serverFileName: String, body: InputStream): File {
        val target = inside(File(serverFileName).name)
        target.outputStream().use { body.copyTo(it) }
        return target
    }

    // every lookup goes through the same containment check
    fun open(name: String): File = inside(name)

    // the decoded segment is checked before it is opened
    fun openForProvider(uri: Uri): ParcelFileDescriptor {
        val name = uri.lastPathSegment ?: throw FileNotFoundException("no file name")
        return ParcelFileDescriptor.open(inside(name), ParcelFileDescriptor.MODE_READ_ONLY)
    }
}

Engineering & System Hardening Checklist

Sources