✳ OMNIVIEWER PNG viewer PNG internals ← back

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.

On this page Why the obvious methods fail The redundant-choice idea Equal-cost distance swaps Key, crypto & site selection Why the file stays byte-exact Detecting it without the key What it does & doesn't protect

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:

ApproachSize 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:

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:

Two distances in the same distance symbol cost the identical number of bits. Same Huffman codeword, same extra-bit width — they differ only in the value of the extra bits. So swapping one for the other changes the stream's bits without changing its length.

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:

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.

Be honest about the false-positive rate. This is a statistic, not proof. Some PNG optimizers and lazy-matching heuristics legitimately emit non-minimal distances, which will trip the flag (false positive); and a very short message on a large image perturbs so few sites that it slips under the threshold (false negative). The tab says "may carry a hidden message," never "does." A dedicated analyst with the original file, or a strong statistical prior, can raise suspicion — this is strong against casual inspection, not a claim of undetectability.

What it protects — and what it doesn't

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.

Try it on a real file

Open the /png toolkit → Drop any PNG, open SECRET: hide a message (with your own passphrase, optionally), download a copy that's the same size to the byte, then reveal it. 100% local. Read: inside a PNG → The chunk grammar, CRCs, and the metadata (including GPS) that hides in a PNG — the structure this feature rewrites without changing a single pixel.