Step 240. Steganography: LSB, zsteg, Audio Spectrograms — The Letter Behind the Visible Picture

Step 240. Steganography: LSB, zsteg, Audio Spectrograms — The Letter Behind the Visible Picture

Level 3 — Forensics Track | Difficulty ★★★☆☆ | Estimated time: 4 hours

Prerequisites: Step 239 (magic bytes, carving) completed. You can handle files as bytes in Python.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

  • What you need: Python 3 + Pillow (measured: Python 3.12.14, Pillow 12.2.0).
  • Caution: zsteg, steghide, and audio-spectrogram tools aren’t available in this environment, so they’re introduced as "screen examples." LSB embedding and extraction are implemented from start to finish with Pillow and measured.

If cryptography hides by making things "unreadable," steganography hides by making them "invisible — you don’t even know they’re there." A holiday landscape photo might actually be a letter envelope. It’s a staple of CTF forensics, and real malware uses it to plant configuration values or payloads inside images for distribution. Today you’ll implement the most classic technique yourself — embedding data in pixels’ Least Significant Bit (LSB) — and see how tools (zsteg, steghide, spectrograms) find that spot.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain the principle of LSB embedding and hide and extract data yourself in Python
  • Prove with numbers "why it’s invisible" by comparing pre- and post-embedding images pixel by pixel
  • Visually confirm where hidden data sits with bit-plane visualization
  • Distinguish the territories of zsteg (lossless PNG) and steghide (JPEG)
  • Know the procedure for reading audio steganography with a spectrogram

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 + Pillow (measured: Python 3.12.14, Pillow 12.2.0)
Today’s code pixel & 0xFE | bit (embed), pixel & 1 (extract), gather 8 bits into a byte
Concepts needed Least Significant Bit (LSB), RGB channels, bit planes, lossy vs lossless compression, spectrograms
Today’s deliverable stego.png with an embedded message + an extraction script + an LSB bit-plane image

2-1. The Least Significant Bit — A 1-in-256 Secret

A pixel’s brightness is one byte, 0~255. Flip this byte’s last bit (LSB, Least Significant Bit) from 0 to 1 and the brightness changes by only 1/255. The human eye can’t distinguish a 1/255 difference between adjacent pixels. In other words, every pixel’s last bit is effectively an ’empty seat.’

Even a small 200×120 picture can hold 24,000 bits = 3,000 bytes in just the R channel. Room for a short document.

2-2. Lossless vs Lossy — The Format Decides the Embedding’s Fate

PNG is lossless compression — pixel values survive a save-and-open unchanged, so LSB embedding survives. JPEG is lossy compression — the saving process changes the pixel values themselves, grinding the LSB away. So the ecosystem splits:

  • PNG/BMP → the LSB family, detection tool zsteg
  • JPEG → methods that slightly alter the lossy compression’s transform coefficients (DCT), tool steghide
  • WAV → LSB of audio samples, or drawing pictures into a spectrogram

This is why tool selection starts from the format.

2-3. zsteg — An Exhaustive Search of Channel Combinations (Screen Example)

Not available here (it’s a Ruby tool, installed via gem install zsteg), but what it does is simple: extract bits under every combination of R/G/B/A channels × bit depth (1~2 bits) × bit order (MSB/LSB first), and report results that look like text.

# Screen example — zsteg full-channel scan (not actually run)
$ zsteg stego.png
b1,r,lsb,xy         .. text: "DH{lsb_m4st3r_2026}"
b1,rgb,lsb,xy       .. text: "...."
b2,g,msb,xy         .. file: data

b1,r,lsb,xy means "1 bit, R channel, least significant bit first, row-major." The method we’ll embed with today is exactly this combination.

2-4. Audio Spectrograms — Sound as a Map (Screen Example)

A spectrogram is a "map of sound": time on the x-axis, frequency on the y-axis, brightness as intensity. Arrange frequency energy in the shape of letters, and you get a file that sounds like noise but reads as letters. When a wav file shows up in a CTF, the standard procedure is:

# Screen example — reading it in Audacity (not actually run)
1. Open suspicious.wav in Audacity
2. Click ▼ next to the track name → switch the view to Spectrogram
3. Check whether letters or Morse code are drawn around 5kHz~10kHz

The principle is simple — the Fourier transform decomposes the time-domain waveform into frequency components, and the author plants those components in the shape of letters.


3. Follow Along

3-1. Making the Cover Image for Experiments

Instead of a real photo, we use a 200×120 picture made from gradients (a regular pattern is teaching material that reveals embedding traces better). This chapter’s local output was measured 2026-09-09.

Input (lsb_lab.py)

from PIL import Image

W, H = 200, 120
img = Image.new("RGB", (W, H))
px = img.load()
for y in range(H):
    for x in range(W):
        px[x, y] = (x % 256, (x + y) % 256, (2 * y) % 256)
img.save("cover.png")

3-2. Embedding a Message in the LSB

We alter only the R channel’s least significant bit. The core line is (r & 0xFE) | bit — clear the last bit to 0 (& 0xFE), then lay the bit to embed on top (| bit).

SECRET = "DH{lsb_m4st3r_2026}"
payload = SECRET + "x00"                      # a null character as the end marker
bits = "".join(f"{b:08b}" for b in payload.encode())
print(f"message: {SECRET!r}  ({len(payload)} bytes = {len(bits)} bits)")

stego = img.copy(); spx = stego.load()
i = 0
for y in range(H):
    for x in range(W):
        if i >= len(bits): break
        r, g, b = spx[x, y]
        spx[x, y] = ((r & 0xFE) | int(bits[i]), g, b)
        i += 1
    if i >= len(bits): break
stego.save("stego.png")
message: 'DH{lsb_m4st3r_2026}'  (20 bytes = 160 bits)

How to read it: 160 bits embedded the message into the last bit of the R values of the first 160 pixels of row one. A tiny fraction of 24,000 pixels — only 0.7% of the image was touched.

3-3. "Is It Really Invisible?" — Proof by Pixel Comparison

Compare before and after numerically:

orig = Image.open("cover.png").load()
stg  = Image.open("stego.png").load()
diff = sum(1 for y in range(H) for x in range(W) if orig[x, y] != stg[x, y])
maxdelta = max(abs(orig[x, y][0] - stg[x, y][0]) for y in range(H) for x in range(W))
print(f"changed pixels: {diff} of {W*H} total, max brightness change: {maxdelta}/255")
changed pixels: 69 of 24000 total, max brightness change: 1/255

(Measured 2026-09-09.)

How to read the output: the reason you can’t tell the two pictures apart no matter how long you stare is in this one line. Only 69 pixels changed (0.29%), and even those by at most 1/255 in brightness. Yet those 69 last bits hold a 160-bit letter. "No change = no information" is false — that is steganography’s reason to exist.

3-4. Extraction — Gathering the Last Bits to Read

The inverse of embedding. Collect R’s & 1 and fold every 8 into a byte:

got = []
for y in range(H):
    for x in range(W):
        got.append(str(stg[x, y][0] & 1))
out = bytearray()
for k in range(0, len(got) - 7, 8):
    byte = int("".join(got[k:k+8]), 2)
    if byte == 0:            # end marker reached
        break
    out.append(byte)
print("extraction result:", out.decode())
extraction result: DH{lsb_m4st3r_2026}

(Measured 2026-09-09. The embedded string was restored exactly.)

How to read it: this script does exactly what zsteg’s b1,r,lsb,xy slot does. zsteg merely automates it across all channel and bit-order combinations — the principle is the code you wrote today.

3-5. Bit-Plane Visualization — Seeing the Hidden Spot with Your Eyes

Let’s pull out each pixel’s R least-significant bit and draw it as black and white (0→black, 1→white). This is bit-plane visualization:

plane = Image.new("L", (W, H)); pp = plane.load()
for y in range(H):
    for x in range(W):
        pp[x, y] = 255 * (stg[x, y][0] & 1)
plane.save("lsb_plane.png")
# do the same for the original cover.png → lsb_plane_orig.png

(Measured 2026-09-09 — both image files confirmed created.)

How to read it: our cover image is a regular gradient of x % 256, so the original’s LSB bit plane comes out as regular stripes. But in the embedded version’s bit plane, the very top row (where the message’s 160 bits sit) shows a band of irregular noise breaking through the stripes. With natural photos this distinction is far harder, but the instinct — "lay out the bit planes and hidden spots leave traces" — is the same. It’s why tools like stegsolve let you flip through these screens per channel, per bit.

3-6. Other Formats’ Worlds — Screen Examples

JPEG doesn’t let LSB survive, so its dedicated tools differ (not available here; screen example):

# Screen example — steghide: data embedded in a JPEG's transform coefficients (not actually run)
$ steghide info photo.jpg
"photo.jpg":
  format: jpeg
  capacity: 1.8 KB
  embedded file "flag.txt":
    size: 26.0 Byte
    encrypted: rijndael-128, cbc

$ steghide extract -sf photo.jpg    # if asked for a password, just press Enter (empty password)
wrote extracted data to "flag.txt".

The skeleton of the field procedure: ① for PNG, run zsteg’s exhaustive scan; ② for JPG, check for embedded content with steghide and try an empty password; ③ for WAV, switch to a spectrogram and read letters or Morse code. And for any format, if nothing comes out, change the bit order (MSB/LSB first) and channel order (RGB vs BGR) and extract again — the authors’ favorite variation.


4. Missions & Exercises

Mission — Author and Crack Your Own Steganography Problem

  1. Modify 3-2’s code to make my_stego.png with the message embedded in the G channel‘s LSB
  2. Generalize 3-4’s extractor to let you choose the channel (a channel argument: 0=R, 1=G, 2=B)
  3. Demonstrate with your own file: extracting on the R channel should yield a broken string, and on the G channel, the message
  4. (Challenge) Switch to 2-bit embedding (& 0xFC | two bits) and record how the pixel-comparison numbers differ from 3-3

Exercises

Exercise 1. Explain why LSB embedding survives in PNG but is destroyed in JPEG, in terms of the difference in compression methods.

Exercise 2. In 3-3, 69 pixels changed, but 160 bits were embedded. Why 69 instead of 160?

Exercise 3. If the extraction result comes out like ÔH{lsp... — only the first character wrong — which variation (bit order, channel) do you suspect, and how do you confirm it?

Exercise 4. In the spectrogram trick, explain the principle that makes "noise to the ear but letters to the eye" possible, from the Fourier transform’s perspective.


5. Model Answers & Completion Criteria

Mission Model Answer

The core of the channel-generalized extractor:

def extract(path, channel):
    im = Image.open(path).load()
    W, H = Image.open(path).size
    got = [str(im[x, y][channel] & 1) for y in range(H) for x in range(W)]
    out = bytearray()
    for k in range(0, len(got) - 7, 8):
        byte = int("".join(got[k:k+8]), 2)
        if byte == 0: break
        out.append(byte)
    return out

Extracting on R (0) produces a garbled string from the cover image’s random LSBs, and on G (1) the message comes out — this contrast itself demonstrates the hiding layer’s defense: "you can’t read it without knowing the channel." Expected result for challenge 4: the max brightness change grows from 1/255 to 3/255, and the changed-pixel count grows too. Double the capacity at the cost of reduced camouflage — a trade-off.

How to verify: ① does G-channel extraction from my_stego.png match the original message? ② is R-channel extraction a broken string (that’s what makes channel choice meaningful)? ③ did you record the 2-bit embedding’s pixel-comparison numbers?

Exercise Answers

Answer 1. PNG is lossless compression, so pixel values are preserved bit-for-bit through save and load, and the embedded LSB returns intact. JPEG is lossy compression that converts pixels to frequency coefficients (DCT) and then rounds off fine values, so the recompression process alters the LSB. That’s why JPEG steganography uses a separate family of techniques (steghide) that embed in the transform coefficients, not the pixels.

Answer 2. Because & 0xFE | bit doesn’t change a pixel whose last bit already equals the bit to embed. Randomly, about half (80) would change, and our measurement (69) is near that. So "bits embedded" and "pixels changed" differ — pixels overwritten with the same value don’t register as changes.

Answer 3. A pattern with only the first character wrong is prime suspect number one for bit order (MSB first vs LSB first within a byte). Reassemble with the bits reversed (bits[k:k+8][::-1]) instead of int("".join(bits[k:k+8]), 2). If that fails, switch channels (R→G→B). What zsteg does is exactly an exhaustive search of these permutations.

Answer 4. The Fourier transform decomposes a waveform into per-frequency components. If the author concentrates energy only at specific times and specific frequencies, those points arrange into letter shapes on the time-frequency map (spectrogram). The ear hears only the sum of the whole waveform and can’t perceive the letter arrangement, but the eye reads the 2-D map’s pattern directly. It’s encoding not for hearing but for seeing.

Completion Criteria Checklist

  • [ ] I can explain LSB embedding’s principle (1/255, lossless formats)
  • [ ] I wrote the code that embeds with (r & 0xFE) | bit and extracts with & 1 myself
  • [ ] I proved the camouflage numerically with a before/after pixel comparison
  • [ ] I confirmed the embedding spot with bit-plane visualization
  • [ ] I know the territory split between zsteg (PNG) and steghide (JPEG)
  • [ ] I know the procedure of switching bit order and channel order when extraction fails
  • [ ] Mission: channel-selectable extractor and G-channel embedding file complete

6. Common Pitfalls & Fixes

Wall 1. ModuleNotFoundError: No module named 'PIL'

Symptom: the script’s first line says Pillow is missing.
Cause: Pillow isn’t installed.
Fix: pip install Pillow. This book’s managed Python already includes it (measured: 12.2.0).

Wall 2. The extraction is all garbled characters

Symptom: no stretch that looks like text at all.
Cause: your channel or bit order differs from the author’s — like extracting from G when it was embedded in R.
Fix: run all six combinations of channel (0,1,2) × bit order (forward/reverse). The mission’s channel-selectable extractor is exactly that tool. Still nothing — then it’s a variation like 2-bit embedding or pixel skipping.

Wall 3. I saved as PNG and the message disappeared

Symptom: you embedded, saved, reopened, extracted — and got nothing.
Cause: you saved as JPEG (saving as stego.jpg lets lossy compression grind the LSB away), or the image went through one resize/re-save in another program.
Fix: always save in a lossless format (PNG/BMP), at the original size. "Pass through lossy compression even once and the LSB dies" is the iron rule.

Wall 4. Extracting without a null terminator yields infinite garbage

Symptom: hundreds of strange characters trail after the message.
Cause: without an end marker (x00) embedded, the extractor interprets the cover image’s original LSBs as text too.
Fix: make a habit of payload + "x00" when embedding, or use the scheme that embeds the message length in the first 4 bytes. Real problems may have neither, so spotting "the point where the text suddenly stops" by eye is also a skill.

Wall 5. zsteg rejects a JPG

Symptom (screen example): zsteg photo.jpg → an error that this tool only supports PNG/BMP.
Cause: you missed the per-format ecosystem split (2-2). JPEG’s LSB doesn’t survive, so it’s not zsteg’s target.
Fix: JPG goes to the steghide family. Checking the file format first is step one of steganography analysis — yesterday’s file is the first move here too.


7. Summary

Today’s Concepts

Concept One-line explanation
LSB embedding A pixel’s last bit is an invisible empty seat — embed there
Lossless vs lossy The LSB lives in PNG and dies in JPEG — the format decides the technique
Bit-plane visualization Render one specific bit in black and white — a technique for seeing hidden spots’ traces
Channel/bit-order permutations The exhaustive list to try when extraction fails — what zsteg automates
Spectrogram A time-frequency map of sound — a lens that makes the inaudible visible

Today’s Code & Commands

Code/command What it does
(r & 0xFE) | bit Embed a bit in the R channel’s LSB
pixel & 1 Pull out the LSB
int(bits, 2) × 8 Reassemble 8 bits into a byte
zsteg FILE (screen example) Exhaustive LSB scan of all PNG/BMP channels
steghide info/extract -sf FILE (screen example) Check/extract embedded JPEG data
Audacity → Spectrogram (screen example) Read picture-letters in audio

An Instinct More Important Than Commands

Steganography’s core question isn’t "what’s hidden" but "which spot in this file escapes inspection." LSBs, metadata fields, after the IEND, a spectrogram’s high frequencies — all "places nobody looks." Conversely, the analyst’s skill is carrying a map of those spots in your head and looking through them in order.

And one truth you learned by embedding it yourself today — hiding is not perfect. Bands remain in bit planes, statistics go off, and switching channels exposes it. That hiding and finding are a game fought over the same principle — that is the contest that will repeat throughout the Forensics track.


Once every box is checked, Step 240 is complete.