Skip to content

[high] fix(radare2): run r2 command batches under radare2's sandbox - #110

Draft
elhoim wants to merge 1 commit into
calebevans:mainfrom
elhoim:fix/radare2-command-sandbox
Draft

[high] fix(radare2): run r2 command batches under radare2's sandbox#110
elhoim wants to merge 1 commit into
calebevans:mainfrom
elhoim:fix/radare2-command-sandbox

Conversation

@elhoim

@elhoim elhoim commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: high.
  • run_radare2 hands an arbitrary command batch to r2 -c with nothing disabled. The argv list is shell-safe, but commands is an r2 script, not an argument list — and r2's own command language can leave r2.
  • Verified against radare2 6.0.7: r2 -q -c 'iI;!touch MARKER' /bin/true — exactly the argv main builds today — creates MARKER. The escape is real, not theoretical.
  • Also reachable: #!pipe sh -c cmd (a second shell route), oo+ then w (reopens the target read-write and patches the evidence, though mulder never passes -w), and o /path (opens any other file the server can read).
  • Fix: enable r2's sandbox as the first command of every batch. r2 then refuses all of the above, and refuses to turn the sandbox back off.
  • Scope: src/mulder/server/tools/extract/misc.py, run_radare2 only — one constant, one 2-line helper, one call site changed. No shared abstraction, no other tool touched.

The bug

        proc = subprocess.run(
            ["r2", "-q", "-c", commands, target_path],
            ...

commands is caller-supplied (default "iI;iS;iz;afl"). Nothing constrains what it may contain.

The fix

R2_SANDBOX_PREFIX = "e cfg.sandbox=true;"


def _sandboxed(commands: str) -> str:
    """Prefix an r2 command batch with the irreversible sandbox setting."""
    return R2_SANDBOX_PREFIX + commands
            ["r2", "-q", "-c", _sandboxed(commands), target_path],

Why a command and not -e cfg.sandbox=true — this is the part worth reviewing, and it is load-bearing rather than stylistic:

$ r2 -q -e cfg.sandbox=true -c "iI" /bin/true
ERROR: Cannot open '/bin/true'

The flag form is applied before the target is opened, and r2 then refuses to open it — the tool would return nothing at all. Set as the first command, it takes effect once the file is already open.

Why appending it to a hostile string is still safe — r2 will not let the rest of the batch undo it:

$ r2 -q -c "e cfg.sandbox=true;e cfg.sandbox=false;!touch MARK" /bin/true
ERROR: Cannot disable sandbox
$ ls MARK
ls: cannot access 'MARK': No such file or directory

What the sandbox costs

Only the debugger, which mulder never uses — it does static triage. The default batch is byte-identical with and without the sandbox:

plain bytes    : 7531
sandboxed bytes: 7531
RESULT: stdout is BYTE-IDENTICAL for the default triage batch

The sandbox does emit INFO: Debugger commands disabled in sandbox mode on stderr; run_radare2 indexes proc.stdout, so nothing reaching the case DB changes.

Deliberately out of scope

  • run_subprocess's OSError path is labelled error_type="timeout", which is inaccurate. Noticed while reading, unrelated to this concern, left alone.
  • No interaction with [high] fix(helpers): stop run_cli_tool reporting a failed tool as a clean run #98 (fix/run-cli-tool-exit-code). That PR changes run_cli_tool, which run_strings/run_hashdeep/run_exiftool/run_ssdeep/run_pasco route through. run_radare2 calls subprocess.run directly and does not use run_cli_tool — verified by reading the module. No other open PR touches misc.py (checked all 15).
  • Exit-code handling for r2 is not addressed here; this PR is about what r2 is permitted to do, not how its failures are reported.

Verification

  • Live r2 evidence, all quoted above, produced against radare2 6.0.7 (birth: git.6.0.7 2025-11-27), not inferred from docs.
  • Five of the nine tests drive a real r2 and are skipif-guarded on shutil.which("r2"), so they exercise the claim wherever r2 exists and skip cleanly in CI where it does not. One of them asserts the unsandboxed escape actually happens first, so the sandbox assertion can never pass vacuously; another asserts the binary really is radare2.
  • uvx pre-commit run --all-files (new test staged first) → ruff, ruff-format, mypy all pass.
  • uv run --locked --extra dev pytest tests/ -q864 passed, nothing deselected, nothing skipped for environmental reasons.
  • Discriminating check — with misc.py restored to origin/main and the tests kept, they fail on their assertions, not on a missing symbol:
FAILED test_the_batch_runs_under_the_sandbox - AssertionError: iI;iS;iz;afl
FAILED test_the_sandbox_is_the_first_command_not_a_flag - AssertionError: assert 'iI' == 'e cfg.sandbox=true'
FAILED test_a_caller_cannot_smuggle_the_sandbox_setting_out - AssertionError: assert False
FAILED test_legitimate_r2_options_are_not_forbidden - ImportError: cannot import name '_sandboxed'
4 failed, 5 passed

The five that pass on both trees are the live-r2 evidence tests — they characterise r2's behaviour rather than mulder's, which is exactly why they belong on both sides.

A narrowness note

An earlier draft of this test suite asserted "-e" not in argv outright. That is too broad: r2 itself recommends -e options — on an ordinary binary it prints Relocs has not been applied. Please use `-e bin.relocs.apply=true` — and a guard like that would forbid following its advice. The assertion here is narrow instead: the sandbox is set first, and no argv element outside the batch carries cfg.sandbox, so a caller cannot displace it. test_legitimate_r2_options_are_not_forbidden pins that.

Context

Recovered from closed PR #87. The rejected outcome framework and classify_tool_exit are not reintroduced — this is a single-function change to one module. Branched fresh from current main (2e5432c); the closed branch was not revised in place.

🤖 Generated with Claude Code

run_radare2 passes `commands` straight to `r2 -c`. The argv list is
shell-safe, but `commands` is an r2 *script*, and r2's own command language
can leave r2: `!cmd` shells out, `#!pipe sh -c cmd` does the same by another
route, `oo+` reopens the target read-write so a batch can patch the evidence
it was asked to examine, and `o /path` opens any other file the server can
read.

Verified against radare2 6.0.7. `r2 -q -c 'iI;!touch MARKER' /bin/true` --
exactly the argv main builds -- creates MARKER. With the sandbox enabled it
does not.

Enable the sandbox as the first command of every batch. It has to be a
command rather than an `-e` flag: `r2 -e cfg.sandbox=true` is applied before
the target is opened and then refuses to open it ("Cannot open ..."), so the
tool would return nothing at all. Set as the first command it takes effect
once the file is open, and r2 refuses to turn it back off ("Cannot disable
sandbox"), so appending it to an attacker-chosen command string is not
something the rest of that string can undo.

The sandbox costs only the debugger, which mulder never uses -- it does
static triage. The default batch `iI;iS;iz;afl` produces byte-identical
stdout with and without it (7531 bytes on /bin/true).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant