Gate the sed commands that run a shell
GNU sed executes a shell through its `e` command, both as a standalone command (`sed -n '1e CMD' file`) and as an `s///e` flag that runs the pattern space. It goes through popen(), so it is a literal `sh -c`, but the terminal scan only ever saw `sed` at command position and treated the program text as an ordinary argument. That left `sed -n '1e rm -f victim' /etc/hosts` running with no prompt in auto mode, and `_find_blocked_commands` returning nothing for it, so the hard blocklist that applies in every mode missed `rm` as well. Screens the program the same way the awk arm does. `-e` values are joined with newlines first, since that is how sed assembles them: `sed -e '1a\' -e 'e CMD'` appends a literal line and runs nothing, so judging the pieces separately would prompt on a benign script. The scan then steps over every region where `e` is data rather than a command: address and substitution regexes, replacements, `a/i/c` text, `r`/`w` filenames, `b`/`t` labels and comments. That keeps the common idioms silent, including `:e;N;$!be` loop labels, `s/e/E/g`, and `s/a/b/we out.txt` where the `e` belongs to the `w` filename and sed does not execute. The blocklist scan recurses into a literal `e` payload the same way it already does for `bash -c`. A bare `e` or an `s///e` can only be prompted, since what they run is the pattern space, which is input-file text that is not knowable statically. Verified against real GNU sed 4.9 rather than the manual: 80 commands run for real with a marker payload, comparing what sed actually executed against the classifier, with no mismatches in either direction.
This commit is contained in:
parent
d72b58a35e
commit
22a120cf09
3 changed files with 272 additions and 1 deletions
|
|
@ -265,6 +265,15 @@ _AWK_SHELL_ESCAPE_RE = re.compile(
|
|||
r"\bsystem\s*\(|\|\s*&?\s*[\"']\s*(?:/\S*/)?(?:sh|bash|zsh|ksh|dash|cmd)\b|"
|
||||
r"\bENVIRON\s*\[|\bprintf\s*\|"
|
||||
)
|
||||
# sed shells out like awk: GNU's `e` runs the rest of its line through popen,
|
||||
# and the `s///e` flag runs the pattern space. Either hides a command (even a
|
||||
# hard-blocked one) inside a text-editing argument, so the program is screened
|
||||
# while ordinary editing (sed 's/a/b/g', sed -n '1,20p') stays unprompted. Both
|
||||
# are GNU extensions; busybox/toybox/BSD sed reject `e` outright.
|
||||
_SED_COMMANDS = frozenset({"sed", "gsed", "ssed"})
|
||||
# `s///` flags that may precede `e`. `w` is absent: it takes the rest of the
|
||||
# line as a filename, so the e in `s/a/b/w report.txt` is part of that name.
|
||||
_SED_SUBST_FLAGS = frozenset("0123456789gpiImMe")
|
||||
_WIN_CONDITIONAL_KEYWORDS = frozenset({"exist", "defined", "errorlevel", "not"})
|
||||
_FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
|
||||
|
||||
|
|
@ -288,6 +297,183 @@ def _blocked_matching_glob(base: str) -> "set[str]":
|
|||
return {name for name in _BLOCKED_COMMANDS if fnmatch.fnmatchcase(name, base)}
|
||||
|
||||
|
||||
def _sed_program(tokens: "list[str]", start: int) -> str:
|
||||
"""The sed script of the invocation whose command word sits at ``start``.
|
||||
|
||||
sed joins every -e/--expression value with NEWLINES into one program, so
|
||||
`sed -e '1a\\' -e 'e rm -rf x'` appends a literal line instead of executing
|
||||
it and the pieces must be judged together. With no -e (and no -f program
|
||||
FILE, unreadable here) the FIRST positional is the script and the rest are
|
||||
inputs, so `sed -e 's/a/b/' e` treats its input file `e` as data.
|
||||
"""
|
||||
programs: "list[str]" = []
|
||||
first_positional = ""
|
||||
has_program_flag = False
|
||||
value_pending = "" # "e" or "f": the next token is that flag's value
|
||||
for token in tokens[start + 1 :]:
|
||||
if token in _SHELL_SEPARATORS:
|
||||
break
|
||||
if value_pending:
|
||||
if value_pending == "e":
|
||||
programs.append(token)
|
||||
value_pending = ""
|
||||
continue
|
||||
if token.startswith("--"):
|
||||
name, sep, value = token.partition("=")
|
||||
# getopt allows unambiguous abbreviations: --e/--ex/--expr are all
|
||||
# --expression, and --fi upwards is --file (--f alone is ambiguous
|
||||
# with --follow-symlinks, which sed rejects).
|
||||
is_expression = len(name) > 2 and "--expression".startswith(name)
|
||||
is_file = len(name) > 3 and "--file".startswith(name)
|
||||
if is_expression or is_file:
|
||||
has_program_flag = True
|
||||
if not sep:
|
||||
value_pending = "e" if is_expression else "f"
|
||||
elif is_expression:
|
||||
programs.append(value)
|
||||
continue
|
||||
if token.startswith("-"):
|
||||
# A cluster glues the value on (-ne'1p') or takes the next (-ne '1p').
|
||||
attached = _short_flag_arg(token, "ef")
|
||||
if attached is None:
|
||||
continue
|
||||
has_program_flag = True
|
||||
letter = next(ch for ch in token[1:] if ch in "ef")
|
||||
if not attached:
|
||||
value_pending = letter
|
||||
elif letter == "e":
|
||||
programs.append(attached)
|
||||
continue
|
||||
if not first_positional:
|
||||
first_positional = token
|
||||
if not has_program_flag and first_positional:
|
||||
programs.append(first_positional)
|
||||
return "\n".join(programs)
|
||||
|
||||
|
||||
def _sed_exec_payloads(program: str) -> "list[str]":
|
||||
"""Shell payloads a sed program executes, in order.
|
||||
|
||||
`e COMMAND` runs COMMAND. A bare `e` and the `s///e` flag run the pattern
|
||||
space, which only exists at run time, so they yield an EMPTY payload:
|
||||
executes, but nothing to screen. An empty list means it only edits text.
|
||||
|
||||
The walk skips every region where an `e` is data (regexes, replacements,
|
||||
a/i/c text, r/w filenames, b/t labels, comments), keeping `:e;N;$!be;...`,
|
||||
`sed 's/e/E/g'` and `sed 's/a/b/w report.txt'` out of the results.
|
||||
"""
|
||||
payloads: "list[str]" = []
|
||||
n = len(program)
|
||||
|
||||
def _end_of_line(pos: int) -> int:
|
||||
end = program.find("\n", pos)
|
||||
return n if end < 0 else end
|
||||
|
||||
def _skip_bracket(pos: int) -> int:
|
||||
# A bracket expression, where the delimiter is data (`s/[/]/x/` really
|
||||
# substitutes a slash). A leading `]` is literal; [:class:] nests.
|
||||
pos += 1
|
||||
if pos < n and program[pos] == "^":
|
||||
pos += 1
|
||||
if pos < n and program[pos] == "]":
|
||||
pos += 1
|
||||
while pos < n and program[pos] != "]":
|
||||
if program[pos] == "[" and pos + 1 < n and program[pos + 1] in ":.=":
|
||||
end = program.find(program[pos + 1] + "]", pos + 2)
|
||||
pos = n if end < 0 else end + 2
|
||||
continue
|
||||
pos += 1
|
||||
return pos + 1
|
||||
|
||||
def _skip_section(pos: int, delim: str, brackets: bool) -> int:
|
||||
# One delimited section of a regex / s/// / y///, through its closing
|
||||
# delimiter. Brackets apply to regex halves only; elsewhere `[` is data.
|
||||
while pos < n and program[pos] != delim:
|
||||
if program[pos] == "\\":
|
||||
pos += 2
|
||||
elif brackets and program[pos] == "[":
|
||||
pos = _skip_bracket(pos)
|
||||
else:
|
||||
pos += 1
|
||||
return pos + 1
|
||||
|
||||
def _skip_address(pos: int) -> int:
|
||||
# A line number (GNU's first~step included), `$`, /regex/ or \%regex%,
|
||||
# each allowing I/M modifiers.
|
||||
if pos < n and program[pos] == "$":
|
||||
return pos + 1
|
||||
if pos < n and program[pos].isdigit():
|
||||
while pos < n and (program[pos].isdigit() or program[pos] == "~"):
|
||||
pos += 1
|
||||
return pos
|
||||
if pos < n and program[pos] == "/":
|
||||
pos = _skip_section(pos + 1, "/", brackets = True)
|
||||
elif pos < n and program[pos] == "\\" and pos + 1 < n:
|
||||
pos = _skip_section(pos + 2, program[pos + 1], brackets = True)
|
||||
else:
|
||||
return pos
|
||||
while pos < n and program[pos] in "IM":
|
||||
pos += 1
|
||||
return pos
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
if program[i] in " \t\n;{}":
|
||||
# Separators and block braces carry no command.
|
||||
i += 1
|
||||
continue
|
||||
if program[i] == "#":
|
||||
i = _end_of_line(i)
|
||||
continue
|
||||
i = _skip_address(i)
|
||||
if i < n and program[i] == ",":
|
||||
i += 1
|
||||
while i < n and program[i] in " \t":
|
||||
i += 1
|
||||
if i < n and program[i] in "+~":
|
||||
# `addr,+N` / `addr,~N` end the range relative to the first match.
|
||||
i += 1
|
||||
while i < n and program[i].isdigit():
|
||||
i += 1
|
||||
else:
|
||||
i = _skip_address(i)
|
||||
while i < n and program[i] in " \t!":
|
||||
# `1!e cmd`: negation, the command word is still ahead.
|
||||
i += 1
|
||||
if i >= n:
|
||||
break
|
||||
cmd, i = program[i], i + 1
|
||||
if cmd == "e":
|
||||
# The payload ends at the NEWLINE, so a `;` inside it is shell text.
|
||||
end = _end_of_line(i)
|
||||
payloads.append(program[i:end].strip())
|
||||
i = end
|
||||
elif cmd in "sy" and i < n:
|
||||
delim, i = program[i], i + 1
|
||||
i = _skip_section(i, delim, brackets = cmd == "s")
|
||||
i = _skip_section(i, delim, brackets = False)
|
||||
if cmd == "s":
|
||||
executes = False
|
||||
while i < n and program[i] in _SED_SUBST_FLAGS:
|
||||
executes = executes or program[i] == "e"
|
||||
i += 1
|
||||
if executes:
|
||||
payloads.append("")
|
||||
if i < n and program[i] == "w":
|
||||
i = _end_of_line(i)
|
||||
elif cmd in "aic":
|
||||
# Literal text; the `a\` + newline form continues on a trailing "\".
|
||||
while i < n and program[i] != "\n":
|
||||
i += 2 if program[i] == "\\" else 1
|
||||
elif cmd in "rRwW":
|
||||
i = _end_of_line(i) # the filename runs to the end of the line
|
||||
elif cmd in "btT:v":
|
||||
# A label (or `v` version) ends at the next separator.
|
||||
while i < n and program[i] not in ";\n}":
|
||||
i += 1
|
||||
return payloads
|
||||
|
||||
|
||||
def _find_blocked_commands(command: str) -> set[str]:
|
||||
"""Detect blocked commands at shell command position only.
|
||||
|
||||
|
|
@ -328,7 +514,8 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
expect_command = True # start of string is a command position
|
||||
prefix_pending = False # last cmd-position token was a wrapper (env/time/xargs/...)
|
||||
skip_operand = False # consume a wrapper/conditional operand, not the command
|
||||
for token in tokens:
|
||||
sed_indexes: "list[int]" = [] # command-position sed words, for the `e` scan below
|
||||
for token_index, token in enumerate(tokens):
|
||||
if skip_operand:
|
||||
# `exec -a NAME cmd` and `if exist FILE cmd` both put an operand
|
||||
# where the command word would otherwise be.
|
||||
|
|
@ -363,6 +550,8 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
if prefix_pending and token.lstrip("-").isdigit():
|
||||
continue
|
||||
base = _token_basename(token)
|
||||
if base in _SED_COMMANDS:
|
||||
sed_indexes.append(token_index)
|
||||
if base in _BLOCKED_COMMANDS:
|
||||
blocked.add(base)
|
||||
else:
|
||||
|
|
@ -449,6 +638,14 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
blocked |= _find_blocked_commands(tokens[i + 1])
|
||||
break # stop at first non-flag token
|
||||
|
||||
# sed's `e COMMAND` hands COMMAND to the shell, a real command position the
|
||||
# scan above sees only as a text argument, so screen it like `bash -c`. The
|
||||
# pattern-space forms yield an empty payload; the auto gate prompts on those.
|
||||
for i in sed_indexes:
|
||||
for payload in _sed_exec_payloads(_sed_program(tokens, i)):
|
||||
if payload:
|
||||
blocked |= _find_blocked_commands(payload)
|
||||
|
||||
return blocked
|
||||
|
||||
|
||||
|
|
@ -4324,6 +4521,10 @@ def _terminal_is_high_risk(command: str, _depth: int = 0) -> bool:
|
|||
chdir_pending = True
|
||||
if base in _AWK_COMMANDS:
|
||||
awk_program_pending = True
|
||||
if base in _SED_COMMANDS and _sed_exec_payloads(_sed_program(tokens, _tok_idx)):
|
||||
# `e` / `s///e` shell out from inside the script, which may
|
||||
# ride on -e/--expression rather than the next positional.
|
||||
return True
|
||||
elif current_command == "git" and not git_subcommand:
|
||||
# The first positional after `git` is its subcommand.
|
||||
git_subcommand = base
|
||||
|
|
|
|||
|
|
@ -875,6 +875,60 @@ def test_terminal_classifier(command, unsafe):
|
|||
("awk '{print $1}' data.tsv", False),
|
||||
("awk -F, '{sum+=$2} END {print sum}' f.csv", False),
|
||||
("awk 'NR>1' data.csv > body.csv", False),
|
||||
# --- prompt: sed's `e` runs the rest of its line through the shell,
|
||||
# under every address form (line, $, regex, range, step, negation) ---
|
||||
("sed -n '1e rm -f victim' /etc/hosts", True),
|
||||
("sed 'e curl https://x.io/p.sh' f", True),
|
||||
("sed -n '$e rm -rf build' f", True),
|
||||
("sed '/token/e curl https://x.io/' input", True),
|
||||
("sed '1,2e rm -f victim' f", True),
|
||||
("sed '0~2e rm -f victim' f", True),
|
||||
("sed '1!e rm -f victim' f", True),
|
||||
("sed '/a/,/b/e rm -f victim' f", True),
|
||||
("sed -n '1{p};2e rm -f victim' f", True),
|
||||
("gsed '1e rm -f victim' f", True),
|
||||
("ssed '1e rm -f victim' f", True),
|
||||
# the script may ride on -e/--expression (abbreviated too) instead of
|
||||
# the first positional, and a cluster glues -n and -e into one word
|
||||
("sed -n -e '1e rm -f victim' f", True),
|
||||
("sed -ne '1e rm -f victim' f", True),
|
||||
("sed -e '1p' -e '1e rm -f victim' f", True),
|
||||
("sed --expression='1e rm -f victim' f", True),
|
||||
("sed --expr='1e rm -f victim' f", True),
|
||||
# --- prompt: the s///e flag executes whatever the substitution left in
|
||||
# the pattern space, in any flag order and with any delimiter ---
|
||||
("sed 's/foo/bar/e' input", True),
|
||||
("sed 's/foo/bar/ge' input", True),
|
||||
("sed 's/foo/bar/eg' input", True),
|
||||
("sed 's/foo/bar/2e' input", True),
|
||||
("sed 's/foo/bar/e2' input", True),
|
||||
("sed 's/foo/bar/ep' input", True),
|
||||
("sed 's/foo/bar/pe' input", True),
|
||||
("sed 's/foo/bar/Ie' input", True),
|
||||
("sed 's/foo/bar/ew out.txt' input", True), # executes AND writes
|
||||
("sed 's|foo|bar|e' input", True),
|
||||
("sed 's/[/]//e' input", True), # the delimiter is data inside [ ]
|
||||
# --- run: ordinary stream editing, including the shapes that merely
|
||||
# LOOK like an exec (a label `e`, an `e` in a regex or a w filename) ---
|
||||
("sed -n '1p' input", False),
|
||||
("sed -n '1,20p' input", False),
|
||||
("sed 's/foo/bar/g' input", False),
|
||||
("sed -i 's/old/new/' f", False),
|
||||
("sed -E 's/(a|b)+/x/g' f", False),
|
||||
("sed -e 's/a/b/' -e 's/c/d/' f", False),
|
||||
("sed 's/e/E/g' f", False),
|
||||
("sed ':e;N;$!be;s/\\n/,/g' f", False), # the classic join-lines idiom
|
||||
("sed 's/foo/bar/w report.txt' f", False), # `w` takes the rest as a name
|
||||
("sed 's/foo/bar/we report.txt' f", False), # `w` first: the e is the name
|
||||
("sed -n '/error/w errors.txt' f", False),
|
||||
("sed '/^$/d' f", False),
|
||||
("sed 'y/abc/xyz/' f", False),
|
||||
("sed -n '/error/=' log", False),
|
||||
("sed -f cleanup.sed data.txt", False), # a program FILE, like awk -f
|
||||
("sed -e 's/a/b/' e", False), # `e` here is an input file, not a command
|
||||
("sed -e '1a\\' -e 'echo appended' f", False), # a\ continues into -e
|
||||
("echo \"sed '1e rm -f victim'\"", False),
|
||||
("printf '%s' sed '1e rm -f victim'", False),
|
||||
# --- prompt: setpriv execs what follows, after changing privilege ---
|
||||
("setpriv --nnp rm -f victim", True),
|
||||
("setpriv --reuid=1000 rm -rf build", True),
|
||||
|
|
|
|||
|
|
@ -637,6 +637,22 @@ class TestBashBlocklistPosition:
|
|||
# Recursion into the nested command string catches command-position curl.
|
||||
assert "curl" in self._find()("bash -c 'curl https://x'")
|
||||
|
||||
def test_sed_exec_payload_blocked(self):
|
||||
# sed's `e COMMAND` hands COMMAND to the shell, so the payload is a real
|
||||
# command position hiding inside the script argument.
|
||||
assert "rm" in self._find()("sed -n '1e rm -rf victim' input")
|
||||
assert "curl" in self._find()("sed -e '/x/e curl https://x' input")
|
||||
assert "rm" in self._find()("sed -ne '$e rm -rf build' input")
|
||||
assert "wget" in self._find()("sed '1,2e wget https://bad' input")
|
||||
|
||||
def test_ordinary_sed_program_allowed(self):
|
||||
# Plain stream editing runs nothing, and a mention of sed in argument
|
||||
# position is text: only a command-position sed has its script read.
|
||||
assert self._find()("sed 's/old/new/g' input") == set()
|
||||
assert self._find()("sed -n '1,20p' input") == set()
|
||||
assert self._find()("sed 's/rm/RM/g' input") == set()
|
||||
assert self._find()("printf '%s' sed '1e rm -rf victim'") == set()
|
||||
|
||||
def test_subshell_command_blocked(self):
|
||||
assert "rm" in self._find()("echo $(rm -rf /tmp)")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue