Skip to main content

Investigative Reversing 2 picoCTF 2019 Solution

A forensics challenge combining binary analysis and steganography to recover a flag concealed across image data.

Published: April 2, 2026Updated: August 25, 2026

Description

Two files: a 64-bit ELF binary called mystery and a bitmap image called encoded.bmp. The binary embedded the flag into the image using LSB steganography with an arithmetic twist. Reverse-engineer the encoder to write a decoder that recovers the flag.

Download both files and make the binary executable.

bash
wget <url>/mystery
bash
wget <url>/encoded.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 1Reverse-engineer the binary in Ghidra
    Observation
    The challenge ships a compiled ELF alongside the encoded image. The encoding logic lives in that binary, so decompiling it in Ghidra is the only way to get the exact arithmetic transform and byte offset a decoder needs.
    Open mystery in Ghidra and decompile it. The binary reads a 50-character flag from flag.txt, subtracts 5 from each character's ASCII value, then encodes the result bit-by-bit into the least significant bit (LSB) of consecutive bytes in an output BMP. The first 2000 bytes (offset 0x7d0) of the BMP are copied unchanged; the encoded region starts immediately after and spans 400 bytes (50 characters x 8 bits each).
    bash
    ghidra mystery &
    What didn't work first

    Tried: Running strings or ltrace on the binary to find the encoding logic without Ghidra.

    strings shows the filenames (flag.txt, encoded.bmp) but not the arithmetic or the offset. ltrace shows libc calls like fread and fwrite while hiding the per-bit work inside a custom loop. Only Ghidra exposes the -5 subtraction and the exact LSB expression.

    Tried: Using steghide or zsteg to extract the hidden data automatically.

    steghide expects a passphrase-protected payload in its own header format, so it reports that the BMP contains no embedded data. zsteg does read BMPs, but it scans the standard channel-and-bit-order combinations from the start of the pixel data, not a run that begins at a fixed file offset of 2000. Neither knows about the custom -5 shift, so even correctly extracted bits would come out five off on every character.

    Learn more

    LSB steganography works by replacing just the lowest bit of each carrier byte with one bit of secret data. Changing a byte from 0xe8 to 0xe9 is a difference of 1, which is invisible to the eye but detectable by reading those bits back. The encoder here uses the C expression (carrier_byte & 0xfe) | (flag_bit & 1), which zeroes the LSB and then OR-s in the new bit.

    The -5 arithmetic shift is a simple obfuscation layer. If the flag character is 'p' (ASCII 112), the encoder embeds 107 instead. The decoder must add 5 back to recover the original character.

  2. Step 2Extract LSBs from the encoded region and decode the flag
    Observation
    The Ghidra decompilation shows the encoder seeking to offset 0x7d0 (2000) in the BMP and storing flag bits in LSB order with a -5 subtraction applied. So the decoder skips the same 2000 bytes, reads LSBs in LSB-first order, and adds 5 back.
    Seek to byte offset 2000 in encoded.bmp, then read 400 bytes. Extract the LSB of each byte in order. Every 8 consecutive bits form one character. After reassembling the 8-bit value, add 5 to reverse the encoder's subtraction and convert to a character.
    python
    python3 << 'EOF'
    with open('encoded.bmp', 'rb') as f:
        f.seek(2000)
        data = f.read(50 * 8)  # 50 chars, 8 bits each
    
    bits = [byte & 1 for byte in data]
    
    flag = ''
    for i in range(50):
        byte_bits = bits[i * 8:(i + 1) * 8]
        # bits were stored LSB-first (little-endian bit order)
        value = 0
        for j, bit in enumerate(byte_bits):
            value |= bit << j
        flag += chr(value + 5)  # reverse the encoder's -5 shift
    
    print(flag)
    EOF
    What didn't work first

    Tried: Reading from offset 0 instead of 2000, treating the entire BMP including the header as the encoded region.

    The encoder copies the first 2000 bytes verbatim, so LSBs pulled from that region are noise from the BMP header and untouched pixel data, and the reassembled characters are not printable. Ghidra shows the skip outright: the encoder seeks to 0x7d0 before the bit-writing loop starts.

    Tried: Reconstructing each byte MSB-first (bit 7 down to bit 0) instead of LSB-first.

    Looping j from 7 down to 0 with value |= bit << j reverses the bit order relative to what the encoder stored, so every character comes out wrong and usually non-printable. The encoder writes bit 0 of the character first, so the decoder has to put the first extracted bit at position 0.

    Learn more

    The bit order matters. Ghidra will show the encoder looping from bit position 0 to 7 (LSB to MSB), so the decoder must reconstruct the byte using the same order: bit 0 of the extracted sequence is bit 0 of the character value. The expression value |= bit << j does exactly that.

    After adding 5 back, every character should fall in the printable ASCII range. If you see garbage, double-check the byte order and the offset (some builds use a slightly different offset based on BMP header size).

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{n3xt_0n3_...}

The flag hash suffix is instance-specific and will differ between runs of the challenge.

Key takeaway

LSB steganography hides data by replacing the least significant bit of each pixel or sample byte, a change too small to see or hear but perfectly recoverable by reading those bits back in order. Add a simple arithmetic transform to the values before embedding and you have a light obfuscation layer that cannot be undone without understanding the encoder. The same primitive drives digital watermarking, covert exfiltration over image-sharing sites, and tools like steghide and zsteg.

Related reading

Useful tools for Forensics

Where to go next