Skip to content

fix(security): detect declared-marker obfuscation - #437

Open
mohgupta-ship-it wants to merge 1 commit into
codex/security-text-normalizationfrom
codex/security-hybrid-obfuscation
Open

fix(security): detect declared-marker obfuscation#437
mohgupta-ship-it wants to merge 1 commit into
codex/security-text-normalizationfrom
codex/security-hybrid-obfuscation

Conversation

@mohgupta-ship-it

Copy link
Copy Markdown
Member

Powered by Codex.

Summary

This stacked PR extends #408 with deterministic detection for instructions that explicitly tell a reader to remove a literal marker before interpreting a command. It closes cases such as a declared xyz marker hiding rm -rf *, while preserving exact source locations and avoiding any execution or LLM dependency.

Deterministic flow

flowchart TD
    A["Original instruction text"] --> B["Find explicit remove or ignore marker directive"]
    B --> C{"Directive safe to reconstruct?"}
    C -- "No or ambiguous" --> D["Fail closed: incomplete analysis"]
    C -- "Yes" --> E["Remove literal marker once within bounded scope"]
    E --> F["Preserve derived-to-source offset map"]
    F --> G["Run existing static analyzers on original and derived views"]
    G --> H{"Dangerous command or policy match?"}
    H -- "Yes" --> I["Emit mapped finding"]
    H -- "No" --> J["Continue normal static analysis"]
Loading

Security behavior

  • Supports quoted markers and tag-like markers with bounded, one-pass literal removal.
  • Rejects ambiguous, encoded, overlapping, multi-marker, and resource-exhausted forms instead of guessing SAFE.
  • Carries exact source offsets and source lines from reconstructed text into findings.
  • Extends rm root-glob detection across split or combined flags, --, redirections, line continuations, quoting, escaped or fragmented command words, command/process substitution, and operand reordering.
  • Avoids executing, evaluating, recursively decoding, or invoking an LLM.
  • Keeps prose, quoted globs, suffix globs, negated directives, and safe marker examples from becoming command findings.

Bounds

The reconstruction path is deliberately bounded: marker length 16, declaration scope 768, lookahead 8192, payload 700, at most 8 active directives, and at most 64 removals. Unsupported forms become an incomplete-analysis ledger entry.

Validation

  • uv run make lint
  • uv run make format-check
  • uv run make test-ci: 3116 passed, 13 skipped, 38 deselected, 4 xfailed
  • Focused reconstruction suite: 118 passed
  • Relevant static analyzer suites: 335 passed, 4 xfailed
  • Security end-to-end suite: 64 passed
  • Remediation and artifact suites: 210 passed
  • uv run python -m build --no-isolation
  • Docker image build and repository smoke test passed
  • Real CLI corpus exercised explicitly with --no-llm: malicious reconstructed commands are detected, unsupported marker forms fail closed, and safe marker text remains SAFE

The exact CI run used Python 3.12, matching the repository workflow. The same initial coverage-only failures observed under Python 3.14 were reproduced on the untouched #408 base and are not caused by this change.

Adversarial review

The final diff was challenged across code standards, design/trust boundaries, false positives, false negatives, source mapping, truncation/window ownership, and no-LLM end-to-end behavior. No unresolved P0, P1, or P2 finding remains.

Deliberate limit

This is not arbitrary undeclared gapped-string or recursive deobfuscation. Reconstruction requires an explicit literal marker-removal instruction. Unsupported syntax fails closed so a future broader DP or scoring layer can be introduced without silently treating uncertain input as safe.

Stack: built directly on #408 (codex/security-text-normalization).

Signed-off-by: Mohit Gupta <mohgupta@nvidia.com>

@yashrajp22 yashrajp22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the whole PR by checking out the head commit and actually running the two new detectors, not just reading them. I reproduced your full 37-case rm boundary matrix with zero mismatches (so my copies of the functions are faithful), then ran extra inputs of my own.

The behavior on the forms you recognize is solid, and the window-seam ownership logic is genuinely well done. Three things I'd flag are inline below. The first one (quoting the rm flags) is the one I'd really want addressed before merge; it's a trivial, natural-looking bypass of the new detector. Skipping a minor scoping nit about rm -- -rf *.

Comment on lines +480 to +483
short_options = [
token.text[1:]
for token in tokens[:options_end]
if not token.has_quoted_content and re.fullmatch(r"-[A-Za-z]+", token.text) is not None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line decides which tokens count as "flags", and it throws away any flag that has quotes around it. The trouble is that bash doesn't work that way: when you write rm "-rf" *, the shell strips the quotes before it looks at the flags, so -rf is still a real flag and the command still deletes the whole folder.

So today:

  • rm -rf * -> caught
  • rm -r""f * (empty quotes) -> caught
  • rm "-rf" * or rm -r'f' * (one real char quoted) -> missed, even though it wipes everything

I actually ran these against the code: the quoted forms slip past both this new tokenizer and the older rm regexes, so nothing catches them. Quoting the flags is about the first thing someone would try to hide an rm -rf *, so this is a real hole.

Heads-up: your own test_tm1_root_glob_boundary_controls locks in rm "-rf" * -> not detected, so this is a deliberate choice. But I think it rests on a wrong assumption: quoting a flag does not disable it the way quoting * disables the glob. The tell is that rm -r""f * (empty quotes) is caught but rm -r'f' * (one character quoted) is not, and both are just rm -rf * to bash.

Suggested fix: treat a quoted flag as a flag. You can keep requiring the * to be unquoted, so rm "-rf" "*" still stays safe, while rm "-rf" * finally gets caught. It shouldn't add realistic false positives.


def _has_destructive_root_glob(tokens: tuple[_ShellToken, ...], command: str) -> bool:
root_glob = any(token.text == "*" and token.unquoted_star for token in tokens)
if _ROOT_GLOB_PROSE_RE.search(command) is not None or not root_glob:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small but real: the "is this just documentation?" check here (_ROOT_GLOB_PROSE_RE) runs on the command text itself, and that command text can stretch ~256 chars from rm. So an attacker can switch the check off just by tacking the magic words onto the same line:

rm -rf * the rm command accepts -r and -f while * denotes a wildcard

I ran it: that line is NOT flagged, but in bash it still runs rm -rf * and wipes the folder (the extra words just become junk filenames, and -f hides the errors).

It's more awkward than the quoting bypass (everything has to sit on one line, no ;, newline, #, or |), but it does turn a safety check into an off-switch. Consider matching the prose only when the whole command reads like documentation (anchor it with re.match at the start) instead of searching for it anywhere inside the command.

Comment on lines +53 to +55
_ACTION_RE: Final = re.compile(
r"\b(?:run|execute|invoke|issue|launch|perform|carry[ \t]+out)\b",
re.IGNORECASE,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the list of "action" words that make the marker-removal logic kick in. If the text uses any word outside this list, the whole thing quietly does nothing: no detection, and (this is the important part) no "couldn't fully analyze this" flag either. So the scan comes back SAFE.

I tested these and all three came back SAFE with no warning at all:

  • Remove 'xyz' and call 'rxyzmxyz -rxyzfxyz *'.
  • Remove 'xyz' and eval 'rxyzmxyz -rxyzfxyz *'.
  • Remove 'xyz' and type 'rxyzmxyz -rxyzfxyz *'.

The PR says unsupported/uncertain cases should "fail closed" instead of being treated as safe, but that safety net only turns on after one of these verbs is found. So a verb you didn't list doesn't fail closed, it slips through silently.

I'd add at least call, eval, and type here (they're clearly execution words). I'd skip generic ones like "do"/"apply" since those would fire on innocent text. A short doc note would help too: fail-closed only applies once an action word is recognized.

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.

2 participants