//nefariousplan

CVE-2026-39973: Apktool's Path Check Was Replaced With a Name Check. The Path Has Two Names.

patterns

cve

proof of concept

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.

CVE-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.

The 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.

The path concat that does the writing

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:

String outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + "/" + entry.getName()
        + (ext.isEmpty() ? "" : "." + ext);

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.

The 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.

The deleted check ran on the path

Apktool 3.0.0's version of the same block:

String outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + "/" + entry.getName();
if (BrutIO.detectPossibleDirectoryTraversal(outResPath)) {
    LOGGER.warning("Potentially malicious file path: " + outResPath + ", using instead: " + inResPath);
    outResPath = inResPath;
} else if (!ext.isEmpty()) {
    outResPath += "." + ext;
}

The 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.

That check was added in 2024 in response to CVE-2024-21633, where attacker-controlled resource entry names containing ../ walked apktool's output out of its working tree. A Windows-specific variant (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.

PR #4041 deleted the check and added a different one

PR #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."

The diff against ResFileDecoder.java:

- String outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + "/" + entry.getName();
- if (BrutIO.detectPossibleDirectoryTraversal(outResPath)) {
-     LOGGER.warning("Potentially malicious file path: " + outResPath + ", using instead: " + inResPath);
-     outResPath = inResPath;
- } else if (!ext.isEmpty()) {
-     outResPath += "." + ext;
- }
+ String outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + "/" + entry.getName()
+         + (ext.isEmpty() ? "" : "." + ext);

Eight lines removed, two lines added. The diff against ResEntrySpec.java:

- private static final Set<String> INVALID_ENTRY_NAMES = Sets.newHashSet(
-     "0_resource_name_obfuscated", // #3067
-     "(name removed)" // #2940
- );
+ private static boolean isValidEntryName(String name) {
+     int len = name.length();
+     if (len == 0) {
+         return false;
+     }
+     if (!Character.isJavaIdentifierStart(name.charAt(0))) {
+         return false;
+     }
+     for (int i = 1; i < len; i++) {
+         char ch = name.charAt(i);
+         if (ch == '.' || ch == '-') {
+             continue;
+         }
+         if (!Character.isJavaIdentifierPart(ch)) {
+             return false;
+         }
+     }
+     return true;
+ }

isValidEntryName is called from the ResEntrySpec constructor. Any entry name that is not a Java identifier (allowing . and -) gets replaced with APKTOOL_RENAMED_<resId>. After this PR, an attacker-controlled string in the KeyStringPool that contained / or \ or .. would be rewritten before it ever reached the path concat.

The 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.

ResTypeSpec validated nothing in v3.0.1

In Apktool 3.0.1, brut/androlib/res/table/ResTypeSpec.java's constructor was:

public ResTypeSpec(ResPackage pkg, int id, String name) {
    assert pkg != null && id > 0 && name != null;
    mPackage = pkg;
    mId = id;
    mName = name;
}

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.

The 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.

The PoC fills the unvalidated input

The PoC at frawlaboy/CVE-2026-39973-PoC is a C# builder for malicious resources.arsc files. Its core is twelve lines:

private const string TraversalSequence = @"..\..\..\..\..\..\..\..\..\..\..\..\";
private const string BashrcPath = @"~\";
private const string WindowsStartupPath = @"Users\<username>\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\";

// ...

var typeName = TraversalSequence + WindowsStartupPath;
// or, in bashrc mode:
// var typeName = TraversalSequence + BashrcPath;

table.ValueStringPool.Strings.Add($"res/{typeName}/{fileName}");
table.Package.TypeStringPool.Strings.Add(typeName);
table.Package.KeyStringPool.Strings.Add(Path.GetFileNameWithoutExtension(fileName));

The 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.

After concatenation, outResPath reads ..\..\..\..\..\..\..\..\..\..\..\..\Users\<username>\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\/evil.txt. The decoder reads the file bytes from the ZIP entry at res/<typeName>/<fileName> (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/<outResPath>. The ..\ segments walk out of outDir, out of the analyst's working tree, out of the analyst's home, into the Windows Startup folder.

The PoC's BuildArchive function pairs the malicious resources.arsc with a ZIP entry at the matching res/<typeName>/<fileName> 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/<typeName>/<fileName> 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.

Run apktool d cve-2026-39973-out-<n>.apk on Windows. The next time the analyst logs in, their startup folder runs whatever was in the payload.

The fix added the validator the refactor should have

Apktool 3.0.2's PR #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."

The new isValidTypeName:

private static boolean isValidTypeName(String name) {
    switch (name) {
        case "anim":
        case "animator":
        case "array":
        case "attr":
        case "^attr-private":
        case "bool":
        case "color":
        case "dimen":
        case "drawable":
        case "font":
        case "fraction":
        case "id":
        case "integer":
        case "interpolator":
        case "layout":
        case "menu":
        case "mipmap":
        case "navigation":
        case "plurals":
        case "raw":
        case "string":
        case "style":
        case "transition":
        case "xml":
            return true;
        default:
            return false;
    }
}

A 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<id> in two hex digits. A traversal sequence in the TypeStringPool now becomes invalid07 in the path, which is no longer attacker-controlled.

The 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.

This is the third path traversal in ResFileDecoder

The class is a design-debt driver. Its job is to write attacker-controlled file paths to disk. Its history:

  • 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.
  • 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.
  • December 12, 2025: PR #4041 deletes the check. Replaces it with isValidEntryName on the entry-name half of the path.
  • April 1, 2026: PR #4113 adds isValidTypeName on the type-name half of the path, fixing CVE-2026-39973.

Two 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.

The 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.

A 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.

The detector decodes the attacker's bytes

Apktool's audience is named in this file's own usage pattern: it is the user running apktool d <file.apk> 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.

This is 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.

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.