DEV Community

Keishin Senzaki
Keishin Senzaki

Posted on AI-assisted

Hiding an image in a PNG and decoding it with SVG filters, without CORS pixel access

I wanted to see whether a browser could reveal an image hidden in another image even when the carrier was hosted on a site that did not allow cross-origin pixel reads. Drawing the carrier to a canvas is easy, but reading the canvas back is not: without the image host's CORS permission, the canvas becomes tainted. A server-side proxy would solve a different problem. I wanted the visitor's browser to fetch and display the original PNG directly.

That experiment became Visual Noise Decoder, a small, dependency-free set of three HTML pages: one to encode images, one to view a decoded image, and one to assemble a gallery. The interesting part is the viewer. It reconstructs the hidden pixels with SVG filters and CSS blending, while JavaScript never reads pixels from the remotely hosted carrier.

Here is an encoded example. It looks like an image of Earth, but its low color bits carry an image of the Moon:

An encoded PNG showing Earth

Open the decoded Moon, or open all three example images as a gallery. The example seed is public; this is a demonstration of the rendering technique, not a secret to protect.

Why canvas cannot do the whole job

The encoder runs on images that the user selects locally. It can use canvas normally: scale the secret, center-crop the cover, modify pixel bytes, and export a new PNG.

The viewer has different constraints. A remote <img> or CSS background can be displayed without CORS permission. But if I draw that remote image on a canvas and call getImageData() or toDataURL(), the browser's origin-clean restriction prevents access. Applying an SVG filter first does not make that readback legal. The problem is not getting the image onto the screen; it is obtaining its pixel values in JavaScript.

The solution here is to keep the carrier in the browser's rendering pipeline. The viewer uses it as a CSS background, extracts the required bits with SVG component-transfer filters, combines it with locally generated mask images, and lets the compositor assemble the result. It never exports the filtered carrier to canvas.

This does not bypass CORS or an image host's access controls. If hotlink protection or a content policy prevents the image from loading, there is nothing to decode.

The PNG layout

For a secret of W × H pixels, the output cover is 2W × 2H. Think of the cover as four W × H quadrants. One secret pixel maps to four cover pixels at the same relative position: one in each quadrant. Each secret pixel has eight bits per RGB channel; each of those four cover pixels carries two of those bits in its channel's two least significant bits. The upper six bits of each cover channel stay as they were.

For one channel, the encoder's core operation is:

const secretBits = (secretChannel >> (quadrant * 2)) & 3;
encodedChannel = (coverChannel & 252) | (secretBits ^ maskBits);
Enter fullscreen mode Exit fullscreen mode

maskBits comes from a deterministic AES-CTR stream. The encoder and viewer generate it in the same way:

const material = `${DOMAIN}\0${index}\0${phrase}`;
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(material));
const key = await crypto.subtle.importKey('raw', digest, { name: 'AES-CTR' }, false, ['encrypt']);
const stream = new Uint8Array(await crypto.subtle.encrypt(
  { name: 'AES-CTR', counter: new Uint8Array(16), length: 128 },
  key,
  new Uint8Array(byteLength)
));
Enter fullscreen mode Exit fullscreen mode

DOMAIN is the fixed string VND-MAIN-HALF-2LSB-MASK-RGB. Encrypting zero bytes with a zero-initialized counter produces the mask stream; it does not decrypt the remote PNG through Web Crypto. The image index matters for galleries: images encoded with the same seed must use different indices so that they do not reuse the same mask stream.

The output must remain an original PNG. Resizing, recompressing, or converting it can change the low bits and destroy the hidden image. A link to the image-hosting page or a thumbnail is not a substitute for a direct link to the encoded PNG.

Reconstructing bits in the browser

The viewer knows the PNG dimensions from an ordinary image load. It reproduces the mask stream locally, and it can safely generate mask PNGs on canvas because those pixels are its own. It creates eight mask planes: two bit planes for each of the four quadrants.

For each plane, an SVG feComponentTransfer with a discrete lookup table turns one selected low bit of a color channel into a binary value. The table is generated from (value >> bit) & 1 for all 256 input values. The remote carrier remains a CSS background behind that filter.

const values = Array.from(
  { length: 256 },
  (_, value) => (value >> planeBit) & 1
).join(' ');

component.setAttribute('type', 'discrete');
component.setAttribute('tableValues', values);
Enter fullscreen mode Exit fullscreen mode

The filtered carrier plane and the corresponding mask plane are blended using CSS difference. For binary values, that implements the required XOR and removes the mask generated above. The rest is bit-plane compositing: extract a bit, XOR it with its mask bit, weight it by its position, then add the eight planes. CSS opacity supplies the weights, from 1/255 to 128/255, and plus-lighter blending combines the planes into an RGB image. The source quadrant and mask position are aligned with CSS background positioning.

.key { mix-blend-mode: difference; }
.plane { mix-blend-mode: plus-lighter; }
Enter fullscreen mode Exit fullscreen mode
plane.style.opacity = String((1 << planeIndex) / 255);
Enter fullscreen mode Exit fullscreen mode

The difference blend is between the carrier and mask inside a plane; opacity weights that result before the planes are added together.

It is an unusual use of the renderer, and it has a cost. The viewer needs eight filtered carrier planes and eight mask layers; it is not a single canvas draw call. Browser rendering and memory use become significant for large images. The gallery therefore mounts its image viewers progressively as they approach the viewport.

There was also a Chromium rasterization problem when a heavily reduced decoded image returned from a hidden tab or a minimized window. The viewer refreshes its raster after visibility and scale changes so that the composited result does not turn into noise. That workaround is about rendering, not about re-decoding the PNG.

Trying the project

You can open the encoder directly through HTMLPreview, or serve the repository directory locally with python -m http.server 8080 and open encode.html.

The encoder accepts multiple secret and cover images, including file drops. Covers cycle in filename order across the secrets. Output can be saved as individual PNGs, into a chosen folder where supported, or as a ZIP archive. The gallery accepts one direct PNG URL per line, extracts PNG URLs from pasted embed code, or loads a ready-made set of URLs and a seed from its URL fragment. image.html can also be placed inside an iframe; a square iframe with border-radius: 100% and pointer-events: none works as a non-interactive circular image.

The repository includes three examples with different dimensions and aspect ratios: Moon inside Earth, Sun inside a hurricane view, and an Antarctic aurora inside cloud imagery. Their source images are credited to NASA Goddard in the README. NASA media are generally not subject to US copyright, though its media guidelines describe conditions and exceptions.

Limits of the idea

This is a research demonstration, not an audited encryption system. Reusing the same seed and image index for two secrets repeats the mask; combining the encoded images can expose a relationship between the secrets. A weak seed phrase is also guessable, since SHA-256 is not a password-hardening function. There is no authentication or integrity check: a wrong seed, wrong index, or damaged PNG can simply produce noise. The visible cover dimensions and the modified low bits may reveal that data is present.

A shared viewer URL includes the seed in its fragment. That fragment is not part of the HTTP request for the page, but it is visible to anyone who receives the full link and to scripts running on the viewer page. In particular, using HTMLPreview means trusting a third-party page with the demonstration seed. Do not put a private seed into a viewer you do not trust.

The source code and examples are available under BSD-2-Clause-Patent. I would be interested in feedback on the SVG/CSS reconstruction, browser compatibility, and whether there is a simpler CORS-independent rendering path that preserves the original PNG's low bits.

Disclosure: This article was drafted with AI assistance. Its technical descriptions were checked against the project's source code and examples.

Top comments (0)