Skip to main content

SansAlpha picoCTF 2024 Solution

The shell rejects letters, so build every character you need from error text and globs until you can read the flag file.

Published: April 3, 2024Updated: August 25, 2026

Description

The Multiverse is within your grasp! Unfortunately, the server that contains the secrets of the multiverse is in a universe where keyboards only have numbers and (most) symbols.

Keyboard-restricted shell

SSH to mimas.picoctf.net on port <PORT_FROM_INSTANCE> with password <PASSWORD_FROM_INSTANCE>.

Experiment with the restricted shell to learn which characters it accepts and how it responds to your input.

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
The Linux CLI for CTF guide covers the bash parameter-expansion and globbing tricks this challenge forces you to lean on, plus the standard kit for harvesting characters out of error messages.
  1. Step 1Capture an error message into a variable
    Observation
    The shell rejects every letter you type, so the only letters available are the ones it prints itself. Trap a predictable error message into a variable and index into it later.
    Pick an invalid token like $ or ? and trap its error output in a variable. _1=$ 2>&1 runs the bare $ as a command, redirects stderr into stdout, and stores "bash: $: command not found" in $_1. Verify with echo "$_1" - if you see that single line printed back, you have your character source.
    bash
    _1=`$ 2>&1`
    bash
    echo "$_1"

    Expected output

    bash: $: command not found
    What didn't work first

    Tried: Using $() instead of backticks to capture the error, without the 2>&1 redirect

    A plain capture takes stdout only, and bash error messages go to stderr, so the variable ends up empty and you have no character source. Merge stderr into stdout inside the capture with 2>&1.

    Tried: Running a random word like foo 2>&1 instead of $ to trigger the error

    Letters are rejected before the shell ever tries to run anything, so no error message is produced. Use a non-letter token like $ instead: the shell tries to evaluate it, fails, and prints the 'command not found' string you need.

    Learn more

    The keyboard restriction blocks letters, but parameter expansion (${_1:N:1}) does not require typing letters - it indexes into a string by position. By trapping bash's own error output we get a long string full of letters we can index into.

    $ as a standalone command is the cleanest trigger because it parses, fails immediately, and produces exactly one line: bash: $: command not found. ? works similarly. Wrapping in backticks runs the command in a subshell and captures its output; the 2>&1 redirection sends stderr (where the error actually lives) onto stdout so it can be captured. Without that redirection $_1 would silently end up empty, and the next step would fail with no useful diagnostic.

  2. Step 2Extract characters with parameter expansion
    Observation
    That captured string, 'bash: $: command not found', puts common lowercase letters at fixed positions. Bash's substring expansion plucks them out one at a time without typing a letter.
    Bash ${var:offset:length} slices a substring out of a variable. Test locally first: echo "${_1:9:1}" should print c, and echo "${_1:10:1}" should print o. From the captured string "bash: $: command not found", you can pluck b/a/s/h/c/o/m/n/d/t/f/u as needed.
    Learn more

    This is the heart of the trick. The captured error string contains the lower-case alphabet's commonly-used letters in fixed positions, so you can spell short command names like echo, cat, sh, or bash by chaining slices.

    String:    bash: $: command not found
    Position:  01234567890123456789012345
                        1111111111222222
    
    Useful indices (verify with: echo "${_1:N:1}"):
      ${_1:0:1}  = b
      ${_1:1:1}  = a
      ${_1:2:1}  = s
      ${_1:3:1}  = h
      ${_1:9:1}  = c   <- needed for /bin/echo
      ${_1:10:1} = o   <- needed for /bin/echo
      ${_1:13:1} = a
      ${_1:15:1} = d
      ${_1:17:1} = n
      ${_1:19:1} = t
      ${_1:23:1} = u
      ${_1:24:1} = n
  3. Step 3Locate the flag file with a glob
    Observation
    Wildcards need no letters at all. A glob like ./*/* enumerates files with no ls available, and bash names the matched path in the error it prints when it tries to execute it.
    Run ./*/* and bash expands the glob to whatever paths exist; the listing here shows the flag at ./blargh/flag.txt, matching pattern ./*/????.???.
    bash
    ./*/*

    Expected output

    bash: ./blargh/flag.txt: Permission denied
    What didn't work first

    Tried: Running /* or /* /* to enumerate files instead of ./*/*

    A glob starting at / expands across the whole filesystem root and buries the challenge directory in system paths. Anchor it with ./ so it stays in the working directory where the flag is.

    Tried: Trying to read the flag directly after seeing the path in the error message

    The shell answered 'Permission denied' when it tried to execute the file as a command, so that route is closed. You have the path; reading it still needs a constructed command or command substitution, which the next steps build.

    Learn more

    ./*/* expands to the list of files exactly one directory deep below the current directory. Bash tries to execute the first match as a command, which (helpfully) fails and prints the file path in the error message - giving you the path even when you can't type letters to ls. The pattern ./*/????.??? later picks out the same path by structure (4-letter name, 3-letter extension).

  4. Step 4Build /bin/echo from globs and slices
    Observation
    /bin is exactly three characters and echo is four. A pattern like /???/?c?o, with the c and o supplied as slices of the captured error string, resolves to /bin/echo with no letters typed.
    Glob /???/?${_1:9:1}?${_1:10:1} into the path /bin/echo. The /??? matches /bin (any 3-char directory under /), and the inner ?c?o pattern matches "echo" via positions 9 and 10 of $_1. The earlier attempt at /usr/bin/cat (/?${_1:2:1}?/???/??${_1:19:1}) failed because the ?s? prefix didn't expand to /usr on the box (the glob found no matches and bash printed "bash: /?s?/???/??t: No such file or directory").
    bash
    /???/?${_1:9:1}?${_1:10:1}
    What didn't work first

    Tried: Trying to reach /usr/bin/cat with /?${_1:2:1}?/???/??${_1:19:1} because cat is more familiar than echo

    That pattern needs a three-character directory under / with s in the middle, and this machine has none. Bash leaves the unmatched pattern literal and the shell reports no such file. The simpler /???/?c?o works because /bin is three characters and echo is four.

    Tried: Constructing the full path as a string in a variable, then running it with ${_cmd}

    Assigning the path to a variable is blocked, since the assignment contains letters. Even built from slices, expanding it as the command name does not behave like a bare glob: bash does not re-split and re-expand a variable used in that position. Put the glob directly in the command slot.

    Learn more

    Verify the glob expands the way you expect before using it as the command: echo /???/?${_1:9:1}?${_1:10:1} should print /bin/echo and nothing else. If it prints multiple matches or the literal pattern, the glob is wrong and the command will not execute echo.

    When a glob has no match, bash on this image leaves the literal pattern in place, so the failure surface is "/?s?/???/??t: No such file or directory" - the unmatched pattern itself appears in the error, which doubles as a diagnostic. That's the failure mode you should expect, and how you knew the /usr/bin/cat path's middle ?s? wedge wasn't hitting /usr.

  5. Step 5Read the flag
    Observation
    Bash's $(<file) reads a file as a builtin, with no cat involved. Pair it with a glob for the flag path and the reconstructed echo, and the flag prints.
    Combine the constructed echo with bash command substitution: /???/?${_1:9:1}?${_1:10:1} "$(<./*/????.???)" prints the contents of ./blargh/flag.txt without ever typing the literal letters cat, less, or grep.
    bash
    /???/?${_1:9:1}?${_1:10:1} "$(<./*/????.???)"

    Expected output

    picoCTF{7h15_mu171v3r53_15_m4dn355_145...}
    What didn't work first

    Tried: Using $(cat ./blargh/flag.txt) instead of $(<./blargh/flag.txt) to read the file

    The word cat contains letters, which the restricted shell blocks before the command can even run. $(<file) is a bash builtin read-file form that requires no external program and no letters, making it the only viable option in this environment.

    Tried: Passing the glob ./*/????.??? directly as a shell argument without the $(<...) wrapper

    Without the $(<...) wrapper the glob expands to the path as a string, so echo prints the path rather than the flag. That wrapper is what makes bash open the file and pass its contents along.

    Learn more

    $(<file) is a bash builtin that reads the contents of file without spawning cat; it's the bash idiom for "file slurp." Combined with the globbed-up echo, the final command never types any disallowed character but still prints the flag contents.

Interactive tools
  • Regex TesterTest regular expressions against a string with live match highlighting, flag toggles, and common CTF pattern shortcuts.
  • 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{7h15_mu171v3r53_15_m4dn355_145...}

There are multiple paths through this challenge - the key insight from the hint 'Where can you get some letters?' is to harvest characters from error messages and use bash parameter expansion to build valid commands.

Key takeaway

Restricted shells and character filters almost always come apart, because the shell leaks the forbidden characters back through its own error messages, and its metacharacters, parameter expansion, globbing, process substitution, are enough to rebuild any command from what you harvest. Real escapes follow that shape: find a channel that emits the restricted content, then let the shell's own evaluation consume it. That is why the answer is a minimal interpreter or a purpose-built sandbox, not a blocklist.

Related reading

Useful tools for General Skills

Where to go next