Your DMV Can Trigger a Out-of-Bounds Read Inside Google Wallet

SECURITY RESEARCH · OATS SOLUTIONS
One ordinary photo from your driver’s-license issuer, and Google Wallet reads past its buffer.
By Mikey Strauss, Dani Levenfeld & Tal Dim — Oats Solutions
TL;DR — Google Wallet renders your driver’s license/digital‑ID portrait using a bundled JPEG2000 decoder whose compositing code assumes every color channel is the same size as the whole image — false for completely standard photo encoding. Real heap out‑of‑bounds read, confirmed against Google’s actual binary. No hacking required to trigger it: you just need to be (or compromise) whoever issued the ID.
We found this through a mix of static and dynamic analysis — disassembly, Ghidra, and a standalone harness calling Google’s real binary directly — then used Runzee, Oats Solutions’ dynamic-analysis platform for Android, to dig further into how this decoder actually gets exercised on a real device. More on how below; first, the bug itself.
The gate that isn’t there
Wallet’s ID feature decodes your portrait photo with a bundled copy of OpenJPEG (libjpeg2k_converter.so), then composites the decoded color planes into the final image with a small, custom loop. That loop assumes every plane — luma, and both chroma planes — is the same pixel count as the whole image.
real code compositing loop, simplified
int trip_count = image->width * image->height; // whole-image size
for (int i = 0; i < trip_count; i++) {
rgba[i*4+0] = comps[0].data[i]; // luma — full size, fine
rgba[i*4+1] = comps[1].data[i]; // chroma — often SMALLER, not fine
rgba[i*4+2] = comps[2].data[i]; // chroma — same problem
}
That’s wrong for any image using ordinary chroma subsampling (4:2:0/4:2:2) — the default behavior of nearly every real-world JPEG2000 encoder, not a malformed or hostile input. OpenJPEG correctly allocates each plane’s own, smaller buffer for a subsampled image; the loop’s single uniform trip count doesn’t know that, and reads straight past the end of it. Whoever issued the credential picks the subsampling factor — and thereby controls exactly how far past the buffer this reads.
Proving it, not just reading it
Instruction-level disassembly, cross-checked against Ghidra, then two independent dynamic confirmations: an ASan reproduction of the exact loop pattern, and a standalone harness that dlopen()s Google’s real, unmodified libjpeg2k_converter.so and calls its real exported entry point directly — no JVM involved. For a deliberately worst-case subsampling factor, that harness reliably produces a real SIGSEGV, and the crash tombstone’s fault address lines up exactly with the predicted chroma-plane boundary.
The reachability detail that matters: every Java-side path into this decoder reads an already-provisioned, locally-stored credential — never live, in-flight bytes from a nearby reader. The trigger has to come from whoever issued the credential: a state DMV’s backend, or any other issuer Google has approved into Android’s Digital Credentials Registry. Decoding an issuer-signed image is exactly what Wallet is designed to do.
The read doesn’t need a crash to be real, either. For an ordinary subsampling value — not the deliberately worst-case one above — we modified the harness to capture the decoder’s actual output buffer instead of discarding it. The out-of-bounds read still happens; it just doesn’t crash. The captured buffer shows a sharp, visible seam baked directly into the decoded image, exactly at the true chroma-buffer boundary — direct confirmation that adjacent heap memory reaches the composited output, not an inferred or simulated result.
What Runzee showed us
We wanted to watch this decoder actually load on a real device, independent of the static analysis. The catch is real, not a testing inconvenience: this decoder only fires for an already-provisioned credential, and no lab device has a real, government-issued digital ID sitting in Wallet. So the exact decode path can’t be exercised live without one — we’re saying that plainly rather than working around it with a fabricated screenshot.
What we could do: watch the broader digital-ID subsystem load, live, inside the real Google Play services process. That’s proof the pipeline this bug lives in is genuinely active on a real phone today.
from runzee import RunzeeClient
client = RunzeeClient(base_url="http://localhost:9999")
session = client.sessions.create(
package="com.google.android.gms",
collectors=["libs"],
force=True,
)
That’s IdentityCredentialsPlatform — the real mdoc/digital-ID module — loading live, inside the actual Google Play services process, the moment we touched a Wallet flow. The JPEG2000 decoder sits one step deeper in that same pipeline, gated behind an actual credential we don’t have. Same limitation the finding has always had, confirmed instead of assumed.
What kind of bug this actually is
This is a heap out-of-bounds read (CWE-125) in Google’s own Wallet code, and any approved credential issuer — a state DMV, or any other issuer added to Android’s Digital Credentials Registry — can trigger it with nothing more than a chosen subsampling value on the photo they issue. No chained exploit, no privilege escalation, no secondary bug required to reach it.
On its own, that’s a real, standalone memory-safety bug. We haven’t demonstrated a data-theft path: we looked specifically for a way the leaked bytes could reach anyone besides the device’s own user, and came up empty. But out-of-bounds reads are exactly the class of bug that, combined with other capabilities — a further leak primitive, or a separate bug reachable from the same sandboxed process — may let an attacker reach internal memory objects and assets this decoder alone can’t expose. We’re naming that risk class plainly, with the emphasis on may: no such chain exists today, as far as we’ve found.
One consequence we did fully confirm by tracing the code, not speculating: there’s no caching or failure-state anywhere in this pipeline, so a bad credential doesn’t just break once — every single time the holder opens Wallet and views it, the same stored bytes get re-read and the same fault re-fires, permanently, until Google ships a fix.
The fix: derive each color plane’s own read-loop bound from that plane’s own dimensions, instead of applying the whole image’s pixel count uniformly to every plane. We checked this exact bug pattern against a separate JPEG2000/OpenJPEG wrapper elsewhere in Google’s own codebase — Google Drive’s — and it has the dx/dy consistency check this shim lacks. The fix is known, simple, and already shipping in a sibling Google product. Reported to Google through the Android Security Rewards 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.