Bypassing a Passcode Screen With Nothing but Timing
Richard · October 18, 2019
Some of the most interesting Android bugs have nothing to do with the code being wrong in an obvious way — they show up in the gap between two things that are each individually correct, but not correctly ordered.
The setup
The app in question registered a custom deep link
(com.android.appname://) that routed straight to its home activity — the
screen with account balances and transaction actions. Sensitive enough
that the app protected it behind a passcode screen shown on every launch,
including launches triggered by the deep link.
A deep link like that is really just a public entry point into an
exported component — any other app (or adb) can send it an intent
directly, bypassing the app’s own UI entirely:
Any exported component is a public entry point — the framework doesn’t know or care whether the intent came from the user tapping a notification, or another app firing it in a loop.
The dead end
The obvious thing to try first was parameter fuzzing: append junk, valid IDs, alternate paths, anything that might make the passcode check get skipped outright. None of it went anywhere — the passcode activity loaded every time, consistently, regardless of what was appended to the intent.
The actual bug
The breakthrough wasn’t a parameter at all. Firing the same deep link intent repeatedly, in rapid succession, showed something the single-shot tests never revealed: for a brief moment before the passcode activity finished loading, the home activity was already visible underneath it.
That’s a race condition, not a logic flaw — the app launches the home activity first and layers the passcode screen on top of it, and if you can retrigger the launch fast enough, you can catch the frame where the home screen is drawn but the passcode screen hasn’t taken focus yet.
Proving it out
A simple loop was enough to demonstrate it:
while true; do
adb shell am start -a android.intent.action.VIEW -d "com.android.appname://"
sleep 0.15
done
For a more reliable PoC across different device speeds, a small Kotlin app firing the same intent ~500 times at a few different intervals (100ms, 150ms, 250ms) made the race window easy to hit consistently regardless of how fast a given device rendered the passcode activity.
Why fuzzing missed it
This is the part worth sitting with: no amount of parameter fuzzing was ever going to surface this, because the vulnerability wasn’t in what data the intent carried — it was in when the intent fired relative to the activity lifecycle. Testing an app’s inputs assumes the bug lives in the inputs. Sometimes it lives in the sequencing instead, and the only way to find that is to stop varying the payload and start varying the timing.