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.
Setup
Download the binary and all five encoded BMPs (Item01_cp.bmp through Item05_cp.bmp).
wget <url>/mystery# plus Item01_cp.bmp ... Item05_cp.bmpchmod +x mysterySolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Read the embedding loop in GhidraObservationI 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.bashghidra 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.
Step 2
Confirm the file operations with straceObservationI 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.bashstrace ./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.
Step 3
Extract the flag with the inverse loopObservationI 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.pythonpython3 - <<'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()) PYExpected 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.