[GH-ISSUE #3337] [Bug] Apktool shell launchers survive output overflow and cancellation #1208

Closed
opened 2026-10-15 18:32:01 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3337

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

decode-android-resources accepts the documented shell-launcher form, but output-budget enforcement leaves the launcher running after REA exits. The CLI returns UNKNOWN with Apktool process cleanup failed instead of the documented resource_constraint result. The output-budget case reproduced three times, each leaving the launcher alive and reparented to PID 1. Two runs with a quiet launcher and SIGINT after decode started also returned the same cleanup error and left the launcher alive. All reproduction processes were stopped afterward.

The controlled local launcher establishes supported launcher lifecycle behavior. It does not establish behavior of a real Apktool/JVM decode.

Reproduction

  1. Start from a clean source checkout and select the baseline with git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063.
  2. From the repository root, run npm ci and npm run build:cached.
  3. Run the following exact command from the repository root. It creates every input file, reports an accepted launcher version, emits one byte beyond the 4 MiB decode output budget, records whether that exact process survives CLI completion, and finally stops only its own process group. Use a fresh checkout or remove the previous process-cleanup-repro fixture directory before repeating it.
python3 - <<'PYTHON'
import json, os, pathlib, signal, subprocess
root = pathlib.Path('process-cleanup-repro').resolve()
root.mkdir()
for name in ('home', 'temporary', 'modules'):
    (root / name).mkdir()
launcher = root / 'apktool'
launcher.write_text('''#!/bin/sh
if [ "$1" = "--version" ]; then
  echo 2.7.0
  exit 0
fi
printf '%s' "$$" > "$REA_REPRO_PID_FILE"
/usr/bin/head -c 4194305 /dev/zero
while :; do /bin/sleep 1; done
''')
launcher.chmod(0o755)
archive = root / 'sample.apk'
archive.write_bytes(b'PK\x05\x06' + b'\x00' * 18)
pidfile = root / 'launcher.pid'
env = dict(os.environ, HOME=str(root / 'home'),
           TMPDIR=str(root / 'temporary'),
           REA_DARWIN_MODULE_CACHE_PATH=str(root / 'modules'),
           REA_APKTOOL_COMMAND=str(launcher),
           REA_REPRO_PID_FILE=str(pidfile))
cli = subprocess.Popen(['node', 'scripts/rea.mjs',
                        'decode-android-resources', str(archive), '--json'],
                       env=env, stdout=subprocess.PIPE,
                       stderr=subprocess.PIPE, text=True)
pid = None
try:
    out, err = cli.communicate(timeout=180)
    pid = int(pidfile.read_text())
    state = subprocess.run(['/bin/ps', '-p', str(pid), '-o', 'ppid='],
                           capture_output=True, text=True).stdout.strip()
    print(json.dumps({'cli_exit': cli.returncode,
                      'launcher_alive_after_cli': bool(state),
                      'launcher_parent_pid': int(state) if state else None,
                      'result': json.loads(out), 'stderr': err}, indent=2))
finally:
    if pid is None and pidfile.exists():
        pid = int(pidfile.read_text())
    if pid is not None:
        try:
            os.killpg(pid, signal.SIGKILL)
        except ProcessLookupError:
            pass
    if cli.poll() is None:
        cli.kill()
        cli.communicate()
PYTHON
  1. Inspect the printed CLI result and launcher_alive_after_cli. The observed result is shown under Relevant output or evidence.

The empty ZIP is only an input carrier. The launcher ignores its contents, so the case needs no Android SDK, Java runtime, network service, or external analysis engine. The runtime used for the observed runs already had dependencies and compiled output present; steps 1 and 2 prepare an equivalent clean checkout.

Expected behavior

REA should stop and verify cleanup of its supported launcher and descendants when the 4 MiB decode output budget is exceeded, then return resource_constraint. docs/apktool-resource-analysis.md:15 supports upstream wrappers and java -jar scripts, docs/apktool-resource-analysis.md:66 describes owned workspace cleanup, and docs/apktool-resource-analysis.md:74 specifies the 4 MiB budget and resource_constraint outcome.

A shell interpreter appearing in the process table must not by itself turn a supported launcher into an unverifiable foreign process. Cleanup must continue to require ownership proof.

Impact and scope

Impact level: P2, supported. Output overflow and cancellation on the supported shell-launcher path leave an owned process alive after CLI exit and replace the useful original failure with an UNKNOWN cleanup error. The effect is bounded to interrupted live launches; successful decode behavior was not implicated.

Workaround status: unverified. Explicitly stopping the recorded reproduction group removed the orphan and allowed the retained cleanup owner to verify cleanup, but no verified caller setting restored automatic cleanup and the original typed error for the supported launcher form.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0; macOS 27.0.1 arm64. The cancellation variant also reproduced on Node.js v24.21.0.

Target and artifact context

Synthetic executable #!/bin/sh Apktool launcher reporting version 2.7.0, an empty ZIP named sample.apk, isolated home and temporary directories, and an isolated Darwin module cache. The launcher emits 4,194,305 bytes and remains alive. The archive bytes and every fixture file are created inline in the reproduction.

Interface

CLI

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

{
  "cli_exit": 1,
  "launcher_alive_after_cli": true,
  "launcher_parent_pid": 1,
  "result": {
    "code": "UNKNOWN",
    "message": "Apktool process cleanup failed"
  },
  "stderr": ""
}

A direct probe of the same supervision path reported owned launcher command identity did not match. The observed command began with /bin/sh followed by the selected launcher, while the retained expected command was the launcher path alone. After explicitly stopping that owned group, retrying cleanup returned verified-cleanup.

Typed outcome or recovery expected

Run the supplied output-budget case and confirm that the CLI returns resource_constraint and the recorded launcher and its descendants have exited before the command completes. Cancel the quiet variant after its PID file appears and confirm that cancellation also stops the owned processes. A control launcher that exits normally must still complete without cleanup errors. Keep mismatched or unverified process identities protected rather than accepting every interpreter-prefixed command.

Analysis or suspected cause

Source-derived reasoning: the supported command reaches src/apktool/ApktoolProvider.ts:178, then src/process/ProviderProcess.ts:259 retains the launcher path as the expected command. src/process/ProcessOwnershipIdentity.ts:19 and src/process/ProcessOwnership.ts:372 reject its interpreter-prefixed live command before the token checks can establish ownership. This is consistent with the observed supervision diagnostic and surviving launcher.

Additional context

  • #1774 concerns Windows batch-wrapper version-probe spawn rejection; this launcher passes version admission on macOS and fails cleanup after decode starts.
  • #1945 concerns missing or relative launcher configuration errors; this uses an absolute admitted launcher and fails during cleanup.
  • #142, closed as not planned, concerns cleanup aborting on an unreadable group member; this concerns a readable leader command rejected because its shell interpreter prefixes the selected launcher.
  • #3032 concerns Ghidra JVM ownership reads during exit returning sysctl_errno_22; this involves a still-running shell launcher and a command-identity mismatch.
  • #1232 concerns capture-process SIGINT routing; this also reproduces through output overflow without an interrupt.
  • #1191 concerns npx terminal-group signal relay during Ghidra cancellation; this uses direct Node invocation and reproduces without signal relay.
  • #2541 concerns loss of a supervisor cleanup reason during error projection; this additionally leaves the owned launcher running.
  • #2656 concerns omitted retained capture-process coordinates in diagnostics; this directly observes a live launcher after CLI exit.
Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3337 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior `decode-android-resources` accepts the documented shell-launcher form, but output-budget enforcement leaves the launcher running after REA exits. The CLI returns `UNKNOWN` with `Apktool process cleanup failed` instead of the documented `resource_constraint` result. The output-budget case reproduced three times, each leaving the launcher alive and reparented to PID 1. Two runs with a quiet launcher and SIGINT after decode started also returned the same cleanup error and left the launcher alive. All reproduction processes were stopped afterward. The controlled local launcher establishes supported launcher lifecycle behavior. It does not establish behavior of a real Apktool/JVM decode. ### Reproduction 1. Start from a clean source checkout and select the baseline with `git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063`. 2. From the repository root, run `npm ci` and `npm run build:cached`. 3. Run the following exact command from the repository root. It creates every input file, reports an accepted launcher version, emits one byte beyond the 4 MiB decode output budget, records whether that exact process survives CLI completion, and finally stops only its own process group. Use a fresh checkout or remove the previous `process-cleanup-repro` fixture directory before repeating it. ```sh python3 - <<'PYTHON' import json, os, pathlib, signal, subprocess root = pathlib.Path('process-cleanup-repro').resolve() root.mkdir() for name in ('home', 'temporary', 'modules'): (root / name).mkdir() launcher = root / 'apktool' launcher.write_text('''#!/bin/sh if [ "$1" = "--version" ]; then echo 2.7.0 exit 0 fi printf '%s' "$$" > "$REA_REPRO_PID_FILE" /usr/bin/head -c 4194305 /dev/zero while :; do /bin/sleep 1; done ''') launcher.chmod(0o755) archive = root / 'sample.apk' archive.write_bytes(b'PK\x05\x06' + b'\x00' * 18) pidfile = root / 'launcher.pid' env = dict(os.environ, HOME=str(root / 'home'), TMPDIR=str(root / 'temporary'), REA_DARWIN_MODULE_CACHE_PATH=str(root / 'modules'), REA_APKTOOL_COMMAND=str(launcher), REA_REPRO_PID_FILE=str(pidfile)) cli = subprocess.Popen(['node', 'scripts/rea.mjs', 'decode-android-resources', str(archive), '--json'], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) pid = None try: out, err = cli.communicate(timeout=180) pid = int(pidfile.read_text()) state = subprocess.run(['/bin/ps', '-p', str(pid), '-o', 'ppid='], capture_output=True, text=True).stdout.strip() print(json.dumps({'cli_exit': cli.returncode, 'launcher_alive_after_cli': bool(state), 'launcher_parent_pid': int(state) if state else None, 'result': json.loads(out), 'stderr': err}, indent=2)) finally: if pid is None and pidfile.exists(): pid = int(pidfile.read_text()) if pid is not None: try: os.killpg(pid, signal.SIGKILL) except ProcessLookupError: pass if cli.poll() is None: cli.kill() cli.communicate() PYTHON ``` 4. Inspect the printed CLI result and `launcher_alive_after_cli`. The observed result is shown under Relevant output or evidence. The empty ZIP is only an input carrier. The launcher ignores its contents, so the case needs no Android SDK, Java runtime, network service, or external analysis engine. The runtime used for the observed runs already had dependencies and compiled output present; steps 1 and 2 prepare an equivalent clean checkout. ### Expected behavior REA should stop and verify cleanup of its supported launcher and descendants when the 4 MiB decode output budget is exceeded, then return `resource_constraint`. `docs/apktool-resource-analysis.md:15` supports upstream wrappers and `java -jar` scripts, `docs/apktool-resource-analysis.md:66` describes owned workspace cleanup, and `docs/apktool-resource-analysis.md:74` specifies the 4 MiB budget and `resource_constraint` outcome. A shell interpreter appearing in the process table must not by itself turn a supported launcher into an unverifiable foreign process. Cleanup must continue to require ownership proof. ### Impact and scope Impact level: P2, supported. Output overflow and cancellation on the supported shell-launcher path leave an owned process alive after CLI exit and replace the useful original failure with an `UNKNOWN` cleanup error. The effect is bounded to interrupted live launches; successful decode behavior was not implicated. Workaround status: unverified. Explicitly stopping the recorded reproduction group removed the orphan and allowed the retained cleanup owner to verify cleanup, but no verified caller setting restored automatic cleanup and the original typed error for the supported launcher form. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0; macOS 27.0.1 arm64. The cancellation variant also reproduced on Node.js v24.21.0. ### Target and artifact context Synthetic executable `#!/bin/sh` Apktool launcher reporting version 2.7.0, an empty ZIP named `sample.apk`, isolated home and temporary directories, and an isolated Darwin module cache. The launcher emits 4,194,305 bytes and remains alive. The archive bytes and every fixture file are created inline in the reproduction. ### Interface CLI ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence ```text { "cli_exit": 1, "launcher_alive_after_cli": true, "launcher_parent_pid": 1, "result": { "code": "UNKNOWN", "message": "Apktool process cleanup failed" }, "stderr": "" } ``` A direct probe of the same supervision path reported `owned launcher command identity did not match`. The observed command began with `/bin/sh` followed by the selected launcher, while the retained expected command was the launcher path alone. After explicitly stopping that owned group, retrying cleanup returned `verified-cleanup`. ### Typed outcome or recovery expected Run the supplied output-budget case and confirm that the CLI returns `resource_constraint` and the recorded launcher and its descendants have exited before the command completes. Cancel the quiet variant after its PID file appears and confirm that cancellation also stops the owned processes. A control launcher that exits normally must still complete without cleanup errors. Keep mismatched or unverified process identities protected rather than accepting every interpreter-prefixed command. ### Analysis or suspected cause Source-derived reasoning: the supported command reaches `src/apktool/ApktoolProvider.ts:178`, then `src/process/ProviderProcess.ts:259` retains the launcher path as the expected command. `src/process/ProcessOwnershipIdentity.ts:19` and `src/process/ProcessOwnership.ts:372` reject its interpreter-prefixed live command before the token checks can establish ownership. This is consistent with the observed supervision diagnostic and surviving launcher. ### Additional context - [#1774](https://github.com/morluto/rea/issues/1774) concerns Windows batch-wrapper version-probe spawn rejection; this launcher passes version admission on macOS and fails cleanup after decode starts. - [#1945](https://github.com/morluto/rea/issues/1945) concerns missing or relative launcher configuration errors; this uses an absolute admitted launcher and fails during cleanup. - [#142](https://github.com/morluto/rea/issues/142), closed as not planned, concerns cleanup aborting on an unreadable group member; this concerns a readable leader command rejected because its shell interpreter prefixes the selected launcher. - [#3032](https://github.com/morluto/rea/issues/3032) concerns Ghidra JVM ownership reads during exit returning `sysctl_errno_22`; this involves a still-running shell launcher and a command-identity mismatch. - [#1232](https://github.com/morluto/rea/issues/1232) concerns capture-process SIGINT routing; this also reproduces through output overflow without an interrupt. - [#1191](https://github.com/morluto/rea/issues/1191) concerns npx terminal-group signal relay during Ghidra cancellation; this uses direct Node invocation and reproduces without signal relay. - [#2541](https://github.com/morluto/rea/issues/2541) concerns loss of a supervisor cleanup reason during error projection; this additionally leaves the owned launcher running. - [#2656](https://github.com/morluto/rea/issues/2656) concerns omitted retained capture-process coordinates in diagnostics; this directly observes a live launcher after CLI exit.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1208
No description provided.