Special picoCTF 2023 Solution

Published: April 26, 2023

Description

Special autocorrects every shell command, so you must abuse parameter expansion and braces to execute raw commands and leak the flag.

SSH to saturn.picoctf.net on port 56058 with the supplied password.

Experiment with bash parameter expansion to bypass the forced capitalization/rewriting and run arbitrary commands under the hood.

bash
ssh -p 56058 ctf-player@saturn.picoctf.net
bash
d8819d45
bash
${parameter=ls blargh}
bash
${parameter=cat < blargh/flag.txt}

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Probe the sanitizer
    Observation
    I noticed the shell autocorrected and blocked bare commands like ls and cat, which suggested the filter was scanning for known command-name tokens at the start of input and that a Bash construct starting with a different character, such as parameter expansion, could bypass it.
    Bare ls or cat are rewritten, but expressions starting with ${ slip through. Test ${parameter=ls} as a smoke check before chaining anything else.
    What didn't work first

    Tried: Trying to escape or quote the command name, like 'l''s' or \ls, to sneak past the filter.

    Quoting tricks sometimes bypass simple string matches, but this shell's rewriter still catches recognized command tokens after unquoting. The sanitizer gets a second pass or normalizes the input before checking, so the quoted form gets blocked just the same. The assign-default parameter expansion works because it changes which token appears first, not because it hides the verb behind quotes.

    Tried: Using ${ls} or ${cat} without an operator, expecting the expansion to run the command.

    Without an operator like = the brace form just expands the variable named 'ls' or 'cat', which is unset and expands to an empty string - nothing gets executed. You need the assign-default form ${parameter=word} so Bash sets the variable to the word and then substitutes that value, which the shell subsequently runs as a command.

    Learn more

    The Special shell wraps every line in a regex-style rewriter that hunts for known command names at the start of input. The regex sees a literal token; it does not understand the rest of Bash's grammar. The form ${parameter=word} is the assign-default parameter expansion: if parameter is unset, Bash sets it to word and substitutes that value, which then runs as a command. From the sanitizer's point of view the line begins with a brace, not a verb, so it leaves it alone.

    A worked trace makes the asymmetry concrete. Input ${parameter=ls} arrives. The sanitizer scans for ls, cat, cd as the first identifier; finding ${ instead, it passes the line through. Bash then parses parameter expansion, assigns parameter=ls, substitutes ls, and only at that point does the shell try to run the command. The rewriter never gets a second look.

    Empirical edge cases worth confirming on the box: ${ ls} with a leading space is a syntax error (Bash expects an identifier immediately after the brace), and ${ls} with no operator simply expands an unset variable to the empty string. The sanitizer rejects neither, but only the assign-default form (=) actually runs anything. JSX renders the literal braces with { and } escapes; the shell line itself is plain.

    The technique generalises across restricted shells: rbash, git-shell, and CTF jails all leak when the filter is built on string matching rather than a full Bash parser. The wider toolbox covers aliases, functions, command substitution $(cmd), process substitution <(cmd), brace expansion, and SUID detours through vim :! or awk system(). See Linux CLI for CTFs for a broader survey of these patterns.

  2. Step 2
    Chain the exploit
    Observation
    I noticed the sanitizer was still catching the filename when passed as a direct argument to cat inside the parameter expansion, which suggested using stdin redirection with < to feed the file to cat without it appearing as an argv argument the filter could inspect.
    Use ${parameter=ls blargh} to enumerate the directory and ${parameter=cat < blargh/flag.txt} to read the flag through stdin redirection.
    What didn't work first

    Tried: Trying ${parameter=cat blargh/flag.txt} with the filename as a normal argument.

    Passing the path as an argument to cat still gets caught by the sanitizer because it matches the argument pattern it is filtering for. The redirection operator approach - using cat < blargh/flag.txt - keeps the filename out of the argument list entirely and feeds the file through stdin instead, which the filter does not inspect.

    Tried: Using ${parameter=ls} without a directory argument and then guessing the flag filename.

    Without ls blargh you only see the top-level directory and may not realize the flag is nested inside the blargh subdirectory. Running ${parameter=ls blargh} first reveals flag.txt inside that folder, giving you the exact path needed for the cat redirect command.

    Learn more

    The redirection trick is the second half of the bypass. The sanitizer matches arguments to commands; it does not parse redirection operators. cat < blargh/flag.txt opens the file as cat's stdin instead of passing it as argv[1], so a filter that blocks cat foo often misses cat < foo. Combine that with the parameter-expansion wrapper and the whole expression sails past the rewriter.

    The same lesson applies to real restricted-shell deployments: kiosk terminals, git-shell, container init shells. Whenever the gate is "match these tokens" rather than "parse the language and enforce a whitelist on the AST," the gate leaks. The only durable fix is to swap the shell for a non-shell interface or to enforce the boundary at the syscall layer with seccomp or namespaces.

Flag

Reveal flag

picoCTF{5p311ch3ck_15_7h3_w0...35}

Any creative use of ${parameter=...} (or similar expansion) that runs cat on blargh/flag.txt yields the answer.

Key takeaway

Shell filters that match command names at the start of input are bypassable via parameter expansion, which places the verb inside a ${parameter=word} substitution that the sanitizer never sees. The durable fix is to enforce the boundary at the syscall or kernel level - a filter built on string matching against an informal grammar will always have edge cases that let a verb through in disguise.

Related reading

Want more picoCTF 2023 writeups?

Useful tools for General Skills

What to try next