Investigative Reversing 4 picoCTF 2019 Solution

Published: April 2, 2026

Description

The final and hardest of the Investigative Reversing series. A C binary LSB-encodes the flag into the pixel bytes of five BMP images, starting at a fixed offset of 2019 bytes into each file. The flag is split across all five, the bits go in little-endian within each byte, and the images are processed in reverse order. Once you read the embedding loop in Ghidra, the extractor is a short Python script.

Download the binary and all five encoded BMPs (Item01_cp.bmp through Item05_cp.bmp).

bash
wget <url>/mystery
bash
# plus Item01_cp.bmp ... Item05_cp.bmp
bash
chmod +x mystery

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Read the embedding loop in Ghidra
    Observation
    I noticed the binary takes BMP image files as arguments with no visible output or flag text, which suggested the flag was embedded inside those images using a custom loop that only the disassembly could reveal.
    Open the binary in Ghidra and find codedChar(). It seeks past a fixed 2019-byte offset in each image, then for one flag byte it spreads that byte's 8 bits across 8 consecutive cover bytes, one bit per byte in the low bit (classic LSB embedding). The outer loop walks 50 bytes per image but only embeds on every 5th position (j % 5 == 0). The five images are written Item01..Item05, but the flag is reassembled from Item05 back to Item01.
    bash
    ghidra mystery &
    bash
    # Confirm: seek(2019); for j in 0..50: if j%5==0 embed one flag byte as 8 LSBs.
    bash
    # Bits are stored little-endian; images are read back in reverse (05 -> 01).
    What didn't work first

    Tried: Use steghide or zsteg on each BMP directly to extract embedded data.

    steghide and zsteg look for data hidden with their own specific embedding schemes (password-protected steghide container, or zsteg's PNG-oriented LSB scan). This binary uses a custom loop with a non-standard 2019-byte start offset and a stride-of-5 pattern that neither tool knows about, so both return 'no data found' or random noise rather than flag bytes.

    Tried: Search for the flag offset by looking at the decompiled main() function rather than codedChar().

    main() in this binary is mostly file-open boilerplate and hands off to codedChar() for the actual embedding. The seek offset (2019), the stride (j % 5), and the bit-order decision all live inside codedChar(). Stopping at main() gives you only file names and nothing about how data is written, leaving the extraction parameters unknown.

    Learn more

    LSB steganography. The least significant bit of a byte can be flipped without visibly changing an image pixel, so hiding data means overwriting those low bits. To recover it you read the same bytes in the same order and collect their low bits. The only per-challenge details are where embedding starts (offset 2019), the stride (every 5th of 50 bytes), the bit order (little-endian), and the image order (reverse). All four come straight from the decompiled loop.

  2. Step 2
    Confirm the file operations with strace
    Observation
    I noticed Ghidra's decompiled output can contain minor inaccuracies, especially for seek offsets derived from pointer arithmetic, which suggested using strace to dynamically confirm the exact lseek and open calls before writing the extractor.
    Run the binary under strace to confirm which files it opens and the byte offsets it seeks to, cross-checking your static read of the loop.
    bash
    strace ./mystery *.bmp 2>&1 | grep -E 'open|lseek|write'
    What didn't work first

    Tried: Run strace without grep and try to read the raw output to find the seek offset.

    strace on a binary that opens five files and seeks repeatedly produces hundreds of lines of output. Without filtering for lseek and write syscalls the signal is buried in noise from mmap, mprotect, and other loader calls. The grep for 'open|lseek|write' narrows output to the three syscall types that encode where data is written, making the 2019-byte offset immediately visible.

    Tried: Pass only one BMP to strace instead of all five to simplify the trace.

    The binary expects five images to be present. Passing fewer causes it to fail on the missing fopen calls before it ever seeks or writes, so the lseek output that confirms the embed offset never appears. All five BMPs must be present for the trace to reach the embedding loop.

    Learn more

    The seek offset and the set of output files you see in the syscall trace should match what Ghidra showed. This catches mistakes like an off-by-one in the start offset before you write the extractor.

  3. Step 3
    Extract the flag with the inverse loop
    Observation
    I noticed that Ghidra's codedChar() function revealed all four extraction parameters (offset 2019, stride 5, little-endian bit order, reverse image sequence), which suggested writing a Python script that mirrors those exact parameters to reconstruct the flag bytes from the LSBs.
    Reproduce the embedding in reverse: for each image from 05 down to 01, seek to 2019, then for 50 positions read a byte; on every 5th position read the next 8 bytes and collect their low bits as a little-endian byte. Join the bits and decode to ASCII.
    python
    python3 - <<'PY'
    from pwn import unbits
    
    bits = ""
    for i in range(5, 0, -1):                  # images in reverse: 05 -> 01
        with open(f"Item0{i}_cp.bmp", "rb") as f:
            f.seek(2019)                        # fixed embed offset
            for j in range(50):
                if j % 5 == 0:                  # only every 5th position carries a byte
                    for _ in range(8):
                        bits += str(f.read(1)[0] & 1)   # collect the LSB
                else:
                    f.read(1)
    
    print(unbits(bits, endian="little").decode())
    PY

    Expected output

    picoCTF{N1c3_R3ver51ng_5k1115_...}

    The decoded string is the flag: picoCTF{N1c3_R3ver51ng_5k1115_0000000000023ef6902}. If you get garbage, the usual culprits are reading the images in the wrong order or using big-endian bit order.

    What didn't work first

    Tried: Iterate the images in forward order (Item01 to Item05) instead of reverse.

    The binary embeds flag bytes starting from Item01 but the reassembly requires reading them back Item05 to Item01 because the outer loop writes the last flag characters into the earlier-indexed files. Reading in forward order produces bytes in the wrong sequence, so unbits() decodes to a scrambled string that looks like random ASCII rather than a picoCTF flag.

    Tried: Use unbits with endian='big' instead of endian='little' to decode the collected bits.

    The embedding loop writes bits from the least significant to most significant bit position (low index = LSB), which is little-endian bit order. Passing endian='big' to unbits() reverses the bit order within every byte, corrupting all 8-bit decoded values. The output looks like ASCII but with wrong characters - for example a letter in the 'N' range becomes a different letter, making the flag unrecognizable without knowing the expected answer.

    Learn more

    Why the order details matter so much. LSB extraction is unforgiving: a single wrong assumption (start offset, stride, bit endianness, image order) corrupts every byte after it. That is the whole point of the Investigative Reversing series, pairing binary reversing in Ghidra with precise data extraction in Python. See the forensics workflow for adjacent carving techniques.

Interactive tools
  • StegallDrop any file and Stegall runs every applicable steg technique in parallel: LSB sweeps, bit planes, spectrograms, polyglot carving, metadata, whitespace decode, and a 6-layer base/ROT/XOR/zlib cascade. Recursively unpacks results and surfaces flag matches.
  • 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.

Flag

Reveal flag

picoCTF{N1c3_R3ver51ng_5k1115_...}

The binary LSB-embeds the flag into five BMPs starting at offset 2019, one flag byte per 8 cover bytes, only on every 5th of 50 positions, bits little-endian. Reassemble by reading the images in reverse (05 to 01) and collecting low bits, then decode to ASCII.

Key takeaway

When a binary custom-embeds data into a file format, the encoder source code is the ground truth for every extraction parameter: start offset, stride, bit order, and file ordering. Guessing any one of these correctly requires reading the loop, because LSB extraction fails silently, producing plausible-looking garbage rather than an obvious error. Combining static analysis in Ghidra with dynamic confirmation via strace is a robust workflow that applies to any forensics challenge where a custom encoder scrambles the data layout.

Related reading

Want more picoCTF 2019 writeups?

Useful tools for Forensics

What to try next