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. The cipher can't be broken, so the only attack on a passphrase file is guessing the passphrase, and the tab makes every guess expensive:
salt = 16 random bytes, fresh per file // stored in the first 128 sites
key = Argon2id(passphrase, salt, 128 MiB, 4 passes)
seed = HKDF-SHA256(key, salt = "omni/png/secret/v2")
order = key-seeded shuffle of the REMAINING sites
Argon2id fills and re-reads 128 MiB for every guess — about a third of a second once for you, but it caps a cracking GPU at roughly a thousand guesses a second, where a bare SHA-256 chain would allow hundreds of millions. The salt makes the same passphrase produce different keys in every file, so no two pictures share a keystream and no precomputed table covers them all. Under the field, a strength meter shows how many guesses your passphrase is worth and how long one GPU would take to try them all, with a green check once even a thousand GPUs would need over a thousand years. With a strong passphrase the message is unreadable, and even the key-free detector below can only say a payload is likely, never read it. The format's version byte lives inside the encrypted header, so it's a framing field, not a key selector: the reader simply tries the salted format first, then the older unsalted one.
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. At every equal-cost site the tab only ever writes one of two distances: c0, the smallest valid one, or c1, the next. So each site's current choice falls into one of three buckets — c0, c1, or a third, further distance the tab never writes:
let c1 = 0, other = 0;
for (const s of equalCostSites) { // every k≥2 distance site
const d = encodedDistance(s);
if (d === s.c1) c1++;
else if (d !== s.c0) other++; // a choice the stego never makes
}
A canonical encoder (zlib, libpng) always takes the nearest copy, so on a clean file both counts are zero, and any payload puts about half its bits on c1. Even a one-byte message is 176 bits, so dozens of c1 choices with no other anywhere can only be a payload — at any image size. (An earlier version measured a rate instead, which a short message on a big image could slip under.)
Not every encoder is canonical. Apple's ImageIO — every iPhone screenshot — doesn't look for the nearest copy: on a real 171 KB screenshot, 84% of the sites are non-minimal, with no message in it at all. That's where the second signal comes in. Every passphrase file keeps its 16 random salt bytes in the first 128 sites, in plain order, because the salt has to be read before any key exists. Writing it leaves that region all c0/c1, about 64 of them on c1, whatever the encoder put there before. The screenshot had 22 other choices in the same 128 sites, so it isn't accused; the same screenshot with a passphrase message in it has none, and is.
c1 floor about once in 18 billion files, so a passphrase message written by this tab is, in practice, always flagged. False positives remain possible — an optimizer that picks c1 but never a third distance looks the same — so the tab still says a picture may hide a message, never that it does. One case the statistics can't see: a file from before the salted format, on a non-canonical encoder. For that, the passphrase field is always one click away, whatever the detector says.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.