fix(lifecycle-guard): close NUL-byte bypass in referenced-script scanning - #79794
fix(lifecycle-guard): close NUL-byte bypass in referenced-script scanning#79794ruochu88s wants to merge 1 commit into
Conversation
…ning `_read_referenced_script()` correctly refuses to `os.open()` a path containing an embedded NUL and reports "nothing to scan" (NousResearch#76762, NousResearch#77703). The refusal is right, but treating it as "nothing to scan" at the call site is exploitable. A POSIX shell DROPS NUL bytes from a word. So this command: bash danger\x00.sh is handed to the guard as the token `danger\x00.sh`, which cannot be opened and is skipped -- while the shell actually executes `danger.sh`. Any script the guard is meant to catch can therefore be smuggled past it by inserting one NUL byte into the path. Fix: when a referenced path cannot be read *and* contains a NUL, rescan the path the shell would actually resolve (NUL bytes removed) before falling through to the remote reader. This scans what will really run instead of trusting a token the shell never uses verbatim. The change is deliberately narrow: - only triggers when the read already failed AND a NUL is present, so the common path is untouched; - an all-NUL word strips to empty and is skipped rather than probing the cwd; - a stripped path that does not exist stays "nothing to scan", so no new false positives; - the remote-reader fallback and recursion limits are unchanged. Tests: 7 tests covering the smuggled `bash` form, the same smuggling through `source`, the unchanged plain-reference baseline, a safe script with a NUL not being flagged, a nonexistent stripped path not being flagged, an all-NUL word not crashing, and the guard never propagating ValueError. Verified all three security assertions fail when the rescan is reverted. Note on test style: the scripts are passed by name with `cwd=` rather than as absolute paths, because `shlex` treats backslashes as escapes and would silently mangle a Windows absolute path, making the assertions vacuous.
|
This was generated by AI during triage. Summary: Problems:
Solution: Checked against |
|
Closing as superseded by #80258, which fixes this whole bug class architecturally rather than per-callsite: path candidates are sanitized once at the ingestion boundary (NUL/empty/unexpandable tokens rejected before any OS call), text from any Your report and fix targeted a real member of this class — thank you. The per-callsite patches kept leaving sibling frames exposed (#76762 → #77703 → #77780 → #78256 each crashed one frame away from the previous fix), which is why we went with the boundary fix instead of merging the fragments individually. #80258 carries regression tests for the NUL-path, binary-callback, oversized-read, unset-HOME, and walk-crash cases plus an adversarial never-raises sweep. |
What does this PR do?
Closes a bypass in the lifecycle guard: a single NUL byte in a script path
makes the guard skip scanning a script that the shell will still execute.
_read_referenced_script()refuses toos.open()a path containing an embeddedNUL and reports "nothing to scan". That refusal is correct and was hardened
recently (#76762, #77703, and 9a9cf6a). But treating it as "nothing to scan" at
the call site is exploitable, because a POSIX shell drops NUL bytes from a
word:
So any script the guard is meant to catch can be smuggled past it by inserting
one NUL byte into the path. This is the exploitable half of the same NUL class:
upstream fixed "the guard must not crash", this fixes "the guard must not be
silently bypassed".
Why this approach
When a referenced path cannot be read and contains a NUL, rescan the path the
shell would actually resolve (NUL bytes removed) before falling through to the
remote reader. The guard should inspect what will really run, not a token the
shell never uses verbatim.
The change is deliberately narrow to avoid new false positives:
common path is completely untouched;
visitedset, and recursion depth limits areunchanged.
Stripping NULs earlier (in
_iter_referenced_shell_scripts) was the alternative,but that would silently rewrite tokens for every consumer of that iterator.
Handling it at the point of the failed read keeps the blast radius to exactly the
case that is currently exploitable.
Related Issue
Same class as #76762 / #77703, which addressed the crash. This addresses the
bypass that remains once the guard stops crashing:
fix(cron): tolerate NUL bytes in referenced-script paths at os.openadded the
ValueErrorcatch. I confirmed on currentmainthat noNUL-stripping rescan exists, so the bypass is still open.
I'm treating this as security-relevant, so I've kept the reproduction in the
tests rather than writing an exploit into the issue tracker. Happy to move the
discussion wherever maintainers prefer.
Type of Change
Changes Made
cron/lifecycle_guard.py_contains_unsafe_gateway_action(), when_read_referenced_script()returns no text and the path contains a NUL, retry the read against the
NUL-stripped path (the form the shell resolves) and honour an
unsafeverdict from it.
tests/cron/test_lifecycle_guard_nul_path_bypass.py— new, 7 tests.Checklist
Code
fix(lifecycle-guard):)test_nul_in_path_does_not_bypass_the_guardbash danger\x00.shform is detectedtest_nul_in_source_directive_is_also_coveredsourceis detectedtest_plain_unsafe_script_is_still_detectedtest_safe_script_with_nul_is_not_flaggedtest_nul_path_with_no_real_file_is_not_flaggedtest_all_nul_word_does_not_crashtest_guard_never_raises_on_nul_pathValueErrorI verified the fix is load-bearing: reverting the rescan turns exactly the
three security assertions red (
nul_in_path,nul_in_source,never_raises), while the false-positive guards stay green.A note on the test style
The scripts are passed by name with
cwd=rather than as absolute paths.shlextreats backslashes as escapes, so a Windows absolute path is silentlymangled (
C:\Users\...→C:Users...) and the resulting candidate neverresolves — which would make the assertions pass for the wrong reason. I hit this
while writing the tests; flagging it in case it's worth a note for other
Windows contributors.
Documentation & Housekeeping
at the fix site)
cli-config.yaml.example— N/A (no config keys)CONTRIBUTING.mdorAGENTS.md— N/Asemantics; the fix is pure string/path logic with no platform branches
all non-NUL input)