How the SECRET tab hides a message without changing a single byte
The /png toolkit's SECRET tab hides an encrypted message inside a PNG under three constraints that, together, rule out every textbook method: the file size doesn't change (123 456 B stays 123 456 B), the image doesn't change (the decoded pixels are bit-for-bit identical — not even a "black → very dark gray" nudge), and there's nothing to see in a hex editor (no readable string, no extra chunk). The surprising part is that the technique that satisfies all three isn't LSB pixel-fiddling or padding tricks — it's steganography in the redundant encoding choices that DEFLATE leaves lying around. Here's how, with the actual code.
Why the obvious methods each break a rule
Steganography in images is an old field, and the two techniques people reach for first both fail one of the constraints:
| Approach | Size stable? | Pixels stable? | Hidden in hex? |
|---|---|---|---|
Append data after IEND | ✗ grows | ✓ | ✗ raw tail |
Extra tEXt / private chunk | ✗ grows | ✓ | ✗ the CHUNKS tab lists it |
| Flip pixel LSBs, re-encode | ✗ re-compresses to a different length | ✓ (sub-visual) | ✓ |
| Equal-cost distance swap | ✓ by construction | ✓ identical | ✓ looks like compression noise |
The killer is the size constraint. PNG pixels are always DEFLATE-compressed inside IDAT. Flip a pixel's low bit and you've raised local entropy, so re-compressing yields a different-length stream — usually a few bytes bigger. You cannot flip a pixel "in place"; you must re-encode, and re-encoding almost never lands on the original byte count. Appending or adding a chunk adds bytes. Freezing the file size eliminates every method that touches the pixels or the chunk list.
The idea: DEFLATE is not a canonical code
What survives is a method that never touches the pixels and only rearranges the compressed encoding among choices a decoder collapses back to the same output. DEFLATE (RFC 1951, the algorithm inside zlib/gzip/PNG) hands us exactly that, because the same decompressed bytes can come from many different compressed streams:
- A run of bytes that already appeared can be emitted as a back-reference
(length L, distance D): "copy L bytes from D bytes ago." If those same L bytes repeat at more than one earlier distance, any of those distances decodes identically. - After the final block's end-of-block symbol, DEFLATE pads to the next byte boundary. A decoder ignores those 0–7 bits entirely.
Both are free choices the decoder can't tell apart. The trick is to use only the ones that are also length-preserving, so the stream's total size never moves while its bit pattern changes. That's where the payload goes.
Channel A: equal-cost distance substitution
DEFLATE doesn't store a distance as a raw number. It stores a distance symbol (Huffman-coded) plus a fixed number of extra bits that pick the exact value inside that symbol's range. For example, symbol 10 covers distances 33–48 with 4 extra bits. The crucial consequence:
So wherever the stream has a match (L, D), we look for another distance D' that (1) copies the exact same L bytes as D — verified against the already-decompressed data — and (2) falls in the same distance symbol as D. If such a D' exists, the swap is byte-exact and pixel-exact by construction, and the extra-bit value is a place to store payload bits. A site with two qualifying distances (sorted ascending so encoder and decoder agree) carries one bit: bit 0 → the smaller distance, bit 1 → the next one.
// At each back-reference, after decoding (L, D): is there a second distance
// in the same symbol that copies the identical L bytes? If so, it's a 1-bit site.
function siteAt(out, p, L, distSymbol) {
const [lo, hi] = symbolRange(distSymbol); // e.g. 33..48
const equalCost = [];
for (let d = lo; d <= hi && d <= p; d++) {
if (copies(out, p, L, d)) equalCost.push(d); // same L bytes at distance d?
if (equalCost.length === 2) break; // one bit needs just two
}
return equalCost.length === 2
? { bitpos: extraBitsOffset, width: extraBitsWidth(distSymbol), d0: equalCost[0], d1: equalCost[1] }
: null; // unique distance → no capacity here
}
How much capacity is that? It's content-dependent — flat regions, gradients and palettes produce many equal-cost repeats; pure noise produces few. Measured on a real 4.7 MB iPhone photo: 2.17 million back-references, of which ~207 000 (9.5%) are usable sites — about 25 KB of capacity, a 761× headroom over a short message. A tiny, heavily-optimized PNG can genuinely be too small, so the tab measures the exact budget up front and refuses rather than silently truncating or falling back to a size-changing method.
Channel B — the 0–7 final padding bits — is a rounding error by comparison, kept only as a fixed header nook.
The key: encrypt and locate
A raw bit-stuffing scheme would be readable by anyone who knows the trick. So the payload is authenticated-encrypted, and the key does double duty — it both encrypts the message and chooses which sites carry it:
seed = HKDF-SHA256(key, salt = "omni/png/secret/v1")
keystream = ChaCha20(seed) // XOR cipher for the body
order = key-seeded shuffle of the enumerated sites // where bits live
header = magic(2) ‖ version(1) ‖ len(2)
body = header ‖ utf8(message)
cipher = body ⊕ keystream
mac = HMAC-SHA256(seed, cipher)[:16] // authenticates before decrypt
embedded = (cipher ‖ mac) written bit-by-bit into order[]
To read it back, the decoder re-enumerates the candidate sites from the parts of the stream we never change (block boundaries, match lengths, distance symbols), replays the same key-seeded permutation, reads one bit per site, checks the MAC, and only then decrypts. A wrong key fails the MAC, so the tab says "no secret for this key" instead of returning garbage — no plaintext ever leaks on a bad key.
The default key ships compiled into the tool. That default is obfuscation, not security — anyone with the page can extract it — which is why the tab also accepts your own passphrase. With a strong passphrase the same construction becomes real authenticated encryption: the message is unreadable, and even the key-free detector below can only say a payload is likely, never read it. The passphrase feeds the one deriveSeed(key) seam, and the format's version byte lives inside the encrypted header — so it's a framing-revision field, not a key selector (the key source is a decode-time input, not something you can read from the file first).
Why the file stays byte-for-byte the size it was
Encoding rewrites only the IDAT run, in place, at identical total length. Everything that could move is accounted for:
- Decoded pixels: identical — we only ever changed an extra-bit value (which equal-cost distance), never the symbol, the match length, or a block boundary.
- The zlib Adler-32 trailer (a checksum over the decompressed data): unchanged and still valid — the decompressed data didn't change.
- Each
IDATchunk's CRC-32 (over the compressed bytes): that does change, so we recompute it per chunk. Same length in, same length out — the CHUNKS tab still reads "CRC ok." - Every other chunk and the file size: untouched, by construction.
The download reuses the toolkit's byte-surgery path: the output is the untouched head slice of the original file, the freshly rewritten equal-length IDAT run, then the untouched tail slice — handed to a Blob, so it streams at any size. Interlaced (Adam7) PNGs need no special handling (interlacing changes only how the inflated bytes map to pixels, not the stream), and APNG works too — the payload rides in the default-image IDAT while the animation frames pass through untouched.
Detecting a secret without the key
The same tab that hides a message is also the best defense against one, and that's the honest framing of the feature. Here's the tell: a canonical DEFLATE encoder resolves an equal-cost match to its smallest valid distance (the most-recent occurrence its hash chain reaches first). Our stego instead writes a payload bit there, so at roughly half the sites it uses, the chosen distance is the second-smallest — a non-minimal choice a canonical encoder would essentially never make. So we count, across every equal-cost site, how often the encoded distance isn't the minimal one:
let sites = 0, nonMinimal = 0;
for (const s of equalCostSites) { // every k≥2 distance site
sites++;
if (encodedDistance(s) !== s.minimalDistance) nonMinimal++;
}
const rate = nonMinimal / sites; // ~0% on a clean encoder; higher with a payload
On two real, freshly-encoded PNGs this rate is 0.00% — the common encoders (zlib, libpng) pick minimal distances — so any meaningful rate is anomalous, and the info bar quietly flags "may hide a message" even when the payload is under a passphrase it can't read.
What it protects — and what it doesn't
- Casual hex inspection: defeated — no string, no chunk, just extra-bits that look like ordinary compression noise.
- Visual inspection / pixel diffing: defeated — the pixels are identical.
- Size / integrity-by-length checks: defeated — the size is frozen.
- Wrong key: the MAC fails, the tool says "no secret," no plaintext leaks.
- Re-saving the PNG: not survived, by design. Any optimizer or editor that rewrites
IDATdestroys the payload. It's a covert courier for exact bytes, not a durable watermark. - Dedicated steganalysis: not claimed to be undetectable — see the caveat above.
Everything runs locally in a WebAssembly worker in your browser: the DEFLATE parse/rewrite, the ChaCha20 + HMAC, the site permutation. No byte of your image is ever uploaded.