mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-22 06:20:56 +00:00
Studio: block sandbox bypass via command-position substitutions in terminal tool (#10860)
* Studio: block sandbox bypass via command-position substitutions in terminal tool Fixes https://github.com/unslothai/unsloth/issues/10835 A command substitution at command position synthesizes the executed word, so the blocked name never appears literally. $(ls /usr/bin/ | grep "^reb") runs reboot and $(ls /usr/bin/ | grep "^rm$") -rf ... runs rm, and neither contains the blocked word, so the blocklist missed both. With tool permission set to Run automatically the ask gate approves by design, so it executed. Backend-only change in _find_blocked_commands (studio/backend/core/inference/tools.py). Command-position $(...) and backticks now get their bodies screened: a blocked literal anywhere in the body is reported, and an executable enumeration (ls/find/compgen of bin dirs, echo/printf glob expansion) is treated as unknowable and blocked. Same screening covers brace groups, branch and loop bodies, find -exec payloads, sudo/env-style wrappers, and laundered variables (c=$(...); $c, printf -v c, c=reboot; $c). Argument-position substitutions like echo $(date) and single-binary lookups like $(which python) stay allowed. The diff is purely additive, 270 insertions and 0 deletions, confined to the blocklist plus tests. Nothing else in the execution path changed. Testing: added 18 blocked cases (both issue repros verbatim) and 14 allowed cases in test_sandbox_tools.py. Full file plus test_windows_bash_shell.py: 349 passed, 4 skipped. ruff check and the repo formatter pass. * Keep the substitution screen off ordinary commands, and off the CPU Six issues found while running the new screen against a corpus of everyday terminal commands and of bypasses verified to really execute, on the posix lexer and on both Windows lexer paths. The screen is a hard block, not a prompt, so a false positive is a dead command. Four of these produced one: - A wrapper was allowed up to four arbitrary words before its operand, so anything within reach of a timeout/nohup/xargs/env read as command position: `timeout 60 python train.py --data $(ls -d data/*)` was refused. A wrapper forwards to ONE operand, so only its own flags, assignments and numeric arguments may sit in front of it now. - A wrapper option taking a separate value has to swallow that value too, or `xargs -P $n` reads the `$n` as the command. The regex cannot pin this down on its own - it just backtracks to the shorter reading - so it is checked against the existing flag table in code. - The assignment screen was missing the arithmetic guard its sibling regexes carry, so `sec=$((60*5)); timeout $sec make test` was refused over a value that can only ever be a number. - Neither the separators nor the bodies were quote-aware. Bash expands nothing inside single quotes and an expansion inside double quotes is a word the outer command receives, so `sed 's|x|$(ls)|' f` and `echo "check if $(ls -1 *.py | wc -l) files"` both ran nothing and were both refused. The body scan also matched `.` - the synonym for `source` - against any dot in a filename, reporting `$(cat .env)` as "Blocked command(s) for safety: .". The remaining two let real commands through, or through the CPU: - Assignment separators were narrower than execution separators, so a launder behind a keyword, a subshell or another assignment was never collected: `if true; then c=$(ls /usr/bin|grep rm); fi; $c`, `(c=$(...); $c)` and `x=1 c=$(...); $c` all delete. So does `"$c"`, the recommended spelling, which the screen did not cover. - Each site costs a span walk over the rest of the line and this function has no length cap, unlike the ask-gate screen. `";$(" * 10000` went from 0.1s before the screen existed to over 30s after it, on the request thread. Past 64 sites the screen now refuses instead of reading, which fails closed. Verified against the merge base on a 125-command corpus: 36 bypasses newly blocked (up from 29), 0 everyday commands newly refused (down from 10), same verdicts under the posix and both Windows lexer paths. The 12 failures in the wider backend suite are identical before and after. * Tighten the comments on the substitution screen Opening comment-reduction pass. Comment lines on the added code drop from 95 to 54 in tools.py and 30 to 17 in the tests, with no code change (AST-gated). Stopped short of a deeper cut: what survives is one line per boundary naming the command that regressed when the boundary sat elsewhere, which is the fact a future reader needs in order not to move it back. * Reach the command word by the four routes the site scan did not walk All four were reported by review and all four really delete: each was run against a stand-in rm on PATH and executed it. - timeout's DURATION is "a floating point number with an optional suffix: 's', 'm', 'h' or 'd'" (timeout --help), but the wrapper argument class took bare integers only, so `timeout 1s $(ls /usr/bin | grep '^rm$')` was never screened. - The wrapper option run was capped at 4 per wrapper and 3 wrappers. `env --help` documents `[OPTION]...`, so a fifth option made the whole site regex fail rather than refuse: `env -u A -u B -u C -u D -u E $(...)` ran. Both repetitions are unbounded now, which is safe because the parts cannot overlap (a wrapper word never matches the argument class) and each repetition must consume a mandatory space, so there is nothing to backtrack. Measured linear to 180 KB, including the near-miss shape that never reaches a `$(`. - Redirections may precede the command word, so `>out.log $(...)` runs the substitution's output. The token walker already knew this; the site scan did not. - A case arm's `)` is followed directly by the commands to run, so `case x in x) $(...) -rf victim;; esac` executes. Added as its own site kind rather than by putting `)` in the separator class, which would refuse an ordinary `echo $(date) $(ls /tmp)`: an arm's `)` is one that does not close a substitution, inside a case ... in ... esac region. Corpus unchanged at 0 regressions and 36 bypasses blocked, on the posix lexer and both Windows lexer paths, plus 10 new tests (5 blocked, 5 allowed controls). * Close the four routes the first round of site fixes left open All four reported by review, all four verified here against a stand-in rm on PATH before changing anything. - timeout's DURATION is a float, and float there means strtod, so `timeout 1e1` and `timeout 1.5e1s` are valid and both ran. The operand class now takes the scientific spellings. - `coproc [NAME] command` (bash `help coproc`) runs COMMAND asynchronously, so its operand is a command position. Added with its optional NAME matched only in the coproc branch, where it cannot widen any other separator. - A case arm runs a laundered variable as readily as a substitution, but the arm handling was wired only into the substitution scan, so `c=$(ls /usr/bin|grep '^rm$'); case x in x) $c -rf victim;; esac` still deleted. The arm walker now takes the pattern to anchor, and the variable scan passes its own. - Bash concatenates adjacent fragments into one word, so `x=$(printf r); ${x}m` runs rm. The scan required the expansion to END the word. Trailing word characters are now part of the same word, and a precise literal is only reported when the expansion is the whole word: glued to more text it is a fragment of an unknown name, not that name. Corpus unchanged at 0 regressions and 36 bypasses blocked across the posix and both Windows lexer paths; timing still linear to 260 KB. 7 new tests. * Follow the command word through spacing, quoting and concatenation Four more reported by review, all four verified against a stand-in rm before changing anything, plus a fifth found while testing them. - Bash allows whitespace between a redirection operator and its target, so `> out.log $(...)` is the same command the attached spelling was fixed for. The target and the space after it are mandatory in the prefix, which is what keeps `>$(ls) cmd` out: a substitution that IS the target names a file. - `env -C/--chdir DIR` takes a separate value, so the DIR read as the command. - A double-quoted backtick expands exactly like `"$(...)"` and opens a command word the same way. - Bash concatenates adjacent fragments into one word, so `"$(printf r)"m` and the bare `$(printf r)m` both run rm while the body reads benign. A substitution with more word text after it now fails closed. That also closes `$(echo reb)oot`, which the corpus had carried as open since the first round. The env fix is deliberately NOT in _WRAPPER_VALUE_FLAGS_BY_CMD. Putting it there broke two real detections, caught by test_permission_mode: that table also drives the high-risk classifier, where `-C /` is exactly what makes a following relative `etc/passwd` sensitive. And `-S`/`--split-string` is left out entirely, because its operand is split into the argv that RUNS - consuming it as a value lost the command, and `env -S 'rm -rf /tmp/x'` went unblocked. Corpus now 0 regressions and 37 bypasses blocked across the posix and both Windows lexer paths; timing linear to 260 KB; 10 new tests. --------- Co-authored-by: danielhanchen <danielhanchen@gmail.com>
This commit is contained in:
parent
61eefe60cd
commit
a09f3765ba
2 changed files with 626 additions and 0 deletions
|
|
@ -1549,6 +1549,118 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
)
|
||||
blocked.update(re.findall(pattern, lowered))
|
||||
|
||||
# A substitution at command position synthesizes the executed word, so `$(ls /usr/bin | grep
|
||||
# "^reb")` never reaches the scan above; screen the body instead. A variable launders the same
|
||||
# shapes (`c=$(...); $c`), so bindings are collected first - bodies reference them too.
|
||||
if _BLOCKED_COMMANDS:
|
||||
quote_states = _shell_quote_states(command)
|
||||
|
||||
def _site_expands(match: "re.Match") -> bool:
|
||||
"""Whether bash expands this substitution AT command position, rather than inside an
|
||||
argument the outer command already owns."""
|
||||
opener = match.end() - 1
|
||||
state = quote_states[opener] if opener < len(quote_states) else ""
|
||||
if state in ("'", "$'", _ESCAPED_CHAR_STATE):
|
||||
return False # bash substitutes nothing here
|
||||
if state == '"':
|
||||
# Double quotes DO substitute, but into a word the outer command receives - unless
|
||||
# the quote opens at the site itself (`"$(...)"`, `"`...`"`), which is a command
|
||||
# word.
|
||||
return match.group(0).endswith(('"$(', '"`'))
|
||||
return True
|
||||
|
||||
# Name -> the literal it holds, or None when unscreenable. `$C` is not `$c`.
|
||||
laundered: "dict[str, str | None]" = {}
|
||||
for assign in _SUBST_ASSIGN_RE.finditer(command):
|
||||
if assign.group(1) in laundered or not _site_expands(assign):
|
||||
continue
|
||||
# An enumerating body is unknowable even when a literal (the grep selector) shows.
|
||||
opener = assign.end() - 1 # `(` of `$(`, or the backtick
|
||||
assign_body = _command_subst_body(command, opener).lower()
|
||||
if _SUBST_ENUMERATES_COMMANDS_RE.search(assign_body):
|
||||
laundered[assign.group(1)] = None
|
||||
continue
|
||||
found = sorted(_blocked_body_words(assign_body))
|
||||
laundered[assign.group(1)] = found[0] if found else None
|
||||
for printf_v in _PRINTF_V_ASSIGN_RE.finditer(command):
|
||||
laundered.setdefault(printf_v.group(1), None)
|
||||
for literal in _ASSIGN_BLOCKED_LITERAL_RE.finditer(command):
|
||||
name, value = literal.group(1), literal.group(2).strip("\"'")
|
||||
first = value.split()[0] if value.split() else ""
|
||||
base = os.path.basename(first)
|
||||
stem, ext = os.path.splitext(base)
|
||||
if ext.lower() in {".exe", ".com", ".bat", ".cmd"}:
|
||||
base = stem
|
||||
if base.lower() in _BLOCKED_COMMANDS:
|
||||
laundered.setdefault(name, base.lower())
|
||||
laundered_refs = "|".join(sorted({re.escape(n) for n in laundered}, key = len, reverse = True))
|
||||
laundered_in_body_pattern = (
|
||||
rf"\$(?:{laundered_refs}|\{{(?:{laundered_refs})\}})" if laundered_refs else None
|
||||
)
|
||||
sites = [
|
||||
site
|
||||
for site in list(_SUBST_AT_CMD_SITE_RE.finditer(command))
|
||||
+ list(_SUBST_EXEC_DIRECTIVE_RE.finditer(command))
|
||||
+ _case_arm_sites(command, quote_states)
|
||||
if _site_expands(site)
|
||||
]
|
||||
if len(sites) > _MAX_SUBST_SITES:
|
||||
# Refuse rather than read: each site costs a span walk to end of line, so `";$(" * 10000`
|
||||
# took over 30s on the request thread. Fail CLOSED, never open.
|
||||
blocked.add(_BLOCKED_SYNTHESIZED_COMMAND)
|
||||
sites = []
|
||||
for site in sites:
|
||||
opener = site.end() - 1 # `(` of `$(`, or the backtick
|
||||
body = _command_subst_body(command, opener)
|
||||
lowered_body = body.lower()
|
||||
blocked.update(_blocked_body_words(lowered_body))
|
||||
if _subst_is_word_fragment(command, opener):
|
||||
# `"$(printf r)"m` concatenates into `rm`. The body alone is benign, so nothing
|
||||
# above can name the command: only the fact that it is unknowable is reportable.
|
||||
blocked.add(_BLOCKED_SYNTHESIZED_COMMAND)
|
||||
elif _SUBST_ENUMERATES_COMMANDS_RE.search(lowered_body):
|
||||
blocked.add(_BLOCKED_SYNTHESIZED_COMMAND)
|
||||
elif laundered_in_body_pattern is not None and re.search(
|
||||
laundered_in_body_pattern, body
|
||||
):
|
||||
# `$(echo $c)`: the body runs over laundered text, so its output is unknowable.
|
||||
blocked.add(_BLOCKED_SYNTHESIZED_COMMAND)
|
||||
if laundered_refs:
|
||||
# Named, not positional: a group added in front would silently renumber these.
|
||||
var_word = (
|
||||
r"(?P<var>\"?\$(?:(?P<bare>"
|
||||
+ laundered_refs
|
||||
+ r")|\{(?P<braced>"
|
||||
+ laundered_refs
|
||||
+ r")\})"
|
||||
# The closing quote ends the word too, or `"$c"` - the recommended spelling -
|
||||
# walks through the screen the bare one is caught by. Trailing word characters
|
||||
# are part of the SAME word: bash concatenates the fragments, so `${x}m` runs
|
||||
# `rm` when x holds `r`, and requiring the expansion to end the word let that
|
||||
# through.
|
||||
r"\"?[\w.\-]*)(?=\s|$|[;&|)\]}])"
|
||||
)
|
||||
var_hits = list(
|
||||
re.finditer(_SUBST_CMD_SEP + r"\s*" + _SUBST_WRAPPER_RUN + var_word, command)
|
||||
)
|
||||
# A case arm runs a laundered variable just as readily as a substitution.
|
||||
var_hits += _case_arm_sites(
|
||||
command, quote_states, re.compile(r"\)\s*" + _SUBST_WRAPPER_RUN + var_word)
|
||||
)
|
||||
for hit in var_hits:
|
||||
if _is_wrapper_flag_operand(command, hit.start("var")):
|
||||
continue # `xargs -P $n`: operand of the option, not the command
|
||||
name = hit.group("bare") or hit.group("braced")
|
||||
precise = laundered.get(name)
|
||||
# A precise literal only stands when the expansion IS the whole word; glued to
|
||||
# more text it is a fragment of an unknown name (`${x}m`), not that name.
|
||||
if precise is not None and hit.group("var").rstrip('"').endswith(
|
||||
("$" + name, "{" + name + "}")
|
||||
):
|
||||
blocked.add(precise)
|
||||
else:
|
||||
blocked.add(_BLOCKED_SYNTHESIZED_COMMAND)
|
||||
|
||||
# Nested shell invocations (bash -c, cmd /c): on a -c/-/c flag, look back for a shell name (skipping flags) and
|
||||
# recursively scan the nested command string.
|
||||
_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "csh", "tcsh", "fish"}
|
||||
|
|
@ -4953,6 +5065,223 @@ _SHELL_C_INTERPRETERS = frozenset({"sh", "bash", "zsh", "dash", "ksh", "fish", "
|
|||
_COMMAND_SUBST_AT_CMD_RE = re.compile(
|
||||
r"(?:^|[;&|\n(]|&&|\|\|)\s*(?:[A-Za-z_]\w*=[^\s;&|()]*\s+)*(?:\$\(|`)"
|
||||
)
|
||||
# Command-position separators for the screens below. `:` is absent on purpose: its arguments are
|
||||
# expanded but never executed.
|
||||
# `coproc [NAME] command` (bash `help coproc`) executes COMMAND asynchronously, so its operand is
|
||||
# a command position; the optional NAME is matched only here, where it cannot widen anything else.
|
||||
_SUBST_CMD_SEP = (
|
||||
r"(?:^|[;&|\n({]|&&|\|\||\b(?:then|do|else|elif|if|while|until)\b\s*"
|
||||
r"|\bcoproc\b\s*(?:[A-Za-z_]\w*\s+)?|!\s*)"
|
||||
)
|
||||
# Wrappers forward to their operand. Repetitions are bounded and flat: unbounded nesting caused
|
||||
# catastrophic backtracking. A wrapper's arguments are flags, `VAR=value` and bare numbers (the
|
||||
# shapes _exec_child_index steps over), NOT "any word" - its first plain word IS its command, and
|
||||
# accepting arbitrary words hard-blocked `timeout 60 python train.py --data $(ls -d data/*)`. An
|
||||
# option taking a separate value swallows it too, or `xargs -P $n` reads the `$n` as the command.
|
||||
_ALL_WRAPPER_VALUE_FLAGS = frozenset(
|
||||
flag for flags in _WRAPPER_VALUE_FLAGS_BY_CMD.values() for flag in flags
|
||||
) | {
|
||||
# `env -C DIR` / `--chdir DIR` (env --help) takes a separate value, so an unconsumed DIR
|
||||
# reads as the command and the real one behind it is never reached. Added HERE rather than
|
||||
# to _WRAPPER_VALUE_FLAGS_BY_CMD because that table also drives the high-risk classifier,
|
||||
# where `-C /` is exactly what makes a following relative `etc/passwd` sensitive.
|
||||
#
|
||||
# `-S`/`--split-string` is deliberately absent: its operand is split into the argv that RUNS,
|
||||
# so consuming it as a value loses the command (`env -S 'rm -rf /tmp/x'` went unblocked).
|
||||
"-C",
|
||||
"--chdir",
|
||||
}
|
||||
_WRAPPER_VALUE_FLAG_ALT = "|".join(
|
||||
re.escape(flag) for flag in sorted(_ALL_WRAPPER_VALUE_FLAGS, key = len, reverse = True)
|
||||
)
|
||||
# A bare number covers `nice 5`; timeout's DURATION is "a floating point number with an optional
|
||||
# suffix: 's', 'm', 'h' or 'd'" (timeout --help). Floating point there means strtod, so the
|
||||
# scientific spellings are valid too and `timeout 1e1 $(...)` really runs - every form this does
|
||||
# not accept is a site that goes unscreened.
|
||||
_SUBST_WRAPPER_ARG = (
|
||||
r"(?:(?:" + _WRAPPER_VALUE_FLAG_ALT + r")\s+[^\s;&|()]+"
|
||||
r"|-[^\s;&|()]*|[A-Za-z_]\w*=[^\s;&|()]*"
|
||||
r"|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[smhd]?)"
|
||||
)
|
||||
# Both repetitions are UNBOUNDED. `env --help` documents `[OPTION]...` before COMMAND, so any cap
|
||||
# is a number of options an attacker just exceeds to make the whole site regex fail - opening, not
|
||||
# closing. Unbounded is safe here because the parts cannot overlap: a wrapper word never matches
|
||||
# _SUBST_WRAPPER_ARG (which needs `-`, `VAR=` or a digit) and every repetition must consume a
|
||||
# mandatory `\s+`, so no token can be split across two of them and there is nothing to backtrack.
|
||||
_SUBST_WRAPPER_RUN = (
|
||||
r"(?:(?:env|command|builtin|exec|time|nohup|nice|setsid|stdbuf|timeout|ionice|chroot"
|
||||
r"|setpriv|sudo|doas|su|xargs)\s+(?:" + _SUBST_WRAPPER_ARG + r"\s+)*)*"
|
||||
)
|
||||
# Redirections may precede the command word (`>out.log $(...)` runs the substitution's output),
|
||||
# and the token walker already treats them as leaving command position intact. Bash allows
|
||||
# whitespace between the operator and its target, so `> out.log $(...)` is the same command.
|
||||
#
|
||||
# The target and the space after it are both MANDATORY, which is what keeps `>$(ls) cmd` out: a
|
||||
# substitution that IS the redirection target names a file, it is not run, and an optional target
|
||||
# would let this prefix match nothing and read that `$(` as the command word.
|
||||
_SUBST_REDIR_PREFIX = r"(?:\d*(?:>>|<<<|<<|>&|<&|>|<)\s*[^\s;&|()]+\s+)*"
|
||||
# VAR=x prefixes are stepped over; `(?!\()` drops arithmetic, which is a number, not a command.
|
||||
# A double-quoted backtick expands exactly like `"$(...)"`, so it opens a command word too.
|
||||
_SUBST_OPENER = r"(?:\"\$\((?!\()|\$\((?!\()|\"`|`)"
|
||||
_SUBST_CMD_WORD_PREFIX = (
|
||||
_SUBST_REDIR_PREFIX
|
||||
+ r"(?:[A-Za-z_]\w*=[^\s;&|()]*\s+)*"
|
||||
+ _SUBST_REDIR_PREFIX
|
||||
+ _SUBST_WRAPPER_RUN
|
||||
)
|
||||
_SUBST_AT_CMD_SITE_RE = re.compile(
|
||||
_SUBST_CMD_SEP + r"\s*" + _SUBST_CMD_WORD_PREFIX + _SUBST_OPENER,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# A `case` arm's `)` is followed directly by the commands to run, so it is a command position -
|
||||
# but only inside `case ... in ... esac`, and only for a `)` that is not closing a substitution.
|
||||
# Treating every `)` as a separator would refuse an ordinary `echo $(date) $(ls /tmp)`.
|
||||
_SUBST_CASE_ARM_RE = re.compile(
|
||||
r"\)\s*" + _SUBST_CMD_WORD_PREFIX + _SUBST_OPENER,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CASE_REGION_RE = re.compile(r"\bcase\b.*?\bin\b(.*?)(?:\besac\b|$)", re.IGNORECASE | re.DOTALL)
|
||||
# The words after a find/fd -exec directive are the executed argv, so `find . -exec $(...) \;`
|
||||
# runs whatever the body prints.
|
||||
_SUBST_EXEC_DIRECTIVE_RE = re.compile(
|
||||
r"(?:-exec(?:dir)?|--exec(?:-batch)?|-ok(?:dir)?)\s+" + _SUBST_CMD_WORD_PREFIX + _SUBST_OPENER,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# An assignment binds wherever it appears, so these three lead on any separator OR whitespace:
|
||||
# under `(?:^|[;&|\n])` a launder behind a keyword or subshell went uncollected and ran. Widening
|
||||
# only COLLECTS; a name is reported only where it is executed at command position.
|
||||
_ASSIGN_LEAD = (
|
||||
r"(?:^|[\s;&|\n(){])(?:(?:export|local|readonly)\s+|(?:declare|typeset)(?:\s+[-\w+]+)*\s+)?"
|
||||
)
|
||||
# The name is laundered, its content unscreenable. `(?!\()` excludes arithmetic, whose inclusion
|
||||
# hard-blocked `sec=$((60*5)); timeout $sec make test`.
|
||||
_SUBST_ASSIGN_RE = re.compile(_ASSIGN_LEAD + r"([A-Za-z_]\w*)=[\"']?(?:\$\((?!\()|`)")
|
||||
# `printf -v NAME` runs nothing, but a later `$NAME` at command position executes it.
|
||||
_PRINTF_V_ASSIGN_RE = re.compile(_ASSIGN_LEAD + r"\bprintf\s+-v\s+([A-Za-z_]\w*)\b")
|
||||
# `c=reboot` makes `$c` at command position run reboot. Only the value's first word counts, and
|
||||
# arrays never match (the value class excludes parens).
|
||||
_ASSIGN_BLOCKED_LITERAL_RE = re.compile(_ASSIGN_LEAD + r"([A-Za-z_]\w*)=([^\s;&|()]+)")
|
||||
|
||||
|
||||
def _command_subst_body(command: str, opener: int) -> str:
|
||||
"""The body text of the `$(...)` (``opener`` = index of ``(``) or backtick (``opener`` = index
|
||||
of the backtick) substitution opening there. A backtick span ends at the next unescaped
|
||||
backtick; an unterminated span of either kind runs to the end of the string."""
|
||||
if command[opener] == "`":
|
||||
closer = opener + 1
|
||||
while True:
|
||||
closer = command.find("`", closer)
|
||||
if closer < 0:
|
||||
return command[opener + 1 :]
|
||||
if command[closer - 1] != "\\":
|
||||
return command[opener + 1 : closer]
|
||||
closer += 1
|
||||
end = _substitution_span(command, opener - 1)
|
||||
return command[opener + 1 : end if end >= len(command) else end - 1]
|
||||
|
||||
|
||||
# For an enumerated command with no literal name to report.
|
||||
_BLOCKED_SYNTHESIZED_COMMAND = "command substitution"
|
||||
# Primitives that enumerate executable names inside a substitution body. A lookup for one literal
|
||||
# binary (`$(which python)`) stays out: that name is visible in the body itself.
|
||||
_SUBST_ENUMERATES_COMMANDS_RE = re.compile(
|
||||
r"(?:\bcompgen\b|\b(?:ls|dir|find)\b|\b(?:echo|printf)\b[^\n;&|]*[*?[])"
|
||||
)
|
||||
# Sites read before refusing instead: each costs a span walk and this function has no length cap.
|
||||
_MAX_SUBST_SITES = 64
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize = 4)
|
||||
def _blocked_body_word_pattern_for(words: "frozenset[str]") -> "re.Pattern":
|
||||
"""A blocked name appearing anywhere in a command-substitution body.
|
||||
|
||||
Looser than the command-position scan on purpose: anything in the body may become the word
|
||||
that runs. `.` (the synonym for `source`) cannot share that boundary, because any dot in a
|
||||
filename then matches it - `$(cat .env)` was refused as "Blocked command(s) for safety: .".
|
||||
Punctuation names get the strict boundary, the only place they can run anyway.
|
||||
"""
|
||||
word_like = sorted(w for w in words if w[:1].isalnum() or w[:1] == "_")
|
||||
punctuation = sorted(w for w in words if w not in set(word_like))
|
||||
parts = []
|
||||
if word_like:
|
||||
alt = "|".join(re.escape(w) for w in word_like)
|
||||
parts.append(rf"(?:^|[^\w./\\-])(?:[\w./\\-]*/)?({alt})(?:\.(?:exe|com|bat|cmd))?\b")
|
||||
if punctuation:
|
||||
alt = "|".join(re.escape(w) for w in punctuation)
|
||||
parts.append(rf"(?:^|[;&|`\n(])\s*({alt})(?=\s)")
|
||||
return re.compile("|".join(parts) if parts else r"(?!)")
|
||||
|
||||
|
||||
def _subst_is_word_fragment(command: str, opener: int) -> bool:
|
||||
"""Whether more of the command WORD follows the substitution opening at ``opener``.
|
||||
|
||||
Bash concatenates adjacent fragments, so `$(printf r)m` and `"$(printf r)"m` both run `rm`.
|
||||
The body is benign in each, so a scan that only reads bodies reports nothing; what is
|
||||
reportable is that the executed word cannot be known.
|
||||
"""
|
||||
if command[opener] == "`":
|
||||
closer = command.find("`", opener + 1)
|
||||
end = len(command) if closer < 0 else closer + 1
|
||||
else:
|
||||
end = _substitution_span(command, opener - 1)
|
||||
if end < len(command) and command[end] == '"':
|
||||
end += 1 # the `"` closing a `"$(...)"` / `"`...`"` word
|
||||
return end < len(command) and (command[end].isalnum() or command[end] in "_.-/")
|
||||
|
||||
|
||||
def _case_arm_sites(
|
||||
command: str,
|
||||
quote_states: "list[str]",
|
||||
pattern: "re.Pattern | None" = None,
|
||||
) -> "list[re.Match]":
|
||||
"""Matches of ``pattern`` anchored on a `case` arm's `)`, which is a command position.
|
||||
|
||||
Only inside `case ... in ... esac`, and only for a `)` that does not close a substitution -
|
||||
otherwise the `)` of an ordinary `echo $(date) $(ls /tmp)` would read as an arm and refuse it.
|
||||
``pattern`` defaults to the substitution-site one; the variable-execution scan passes its own,
|
||||
since a laundered `$c` runs in an arm just as readily as a substitution does.
|
||||
"""
|
||||
pattern = pattern if pattern is not None else _SUBST_CASE_ARM_RE
|
||||
if not _CASE_REGION_RE.search(command):
|
||||
return []
|
||||
# Every `)` that closes an unquoted `$(`: those belong to the substitution, not to an arm.
|
||||
closers: "set[int]" = set()
|
||||
for opener in re.finditer(r"\$\((?!\()", command):
|
||||
at = opener.start()
|
||||
if at < len(quote_states) and quote_states[at] in ("'", "$'", _ESCAPED_CHAR_STATE):
|
||||
continue
|
||||
end = _substitution_span(command, at)
|
||||
if end <= len(command):
|
||||
closers.add(end - 1)
|
||||
sites = []
|
||||
for region in _CASE_REGION_RE.finditer(command):
|
||||
start, stop = region.start(1), region.end(1)
|
||||
for site in pattern.finditer(command, start, stop):
|
||||
if site.start() not in closers:
|
||||
sites.append(site)
|
||||
return sites
|
||||
|
||||
|
||||
def _is_wrapper_flag_operand(command: str, start: int) -> bool:
|
||||
"""Whether the word at ``start`` is the VALUE of a wrapper option rather than a command.
|
||||
|
||||
`xargs -P $n` gives `-P` the `$n`, which is never the command the wrapper forwards to. Checked
|
||||
here, not in the regex: a regex consuming the option and its value backtracks to the shorter
|
||||
reading, and the atomic group that would pin it needs 3.11 while this file supports 3.9.
|
||||
"""
|
||||
preceding = command[:start].split()
|
||||
return bool(preceding) and preceding[-1] in _ALL_WRAPPER_VALUE_FLAGS
|
||||
|
||||
|
||||
def _blocked_body_words(body: str) -> "set[str]":
|
||||
"""Every blocked name the substitution body ``body`` (already lowercased) mentions."""
|
||||
found: "set[str]" = set()
|
||||
for groups in _blocked_body_word_pattern_for(frozenset(_BLOCKED_COMMANDS)).findall(body):
|
||||
if isinstance(groups, str):
|
||||
groups = (groups,)
|
||||
found.update(g for g in groups if g)
|
||||
return found
|
||||
|
||||
|
||||
# A command substitution appearing anywhere ($(...) that is not arithmetic, or a backtick). Used to catch a
|
||||
# substitution stashed in a variable (x=`...`) that a later dynamic exec runs, which never surfaces as literal text.
|
||||
|
|
|
|||
|
|
@ -832,6 +832,75 @@ class TestBashBlocklistPosition:
|
|||
# Only the long spellings carry an attached command; -x belongs to too
|
||||
# many other utilities to read its neighbour as one.
|
||||
pytest.param("grep -x rm file.txt", id = "short_flag_neighbour_not_read_as_command"),
|
||||
# `$(which python)` leaves the executed name visible in the body; the rest only
|
||||
# feed text to their outer command.
|
||||
pytest.param("$(which python) script.py", id = "which_lookup_allowed"),
|
||||
pytest.param("env $(which python) script.py", id = "wrapper_which_lookup_allowed"),
|
||||
pytest.param("echo $(date)", id = "arg_position_subst_allowed"),
|
||||
pytest.param("echo $(ls /tmp)", id = "arg_position_listing_allowed"),
|
||||
pytest.param("$(echo hello)", id = "subst_benign_literal_allowed"),
|
||||
pytest.param("FOO=$(date) echo hi", id = "assignment_value_subst_allowed"),
|
||||
pytest.param("echo $((1+2))", id = "arithmetic_expansion_allowed"),
|
||||
# Same slots, benign bodies.
|
||||
pytest.param("{ echo hi; }", id = "brace_group_benign_allowed"),
|
||||
pytest.param("if true; then echo hi; fi", id = "branch_body_benign_allowed"),
|
||||
pytest.param("for f in a b; do echo $f; done", id = "loop_var_benign_allowed"),
|
||||
pytest.param("find . -name x -exec echo {} \\;", id = "find_exec_benign_allowed"),
|
||||
pytest.param("c=hello; echo $c", id = "benign_var_arg_allowed"),
|
||||
pytest.param("c=reboot; echo $c", id = "blocked_var_arg_allowed"),
|
||||
pytest.param("PATH=/usr/bin; ls", id = "path_assignment_allowed"),
|
||||
# A wrapper is spent on its first plain word, so nothing further along the line is
|
||||
# at command position. Reading any word within reach of one refused these.
|
||||
pytest.param(
|
||||
"timeout 60 python train.py --data $(ls -d data/*)",
|
||||
id = "wrapper_spent_on_its_own_command_allowed",
|
||||
),
|
||||
pytest.param(
|
||||
"stamp=$(date +%F); nohup ./run.sh $stamp &",
|
||||
id = "laundered_var_as_wrapper_argument_allowed",
|
||||
),
|
||||
pytest.param(
|
||||
"out=$(pwd); timeout 300 ./run.sh $out", id = "laundered_var_as_operand_allowed"
|
||||
),
|
||||
pytest.param(
|
||||
"v=$(date); env FOO=1 ./run.sh $v", id = "laundered_var_after_env_assign_allowed"
|
||||
),
|
||||
# `-P` takes a separate value, so the `$n` is that value, not the command.
|
||||
pytest.param(
|
||||
"n=$(nproc); xargs -P $n -I{} echo {}", id = "wrapper_value_flag_operand_allowed"
|
||||
),
|
||||
# Arithmetic can never hold a command name, in an assignment either.
|
||||
pytest.param(
|
||||
"sec=$((60*5)); timeout $sec make test", id = "arithmetic_assignment_allowed"
|
||||
),
|
||||
# Single quotes expand nothing; double quotes expand into an argument, not a command.
|
||||
pytest.param(
|
||||
'echo "check if $(ls -1 *.py | wc -l) files"',
|
||||
id = "subst_inside_double_quoted_argument_allowed",
|
||||
),
|
||||
pytest.param("sed 's|x|$(ls)|' f", id = "subst_inside_single_quotes_allowed"),
|
||||
# A `)` is a command position only in a case arm. Treating every one as a separator
|
||||
# would refuse these.
|
||||
pytest.param("echo $(date) $(ls /tmp)", id = "two_arg_position_substs_allowed"),
|
||||
pytest.param("(cd /tmp) $(date)", id = "subshell_close_then_subst_allowed"),
|
||||
pytest.param("case x in x) echo hi;; esac", id = "case_arm_benign_allowed"),
|
||||
pytest.param(">out.log echo hi", id = "leading_redirection_benign_allowed"),
|
||||
pytest.param(
|
||||
"timeout 1s python train.py --data $(ls -d data/*)",
|
||||
id = "suffixed_duration_wrapper_spent_allowed",
|
||||
),
|
||||
pytest.param("coproc echo hi", id = "coproc_benign_allowed"),
|
||||
pytest.param("> out.log echo hi", id = "spaced_redirection_benign_allowed"),
|
||||
# A substitution that IS the redirection target names a file; nothing runs.
|
||||
pytest.param("> $(date).log echo hi", id = "subst_as_redirection_target_allowed"),
|
||||
pytest.param('echo "`date`"', id = "quoted_backtick_in_argument_allowed"),
|
||||
pytest.param('echo "$(printf r)m"', id = "glued_subst_in_argument_allowed"),
|
||||
pytest.param(
|
||||
"env --chdir /tmp python train.py --data $(ls -d data/*)",
|
||||
id = "env_long_option_value_wrapper_spent_allowed",
|
||||
),
|
||||
# A laundered expansion glued into an ARGUMENT is not a command word.
|
||||
pytest.param("v=$(date); echo ${v}Z", id = "laundered_prefix_in_argument_allowed"),
|
||||
],
|
||||
)
|
||||
def test_bash_blocklist_finds_nothing_in_safe_commands(self, command):
|
||||
|
|
@ -864,6 +933,234 @@ class TestBashBlocklistPosition:
|
|||
pytest.param(
|
||||
"curl", "while true; do curl --version; break; done", id = "while_do_blocked"
|
||||
),
|
||||
# `$(echo reboot)` runs reboot without the name appearing literally.
|
||||
pytest.param("reboot", "$(echo reboot)", id = "subst_synthesized_literal_blocked"),
|
||||
pytest.param(
|
||||
"shutdown",
|
||||
'$(printf "%s" shutdown)',
|
||||
id = "subst_printf_literal_blocked",
|
||||
),
|
||||
# An enumeration through a selector gives an unknowable name: fail closed.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
'$(ls /usr/bin/ | grep "^reb")',
|
||||
id = "subst_enumerated_name_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
'$(ls /usr/bin/ | grep "^rm$") -rf ~/Downloads/can_delete',
|
||||
id = "subst_enumerated_rm_with_args_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
'`ls /usr/bin/ | grep "^reb"`',
|
||||
id = "backtick_enumerated_name_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"sudo $(ls /usr/bin | grep reb)",
|
||||
id = "wrapper_prefixed_subst_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"env $(ls /usr/bin | grep reb)",
|
||||
id = "env_wrapper_subst_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
'$(compgen -c | grep "^rm$")',
|
||||
id = "subst_compgen_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"$(find /usr/bin -name 'r*')",
|
||||
id = "subst_find_bin_dir_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"$(echo /usr/bin/r*)",
|
||||
id = "subst_glob_expansion_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
'"$(ls /usr/bin | grep reb)"',
|
||||
id = "quoted_subst_enumeration_blocked",
|
||||
),
|
||||
# The same synthesis past separators, all of which execute it.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"{ $(ls /usr/bin|grep reb); }",
|
||||
id = "brace_group_subst_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"if true; then $(ls /usr/bin|grep reb); fi",
|
||||
id = "branch_body_subst_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"while true; do $(ls /usr/bin|grep reb); done",
|
||||
id = "loop_body_subst_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"find /tmp -name x -exec $(ls /usr/bin | grep reb) \\;",
|
||||
id = "find_exec_subst_blocked",
|
||||
),
|
||||
# A variable launders the synthesis, a printf -v, or a plain literal.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"c=$(ls /usr/bin|grep reb); $c",
|
||||
id = "laundered_subst_var_blocked",
|
||||
),
|
||||
pytest.param("reboot", "c=$(echo reboot); ${c}", id = "laundered_literal_precise"),
|
||||
pytest.param("reboot", "c=reboot; $c", id = "laundered_plain_literal_blocked"),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"printf -v c reboot; $c",
|
||||
id = "printf_v_laundered_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"export c=$(compgen -c | grep rm); $c",
|
||||
id = "exported_laundered_subst_blocked",
|
||||
),
|
||||
# An assignment binds wherever it appears. Each of these really deletes: verified
|
||||
# against a stand-in `rm` on PATH.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"if true; then c=$(ls /usr/bin|grep rm); fi; $c",
|
||||
id = "laundered_behind_keyword_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"(c=$(ls /usr/bin|grep rm); $c)",
|
||||
id = "laundered_in_subshell_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"x=1 c=$(ls /usr/bin|grep rm); $c",
|
||||
id = "laundered_behind_assignment_prefix_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"for i in 1; do c=$(ls /usr/bin|grep rm); done; $c",
|
||||
id = "laundered_in_loop_body_blocked",
|
||||
),
|
||||
# The quoted spelling is the recommended one; it cannot be the one that escapes.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
'c=$(ls /usr/bin|grep rm); "$c"',
|
||||
id = "laundered_quoted_exec_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
'c=$(ls /usr/bin|grep rm); "${c}"',
|
||||
id = "laundered_quoted_brace_exec_blocked",
|
||||
),
|
||||
pytest.param("reboot", 'c=reboot; "$c"', id = "laundered_literal_quoted_exec_blocked"),
|
||||
# Thousands of unterminated `$(` openers are refused, not scanned: each costs a
|
||||
# span walk and this screen has no length cap.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
";$(" * 200,
|
||||
id = "substitution_flood_refused_not_scanned",
|
||||
),
|
||||
# Four spellings that reach the command word by a route the site scan did not walk.
|
||||
# Each really deletes: verified against a stand-in `rm` on PATH.
|
||||
#
|
||||
# timeout's DURATION is a float with an optional s/m/h/d suffix (timeout --help), not
|
||||
# the bare integer the scan accepted.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"timeout 1s $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "suffixed_duration_subst_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"timeout 0.5 $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "fractional_duration_subst_blocked",
|
||||
),
|
||||
# `env [OPTION]...` is unbounded, so any cap on the option run is a count an attacker
|
||||
# exceeds to make the whole site regex fail open.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"env -u A -u B -u C -u D -u E $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "many_wrapper_options_subst_blocked",
|
||||
),
|
||||
# Redirections may precede the command word.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
">out.log $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "leading_redirection_subst_blocked",
|
||||
),
|
||||
# A case arm's `)` is followed directly by the commands to run.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"case x in x) $(ls /usr/bin | grep '^rm$') -rf victim;; esac",
|
||||
id = "case_arm_subst_blocked",
|
||||
),
|
||||
# timeout's DURATION is a float, so strtod accepts the scientific spellings too and
|
||||
# `timeout 1e1 true` really runs.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"timeout 1e1 $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "scientific_duration_subst_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"timeout 1.5e1s $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "scientific_suffixed_duration_subst_blocked",
|
||||
),
|
||||
# `coproc [NAME] command` (help coproc) runs COMMAND asynchronously.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"coproc $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "coproc_subst_blocked",
|
||||
),
|
||||
# The two laundering routes the site fixes left behind: an arm runs a variable just
|
||||
# as readily as a substitution, and bash concatenates `${x}m` into one command word.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"c=$(ls /usr/bin|grep '^rm$'); case x in x) $c -rf victim;; esac",
|
||||
id = "case_arm_laundered_var_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"x=$(printf r); ${x}m -rf victim",
|
||||
id = "laundered_prefix_command_word_blocked",
|
||||
),
|
||||
# Bash allows whitespace between a redirection operator and its target.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"> out.log $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "spaced_redirection_subst_blocked",
|
||||
),
|
||||
# `env -C/--chdir DIR` takes a separate value (env --help); unconsumed, the DIR read
|
||||
# as the command and the real one behind it was never reached.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"env --chdir /tmp $(ls /usr/bin | grep '^rm$') -rf victim",
|
||||
id = "env_long_option_value_subst_blocked",
|
||||
),
|
||||
# A double-quoted backtick expands exactly like `"$(...)"`.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"\"`ls /usr/bin | grep '^rm$'`\" -rf victim",
|
||||
id = "quoted_backtick_subst_blocked",
|
||||
),
|
||||
# Bash concatenates adjacent fragments, so the body being benign proves nothing about
|
||||
# the word that runs: `$(printf r)m` is `rm`.
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
'"$(printf r)"m -rf victim',
|
||||
id = "quoted_subst_glued_to_literal_blocked",
|
||||
),
|
||||
pytest.param(
|
||||
"command substitution",
|
||||
"$(printf r)m -rf victim",
|
||||
id = "bare_subst_glued_to_literal_blocked",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_bash_blocklist_flags_the_command(self, expected, command):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue