perplexed picoCTF 2025 Solution

Published: April 2, 2025

Description

A stripped 64-bit ELF asks for a password and only prints "Wrong :(" when you guess incorrectly. Reverse the bitwise check function to reconstruct the expected bytes and feed them back to the program.

Grab the binary, mark it executable, and run it once to see the password prompt.

Confirm with file that it is a stripped 64-bit ELF. strings perplexed | grep -E 'Wrong|Correct|picoCTF' confirms the prompt strings exist but the flag is not stored as plaintext.

Load the executable into Ghidra (or IDA/Hopper) and inspect main, which forwards user input to check.

bash
wget https://challenge-files.picoctf.net/c_verbal_sleep/2326718ce11c5c89056a46fce49a5e46ab80e02d551d87744306ae43a4767e06/perplexed
bash
chmod +x perplexed && ./perplexed
bash
file perplexed
bash
strings perplexed | grep -E 'Wrong|Correct|picoCTF'

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
The decompilation step is straight out of the Ghidra Reverse Engineering guide: copy a hardcoded byte array out of the decompiler, replay the bit-shuffle in Python, and the "password" falls out as the flag itself.
  1. Step 1
    Analyze the check routine
    Observation
    I noticed that strings found the 'Wrong' and 'Correct' prompts but no plaintext flag, which suggested the comparison logic was obfuscated inside a compiled function rather than a simple string equality, making static decompilation of the check routine the natural first step.
    Decompiling check reveals a 0x17-byte array local_58 and two nested loops that compare each bit of the user input to each bit of local_58. The function also requires an exact 26-byte password (strlen(input) == 0x1a).
    Learn more

    Static reverse engineering is the process of analyzing a compiled binary without executing it. Tools like Ghidra (free, NSA-developed), IDA Pro (industry standard), and Binary Ninja disassemble machine code and use heuristics to reconstruct higher-level pseudocode. The decompiler output isn't perfect C, but it reveals the structure of loops, conditionals, and data accesses well enough to understand the algorithm.

    Ghidra names stack variables by offset from the saved frame pointer. The slots in check map cleanly: local_58 is the 23-byte hardcoded array, local_20 is the bit counter (0..7) inside the inner loop, local_2c is the byte accumulator that gets emitted every 8 bits, and local_30 / local_34 are the per-iteration bit masks computed from the bit index. When you read an unfamiliar decompilation, lining up these stack slots with their roles is what turns "wall of pseudocode" into "I can replay this".

    The byte-array literals print with signed values like (char)-0x1f because Ghidra renders signed char; that is the two's-complement of the raw byte. -0x1f equals 0xe1 at the byte level, and Python lines them up via value & 0xff when you need an unsigned reading. When a binary is stripped (compiled without debug symbols), function names like check are replaced with addresses; Ghidra still finds boundaries via prologue/epilogue heuristics, but you rename as you go. Symbols can sometimes be recovered by matching code against known library versions, a technique called FLIRT (Fast Library Identification and Recognition Technology) in IDA.

  2. Step 2
    Recreate the bit logic in Python
    Observation
    I noticed that Ghidra's decompilation of check revealed a hardcoded 23-byte array (local_58) and a fully deterministic nested bit-shuffle loop with no randomness, which suggested that replaying the exact same loop in Python on those embedded bytes would directly produce the one and only accepted password.
    Right-click the local_58 array in Ghidra and copy the 23 literal values (some appear as signed bytes - keep them as the negative ints Ghidra prints, since Python signs map cleanly via & 0xff if needed). Reproduce the nested loops: for every set bit in local_58, set the matching bit in an accumulator and emit a character every 7 bits (bit positions 6 down to 0; the MSB is never written, so every output character is in the 7-bit ASCII range).
    python
    python3 - <<'PY'
    local_58 = [-0x1f, -0x59, 0x1e, -8, ord('u'), ord('#'), ord('{'), ord('a'), -0x47, -99, -4, ord('Z'), ord('['), -0x21, ord('i'), 0xd2, -2, 0x1b, -0x13, -0xc, -0x13, ord('g'), -0xc]
    flag = []
    local_20 = 0
    local_2c = 0
    for value in local_58:
        for bit in range(8):
            if local_20 == 0:
                local_20 = 1
            local_30 = 1 << (7 - bit)
            local_34 = 1 << (7 - local_20)
            if value & local_30:
                local_2c |= local_34
            local_20 += 1
            if local_20 == 8:
                flag.append(chr(local_2c))
                local_20 = 0
                local_2c = 0
    print(''.join(flag))
    PY

    Expected output

    picoCTF{0n3_bi7_4t_a_7im3}
    What didn't work first

    Tried: Using angr to symbolically execute the binary and find the satisfying input automatically, without reading the decompilation at all.

    angr can in principle solve this, but the nested bit loops create a very large constraint graph that causes the solver to time out or return no solution within a reasonable window. The check function's inner loop runs 8 * 23 = 184 iterations, each adding a bitvector constraint, which pushes angr's Z3 backend past its practical limit for this kind of bit-permutation logic. Reading the decompilation and replaying the logic in a plain Python loop is orders of magnitude faster for a fully deterministic, hardcoded comparison like this one.

    Tried: Copying the local_58 byte values from Ghidra as-is (including negative signed values like -0x1f) and treating them as Python ints without masking, then wondering why the output is garbled.

    Ghidra renders stack bytes as signed chars, so -0x1f in Ghidra is 0xe1 at the byte level. When the bit-loop does value & local_30, Python's arbitrary-precision integers handle negative numbers differently than C's signed char arithmetic: a negative Python int has infinite sign-extended bits, so (-0x1f) & 0x80 is 0x80 as expected, but checking individual bits in the upper range can still silently diverge. Applying & 0xff to each element of local_58 before the loop normalizes every value to an unsigned 8-bit integer and eliminates the signed/unsigned mismatch.

    Learn more

    Bit manipulation is a favourite obfuscation technique in CTF reverse engineering challenges. By operating on individual bits rather than whole bytes or characters, the author makes it harder to immediately recognize the algorithm from the decompiled output. Common bit operations include XOR masking, bit rotation (ROL/ROR), interleaving bit fields from two values, and permuting individual bit positions.

    The key insight for this challenge is that the check function is deterministic - given the same local_58 array (which is hardcoded in the binary), it always accepts exactly one password. Rather than guessing the password, you replay the same logic in Python to compute what the password must be. This "emulate the validator" approach works whenever the comparison function has no randomness and the key material is embedded in the binary.

    Python is ideal for this kind of reconstruction because its integers are arbitrary precision (no overflow surprises), it handles signed/unsigned values transparently when you use & 0xFF or ctypes.c_int8, and list comprehensions make bit manipulation loops compact. Alternatively, tools like angr (a symbolic execution engine) can automatically find inputs that satisfy a binary's constraints without manual analysis - useful for more complex validation logic.

  3. Step 3
    Submit the recovered password
    Observation
    I noticed the Python script output began with 'picoCTF{', which confirmed the bit-loop reconstruction was correct and that the recovered string was both the valid password and the flag, making direct submission the final step.
    Running the script prints the picoCTF flag in plaintext. The first 8 bytes of the buffer should already read picoCTF{ after the bit loop completes - if they don't, your bit indexing is wrong (most often an off-by-one in the inner mask). Paste the string back into the program to see "Correct!! :D" and submit the same string as the challenge answer.
    Learn more

    This final step validates your understanding of the algorithm. If the script's output triggers "Correct!! :D" when fed to the binary, you've successfully reversed the bit permutation. If not, the most common errors are off-by-one mistakes in the bit indexing, incorrect handling of signed vs. unsigned bytes from the Ghidra decompilation, or mis-transcribed constants from the local_58 array.

    A useful debugging technique is to run the binary under GDB and set a breakpoint inside check to observe what value the loop is building on each iteration, then compare that to your Python output at each step. The pwndbg and GEF GDB plugins add rich visualization of registers, stack frames, and memory - making dynamic analysis much more comfortable than vanilla GDB.

    For competitive CTF play, the "reversed algorithm" technique generalizes broadly: if you can understand what transformations a binary applies to your input before comparing it to a target, you can invert those transformations on the target to recover the expected input. This works for XOR ciphers, custom hash functions, encoding schemes, and many other challenge types.

Interactive tools
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
  • 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.
  • File Magic IdentifierIdentify file types from magic numbers. Paste hex bytes or drop a file to detect PNG, JPEG, ZIP, PDF, ELF, PCAP, SQLite, and dozens of other formats.
Alternate Solution

The Bit Shift Calculator is a learning aid for visualizing the bit operations, not a faster solve path - the Python script above is the actual solve. Use it to single-step 1 << (7 - bit) shifts and OR assignments when your reconstruction prints garbage and you want to confirm which bit position is wrong.

Flag

Reveal flag

picoCTF{0n3_bi7_4t_a_7im3}

The 23 encoded bytes in `local_58` already contain the password, so no bruteforce is required once you mirror the bit loop in a higher-level language. Running the script prints the flag directly.

Key takeaway

Bit-level obfuscation transforms plaintext byte-by-byte using individual bit operations, making the algorithm harder to spot in decompiled output than a simple XOR or Caesar shift, but the math is still fully deterministic and reversible. Whenever a validation function stores secret data as a hardcoded array and applies a fixed transformation before comparing against user input, replaying that same transformation in a scripting language directly yields the only accepted input, with no brute force needed. This 'emulate the validator' approach generalizes to any challenge where the comparison function is side-effect-free and the key material is embedded in the binary, including custom hash functions, encoding schemes, and serial-number checks in license-verification code.

Related reading

Want more picoCTF 2025 writeups?

Useful tools for Reverse Engineering

What to try next