Repository navigation
Command injection via ZIP directory name in Android custom build setup (shell=True + string concatenation) #5477
Description
Summary
ClusterFuzz builds a shell command by string-concatenating an extracted ZIP
path into a subprocess.run(..., shell=True) call. A ZIP whose directory name
contains $() causes Bash to execute the injected command on the trusted host
process before the fuzz target ever runs, bypassing the isolation boundary
between the untrusted runner and the host.
Affected versions
- v2.36.2, v2.37.0, v2.37.1 (tested at revision 7758497)
- Unpatched as of Aug 23, 2026
Root cause
In adb.py, get_package_name() constructs the aapt command by concatenation:
# Vulnerable pattern
aapt_command = '%s dump badging %s' % (aapt_path, apk_path)
subprocess.run(aapt_command, executable='/bin/bash', shell=True)apk_path comes from an extracted ZIP. If the ZIP contains a directory named
payload$(touch /tmp/pwned), that name is preserved on disk and lands verbatim
in the shell command:
/resources/aapt dump badging /build/custom/payload$(touch /tmp/pwned)/app.apk
Bash evaluates $(touch /tmp/pwned) on the host while processing the word,
before aapt runs. The single quotes visible in write_command_line_file do not
protect against this because they are inside an outer double-quoted word.
Who can trigger this
Requires privileged ClusterFuzz project access, specifically the role that can
create or update fuzzing jobs and upload custom builds. Standard testcase
uploaders cannot trigger this.
Why the boundary matters
ClusterFuzz's security model isolates fuzz target code in an untrusted runner,
separate from the trusted host process that holds credentials, task metadata,
and cluster state. A project operator is permitted to supply code for the
untrusted runner. This bug lets that same operator run code in the trusted host
process, a boundary the design is supposed to prevent.
Proof of concept
The PoC extracts the exact vulnerable functions from the pinned source and runs
them with a harmless fake aapt. No Android device required.
docker build -f poc/Dockerfile -t clusterfuzz-cmdi-poc poc
docker run --rm -v "$(pwd)/clusterfuzz:/src:ro" clusterfuzz-cmdi-poc
Expected output:
CONTROL_MARKER_CREATED=False ← normal path, no injection
EXPLOIT_MARKER_CREATED=True ← $() in directory name executed on host
APP_PATH_CONTAINS_COMMAND_SUBSTITUTION=True
CONTROL_MARKER_CREATED=False confirms a normal path goes through the same
code without triggering anything. EXPLOIT_MARKER_CREATED=True confirms the
injected directory name causes Bash to execute on the host while package
inspection completes normally.
Fix
Replace string concatenation + shell=True with an argument list + shell=False:
## get_package_name() - safe version
result = subprocess.run(
[aapt_path, 'dump', 'badging', apk_path],
shell=False,
capture_output=True,
text=True
)
# write_command_line_file() - write via Python file I/O, not shell
with open(command_line_path, 'w') as f:
f.write(apk_path)
apdk_path becomes a list element, never concatenated into a shell string.
Archive paths should be treated as untrusted regardless of uploader privilege level.
Disclosure timeline
- Aug 23, 2026 - Reported to Google VRP
- Aug 24, 2026 - Google confirmed the issue is real but below their security
escalation threshold; suggested public disclosure on GitHub
Activity
Denimworld12 commentedon Oct 1, 2026
I'd like to pick this up, if nobody is already on it — I don't see a linked PR, unlike #5436 → #5437 and #5428 → #5429.
Confirmed still present on
master(2c3ccff):platforms/android/app.py:87—aapt_command = '%s dump badging %s' % (aapt_binary_path, apk_path)platforms/android/adb.py:130-135—subprocess.Popen(cmd, executable='/bin/bash', shell=True)So
apk_pathreaches bash as part of a concatenated word, exactly as described.Two things I'd like a maintainer's steer on before I send a patch, since they change the shape of the fix:
1. Scope.
execute_commandtakes a pre-built string and is called from a number of places, some of which deliberately rely on shell behaviour (run_shell_commandbuilds quotedtest -d "..."; echo $?constructs, and the cuttlefish branch wraps the whole command in anssh "..."). Convertingexecute_commanditself to an argv list would touch all of those. The narrower change is to stopget_package_namefrom building a shell string at all — invokeaaptdirectly with a list andshell=False— and leaveexecute_commandas the shell-oriented helper it is today. That fixes the reported path without disturbing the callers that want a shell.Happy to do the broader cleanup instead if you'd prefer the sink hardened rather than the one caller, but it is a much larger diff and I would not want to guess.
2. Other callers of the same shape. If the narrow fix is right, I'd also check whether any other caller concatenates an attacker-influenced path into a command string, and either fix those in the same PR or list them for a follow-up — whichever you prefer.
I'll hold off until someone confirms the direction. Tests would cover a directory name containing
$(...)reaching the aapt invocation without being evaluated, plus the ordinary path still resolving a package name.