Skip to content
All posts
stegoJune 16, 20267 min read

A workflow for image steganography, from magic bytes to bit planes

Stego challenges reward order, not inspiration. The sequence that finds the payload: container checks before pixel checks, structure before statistics, and the specific tells that separate a PNG trick from a JPEG one.

Image steganography challenges feel like guessing games, and they are - unless you work them in a fixed order. The order exists because the techniques form a hierarchy: some hide data *around* the image, some hide it *in the image’s structure*, and some hide it *in the pixels*. Checking them out of order means running expensive pixel analysis on a file whose payload was appended after the last byte of the image.

So: container, then structure, then pixels. Every time.

Layer 1: the container

Before you treat it as an image, treat it as a file. Four checks, none taking longer than a few seconds.

  1. Magic bytes versus extension. A .png that starts with FF D8 FF is a JPEG, and a .jpg that starts with PK is a ZIP. Believe the bytes.
  2. Appended data. Image formats have a defined end - IEND for PNG, FF D9 for JPEG. Anything after it is invisible to viewers and is very often the whole challenge. Carve it out.
  3. Metadata. EXIF comments, XMP blocks, PNG tEXt/iTXt/zTXt chunks, GPS coordinates. Authors hide flags here constantly because it survives most processing.
  4. Strings. Run a strings pass over the raw bytes and grep for the flag prefix, for {, and for base64-shaped runs. Compressed image data is high-entropy noise, so any long readable ASCII run in an image is anomalous by definition.
file chal.png
exiftool chal.png
binwalk -e chal.png           # carve appended/embedded files
strings -n 10 chal.png | grep -iE 'flag|ctf|\{'
xxd chal.png | tail -20       # what sits after IEND?

Layer 2: the structure

This is the layer most people skip, and it is where a large fraction of intermediate challenges live. PNG in particular is a self-describing chunked format, and every field in it is a place to hide or break something.

PNG chunk checks

  • IHDR dimensions versus visual size. If the header claims 800x600 and the image renders as 800x200, the height was edited to crop the flag out of view. Restore it and the flag reappears - this requires fixing the IHDR CRC, or using a viewer that ignores CRC errors.
  • Chunk CRCs. Every chunk carries a CRC-32 of its type and data. A mismatched CRC means someone edited that chunk by hand, and it tells you *which* chunk to look at.
  • Unknown or duplicated chunks. Data hidden in a non-standard chunk type is ignored by every decoder and preserved by most editors.
  • Trailing chunks after IEND. Some tools write an extra chunk past the logical end.
  • Colour type and palette. Palette-based PNGs can hide data in unused palette entries, or in a palette where two indices map to nearly identical colours.
import struct, zlib

data = open('chal.png', 'rb').read()
assert data[:8] == b'\x89PNG\r\n\x1a\n'
off = 8
while off < len(data):
    (length,) = struct.unpack('>I', data[off:off+4])
    ctype = data[off+4:off+8]
    body  = data[off+8:off+8+length]
    (crc,) = struct.unpack('>I', data[off+8+length:off+12+length])
    ok = zlib.crc32(ctype + body) & 0xffffffff == crc
    print(f'{ctype.decode():4} len={length:<8} crc={"ok" if ok else "BAD"}')
    off += 12 + length
    if ctype == b’IEND' and off < len(data):
        print(f'  !! {len(data) - off} bytes after IEND')
Fifteen lines that catch edited dimensions, hand-modified chunks, and appended data in a single pass.

JPEG structure checks

JPEG is a marker stream rather than a chunk list, but the same idea applies: walk the markers and look for what does not belong. COM comment segments and APPn application segments carry arbitrary bytes and are a standard hiding place. Data after the FF D9 end-of-image marker is, again, free real estate.

Layer 3: the pixels

Only now do you look at pixel data. The dominant technique is least significant bit encoding: replace the lowest bit of each colour channel with a bit of the payload. The change is one part in 256 per channel, invisible to the eye and trivially recoverable if you know the ordering.

The catch is that 'if you know the ordering' hides a real search space. Which channels, in what order, most-significant bit first or least, row-major or column-major, all pixels or every *n*-th. This is why sweeping matters more than any single extraction: the payload is usually in one of a few dozen standard combinations, and trying all of them is cheap.

# zsteg sweeps the common LSB orderings for PNG and BMP
zsteg -a chal.png

# stegsolve (GUI) for bit planes and channel isolation
java -jar stegsolve.jar

# steghide: JPEG, BMP, WAV, AU - password-based, so try an empty
# password first, then a wordlist
steghide extract -sf chal.jpg -p ''
stegseek chal.jpg rockyou.txt

Bit planes are worth looking at directly

Extract the *n*-th bit of one channel across the whole image and render it as a black-and-white picture. For a natural photograph, low bit planes look like static. Structure in a low bit plane - text, a QR code, a rectangle, a gradient - means data was written there, and often you can simply *read* the flag off the plane without decoding anything.

This is also the fastest way to notice that only part of the image carries a payload. A bit plane that is noise on the left half and flat on the right tells you the message is short and stored row-major from the top-left corner.

JPEG is different, and it matters

JPEG is lossy: pixel values are not stored, DCT coefficients are, and those coefficients are quantised. Naive pixel-LSB does not survive a JPEG re-encode, so JPEG stego tools operate on the coefficients instead - jsteg, outguess, steghide, and F5 all do. The practical consequence is that if the file is a JPEG, zsteg is the wrong tool and coefficient-level analysis is the right one.

Chi-squared analysis on the coefficient histogram detects this class of embedding statistically. Natural DCT coefficient distributions are smooth and roughly symmetric; LSB embedding pairs up adjacent values and flattens each pair toward a common frequency, which shows up as a distinctive staircase.

When the image is not the point

  • The image is a QR code, or contains one. Scan it. If it is damaged, the error correction may still recover it; if it is inverted, invert it.
  • The image is a spectrogram. Audio challenges hide text in the frequency domain, and the reverse also appears - an image that is really a rendered waveform.
  • The image is an archive in disguise. Covered above, but worth repeating because it is the most common single trick.
  • The filename or the dimensions encode the payload. 65 x 84 x 70 is ASCII for AtF. Silly, and it shows up.
  • Two nearly identical images. XOR them, or diff them pixel by pixel. The difference *is* the message.

The whole workflow

  1. file, then hexdump the header and the tail.
  2. Metadata: EXIF, comments, text chunks.
  3. Carve appended and embedded files.
  4. Strings, grepped for the flag format.
  5. Structure: chunk CRCs, declared dimensions, unknown chunks, markers.
  6. LSB sweep across channel and bit orderings.
  7. Bit planes, viewed as images.
  8. Format-specific tools: zsteg for PNG/BMP, steghide/stegseek and chi-squared for JPEG.
  9. Reconsider the container: is this actually an audio file, an archive, or a QR code?

If you get to step nine with nothing, go back to step one and read the challenge description again. Stego authors nearly always leave a hint in the title - a password, a tool name, a colour, a word like 'quiet' or 'shallow' - and the hint usually points at exactly one step in this list.

Further reading

  • zsteg - The standard LSB sweeper for PNG and BMP
  • stegseek - Cracks steghide passphrases orders of magnitude faster than steghide itself
  • PNG specification: chunk layout - Chunk types, CRC computation, and what a decoder is allowed to ignore
steganographypngjpeglsbbit-planesforensicszsteg