ByteBlind Security
Back to blog
Race ConditionBug BountyAuthentication Bypass

Bypassing a Passcode Screen With Nothing but Timing

Richard Tan · October 20, 2026 · Updated October 20, 2026

The setup

The app used a custom deep link, com.android.appname://, that opened straight to the home screen. That screen showed account balances and a transfer option. Normally this screen sat behind a passcode, shown every time the app opened, including when it opened through the deep link.

A deep link like this is simply an intent aimed at an exported component. Any other app, or adb, can send this intent directly, without going through the app’s own interface at all.

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

Any exported component works this way. It is a public entry point. The system does not check whether the intent came from a user tapping a notification or from another app sending it repeatedly.

The dead end

The first test was fuzzing the deep link parameters. Junk values, valid account IDs, extra fields, anything that might cause the check to be skipped. None of it worked. The passcode screen loaded every time, no matter what was added to the link.

That kind of consistency is easy to trust too much. The check ran every time, so it seemed solid. But passing every single test does not always mean the check cannot be bypassed.

The actual bug

The real issue had nothing to do with parameters. Firing the same deep link over and over, without any delay, showed something the single tests never showed. For a brief moment before the passcode screen finished loading, the home screen was visible underneath it.

This was a timing issue, not a logic error. The app opened the home screen first, then placed the passcode screen on top of it a moment later. If the same launch is triggered fast enough, it becomes possible to catch the exact moment the home screen has drawn but the passcode screen has not yet taken focus. The check itself was correct. It simply was not fast enough to always come first.

Why this happens

It helps to look closer at what is actually going on, since saying “it’s a timing issue” does not explain much on its own.

When the deep link is triggered, the home activity’s onCreate() method runs first. This is a normal, synchronous call. It builds the sensitive layout in memory, but nothing appears on screen yet. onStart() and onResume() run right after, in the same sequence. In this app, the passcode check was placed inside onResume(). As soon as it found the user was not authenticated, it called startActivity() to open the passcode screen.

This is the part that is easy to miss. That call does not wait for anything to finish. It simply sends a request to the system, which then has to create a separate activity, with its own onCreate, onStart, and onResume, placed in line on the main thread.

Meanwhile, the home activity’s onResume() has already completed. As far as the system is concerned, that activity is now resumed. A resumed activity’s window is allowed to appear and accept input on the very next frame, often before the passcode activity has even had a turn to run.

This means there are two separate timelines. One is the order in which the code runs, which can be seen directly in the source. The other is the order in which things are actually drawn and given input focus, which depends on system requests, message queue scheduling, and any animation currently playing. Getting the first one right, check first, then cover, says nothing about the second. Starting an activity is a request, not something that happens immediately, and the system makes no promise that the two will match up.

Code execution onCreate() runs Draws sensitive screen onResume() runs Requests passcode screen What's on screen Home screen visible Visible and touchable Loading Not yet focused Passcode takes focus Screen now covered Exploitable window

This also explains a few things that might otherwise seem unrelated. Slower devices, or a higher animation scale in developer settings, both make this easier to trigger, since both slow down how long the handoff takes. The code itself does not change. This is also why a tap, not just a screen recording, can land on the hidden screen during that same period. Input focus is tracked separately from what is currently drawn on screen, so a tap sent at the right moment goes to whichever window currently holds focus. For a brief period, that can still be the screen assumed to already be covered.

None of this requires a bug in the operating system. It is simply a result of the code deciding one thing while the display and input system are still catching up. This is also why the fix works the way it does. Checking the authentication state before the sensitive screen is built, rather than after, prevents this issue entirely. If the screen is never created while locked, there is nothing left to race.

Proving it out

A simple loop was enough to demonstrate this:

while true; do
  adb shell am start -a android.intent.action.VIEW -d "com.android.appname://"
  sleep 0.15
done

For a more reliable test across different devices, a small Kotlin app was built to fire the same intent around 500 times, at a few different intervals: 100ms, 150ms, and 250ms. This made the issue easy to reproduce regardless of how fast a device rendered the screens. Older or slower devices made this easier without much extra effort, but even newer devices showed the same result once the timing was adjusted correctly.

Why timing bugs like this slip past normal review

It is worth asking why this kind of issue passes a code review, especially when the passcode logic itself is correct. A reviewer reading the code will see a check that runs on every launch path, and that much is true. What is harder to see from the code alone is the actual order in which things appear on screen, for the reasons explained above, since that part is not represented in the source at all. This can only be observed by watching the device render frames in real time, which a code review cannot do.

Why fuzzing missed it

This part matters the most. No amount of parameter fuzzing could have found this issue, because the problem was never in what the intent carried. It was in when the intent was sent relative to the activity lifecycle. Fuzzing inputs assumes the issue is in the data. In this case, the issue was in the order of events, and the only way to find that is to stop changing the input and start changing the timing.