-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The path concat that does the writing\n\n`brut/androlib/res/decoder/ResFileDecoder.java` is the routine that turns a resource entry in `resources.arsc` into a file on disk in the analyst's working tree. Every output path it writes is built by one concat. In Apktool 3.0.1, the concat reads:\n\n```java\nString outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + \"/\" + entry.getName()\n + (ext.isEmpty() ? \"\" : \".\" + ext);\n```\n\n`entry.getTypeName()` returns a string from the `resources.arsc` TypeStringPool, which an APK author controls byte for byte. `entry.getName()` returns a string from the KeyStringPool, which an APK author also controls byte for byte. Two attacker-controlled strings, concatenated with a literal `/` between them, written to disk relative to the analyst's output directory.\n\nThe decoder takes `outResPath` and writes the file. There is no path-traversal check on the concat in 3.0.1. There was one in 3.0.0. Removing it is what shipped this CVE.\n\n## The deleted check ran on the path\n\nApktool 3.0.0's version of the same block:\n\n```java\nString outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + \"/\" + entry.getName();\nif (BrutIO.detectPossibleDirectoryTraversal(outResPath)) {\n LOGGER.warning(\"Potentially malicious file path: \" + outResPath + \", using instead: \" + inResPath);\n outResPath = inResPath;\n} else if (!ext.isEmpty()) {\n outResPath += \".\" + ext;\n}\n```\n\nThe check ran on `outResPath` after the concat. It accepted the composite. If anything anywhere in the composite looked like a traversal, the decoder fell back to the input path (the path the file already had inside the APK ZIP), which the ZIP loader had already rejected for `..` segments. The check was holistic by design: it did not care which input the dangerous bytes came in on, because it ran on the result.\n\nThat check was added in 2024 in response to [CVE-2024-21633](https://github.com/iBotPeaches/Apktool/security/advisories/GHSA-2hqv-2xv4-5h5w), where attacker-controlled resource entry names containing `../` walked apktool's output out of its working tree. A Windows-specific variant ([GHSA-vgwr-4w3p-xmjv](https://github.com/iBotPeaches/Apktool/security/advisories/GHSA-vgwr-4w3p-xmjv)) followed weeks later and was fixed by tightening the same check. It had been in this file, on this concat, doing its job, for almost two years.\n\n## PR #4041 deleted the check and added a different one\n\n[PR #4041](https://github.com/iBotPeaches/Apktool/pull/4041), titled \"refactor: Improve renaming/injection of resources in stripped APKs,\" merged on December 12, 2025. The PR's stated purpose is to make stripped and obfuscated APKs recompilable. Its first listed change is \"Strict entry spec naming ensures invalid entry names (including directory traversal attempts) are replaced.\"\n\nThe diff against `ResFileDecoder.java`:\n\n```diff\n- String outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + \"/\" + entry.getName();\n- if (BrutIO.detectPossibleDirectoryTraversal(outResPath)) {\n- LOGGER.warning(\"Potentially malicious file path: \" + outResPath + \", using instead: \" + inResPath);\n- outResPath = inResPath;\n- } else if (!ext.isEmpty()) {\n- outResPath += \".\" + ext;\n- }\n+ String outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + \"/\" + entry.getName()\n+ + (ext.isEmpty() ? \"\" : \".\" + ext);\n```\n\nEight lines removed, two lines added. The diff against `ResEntrySpec.java`:\n\n```diff\n- private static final Set INVALID_ENTRY_NAMES = Sets.newHashSet(\n- \"0_resource_name_obfuscated\", // #3067\n- \"(name removed)\" // #2940\n- );\n+ private static boolean isValidEntryName(String name) {\n+ int len = name.length();\n+ if (len == 0) {\n+ return false;\n+ }\n+ if (!Character.isJavaIdentifierStart(name.charAt(0))) {\n+ return false;\n+ }\n+ for (int i = 1; i < len; i++) {\n+ char ch = name.charAt(i);\n+ if (ch == '.' || ch == '-') {\n+ continue;\n+ }\n+ if (!Character.isJavaIdentifierPart(ch)) {\n+ return false;\n+ }\n+ }\n+ return true;\n+ }\n```\n\n`isValidEntryName` is called from the `ResEntrySpec` constructor. Any entry name that is not a Java identifier (allowing `.` and `-`) gets replaced with `APKTOOL_RENAMED_`. After this PR, an attacker-controlled string in the KeyStringPool that contained `/` or `\\` or `..` would be rewritten before it ever reached the path concat.\n\nThe implicit argument the PR makes is that the per-entry validator subsumes the per-path validator. The argument would be correct if the path concat read only one attacker-controlled string. It reads two.\n\n## ResTypeSpec validated nothing in v3.0.1\n\nIn Apktool 3.0.1, `brut/androlib/res/table/ResTypeSpec.java`'s constructor was:\n\n```java\npublic ResTypeSpec(ResPackage pkg, int id, String name) {\n assert pkg != null && id > 0 && name != null;\n mPackage = pkg;\n mId = id;\n mName = name;\n}\n```\n\n`mName = name`. No validator. Whatever string came out of the TypeStringPool at parse time landed in `mName` and was returned verbatim by `getName()`. Verbatim was what `entry.getTypeName()` then returned to the path concat in `ResFileDecoder`.\n\nThe PR audited the input that the previous CVE came in on. Resource entry names were what 2024's CVE-2024-21633 exploited, so resource entry names were what the PR's new validator covered. Resource type names had not been exploited yet, so resource type names were left as `mName = name`. The replaced defense was holistic; the replacement was scoped to the input that was already known to be dangerous.\n\n## The PoC fills the unvalidated input\n\nThe PoC at [frawlaboy/CVE-2026-39973-PoC](https://github.com/frawlaboy/CVE-2026-39973-PoC) is a C# builder for malicious `resources.arsc` files. Its core is twelve lines:\n\n```csharp\nprivate const string TraversalSequence = @\"..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\\";\nprivate const string BashrcPath = @\"~\\\";\nprivate const string WindowsStartupPath = @\"Users\\\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\\";\n\n// ...\n\nvar typeName = TraversalSequence + WindowsStartupPath;\n// or, in bashrc mode:\n// var typeName = TraversalSequence + BashrcPath;\n\ntable.ValueStringPool.Strings.Add($\"res/{typeName}/{fileName}\");\ntable.Package.TypeStringPool.Strings.Add(typeName);\ntable.Package.KeyStringPool.Strings.Add(Path.GetFileNameWithoutExtension(fileName));\n```\n\nThe traversal lives in the TypeStringPool. The KeyStringPool gets a clean filename. The KeyStringPool is what `ResEntrySpec.isValidEntryName` runs against. The TypeStringPool is what `ResTypeSpec`'s constructor stored verbatim. The new validator looks at the string the attacker did not poison and approves it. The string the attacker did poison flows through `entry.getTypeName()` into the concat.\n\nAfter concatenation, `outResPath` reads `..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\Users\\\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\/evil.txt`. The decoder reads the file bytes from the ZIP entry at `res//` (which the ZIP loader does not flag because the entry's own internal name is its own internal name, not an output path) and writes them to `outDir/res/`. The `..\\` segments walk out of `outDir`, out of the analyst's working tree, out of the analyst's home, into the Windows Startup folder.\n\nThe PoC's `BuildArchive` function pairs the malicious `resources.arsc` with a ZIP entry at the matching `res//` path so the decoder finds the bytes when it goes looking. The traversal is in the type name twice: once in the ZIP entry's `res//` path (so the decoder finds the source bytes), and once in the resolved `outResPath` (so the decoder writes them through the analyst's home directory). The first one is wrapped in `res/` and never walks anywhere meaningful; the second one is the attack.\n\nRun `apktool d cve-2026-39973-out-.apk` on Windows. The next time the analyst logs in, their startup folder runs whatever was in the payload.\n\n## The fix added the validator the refactor should have\n\nApktool 3.0.2's [PR #4113](https://github.com/iBotPeaches/Apktool/pull/4113) is titled \"fix: validate type names.\" Its description says: \"Fixes a reported security vulnerability where a malicious APK with manipulated type name(s) causing a directory traversal. This is done by renaming invalid type names that can't be handled by aapt2 in `ResTypeSpec`, just like we're doing for entry names in `ResEntrySpec`.\"\n\nThe new `isValidTypeName`:\n\n```java\nprivate static boolean isValidTypeName(String name) {\n switch (name) {\n case \"anim\":\n case \"animator\":\n case \"array\":\n case \"attr\":\n case \"^attr-private\":\n case \"bool\":\n case \"color\":\n case \"dimen\":\n case \"drawable\":\n case \"font\":\n case \"fraction\":\n case \"id\":\n case \"integer\":\n case \"interpolator\":\n case \"layout\":\n case \"menu\":\n case \"mipmap\":\n case \"navigation\":\n case \"plurals\":\n case \"raw\":\n case \"string\":\n case \"style\":\n case \"transition\":\n case \"xml\":\n return true;\n default:\n return false;\n }\n}\n```\n\nA 23-entry whitelist. Unlike `isValidEntryName`, which accepts arbitrary Java identifiers, `isValidTypeName` accepts only the closed set of Android resource type strings, because that set is closed by the Android resource framework. Anything else gets renamed to `invalid` in two hex digits. A traversal sequence in the TypeStringPool now becomes `invalid07` in the path, which is no longer attacker-controlled.\n\nThe 3.0.2 release notes do not mention the security fix. The PR title is \"fix: validate type names.\" There is no entry on the CHANGELOG that names CVE-2026-39973. The advisory was filed separately, after the release.\n\n## This is the third path traversal in ResFileDecoder\n\nThe class is a [design-debt driver](/patterns/design-debt-driver). Its job is to write attacker-controlled file paths to disk. Its history:\n\n- January 3, 2024: CVE-2024-21633. Malicious entry names containing `../` traverse out of the output directory. Fixed in 2.9.2 by adding `BrutIO.detectPossibleDirectoryTraversal()` on the composite output path.\n- January 20, 2024: GHSA-vgwr-4w3p-xmjv. Windows-specific variant of the same bug, fixed in 2.9.3 by tightening the same check for backslash separators.\n- December 12, 2025: PR #4041 deletes the check. Replaces it with `isValidEntryName` on the entry-name half of the path.\n- April 1, 2026: PR #4113 adds `isValidTypeName` on the type-name half of the path, fixing CVE-2026-39973.\n\nTwo and a half months elapsed between the regression landing in `main` and the PoC dropping. The decoder shipped to anyone who built from source in that window, including anyone consuming the snapshot artifacts that the JitPack and Maven Central pipelines produce off `main`.\n\nThe check that 2024 added ran on the composite. The check that 2025 deleted was the composite check. The check that 2026 added runs on one of the two inputs. ResFileDecoder is now defended by two narrow per-input validators (one on `ResEntrySpec`, one on `ResTypeSpec`) doing the job that one validator on the composite used to do.\n\nA holistic check on the result of a concat catches a traversal sequence regardless of which input contributed it. Per-input validators only catch what their author thought to validate. Adding an input to the concat without adding a validator is the bug class this file has now produced three times.\n\n## The detector decodes the attacker's bytes\n\nApktool's audience is named in this file's own usage pattern: it is the user running `apktool d ` against bytes whose provenance they do not control. The PoC author's README opens with credit to \"iBotPeaches/Apktool, Documentation & parsing,\" because the PoC author needed to learn the resources.arsc format to write the exploit. The format is documented because Apktool's users need it to be documented; the format is parsed by Apktool because Apktool's users need it to be parsed. The same parser that serves the analyst serves the malicious APK the analyst points it at.\n\nThis is [the-detector-is-the-target](/patterns/the-detector-is-the-target) at the analyst's workstation rather than at the appliance. The pattern's standing examples are sandboxes and SOC tools, network-positioned boxes that accept hostile input by occupation. Apktool occupies a different rung of the same ladder: a developer-laptop binary whose job is to make sense of files the developer cannot trust. The blast radius is smaller than a sandbox compromise, but the prerequisite is also smaller. There is no submission API to reach. There is one analyst, one apktool, one sample. The sample writes to the analyst's `~/.bashrc`, and the analyst is the security team.","closing_line":"v3.0.0 ran one check on the path. v3.0.1 ran one check on the name. v3.0.2 runs two. The path is still two names.","hook_md":"The audience for `apktool d` is, by occupation, the audience that cannot trust the file it is about to run the command against. Reverse engineers pulling a sample from VirusTotal. Malware analysts staring at a stripped APK out of a customer's compromised phone. SDK auditors looking at a third-party binary they did not build. Every member of that audience runs apktool on bytes from a hostile source.\n\nCVE-2026-39973 is the bytes reaching back. A malicious APK whose `resources.arsc` carries a single field full of `..\\..\\` writes through `apktool d` into the analyst's home directory, into `~/.bashrc`, or on Windows into `%APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup`. The next shell the analyst opens is the attacker's shell.\n\nThe bug is a regression. The check that would have stopped it was deleted four months earlier, in a refactor whose stated reason was that a narrower check had made it redundant. The narrower check covered one of the two attacker-controlled strings the output path is built from. The other is what the PoC fills.","post_id":265,"slug":"apktool-path-was-two-names","title":"CVE-2026-39973: Apktool's Path Check Was Replaced With a Name Check. The Path Has Two Names.","type":"initial","unreadable_sentence":"The deleted check ran on the path. The replacement runs on the name. The path has two names. The PoC fills the unvalidated one."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCararIAAKCRDeZjl4jgkQ JgXIAQCp40vnr4RybnU7sSaHcAQ2CW0X14ux0VZl6D1WwW8FdwD7BJGkZvATO9TG 7zKf8/3oMg6LK/IOicbZyMa91vaS7wU= =B91i -----END PGP SIGNATURE-----