home intel cve-2026-39973-apktool-path-traversal-rce
CVE Analysis 2026-04-21 · 7 min read

CVE-2026-39973: Apktool Path Traversal via Stripped Sanitization Call

A security regression in Apktool 3.0.0–3.0.1 removed BrutIO.sanitizePath(), allowing ../sequences in resources.arsc to escape the output directory and write arbitrary files, enabling RCE.

#path-traversal#apk-decoding#file-write#android-reverse-engineering#arbitrary-file-write
Technical mode — for security professionals
▶ Attack flow — CVE-2026-39973 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-39973Android · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-39973 is a path traversal vulnerability in Apktool 3.0.0 and 3.0.1 introduced by a single-line regression in commit e10a045 (PR #4041, December 12, 2025). During standard APK decoding (apktool d), resource file output paths derived from the resources.arsc Type String Pool are written to disk without sanitization. An attacker who distributes a crafted APK can write arbitrary files to the analyst's filesystem — including SSH configs, shell startup scripts, and Windows Startup folder binaries — escalating a passive reverse-engineering action to remote code execution.

CVSS 7.1 (HIGH) reflects the user-interaction requirement (analyst must decode the APK) offset against the full OS-level write primitive and reliable RCE escalation path.

Root cause: Commit e10a045 removed the BrutIO.sanitizePath() guard from ResFileDecoder.java, allowing attacker-controlled ../ sequences in resources.arsc Type String Pool entries to escape the configured output directory during apktool d.

Affected Component

The vulnerable code lives in brut/androlib/res/decoder/ResFileDecoder.java, specifically in the method responsible for resolving the destination path for each decoded resource file. The Type String Pool in resources.arsc supplies the filename strings — these are attacker-controlled in a crafted APK.

Affected versions: 3.0.0, 3.0.1. Fixed in 3.0.2.

Root Cause Analysis

Prior to e10a045, ResFileDecoder.decode() called BrutIO.sanitizePath() on the resolved output path before writing. The commit removed this call, ostensibly as cleanup. Below is the reconstructed vulnerable logic (Java, matching the structure from the advisory):

// ResFileDecoder.java — versions 3.0.0 and 3.0.1
// Reconstructed from patch diff and advisory source material

public void decode(ResResource res, File outDir, String fileName)
        throws AndrolibException {

    // fileName originates from resources.arsc Type String Pool
    // e.g., "res/drawable/icon.png" — but attacker controls this string
    File outFile = new File(outDir, fileName);

    // BUG: BrutIO.sanitizePath() call was removed in commit e10a045
    // Previously: outFile = new File(outDir, BrutIO.sanitizePath(outDir, fileName));
    // Now: no traversal check — "../../../.ssh/authorized_keys" passes through

    InputStream in = res.getFileValue().open();
    try {
        BrutIO.copyAndClose(in, new FileOutputStream(outFile)); // arbitrary write
    } catch (IOException ex) {
        throw new AndrolibException("Could not decode file: " + fileName, ex);
    }
}

The sanitizePath() function that was removed performs canonical path comparison:

// BrutIO.java — sanitizePath() (present in 3.0.2, absent in 3.0.0–3.0.1)

public static String sanitizePath(File outDir, String fileName)
        throws BrutException {

    // Resolve canonical paths to collapse ../ sequences
    String canonicalOutDir  = outDir.getCanonicalPath();
    String canonicalOutFile = new File(outDir, fileName).getCanonicalPath();

    // BUG GUARD: if resolved path doesn't start with the output dir, reject it
    if (!canonicalOutFile.startsWith(canonicalOutDir + File.separator)) {
        throw new BrutException(
            "Refusing path traversal attempt: " + fileName
        );
    }
    return fileName;
}

Without sanitizePath(), a fileName of ../../../home/user/.ssh/authorized_keys resolves outside outDir and FileOutputStream happily creates or truncates the target file with attacker-supplied bytes.

Exploitation Mechanics

Crafting the malicious APK requires injecting a controlled string into the resources.arsc Type String Pool. The pool encodes filenames used to name resource file entries — these map directly to the fileName parameter above.

#!/usr/bin/env python3
# Minimal resources.arsc Type String Pool injection sketch
# Overwrites analyst's ~/.bashrc on apktool d

import struct, zipfile, io

TRAVERSAL_PATH = b"../../../home/analyst/.bashrc\x00"
PAYLOAD        = b'\nexport PATH="$HOME/.local/malware:$PATH"\n'

# String pool header (simplified — real format per AOSP chunk spec)
# StringPool_header: type=0x0001, headerSize=0x1C, chunkSize, stringCount ...
STRING_COUNT   = 1
STRING_OFFSETS = struct.pack("
EXPLOIT CHAIN:
1. Attacker crafts resources.arsc with Type String Pool entry:
      "res/raw/../../../../home/analyst/.bashrc"
   pointing to a resource file containing shell payload.

2. Attacker packages this into a valid-looking APK and distributes it
   (malware sample, bug bounty submission, CTF challenge, etc.).

3. Analyst runs: apktool d malicious.apk -o ./output

4. ResFileDecoder.decode() is called per resource entry.
   fileName = "res/raw/../../../../home/analyst/.bashrc"
   outFile  = new File("./output", fileName)
            → resolves to /home/analyst/.bashrc   (traversal succeeds)

5. BrutIO.copyAndClose() writes attacker payload to /home/analyst/.bashrc
   — no exception, no warning, Apktool exits cleanly.

6. On next shell login (or sourced session), .bashrc executes payload.
   Alternatively: ~/.ssh/config → redirect connections through attacker proxy
                  ~/.ssh/authorized_keys → persistent SSH backdoor
                  %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\*.bat
                  → immediate RCE on next Windows login

Memory Layout

This is a logic/path traversal class vulnerability rather than a memory corruption bug, so there is no heap corruption to diagram. The relevant "layout" is the filesystem write primitive and the path resolution chain:

PATH RESOLUTION — VULNERABLE (3.0.0 / 3.0.1):

  resources.arsc Type String Pool
  ┌──────────────────────────────────────────────────────┐
  │ entry[0]: "res/raw/../../../../home/analyst/.bashrc" │  ← attacker-controlled
  └──────────────────────────────────────────────────────┘
                        │
                        ▼
  ResFileDecoder.decode(res, outDir="/tmp/output", fileName=entry[0])
                        │
                        ▼
  new File("/tmp/output", "res/raw/../../../../home/analyst/.bashrc")
                        │
              File.getPath() (no canonicalization)
                        │
                        ▼
  /tmp/output/res/raw/../../../../home/analyst/.bashrc
         = /home/analyst/.bashrc                     ← ESCAPED OUTPUT DIR
                        │
                        ▼
  FileOutputStream("/home/analyst/.bashrc") → WRITE PAYLOAD

PATH RESOLUTION — PATCHED (3.0.2):

  same fileName input
        │
        ▼
  BrutIO.sanitizePath(outDir, fileName)
        │
        ├─ canonical(outDir)  = /tmp/output
        ├─ canonical(outFile) = /home/analyst/.bashrc
        └─ startsWith("/tmp/output/") → FALSE
               │
               ▼
        BrutException("Refusing path traversal attempt")  ← BLOCKED

Patch Analysis

The fix in 3.0.2 re-introduces the single BrutIO.sanitizePath() call that e10a045 removed. The diff is minimal but decisive:

// BEFORE — ResFileDecoder.java (3.0.0 / 3.0.1, commit e10a045):

public void decode(ResResource res, File outDir, String fileName)
        throws AndrolibException {

    File outFile = new File(outDir, fileName);
    // No path validation — fileName goes straight to FileOutputStream

    InputStream in = res.getFileValue().open();
    BrutIO.copyAndClose(in, new FileOutputStream(outFile));
}


// AFTER — ResFileDecoder.java (3.0.2, fix commit):

public void decode(ResResource res, File outDir, String fileName)
        throws AndrolibException {

    // Re-introduced: reject paths that escape outDir via canonical resolution
    String sanitized = BrutIO.sanitizePath(outDir, fileName);
    File outFile     = new File(outDir, sanitized);

    InputStream in = res.getFileValue().open();
    BrutIO.copyAndClose(in, new FileOutputStream(outFile));
}

// BrutIO.sanitizePath() — throws BrutException on traversal attempt:
public static String sanitizePath(File outDir, String fileName)
        throws BrutException {
    if (!new File(outDir, fileName)
            .getCanonicalPath()
            .startsWith(outDir.getCanonicalPath() + File.separator)) {
        throw new BrutException("Refusing path traversal: " + fileName);
    }
    return fileName;
}

One interesting detail: the fix uses getCanonicalPath() which resolves symlinks. This means a symlink inside outDir pointing outside it would also be caught — a subtlety that pure getAbsolutePath() comparison would miss.

Detection and Indicators

Detection is straightforward at the APK layer before execution:

STATIC DETECTION — scan resources.arsc String Pool before decoding:

  grep for "../" sequences in raw arsc bytes:
    $ python3 -c "
    import sys, re
    data = open(sys.argv[1],'rb').read()
    for m in re.finditer(rb'\.\./', data):
        ctx = data[m.start()-16:m.end()+48]
        print(f'[!] Traversal candidate @ 0x{m.start():08x}: {ctx}')
    " suspicious.apk/resources.arsc

BEHAVIORAL DETECTION:
  - Apktool write syscalls targeting paths outside the specified -o directory
  - strace: openat() calls with O_WRONLY|O_CREAT to ~/ or system paths
  - auditd rule: -w /home -p w -k apktool_traversal

YARA:
  rule apktool_path_traversal_arsc {
      strings:
          $dotdot_unix = { 2E 2E 2F 2E 2E 2F }   // ../../
          $dotdot_win  = { 2E 2E 5C 2E 2E 5C }   // ..\..\ 
      condition:
          uint32(0) == 0x52455300                  // "RES\0" arsc magic
          and any of them
  }

Remediation

  • Update immediately to Apktool 3.0.2 or later. The fix is a one-line reintroduction of an existing sanitization call.
  • If upgrading is not immediately possible: wrap apktool d invocations in a sandboxed environment (Docker with --read-only home mount, or a dedicated throwaway VM) before decoding untrusted APKs.
  • Analysts handling untrusted samples should treat any APK from an unverified source as potentially weaponized against tooling, not just against end-users.
  • For CI/CD pipelines running automated APK analysis: pin Apktool to 3.0.2+, validate the jar checksum, and restrict the process's filesystem write scope via seccomp or AppArmor profiles targeting apktool's working directory.

This class of regression — removing a security-critical call during refactoring — is particularly dangerous because it produces no functional difference in benign inputs. The code works correctly on legitimate APKs; only a maliciously crafted resources.arsc exposes the missing guard. Standard regression testing will not catch it. Security-specific test cases covering path traversal inputs in String Pool entries should be part of Apktool's test suite going forward.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →