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 1Read the embedding loop in Ghidra
ObservationThe 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.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 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.
Step 2Confirm the file operations with strace
ObservationGhidra'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.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. 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.
Step 3Extract the flag with the inverse loop
ObservationGhidra'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.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, 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.