Description
Two files: a mystery binary and an encoded.bmp image. The binary hides a flag inside the BMP using a more complex LSB encoding than the earlier challenges in this series.
Setup
Download both files.
wget <url>/mysterywget <url>/encoded.bmpchmod +x mysterySolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Decompile the binary in Ghidra
ObservationThe challenge gives a compiled binary and an encoded BMP but no source. Decompiling mystery in Ghidra is what reveals the exact start offset and stride pattern the encoder used, before any extraction.Load mystery into Ghidra and inspect the decompiled main. The binary opens flag.txt, original.bmp, and encoded.bmp. It reads the flag one byte at a time and encodes each bit into the LSB of a corresponding byte in encoded.bmp, but with a twist: after every 8 encoded bytes it writes one unmodified byte from the original image. That 8-encoded + 1-passthrough pattern repeats 100 times starting at file offset 0x2d3 (decimal 723).bashghidra mystery &What didn't work first
Tried: Run strings or strace on mystery to infer the encoding scheme without Ghidra.
strings shows the filenames (flag.txt, original.bmp, encoded.bmp) and nothing about the 9-byte cycle or the 0x2d3 start. strace shows open and read calls but not the loop arithmetic. Without the decompiled loop body you will guess wrong parameters and extract garbage.
Tried: Assume the encoding matches the earlier Investigative Reversing challenges and reuse a previous extractor script directly.
Earlier challenges in this series use a simpler stride, no passthrough bytes, and a different start offset. Reuse that script here and every 9th byte gets consumed as data instead of skipped, so the output is garbled. Read this binary's loop first to learn the 8-plus-1 cycle.
Learn more
LSB steganography works by replacing the lowest-order bit of each image byte with one bit of hidden data. A 1-bit change shifts the byte value by at most 1, which is invisible to the eye but detectable by reading that single bit back. The 9-byte cycle here (8 encoded + 1 passthrough) is a deliberate complexity layered on top of basic LSB: the passthrough byte acts as padding that the decoder must skip.
Step 2Confirm the encoding pattern with xxd
ObservationGhidra's decompiled loop points at offset 0x2d3 as the encoding start, with a 9-byte cycle. Worth verifying both against the raw bytes with xxd before building an extractor around them.Before writing the extractor, verify the offset. BMP pixel data in this file begins at byte 54 (the standard BMP header size), but the flag encoding starts much later at 0x2d3. Use xxd to inspect that region and confirm the 9-byte cycle boundary by comparing encoded.bmp to the unmodified original. The LSBs of those first eight bytes read 0,0,0,0,1,1,1,0, which is 0x70 little-endian: the 'p' of picoCTF.bashxxd -s 0x2d3 -l 9 encoded.bmpExpected output
000002d3: 5050 4e4e 5151 5152 50 PPNNQQQRP
What didn't work first
Tried: Start inspecting LSB differences from byte 54, the standard BMP pixel data offset, instead of 0x2d3.
The standard BMP header ends at byte 54, but the encoder deliberately skips the first 0x2d3 bytes before writing any flag bits. Bytes 54 through 0x2d2 are unmodified image data, so diffing that region against the original shows nothing and suggests the encoding has not started. Seek xxd straight to 0x2d3 to land on the real boundary.
Tried: Use a visual diff tool or steghide to detect the modified region rather than xxd.
Visual diff tools compare pixel colors and cannot show a 1-bit LSB change, since the shift is at most one intensity unit, well under perception. steghide expects a passphrase and its own embedding algorithm, so it extracts nothing from a binary that rolled its own scheme. xxd at a specific offset works because it shows raw byte values you can check by hand against the 9-byte cycle.
Learn more
BMP files store pixels row by row, starting after a fixed header. The encoding in this challenge deliberately skips the first 0x2d3 bytes so the image header and some initial pixel data remain clean, making a quick visual comparison harder to spot.
Step 3Write the extractor
ObservationGhidra confirms an 8-encoded plus 1-passthrough cycle starting at 0x2d3, and xxd validates the boundary. That is every parameter needed: read 9 bytes per flag character, collect the LSB of the first 8, skip the 9th.Open encoded.bmp as raw bytes, seek to offset 0x2d3, then loop 50 times (one iteration per flag character). For each character, read 9 bytes: collect the LSB of each of the first 8 bytes to build a bit string (LSB-first, little-endian), then skip the 9th passthrough byte. Convert the 8-bit string to a character and append it to the flag.pythonpython3 << 'EOF' with open('encoded.bmp', 'rb') as f: f.seek(0x2d3) data = f.read(50 * 9) # 50 characters, 9 bytes each flag = '' for i in range(50): bits = '' for j in range(8): # 8 LSB-encoded bytes per character byte = data[i * 9 + j] bits += str(byte & 1) # collect LSB, LSB-first order char_val = int(bits[::-1], 2) # reverse for big-endian byte value if char_val == 0: break flag += chr(char_val) print(flag) EOFExpected output
picoCTF{4n0th3r_L5b_pr0bl3m_...}What didn't work first
Tried: Collect LSB bits in MSB-first order and convert directly without reversing the bit string.
The binary writes the least significant bit of each flag byte first, so the raw bit string comes out LSB-first. Call int(bits, 2) without reversing and bit 0 gets treated as the most significant position, giving the wrong character. The [::-1] reversal fixes the ordering, and you can check it: the first decoded character should be 'p' (0x70).
Tried: Read all 50 * 9 = 450 bytes and then iterate over them with a stride of 8 instead of 9, ignoring the passthrough bytes entirely.
Striding by 8 instead of 9 means that by the 9th character you are eating the 8th character's passthrough byte as data, and everything after it shifts one position. The output looks plausible for the first few characters (picoCTF{...) and then corrupts from character 9 on. The loop must consume 8 data bytes and skip 1 passthrough byte per iteration.
Learn more
Bit-ordering matters: the binary encodes the least-significant bit of each flag byte into the first LSB slot it writes, so the collected bit string is LSB-first and must be reversed before converting to an integer with int(bits[::-1], 2). Always verify bit order by checking known bytes - the 'p' in 'picoCTF' is 0x70 (0111 0000), so the first few extracted bits should spell that out once correctly ordered.
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{4n0th3r_L5b_pr0bl3m_...}
The hash suffix is per-instance and will differ from the value above. Run the extractor against your own encoded.bmp to get the correct flag.