Lion Cipher Lab
Harden a subprocess wrapper against argument injection (CWE-88)
- AppSec / DevSecOps
- Advanced
- ~90 min
- Free · no account required
Why it matters
Every secure-coding checklist tells you the same thing about running external programs: never build a shell string, always pass a list of arguments so nothing you inject can start a new command. That advice is correct, and it is exactly what subprocess.run([...], shell=False) gives you -- no shell, so ';' and '$(...)' and backticks are just inert characters in a bad filename. The problem is that the advice is incomplete, and almost no tutorial says so. Once your user's value lands in the argument vector (argv) of a program that has its OWN rich option parser -- tar, find, sort, curl, grep,…
What you'll learn
- argument injection (CWE-88): how an attacker-controlled VALUE becomes an OPTION even with shell=False -- the bug that lives one rung above OS command injection (CWE-78)
- proving the honest baseline: shell metacharacters (; | & $(...) backticks) are inert against subprocess.run([...], shell=False) -- 4 payloads, 0 marker files
- breaking the 'textbook-safe' wrapper four ways: tar --checkpoint-action=exec, find -exec, sort -oFILE, and curl -Kconfig reading a file the caller never named
- the 'no shell means safe' fallacy, quantified on ONE wrapper: 0/4 shell payloads vs 4/4 argument payloads
- the '--' end-of-options marker: where it saves you (tar, sort, grep) and where it does NOT (find errors with 'unknown predicate `--'')
- GNU option permutation: why 'I put the user value last so it is a positional' is false, and what POSIXLY_CORRECT=1 changes
- the leading-dash allowlist that isn't enough: rejecting '-' BEFORE decoding is bypassed by '%2D...'; decode exactly once, THEN check
- the callee's own defenses: modern git blocks 'ssh://-o...' hostnames but still honours a 2-token '--upload-pack=' -- defenses live in the callee too, and you cannot rely on them
- the layered fix: anchored allowlist regex, reject leading '-' after normalization, use '--' and pin position -- and the strongest fix of all: use the library (tarfile, os.walk, sorted) instead of spawning the program
- why shlex.quote is the WRONG tool here (there is no shell to quote for), and the pro move: take an opaque id from the user, not a filename, and map it to a path YOU chose
Steps in this lab
- The one sentence that this whole lab exists to correct
- Open a terminal on a GNU/Linux system (zero-assumption setup)
- Create docsvc.py -- the document service (naive AND hardened wrappers)
- Create harness.py -- the thing that measures every claim
- Run it once and read the headline
- Section [1]: the honest baseline -- shell=False really does stop shell injection
- Section [2]: the break -- four injections the same wrapper cannot stop
- Failure mode 1: the 'no shell means safe' fallacy, quantified
- Failure mode 2: '--' end-of-options, and exactly where it does NOT save you
- Failure mode 3: GNU option permutation -- 'I put it last, so it's positional' is false
- Failure mode 4: the leading-dash allowlist that isn't enough (order of operations)
- The callee can fight back -- and why you still cannot rely on it
- Section [4] and [5]: the fix, measured -- and why the strongest layer is 'no subprocess'
- Judgment: why this fix and not that one
- Find it in your own code, and what a reviewer looks for