A Zero-Day That Lets Any App Rewrite What Your Screen Reader Says

No permission, no prompt — any app can rewrite what TalkBack reads aloud to a blind user, System UI included.
By Mikey Strauss, Dani Levenfeld & Tal Dim — Oats Solutions
TL;DR: Android’s accessibility system exposes a Binder call that lets any app change the title and language another app’s window reports — including System UI’s own window. That title and language are exactly what a screen reader like TalkBack announces to tell a blind or low-vision user what they’re currently looking at. No permission, no user interaction, no accessibility service enabled by the attacker — any zero-permission app can do this to any other window on the device it shares an Android user with (this isn’t a cross-user bug — on an ordinary single-user phone, that’s every window). We proved it live: blind-swept a small range of window IDs with no prior knowledge of which one belonged to the victim, and relabeled a real, focused window’s title and language mid-use. The same sweep hit Android’s own System UI window too.
We independently discovered this reading AOSP’s accessibility service source, then proved it end-to-end with a live PoC on a real device — then used Runzee, Oats Solutions’ dynamic-analysis platform for Android, to independently confirm the exact call crossing the kernel boundary, from a vantage point the PoC app itself can’t influence. More on how below; first, the bug itself.
The gate that checks the wrong thing
Accessibility services (screen readers, switch access, and Android’s own internal tooling) can attach descriptive metadata — a title, a language — to any window on the device, so assistive tech has something useful to announce. The method that writes that metadata checks who’s asking, but only checks half of what it should:
// AccessibilityWindowManager.java
public void setAccessibilityWindowAttributes(int displayId, int windowId, int userId,
AccessibilityWindowAttributes attributes) {
synchronized (mLock) {
final int resolvedUserId =
mSecurityPolicy.resolveCallingUserIdEnforcingPermissionsLocked(userId);
if (getWindowTokenForUserAndWindowIdLocked(resolvedUserId, windowId) == null) {
return;
}
mWindowAttributes.put(windowId, attributes); // no ownership check beyond "exists for my user"
...
The first check is real: it correctly confirms the caller is asking about its own Android user, re-derived from the kernel’s own view of who’s calling. But the second check — the window lookup — only confirms that some window with that ID exists for that user. It never compares who actually owns that window against who’s asking. And windowId is nothing secret: it’s a single counter that increments by one for every window the system creates. Guessing a live one takes no more than a short loop.
The Java method for this is marked @hide — but that only blocks the ordinary Java call path. The underlying Binder method itself is marked @RequiresNoPermission, so any app that resolves a raw IBinder to the accessibility service (a well-known, publicly-documented technique, not novel research on our part) can speak the same protocol directly and reach the identical, unprotected code.
Proving it, not just reading it
We built a victim window with a known, fixed title, a headless zero-permission attacker (deliberately headless, so it never even occludes the victim window on screen), and a silent verifier — not TalkBack itself, but a minimal accessibility service reading window titles through the exact same public API TalkBack uses, so we could inspect the effect without triggering real speech output on a shared lab device.
The attacker never looked up the victim’s real window ID. It blind-swept IDs 1 through 120 and fired the same raw Binder call at each one, live, on a real, production Pixel 10 — non-rooted, release-keys, the same locked-down build state an ordinary user’s phone ships in, not a rooted lab rig with the protections stripped out:
BEFORE AFTER
id=94 title=VICTIM_WINDOW_ORIGINAL_TITLE -> title=FINAL_PWNED_TITLE_XYZ
locale=[en_IL] locale=[fr_FR]
focused=true focused=true (never lost focus)
The victim window was the real, focused, in-use window for the entire test — this isn’t an artifact of the window losing focus or getting occluded. The window labeled above lives in the same test app as the attacker, standing in for “another app’s window” rather than a literally separate one. The same 120-guess sweep, with zero extra effort, also hit com.android.systemui’s own window — a completely different app, a completely different UID, relabeled identically. That’s the real cross-app evidence: nothing in the vulnerable code distinguishes “my own test window” from “the system’s own UI.” It just doesn’t check.
What Runzee showed us
Everything above still relies on the PoC app’s own logs and the verifier’s own reads — both userspace-generated, both technically things the app under test could in theory be lying about. So we re-ran the identical attack on a separate lab device, with Runzee’s Binder collector recording raw kernel-level Binder traffic — a channel that never touches the PoC app’s own code or instrumentation at all:
from runzee import RunzeeClient
client = RunzeeClient(base_url="http://localhost:9999")
session = client.sessions.create(
package="com.runzee.hooktest",
collectors=["binder"],
force=True,
)
That session recorded raw Binder traffic for the whole sweep:
interface = android.view.accessibility.IAccessibilityManager
code = 32 (TRANSACTION_setAccessibilityWindowAttributes)
uid = 10329 (matches the attacker app's own logged caller_uid, exactly)
is_oneway = true
status = 0, success = true
Runzee recorded exactly 120 of these transactions — matching the attacker’s own sweep count precisely — with no other transaction code toward this interface coming anywhere close. That’s independent, kernel-level confirmation that 120 real Binder calls, with the exact interface, transaction code, and calling identity this report claims, actually crossed into the accessibility service — from a source the PoC app has no way to influence. It also incidentally confirmed the bug reproduces on a second, different Android version (Android 15, not just the original Android 16 device).
[Runzee UI screenshot of this capture — pending a follow-up instrumentation pass. What’s above is the real, raw session data; the UI view showing it is the one piece still to come.]
Who this actually hurts
Window title and language aren’t cosmetic metadata here — they’re the literal words a screen reader speaks to tell a blind or low-vision user what currently has focus. That makes this a targeted spoofing primitive against exactly that population:
- Relabel a malicious overlay with a trusted app’s name — a banking app, say — so a screen-reader user is told they’re interacting with something they aren’t.
- Relabel a legitimate window with something alarming (“SECURITY WARNING”), announced as if it were genuine system text.
- Force a window’s announced language to something the user’s text-to-speech engine mishandles or mispronounces — a targeted denial-of-accessibility.
None of it requires the victim to do anything, or the attacker to hold any permission, root, or an enabled accessibility service of their own. The only precondition is that someone’s screen reader is already running to notice the effect — which is exactly the population this bug is capable of targeting. It’s present, unchanged, on every currently supported Android release we checked, including current public AOSP — this isn’t a regression from a recent change, it’s been reachable for a long time.
The fix
Add the ownership check that’s conspicuously missing: compare the window’s real owning UID against the caller’s own UID before writing, the same pattern Android’s own accessibility service already uses correctly elsewhere in the same file. The information needed to do this is already resolved by the existing lookup — it’s just never checked.
Reported to Google through the Android and Google Devices Security Reward Program, following standard coordinated disclosure.
Found using Runzee, Oats Solutions’ dynamic-analysis platform for Android — built to show you what a device is actually doing at the Binder, JNI, filesystem, and network layers, independent of anything the app under test claims about itself.
Originally published on Medium.