App Name: PDF AI: Podcast, Notes, Slides
Package Name: chatpdf.pro
Version Tested: 4.22.0
Developer: iAI Lab
Vulnerability Type: Dirty Stream - Arbitrary File Creation in Victim App's Private Storage via Trusted _display_name Metadata
Play Store: https://play.google.com/store/apps/details?id=chatpdf.pro
chatpdf.pro accepts file-share intents (ACTION_SEND / ACTION_VIEW) carrying a content:// URI from any other app on the device. When the URI points at an attacker-controlled ContentProvider, the app reads the URI's _display_name metadata column and uses that string verbatim as the destination filename when copying the streamed bytes to disk. Because the value is attacker-controlled and never sanitized, supplying a _display_name containing ../ sequences gives the attacker arbitrary file creation inside the victim app's own private storage, both internal (/data/data/chatpdf.pro/) and external (/sdcard/Android/data/chatpdf.pro/).
The trust model is the bug: the app treats _display_name as a safe filename when the documentation explicitly states it is a display string and may contain anything the source provider chooses to return. A zero-permission attacker app delivers a single intent, and the victim app - running in its own process with its own UID - opens an output stream at the attacker-resolved path and writes the attacker-supplied bytes. All filesystem operations execute inside the victim's security context.
What the primitive yields:
- Arbitrary file creation inside
/data/data/chatpdf.pro/- the victim app's UID-protected internal storage. Every subdirectory is reachable:files/,files/sp/(MMKV preference stores),databases/,shared_prefs/,code_cache/, plus arbitrary new directories at any depth. - Arbitrary file creation inside
/sdcard/Android/data/chatpdf.pro/- the victim app's external app-private storage, includinggetExternalFilesDir(null)andgetExternalCacheDir(). - Intermediate directory creation at any depth - the sink calls
mkdirs()on the resolved path, so the attacker can plant payloads inside fresh, attacker-named directory trees that did not previously exist. - Full attacker control over the file's contents via
ContentResolver.openInputStream()against the attacker'sContentProvider.
All of this is reachable from any of seven exported <activity-alias> entries in the manifest that accept mimeType="*/*", requiring zero permissions on the attacker app.
CVSS 3.1 base score: 7.3 (High) - CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:N
| Metric | Value | Justification |
|---|---|---|
| Attack Vector | Local | Exploitation requires a malicious application installed on the same device. The vulnerable component is reachable via Android's local Binder IPC bus, not over the network. No shell access, physical access, or authenticated session is required - only co-residence on the device. |
| Attack Complexity | Low | No race condition, no environmental dependencies, no timing window. A single ACTION_SEND intent with the path-traversal _display_name succeeds on first try. Confirmed by runtime PoC. |
| Privileges Required | None | The attacking app declares zero Android permissions. The seven exported <activity-alias> entries targeting SplashActivity accept mimeType="*/*" with no permission attribute. |
| User Interaction | None | The PoC dispatches via setClassName() directly to one of the exported aliases. No share sheet, no user prompt; the intent flows through BaseSplashActivity.onCreate to the vulnerable sink autonomously. |
| Scope | Changed | The attacker (unprivileged third-party app, UID X) causes file writes inside the victim app's UID-Y-protected directory /data/data/chatpdf.pro/, which Android's app-sandbox model normally isolates from every other UID on the device. The vulnerable component (the intent-handling and file-copy code in chatpdf.pro) lives in one security authority; the impact (modification of UID-Y-owned private storage by an unprivileged third party) crosses into another. |
| Confidentiality | None | Write-only primitive. The sink writes attacker-supplied bytes to disk; it does not return any of the victim app's existing content to the attacker. No information disclosure has been demonstrated. |
| Integrity | High | Confirmed by runtime PoC: attacker has full control over filename, directory structure, and byte content of files written to /data/data/chatpdf.pro/{files,files/sp,databases,shared_prefs,…} and /sdcard/Android/data/chatpdf.pro/. Reachable targets include MMKV preference stores (billing_cache, paid_storage_sp, chatpdf.pro_preferences, inappconfig, the Firebase auth state in com.google.firebase.auth.api.Store.*), every SQLite database the app uses (chat-database, chatv2-database, history-database, reader-doc.db, the cached Firestore DB, gallery-database), and bundled cached PDFs the app's renderer consumes. Total compromise of the victim app's persistent state. |
| Availability | None | No availability impact has been demonstrated. The primitive creates files but overwrite of pre-existing files was not confirmed by the runtime PoC. |
Intent.ACTION_SEND(andACTION_SEND_MULTIPLE,ACTION_VIEW)Intent.EXTRA_STREAMand/orClipDataURI- Attacker-controlled
content://URI returning a path-traversal_display_name - Reachable from any of seven exported
<activity-alias>entries declaringmimeType="*/*"(see Entry Points below)
Identified by static analysis of the decompiled APK. Class names are obfuscated using Thai Unicode characters; original semantics reconstructed below.
Class: Lo40; (located in classes.dex)
Method 1 - _display_name extractor:
public static Object getDisplayName(Context ctx, Uri uri) {
Cursor c = ctx.getContentResolver().query(
uri, new String[]{"_display_name"}, null, null, null);
if (c != null && c.moveToFirst()) {
int idx = c.getColumnIndex("_display_name");
return Result.success(c.getString(idx)); // raw, untrusted
}
return Result.failure(new Exception("unknown"));
}Method 2 - vulnerable copy:
public final Object copyUriToDir(FragmentActivity act, Uri src,
File destDir, Continuation cont) {
String displayName = (String) getDisplayName(act, src);
if (displayName == null) return failure("get file name error");
if (!destDir.exists()) destDir.mkdirs();
File dest = new File(destDir, displayName); // ← VULNERABLE
InputStream in = act.getContentResolver().openInputStream(src);
FileOutputStream out = new FileOutputStream(dest);
copyStream(in, out);
return Result.success(dest);
}new File(parent, child) performs no path normalization on the child argument; .. resolution occurs at I/O time on the resolved string. There is no .. filter, no canonical-path check, no character whitelist. The query projection is hardcoded to _display_name, so _data is not a vector here.
The sink also calls destDir.mkdirs() before opening the output stream. Because mkdirs() is recursive and operates on the post-traversal resolved path, the primitive creates intermediate directories along arbitrary resolved paths. Attacker-named directory trees can be stamped into any reachable region - there is no "parent must exist" precondition.
Every subdirectory under the package's internal data root is reachable by extending the attacker's _display_name payload:
files/- app data including bundled cached PDFs (Welcome to PDF AI🤖.pdf,[Try to Read Novel] The little prince.pdf,[Try to Read Paper] Attention is all you need.pdf); Firebase Installation IDs; Crashlytics state; Firebase Sessions DataStore protobufsfiles/sp/- MMKV-backed preference stores:billing_cache,paid_storage_sp,chatpdf.pro_preferences,PrefCommon,inappconfig,splash_v_info,update,mmkv.default, plus per-SDK stores includingcom.google.firebase.auth.api.Store.*(Firebase auth state, 32 KB),com.google.firebase.messaging,com.google.firebase.crashlytics,com.applovin.sdk.*,com.vungle.sdk,admob(256 KB),WebViewChromiumPrefsdatabases/- every SQLite database the app uses:chat-database,chatv2-database(chat content),history-database,reader-doc.db,local-file-database,chat-template-database,gallery-database(516 KB),firestore.[DEFAULT].chatpdf-pro-9d3ad.(default)(cached Firestore, 160 KB),work_database,google_app_measurement_local.dbshared_prefs/- empty in v4.22.0 (the app uses MMKV infiles/sp/instead) but writable as confirmedcode_cache/- empty in v4.22.0 (no DEX/native artifacts dynamically loaded), but writable
2. Arbitrary file creation in the victim app's external private storage - /sdcard/Android/data/chatpdf.pro/
The classical Dirty Stream surface. The sink anchors at /sdcard/Android/data/chatpdf.pro/cache/tmp/ and writes propagate trivially throughout the package's external-private tree.
This region houses any cached or downloaded documents the app brings back from the network; overwriting one is a content-injection vector against the victim's document-handling code paths.
The sink calls destDir.mkdirs() on the resolved post-traversal path. Because mkdirs() is recursive, the attacker can stamp out fresh attacker-named directory trees at any depth as part of a single intent fire - there is no "parent must exist" precondition.
Confirmed: a payload of ../../../../../../../../data/data/chatpdf.pro/PROBE1_DEEP/a/b/c/d/e/f/g/h/i/j/POC.txt produced eleven new directory layers (PROBE1_DEEP/, a/, b/, … j/)
copyUriToDir calls ContentResolver.openInputStream(src) on the attacker-supplied URI and copies the resulting stream byte-for-byte to the resolved destination. The attacker's ContentProvider can return any payload; in the PoC, EvilProvider.openFile() returns a ParcelFileDescriptor.createPipe() whose write-end is fed arbitrary bytes from a worker thread. There is no content-type check, no signature verification, no size limit at the sink.
The vulnerability gives a zero-permission attacker app arbitrary file creation inside the victim app's own private storage - both internal (/data/data/chatpdf.pro/, normally inaccessible to any other UID) and external app-private (/sdcard/Android/data/chatpdf.pro/) - with full control over filename, directory structure, and contents. The writes execute inside the victim app's security context as a consequence of the victim app trusting attacker-supplied _display_name metadata as a safe filename.