Skip to main content

Special picoCTF 2023 Solution

Break out of a limited shell environment by finding alternative ways to execute commands and read the flag.

Published: April 26, 2023Updated: August 25, 2026

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 1Probe the sanitizer
    Observation
    The shell autocorrects and blocks bare commands like ls and cat, so the filter is matching known command names at the start of the input. A construct that begins with a different character, such as a parameter expansion, gets past 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 defeats a naive string match, but this rewriter still recognizes the command token after unquoting, so the quoted form is blocked all the same. The assign-default expansion works for a different reason: it changes which token comes first, not where the verb is hidden.

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

    Without an operator, the brace form just expands a variable named ls or cat. Those are unset, so it expands to nothing and nothing runs. The assign-default form makes Bash set the variable to the word and substitute that value, which the shell then executes.

    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 2Chain the exploit
    Observation
    Once the expansion runs real commands, list blargh to find flag.txt and then read it. Be precise about what the shell does with the payload: a < written inside the expansion is not a redirection, because redirections are recognised at parse time and this one only appears after the expansion has already happened.
    Use ${parameter=ls blargh} to enumerate the directory, then ${parameter=cat blargh/flag.txt} to read the flag. The ${parameter=cat < blargh/flag.txt} form also prints it, but the < is a plain argument rather than a redirection, so cat complains about it first.
    What didn't work first

    Tried: Reading the < in ${parameter=cat < blargh/flag.txt} as a genuine stdin redirection.

    It is not one. Bash decides which tokens are redirection operators while parsing the line, and at that moment the whole thing is still a single ${...} word. After the expansion, word splitting hands cat two arguments, a literal < and blargh/flag.txt, so cat prints "cat: '<': No such file or directory" and then dumps the flag anyway. The exit status is 1 even though the flag appeared.

    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 command.

    Learn more

    The second half is just argument passing, and it pays to be exact about it. Bash identifies redirection operators while parsing the line, and at parse time ${parameter=cat < blargh/flag.txt} is one single word. Only afterwards does the expansion produce cat < blargh/flag.txt, and word splitting then hands cat two ordinary arguments: a literal < and blargh/flag.txt. You can reproduce that locally: the shell prints cat: '<': No such file or directory and then the file contents, exiting 1. The flag appears, but not because anything was redirected. Dropping the < entirely (${parameter=cat blargh/flag.txt}) is the clean form and exits 0.

    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.

Interactive tools
  • Regex TesterTest regular expressions against a string with live match highlighting, flag toggles, and common CTF pattern shortcuts.
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.
  • Reverse Shell GeneratorGenerate reverse shell payloads (bash, nc, python, perl, ruby, php, node, powershell) and matching listeners. Set host and port once, copy any variant.

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

Useful tools for General Skills

Where to go next