DEV Community

zowskyy
zowskyy

Posted on

The Advisory Said It Was Fixed. I Didn't Believe It Until I Broke It Myself.

Update: This investigation spawned a complete stress-testing toolkit. See the apktool-diagnostics repo for the full tool with security scanning, round-trip validation, and fuzzing setup.

The Setup

I was deep into a project stress-testing apktool — the standard tool for decompiling and rebuilding Android APKs — hunting for real bugs to fix, not hypothetical ones. While digging through recent issues and advisories, I ran across a patched vulnerability: CVE-2026-39973, a path traversal bug that let a malicious resources.arsc make apktool write decoded files outside the intended output directory. Already fixed upstream. Advisory published. Case closed, according to everyone else.

I didn't believe it until I broke it myself.

Why "already patched" wasn't good enough

Advisories describe what should happen. They don't prove it. And a tool like apktool has one job that matters more than any other: you point it at a file you don't trust — someone else's APK — and it processes that file on your machine. If the sanitization it depends on for that trust boundary can silently regress in a refactor (which is exactly what happened here — a PR reorganizing resource-file-path construction dropped the call to BrutIO.detectPossibleDirectoryTraversal() without anyone noticing), then "patched now" only tells you about today. It doesn't tell you the mechanism actually holds, and it doesn't tell you what "vulnerable" concretely looks like on your own machine, with your own files.

So instead of writing "confirmed CVE-2026-39973, see upstream fix" and moving on, I decided to reproduce it myself, end to end.

Building both sides of the fence

Step one: build apktool twice from source — once at v3.0.1 (the last vulnerable tag), once at current HEAD (post-fix). Now I had a vulnerable binary and a patched binary sitting side by side, and no more reason to trust anyone's summary of what the difference actually did.

Step two: I needed a malicious input. The vulnerability lived in how ResFileDecoder.java constructs output file paths from resource type names inside the binary resources.arsc table. So I took a real APK, parsed its Type String Pool directly by hand — walking the binary chunk format byte by byte until I found it at offset 2236800, 21 entries — and overwrote one type name,_ interpolator_, with ../../../etc. Both strings were exactly 12 bytes in UTF-16, so no chunk resizing, no length fields to fix up, just a clean byte-for-byte swap. Repackaged the APK with the patched table.

The moment it actually mattered

I ran apktool d against the crafted APK using the vulnerable v3.0.1 build.

18 real resource files landed three directories above the intended output folder. Not a warning. Not a crash. A silent arbitrary-file-write, exactly as described, except now it wasn't a paragraph in an advisory — it was files sitting in _/home/claude/etc/ _that had no business being there.

Then I ran the identical malicious APK against the HEAD build. The sanitizer caught the traversal attempt on the same interpolator type name and rerouted all 18 files safely into res/invalid0B/ inside the intended directory. Same input. Same code path. One version bleeds files outside the sandbox, the other doesn't.

That's the whole difference between "I read that this was fixed" and "I watched it fail, then watched it not fail."

What I'm proud of

Not finding the CVE — I didn't find it, the advisory already existed. What I'm proud of is refusing to stop at reading it. The advisory's escalation path talks about overwriting things like ~/.bashrc or ~/.ssh/config for real RCE — I didn't need to point the same technique at a real target to know it was real, because I'd already watched it write 18 files somewhere it wasn't supposed to. Redirecting the same demonstrated mechanism at a sensitive path is a difference of where, not whether, and that's exactly why I deleted the PoC APK and the patching script the moment verification was done — the writeup documents the method for transparency, since the vulnerability is already public and patched, but it isn't a ready-to-run weapon sitting in a repo somewhere.

What I learned

Version-pin discipline matters more than I used to give it credit for. Every APK I'd already round-trip-tested earlier in this project turned out to sit cleanly on either side of this vulnerability window without me planning it that way — apktool 2.9.3 predates the vulnerable refactor entirely, and the HEAD build I'd compiled for unrelated testing already had the fix. Anyone frozen on 3.0.0 or 3.0.1 specifically, though, is exposed right now, running a tool whose entire purpose is processing untrusted input.
And the bigger lesson: a security fix isn't real to me until I've watched the exploit fail against it. Reading that a sanitization call got removed in a refactor is an abstraction. Watching 18 files land in the wrong directory, then watching the same 18 files land in the right one after a version bump, is not.

Full verification writeup: zowskyy/apktool-diagnostics — SECURITY_FINDING_CVE-2026-39973.md

Top comments (0)