ByteBlind Security
Back to blog
Race ConditionBug BountyAuthentication Bypass

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:

Attacker App Attack Component Victim App Exported Component Android Framework & Kernel IPC request / intent

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.