Skip to main content

Investigative Reversing 4 picoCTF 2019 Solution

Reverse a steganographic encoder that scatters flag bits across multiple images and reconstruct the flag.

Published: April 2, 2026Updated: August 25, 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 1Read the embedding loop in Ghidra
    Observation
    The binary takes BMP files as arguments and produces no visible output or flag text. So the flag is embedded into those images by a custom loop that only the disassembly will 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 by their own schemes: a password-protected steghide container, or zsteg's PNG-oriented LSB scan. This binary uses a custom loop with a 2019-byte start offset and a stride of 5 that neither tool knows about, so both return no data or noise.

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

    main() here is mostly file-open boilerplate and hands off to codedChar() for the real work. The seek offset (2019), the stride (j % 5), and the bit-order decision all live inside codedChar(). Stop at main() and you get filenames and nothing about how the data is written.

    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 2Confirm the file operations with strace
    Observation
    Ghidra's output can be slightly off, especially on seek offsets derived from pointer arithmetic. strace confirms the actual lseek and open calls before any extractor gets written.
    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. Without filtering, the signal is buried under mmap, mprotect, and other loader calls. Grepping for open, lseek, and write narrows it to the three syscall types that show where data goes, which makes the 2019-byte offset obvious.

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

    The binary expects five images. Pass fewer and it fails on the missing fopen calls before it ever seeks or writes, so the lseek lines that confirm the offset never appear. All five BMPs have to 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 3Extract the flag with the inverse loop
    Observation
    Ghidra's codedChar() gives all four extraction parameters: offset 2019, stride 5, little-endian bit order, and reverse image sequence. A Python script mirroring those exactly will 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, in the form picoCTF{N1c3_R3ver51ng_5k1115_...} with a per-instance hex suffix after the last underscore. 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 starting from Item01, but reassembly has to read back Item05 to Item01, because the outer loop writes the last flag characters into the earlier-indexed files. Read forward and the bytes come out in the wrong order, so unbits() decodes to what looks like random ASCII.

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

    The embedding loop writes bits from least significant to most significant position, which is little-endian bit order. Pass endian='big' to unbits() and the bit order reverses inside every byte, corrupting all eight decoded values. The output still looks like ASCII, just with the wrong letters, so the flag is unrecognizable unless you already know the 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 trailing suffix is per-instance, so run the extractor against your own images rather than copying the value above. 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 is ground truth for every extraction parameter: start offset, stride, bit order, file ordering. You cannot guess these, because LSB extraction fails silently and hands back plausible-looking garbage rather than an error. Pairing static analysis in Ghidra with dynamic confirmation from strace is a robust workflow for any forensics challenge where a custom encoder scrambles the layout.

Related reading

Useful tools for Forensics

Where to go next