The Bitplane Combination Your Scanner Can't Express

Every mainstream LSB scanner applies one bit index across all three channels. Payloads that don't follow that rule are invisible by construction — not by accident.

I spent two days on a steganography challenge, ran five independent statistical detectors, and produced a clean proof that the file contained no hidden data.

The proof was correct. The file contained a 410-byte encrypted ZIP.

Understanding why both of those statements are true is the reason this tool exists.


The assumption nobody states

Every mainstream LSB scanner shares one design decision. zsteg iterates b1, b2, b3 and so on, and applies that index to all three colour channels at once. StegOnline shows you bitplane N across the image. Aperisolve stacks the same. stegsolve does the same thing with a slider.

None of them can express different bit indices per channel.

It isn’t a limitation anyone chose. It follows from how the tools present bitplanes — as a property of the image, rather than as a property of each channel independently. Once you accept that framing, the syntax has nowhere to put a second number.

So a payload written to red bit 0, green bit 1 and blue bit 0 isn’t hidden well. It’s hidden outside the search space. The tools aren’t failing to find it; they’re structurally incapable of describing where it is.

Why the statistics agree with the tools

This is the part that cost me the most time, because negative results from independent methods feel like convergent evidence.

I ran SPA via Aletheia, RS analysis, weighted stego, chi-squared, and HCF centre of mass. All negative. I ran zsteg across 451 prime-indexed variants. Negative.

The payload was 410 bytes in a 2.4 MB image. That’s 0.06% of available LSBs. Statistical steganalysis estimates the rate of embedding across a plane — it’s built to detect a signal that’s diffuse enough to shift a distribution. A payload occupying six ten-thousandths of the capacity doesn’t shift anything measurable. The detectors reported no embedding, which was an accurate statement about embedding rate and a useless one about whether the file had something in it.

Two independent blind spots, pointing the same direction:

  • The tools search a space the payload isn’t in
  • The statistics measure a quantity the payload doesn’t affect

Neither is a bug. Together they produce total silence.

The search space

If you drop the single-index assumption, the space isn’t large:

DimensionValuesCount
Bit index per channel, independent0–7 for each of R, G, B512
Channel orderRGB, RBG, GRB, GBR, BRG, BGR6
Bit orderMSB-first, LSB-first2
Pixel orderrow-major, column-major2

12,288 variants. That’s nothing. The reason no tool searches it isn’t cost — it’s that the cost only looks reasonable once you’ve stopped thinking of a bitplane as belonging to the image.

Making it fast

Naive implementation re-gathers pixel data for every variant and dies on anything above 1000×1000. Two decisions carry the entire performance budget.

Precompute once. Sample the bitplanes a single time, then combine:

planes = {(c, b): (arr[:, :, c] >> b) & 1
          for c in range(3) for b in range(8)}

24 arrays, computed once. Every variant after that is a stack and a packbits — no second pass over the image.

Extract prefixes only. Scoring needs the first 4 KB. Nothing in the detection logic looks further. Full extraction runs only for candidates that survive scoring.

That second one is worth a factor of a hundred on large images and it is the difference between a tool people run and a tool people mean to run later.

Everything streams. Generate, score, discard, keep a heap of size N. All 12,288 results never exist simultaneously.

Scoring, or: how not to build a slot machine

Presenting 12,288 extractions is worse than presenting nothing. It moves the work from the tool back to the human, with extra steps.

Four stages, cheap to expensive, each shrinking the candidate set:

Magic bytes. Dictionary lookup against PK\x03\x04, \x89PNG, \xff\xd8\xff, %PDF, 7z\xbc\xaf, Rar!, gzip, ELF. Essentially free, and it catches the single most common real case: an archive starting at byte 0.

Shannon entropy. Random planes sit near 8.0 bits per byte. Text sits at 4–5.

Printability. Ratio of bytes in 0x200x7e, plus the leading printable run length from byte 0.

Dictionary. Only for survivors. http, flag{, BEGIN, common English words.

Two calibration decisions turned out to matter more than the stages themselves.

Low entropy scores nothing on its own. High bitplanes of smooth images are long runs of 0x00 and 0xFF — very low entropy, entirely innocent. A rule saying “entropy below 7.5 is suspicious” fires on every clean gradient. Entropy only contributes when a text signal is already present.

Leading printable run matters more than overall ratio. A 30-byte URL inside a 4 KB window is 0.7% of the sample; the ratio drowns it. But real payloads are headerless and start at byte 0, so the run length from the first byte separates them cleanly.

Results

The test corpus is 20 positives — varying masks, all six channel orders, both bit orders, both pixel orders, payloads from 30 bytes to 4 KB — plus five negative controls with no payload at all.

CriterionResult
True mask in top 320/20, all rank #1
Negative controls below threshold5/5 clean
1920×1080 full scan0.92 s
Peak memory107 MB

Lowest positive scored 36. Highest negative scored 8. Threshold sits at 15. The separation is wide, not marginal — which matters, because the negative controls are the harder criterion. A scanner that finds something everywhere has told you nothing.

Then the real test: the file from the challenge that started this. Different author, different encoder, no knowledge of my implementation.

chall_something_else_is_here.png  1852x978  12288 variants in 0.6s

 #  SCORE  MASK       ORD BO  PX   MAGIC  PREVIEW
>1   46.6  R0,G1,B0   RGB msb row  zip    PK..3..c....]........
 2    5.6  R0,G0,B6   GBR lsb row         !Z&H0kA.0@..
 3    5.6  R0,G0,B0   GBR lsb row         !zfX4"A.q...

Rank one, correct mask, ZIP magic detected, 0.6 seconds. Second place scored an eighth as much.

Five statistical detectors and 451 zsteg variants found nothing in this file. The payload was never hidden deeply. It was hidden in a coordinate system nothing was looking at.

What it cannot do

Headerless random bytes are undetectable. Not difficult — undetectable.

No magic signature, no printable structure, no entropy deviation from a clean LSB plane. Every property a content-based scanner could key on is absent by construction.

This isn’t a gap to close in a later version. It’s the same boundary that makes the negative controls pass: a scanner sensitive enough to flag high-entropy noise as a payload flags every clean image as a payload. You can have quiet negatives or you can have random-byte detection. Not both.

Encrypted payloads are findable in practice because they’re almost never raw — they’re wrapped in a container, and containers have magic bytes. The ZIP above was AES-encrypted. It scored 46.6 on four bytes of header.

Using it

Code and install instructions: github.com/spuria-app/multi-bitplane-scanner.

stegscan image.png --top 10
stegscan image.png --json
stegscan image.png --min-entropy 7.5
stegscan image.png --dump-all ./out

--min-entropy inverts the usual filter for when you suspect an encrypted payload without a container. --dump-all fully extracts everything above threshold.

The encoder ships too, because a scanner you can’t generate ground truth for is a scanner you can’t trust:

stegembed cover.png secret.txt -o out.png --bits R0,G1,B0

The general shape

The specific finding is narrow: multi-bitplane LSB exists and nothing scans for it.

The transferable one is that tools encode assumptions in their syntax, and those assumptions become invisible through use. zsteg’s b1,rgb doesn’t announce that it can’t express R0,G1,B0. It just quietly defines what a bitplane is, and after enough negative results you start believing the file is clean rather than that your question was.

Worth asking, next time five independent methods agree: are they independent in the way that matters, or do they share an assumption further upstream than any of them state?

Sources