ByteBlind Security
Back to blog
Race ConditionBug BountyAuthentication Bypass

Bypassing a Passcode Screen With Nothing but Timing

Richard Tan · October 18, 2019

Some of the most interesting Android bugs have nothing to do with the code being wrong in any obvious way. They show up in the gap between two things that are each individually correct, just not correctly ordered relative to each other.

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, appending 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.

That consistency is actually worth noting on its own. A lot of testers would stop here, reasonably enough, since the check clearly runs every time and clearly can’t be bypassed by changing what the intent carries. The mistake would be assuming that “every single-shot test passes” means the check is airtight.

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. The check itself was never wrong, it just wasn’t guaranteed to win the race against the screen it was supposed to be covering.

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 proof of concept across different device speeds, a small Kotlin app firing the same intent roughly 500 times at a few different intervals, 100ms, 150ms, and 250ms, made the race window easy to hit consistently regardless of how fast a given device rendered the passcode activity. Slower or older devices tended to widen the window without much effort, but even flagship hardware showed the gap reliably once the interval was tuned.

Why timing bugs like this slip past normal review

It’s worth thinking about why this kind of thing survives code review even when the passcode logic itself is written correctly. A reviewer reading the activity code sees a passcode check that runs on every launch path, and that’s true, it does. What’s much harder to see just by reading the code is the actual order operations happen in on screen, since Android’s activity lifecycle involves several asynchronous steps, and the visual frame where one activity is drawn but hasn’t yet been covered by the next one isn’t something that shows up anywhere in the source. It only shows up by actually watching the device render frames in real time, which static code review can’t do.

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.