June 6, 2026
Hunting Android Lockscreen Bypasses on Pixel: A Campaign Walkthrough — Contd. — III
06 — L5: WindowManager Occlusion — showWhenLocked and Friends
Farhad Sajid Barbhuiya
4 min read
06 — L5: WindowManager Occlusion — showWhenLocked and Friends
Phase A static review · frameworks/base/services/core/java/com/android/server/wm/
Layers L1–L4 are about dismissing the keyguard. L5 is about drawing on top of it. Android lets specific activities render over a locked keyguard — the camera, an incoming call, a ringing alarm — by declaring showWhenLocked (SWL). WindowManager calls this state "occluded": the keyguard is still locked, but something trusted is covering it.
Our exit criterion EC2 lives here: if we can get an arbitrary non-SWL activity rendered, focused, and accepting input while WM still thinks the keyguard is occluded, that's a bypass. Conceptually it's a TOCTOU: WM checked "is the top activity SWL?" at time T, said yes, granted occlusion — and then the top activity changed without WM re-checking.
Step 1: Understand exactly how mOccluded is computed
Everything hinges on KeyguardController.KeyguardDisplayState.updateVisibility() (KeyguardController.java:711–791). It runs at the start of every ensureActivitiesVisible() pass and:
- Picks one root task via
getRootTaskForControllingOccluding(:800–803): the topmost focusable, non-pinned root task. - Reads
top = task.getTopNonFinishingActivity(). - Sets
mOccluded = top.canShowWhenLocked()(roughly).
Key insight: mOccluded is derived from the top activity of one task, not from whatever's actually resumed/focused. That's the gap to probe.
When top flips from SWL → non-SWL, handleOccludedChanged (:434–497) does three things:
- Stages
mPendingKeyguardOccluded=falseinPhoneWindowManager(does not raise the keyguard window yet). - Requests a
TRANSIT_KEYGUARD_UNOCCLUDEshell transition. - Acquires the keyguard sleep token (:566–573).
The sleep token is the real guard. With the display "sleeping," ActivityRecord.shouldBeVisibleUnchecked() returns false and TaskFragment.resumeTopActivity() bails. So the non-SWL activity is paused/hidden inside the same WM lock that flipped mOccluded. The keyguard window comes back asynchronously via SystemUI, but during that frame gap the non-SWL activity has visibleRequested=false and isn't an input target.
We named the three things that have to hold for this to be safe:
- G1:
updateVisibility()actually runs on the path that changed the top activity. - G2:
getRootTaskForControllingOccluding()selects the task whose top is the now-non-SWL activity (somOccludedflips). - G3: the sleep token is acquired (
mKeyguardShowing && !mKeyguardGoingAway).
Every escape vector is a violation of one of those.
Step 2: Rule out the dismiss-without-bouncer paths
Before chasing desync, we checked WM's own dismiss surface:
PathGateBypass on secure keyguard?ActivityClientController.dismissKeyguardown-activity token; forwards to KVMNo — bouncer shown if secureATMS.keyguardGoingAway(flags)CONTROL_KEYGUARD (signature)No — permission-gatedATMS.setLockScreenShown(false,…)DEVICE_POWER (signature)NoActivityOptions.setDismissKeyguardIfInsecure()CONTROL_KEYGUARD + !isKeyguardSecureNoFLAG_DISMISS_KEYGUARD window flagsecure → requests bouncer; insecure → occludesNo
WM never sets keyguard-dismissed without (a) a signature permission, (b) !isKeyguardSecure, or (c) isKeyguardTrustedLw(). Clean. The interesting surface is occlusion desync.
Step 3: Probe each desync vector
We took each plausible "make the top activity change without mOccluded flipping" scenario and traced whether G1/G2/G3 hold.
G2-PiP — SWL occluder enters Picture-in-Picture
The pinned-mode predicate at :802 (!task.inPinnedWindowingMode()) means a PiP'd task is skipped for occlusion control. So if our SWL activity enters PiP, does the next root task (home, non-SWL) become resumed before mOccluded flips?
RootWindowContainer.moveActivityToPinnedRootTaskInner (:2064–2307) runs entirely under mGlobalLock. It defers visibility updates, reparents to pinned, then in finally calls ensureActivitiesVisible() before resumeFocusedTasksTopActivities(). So updateVisibility re-runs with the SWL task already pinned → predicate skips it → next root (home) is non-SWL → mOccluded=false → sleep token acquired — all before any resume. No window. Closed.
G2-adjacent — split-screen with SWL on one side
getRootTaskForControllingOccluding returns the z-topmost focusable root. In split, both sides satisfy isFocusableAndVisible(); the higher-z (last-focused) side wins. If that's SWL, mOccluded=true. Does the non-SWL side then get to draw?
No — the second gate catches it. Each activity also goes through shouldBeVisibleUnchecked() → checkKeyguardVisibility() → canShowWhileOccluded(false,false) → false. The non-SWL side is geometrically arranged but visibleRequested=false. For embedded adjacent TaskFragments, ActivityRecord.canShowWhenLocked() (:4860–4865) requires both sides SWL. Closed.
G1-setShowWhenLocked(false) — runtime flag drop
ActivityClientController.setShowWhenLocked(token,false) → ActivityRecord.setShowWhenLocked() (:4813–4816) → immediately ensureActivitiesVisible() under mGlobalLock. G1 satisfied synchronously. Same for setInheritShowWhenLocked. Closed.
G1-finish — SWL activity finish()es
finishIfPossible() doesn't itself call ensureActivitiesVisible; it relies on the activityPaused → completePause → ensureActivitiesVisible round-trip. During the [finishing=true … activityPaused arrives] window, mOccluded is stale-true — but no other activity has been resumed yet (next-resume is gated on pause-complete). So there's no interactive non-SWL surface in the gap. Closed.
G2-inherit — inheritShowWhenLocked (KB-LSB-17 territory)
This is the one that survived. ActivityRecord.canShowWhenLocked(r) (:4833–4845):
if (!canShowWhenLockedInner(r) && r.mInheritShownWhenLocked) {
ActivityRecord below = r.getTaskFragment().getActivityBelow(r);
return below != null && canShowWhenLockedInner(below);
}if (!canShowWhenLockedInner(r) && r.mInheritShownWhenLocked) {
ActivityRecord below = r.getTaskFragment().getActivityBelow(r);
return below != null && canShowWhenLockedInner(below);
}A non-SWL activity with inheritShowWhenLocked=true whose immediate-below sibling in the same TaskFragment is SWL ⇒ canShowWhenLocked()==true ⇒ mOccluded stays true ⇒ no sleep token ⇒ non-SWL activity is RESUMED and input-focused over a secure keyguard.
The flag is set by the top activity itself — manifest attribute android:inheritShowWhenLocked or runtime Activity.setInheritShowWhenLocked(). There's no permission check, no UID check on the "below" activity.
For an attacker app, the same-app case is tautological (you could just set showWhenLocked directly). The cross-app case is the prize: any exported system/priv activity that declares inheritShowWhenLocked="true" becomes launchable on top of an attacker-controlled SWL task and runs interactive over a secure keyguard. This became O-L5-1, confidence M.
We also noted O-L5–3: getActivityBelow uses alwaysTruePredicate(), which includes finishing activities. So if the SWL activity finish()es and, before it's destroyed, an inheritShowWhenLocked activity is launched on top, the finishing-but-not-yet-removed SWL record still grants inheritance. Narrow race; confidence L; needs dynamic confirmation (V13c).
Output
IDWhatO-L5–1Cross-app inheritShowWhenLocked — exported priv activity with the attr runs over secure keyguard atop attacker SWL taskO-L5-2Single-task occlusion predicate vs. split-screen z-order — second gate (canShowWhileOccluded) holdsO-L5-3getActivityBelow includes finishing activities → inherit-from-finishing race
Plus four firm negatives: PiP, runtime-setShowWhenLocked(false), runtime-setInheritShowWhenLocked, and finish-during-transition all hold G1/G2/G3 atomically.
Headline: WM's occlusion machinery is tighter than expected — the sleep-token + per-activity checkKeyguardVisibility double gate closes most of the obvious races. The real attack surface is O-L5-1: the inventory problem. Does any exported system activity on the device declare inheritShowWhenLocked="true"? That became the V13b dynamic gate.