Skip to content

fix(cron): close NUL-padded script bypass in lifecycle guard (#77927) - #77928

Closed
Mengchee118 wants to merge 1 commit into
NousResearch:mainfrom
Mengchee118:fix/lifecycle-guard-nul-padded-bypass
Closed

fix(cron): close NUL-padded script bypass in lifecycle guard (#77927)#77928
Mengchee118 wants to merge 1 commit into
NousResearch:mainfrom
Mengchee118:fix/lifecycle-guard-nul-padded-bypass

Conversation

@Mengchee118

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes a guard bypass introduced by the #76762 binary check. That check
treats any NUL byte in a file's first chunk as "compiled binary, nothing to
scan" — but bash executes a text script straight past an embedded NUL, so a
single pad byte disables the scan while the script still runs its lifecycle
command.

# main
if b"\x00" in data:
    return None, False
p.write_bytes(b"#!/bin/bash\n# pad\x00\nhermes gateway restart\n")
scan(f"bash {p}")   # -> False on main: allowed, and bash runs it

This shape was blocked before #76762, so the crash fix traded a loud failure
for a silent one. "Contains a NUL" and "is a compiled binary" are different
questions, and the bypass lives in the gap between them.

Fix

Identify binaries by magic number — ELF, Mach-O (incl. byte-swapped and
universal/fat), PE/COFF, static archive, gzip, zip — with a shebang always
winning. NUL-bearing text is scanned with its NULs stripped, which can only
splice tokens together and never apart, so it fails closed. As a side benefit
hermes gateway rest\x00art inside a file stops evading the matcher.

Extensions are deliberately not consulted: a suffixless shell script must
still be scanned, and an oversized one must still fail closed.

Return values are unchanged ((None, False) for a binary), so this does not
conflict with the in-flight crash-class fixes to the same function.

Related Issue

Fixes #77927

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • 🔒 Security fix

Changes Made

  • cron/lifecycle_guard.py
    • new _BINARY_MAGICS tuple + _has_binary_magic() helper, documenting why a
      NUL census is the wrong test and why a shebang wins.
    • _read_referenced_script: match on signature; strip NULs from text instead
      of skipping the file.
    • size check moved before the strip — stripping shrinks the buffer, so
      checking afterwards let an oversized file slip under the threshold and skip
      the fail-closed branch. This was a real bug in the first cut of this patch,
      caught by the oversize test below.
  • tests/hermes_cli/test_gateway_restart_loop.py — six tests in
    TestLifecycleGuardModule.

How to Test

Reproduce on main:

from pathlib import Path
from cron.lifecycle_guard import (
    contains_gateway_lifecycle_command_or_referenced_script as scan,
)

p = Path("/tmp/padded.sh")
p.write_bytes(b"#!/bin/bash\n# pad\x00\nhermes gateway restart\n")
assert scan(f"bash {p}") is False    # BUG on main

q = Path("/tmp/padded2.sh")          # no shebang — also bypassed
q.write_bytes(b"# ok\n# pad\x00\nhermes gateway restart\n")
assert scan(f"bash {q}") is False    # BUG on main

Swap the payload for echo EXECUTED to confirm bash really runs these without
restarting anything. With this PR both become True.

Automated:

pytest tests/hermes_cli/test_gateway_restart_loop.py -q
  • 88 passed with this PR.
  • Reverting only cron/lifecycle_guard.py to main and keeping the new tests
    fails exactly 3 — the two bypass shapes and the oversize case — so the tests
    cover the defect rather than passing vacuously.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix
  • I've run pytest tests/hermes_cli/test_gateway_restart_loop.py -q and all tests pass (88 passed)
  • I've added tests for my changes
  • I've tested on my platform: macOS 26.6 (Apple Silicon)

Documentation & Housekeeping

  • I've updated relevant documentation (docstrings) — _has_binary_magic documents the reasoning
  • cli-config.yaml.example — N/A, no config keys
  • CONTRIBUTING.md / AGENTS.md — N/A, no architecture change
  • Cross-platform impact considered — PE/COFF (MZ) and ELF are in the magic
    list alongside Mach-O, so Windows and Linux binaries are recognised too;
    previously they were caught only incidentally by their NUL bytes
  • Tool descriptions/schemas — N/A, no tool behaviour change

Notes for reviewers

On the shebang-only shortcut. My first attempt keyed the check on a leading
#! and it was insufficient — a shebang-less file with a NUL on any line but the
first also executes. Measured bash file behaviour:

shape bash runs it?
shebang, NUL anywhere yes
no shebang, NUL on line 2+ yes
no shebang, NUL after the payload yes
no shebang, NUL on line 1 no — exit 126

Only the last is unrunnable via bash file, and even that one is executable via
. file. Hence magic-number detection rather than a shebang test.

On duplicates / overlap. The open PRs on this function (#77383, #77729,
#77806, #77894) all address the crash class. This is the bypass class. Worth
flagging specifically: #77383's _looks_like_script helper preserves the same
premise
— its docstring says "real scripts are NUL-free text; shebang or
not"
— so b"\x00" not in head keeps this hole open. I executed that helper
directly against the shapes above to confirm rather than reading it. Happy to
rebase on whichever crash fix lands first; this patch touches the decision, not
the return contract.

On NUL in argv vs in a file. A NUL inside a command-line argument is
harmless — execve(2) takes NUL-terminated strings, so such a command is
unrunnable. That is not true inside a script file, which is why this needs
fixing at the file-read boundary. Same byte, different channel, opposite
conclusion.

The NousResearch#76762 binary check treats any NUL byte in the first chunk as "compiled
binary, nothing to scan":

    if b"\x00" in data:
        return None, False

"Contains a NUL" and "is a compiled binary" are different questions, and the
gap between them is a guard bypass. `bash` executes a *text* script straight
past an embedded NUL, so one pad byte disables the entire scan while the
script still runs:

    #!/bin/bash
    # pad<NUL>
    hermes gateway restart

    scan("bash padded.sh")  -> False   (not blocked)
    bash padded.sh          -> executes the lifecycle command

This shape was blocked before NousResearch#76762, so the crash fix traded a loud failure
for a silent one.

Keying the check on a leading `#!` is not sufficient: a shebang-less file with
a NUL on any line but the first also executes normally. (A NUL on line 1 of a
shebang-less file is the one shape bash rejects, exit 126 — but that same file
is still executable via `. file`.)

Fix: identify binaries by MAGIC NUMBER — ELF, Mach-O (incl. byte-swapped and
universal/fat), PE/COFF, static archive, gzip, zip — with a shebang always
winning. A NUL-bearing *text* file is scanned with its NULs stripped;
stripping can only splice tokens together, never apart, so it fails closed.
File extensions are deliberately not consulted, so a suffixless shell script
is still scanned.

The size check now runs BEFORE the strip: stripping shrinks the buffer, so
checking afterwards would let an oversized file slip under the threshold and
skip the fail-closed branch. (Caught by
test_oversized_nul_bearing_text_still_fails_closed, which failed on the first
cut of this patch.)

Return values are unchanged, so this does not conflict with the in-flight
crash-class fixes to the same function.

Tests (tests/hermes_cli/test_gateway_restart_loop.py), 3 of which fail on main:

- test_nul_padded_script_is_still_scanned
- test_nul_padded_script_without_shebang_is_scanned
- test_oversized_nul_bearing_text_still_fails_closed
- test_elf_binary_is_not_scanned_as_script       (NousResearch#76762 stays fixed)
- test_macho_binary_is_not_scanned_as_script     (incl. fat binary)
- test_clean_script_without_lifecycle_command_not_blocked
@alt-glitch alt-glitch added type/bug Something isn't working comp/cron Cron scheduler and job management tool/terminal Terminal execution and process management P2 Medium — degraded but workaround exists labels Aug 3, 2026
Mengchee118 added a commit to Mengchee118/hermes-agent that referenced this pull request Aug 18, 2026
Upstream's rewritten guard sniffs the file prefix and bails on ANY NUL
byte, which makes the post-read magic-number check unreachable dead code
and silently reopens the NUL-padded script bypass (NousResearch#77928).

bash executes a text script straight past an embedded NUL and rejoins
the halves, so "contains a NUL" and "is a compiled binary" are different
questions. Bailing on the former lets a single pad byte -- or a keyword
split across one -- skip the scan entirely.

Both sites now use the same predicate. Added a warning comment so the
NUL test is not reintroduced at the early sniff.

Same defect shape as 2026-08-07: a clean cherry-pick left the restored
patch unreachable because upstream added a competing implementation
earlier in the flow. No merge conflict means textual independence, not
semantic compatibility.

Verified: 134 tests pass, 22/22 bypass harness, behavioural probe
confirms a NUL-padded referenced script is blocked again.
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #93411 (a9e4622) — your commit cherry-picked onto current main with authorship preserved in git log, plus a follow-up on our side: a newer main-side sniff fast-path also keyed on NUL-in-head and would have short-circuited your magic-number check, so it was re-keyed to executable magic only. Earliest submitter (Aug 3) — thanks for the thorough incident writeup and the size-before-strip catch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management P2 Medium — degraded but workaround exists tool/terminal Terminal execution and process management type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lifecycle_guard: NUL-padded text script bypasses the scan (regression from #76762 binary check)

3 participants