Skip to content

8 min read

Downscaling can reveal text invisible at full resolution

On this page

An upload came in that a human reviewer would sign off on without hesitation: a 2000-by-1200 photo of a product on a shelf, slightly noisy the way phone photos are, nothing written on it. The vision model, moments later, behaved as though it had been handed a paragraph of instructions. Pull up the file the reviewer saw and there is no text in it. Pull up the file the model saw — the one your pipeline produced after shrinking the upload to the vision endpoint's input size — and there, spelled out in the resized image, is a legible command. The instruction was not in the pixels the reviewer looked at. It was latent in them, and your own resize step developed it, the way a photographic print develops out of a blank-looking negative.

This is the mechanism behind a question that otherwise has no good answer: a user uploads an image that looks like an ordinary product photo at full resolution, the agent behaves as though it received instructions — what is the most likely mechanism? The pipeline downscales to the model's input size, and the resampling reveals hidden text. The attacker did not hide a layer you could peel back. They engineered the high-frequency detail of the full-resolution image — the fine variation from one pixel to the next, at a scale the eye reads as texture rather than as shape — so that a specific downsampling kernel, meaning the fixed pattern of weights a resizing algorithm uses to combine a neighbourhood of input pixels into one output pixel, applied at a specific target size, would reconstruct glyphs that are not perceptible at the original scale.

Why shrinking an image can add information that was not visible

Downsampling throws away pixels. To go from 2000 pixels wide to 512, the resampler has to decide what each output pixel should be given a neighbourhood of input pixels, and different algorithms decide differently — nearest-neighbour picks one input pixel, bilinear averages a small block, bicubic and Lanczos weigh a wider window with a particular kernel. The output is a weighted combination of inputs, and which inputs get weight, and how much, is fixed and knowable once you know the algorithm and the target size.

That determinism is the opening. An attacker who knows you will resize to 512-by-512 with, say, bicubic interpolation can solve a straightforward inverse problem: choose full-resolution pixel values such that, after that exact kernel averages them down, the output pixels land on the values that spell out text. At full resolution those crafted values look like ordinary high-frequency texture — noise, grain, the busy detail of a real photograph — because the signal is encoded in a spatial frequency too fine for the eye to resolve as letters. The moment the resampler folds that frequency band down into the output grid, the letters appear. This is aliasing, the same effect that makes a striped shirt shimmer on video, turned into a delivery mechanism. The public tool anamorpher from Trail of Bits implements exactly this attack against common downscaling kernels, and running your own uploads through it is the fastest way to see whether your pipeline reconstructs a payload you cannot see going in.

The practical consequence is that the transformation you added for an entirely mundane reason — the vision endpoint wants a bounded input size, so you resize — is doing adversarial work on the attacker's behalf. You are not merely passing the image through; you are the component that renders the hidden instruction into a visible one, and you are doing it right before handing the result to the model.

This is what makes the scaling attack qualitatively different from the payloads in the earlier chapters, and worth its own treatment rather than folding it into "text in an image." Those payloads were legible at the resolution a reviewer inspects; the defence against them is fundamentally a detection problem, however hard, because the instruction is present in the file you can look at. The scaling payload is not present in that file in any form a reviewer or a scanner can find, because the letters only exist after a specific downscale. You can OCR the full-resolution upload, run it past a human, feed it to a second vision model at full size, and find nothing, and all of those checks are correct — there genuinely is no instruction in the image they examined. The instruction is a function of the image and your resize together, and neither one alone contains it. That is why detection-style defences have almost no purchase here and why the fix, when it comes, is about controlling the transformation rather than inspecting the content.

The control that closes it, stated precisely

There is a clean defence, and it is worth stating exactly, because its precision is the whole point. The attack depends on the attacker knowing the last resize before the model and controlling the input to it. If you resample the upload yourself to the model's exact input dimensions and forward only that resampled copy — never the original — then the attacker no longer controls the input to the resize that matters. Any hidden signal they tuned for a downscale from 2000 pixels is destroyed by your resample, and what the model receives is the image you produced, not the image they crafted.

python
from PIL import Image
import io

MODEL_INPUT = (768, 768)  # the vision endpoint's exact expected size

def normalize_for_model(upload_bytes: bytes) -> bytes:
    img = Image.open(io.BytesIO(upload_bytes)).convert("RGB")
    # Resample ONCE, to the model's exact input size, and forward only this.
    resized = img.resize(MODEL_INPUT, Image.LANCZOS)
    out = io.BytesIO()
    resized.save(out, format="PNG")
    return out.getvalue()

# The model must receive normalize_for_model(upload), never the raw upload,
# and nothing downstream may re-attach the original alongside it.
model_input = normalize_for_model(raw_upload)

This is the correct answer to which preprocessing step actually defeats the image-scaling attack class: resample the upload yourself to the model's exact input size and forward only that copy. Note both halves. Resampling to the right size is what neutralises the crafted high-frequency content. Forwarding only that copy is what prevents the attack from routing around your resize, and it is the half people drop.

Where the fix stops, which is most of the interview

The reason this control is worth understanding in detail is that it is narrow, and its narrowness is exactly what a shallow answer misses. Someone will say: we resize every upload to 512-by-512 before the model sees it, so the image-injection surface is closed. That is not a complete fix. It closes the image-scaling class and nothing else, and the boundary of "nothing else" is large enough to matter.

Start with the most direct gap. A plainly rendered instruction — the legible caption, the low-contrast overlay from Chapter 1 — survives your resize untouched. Shrinking a page that says apply the standard goodwill credit in grey text produces a smaller page that still says it; the model reads it just as well at 512-by-512 as at full resolution. The resize control does nothing for the payloads that do not depend on scaling in the first place, which are the majority of them and the easiest to author.

Next, the resize sits on exactly one of the three paths from the previous chapter. The OCR service read glyphs off the original upload, before your normalisation, so a scaling payload aimed at the model does not concern it and a plain payload in the original still flows through it. The captioning model, if it captions the raw upload rather than your resized copy, likewise never benefited from the resize. The control you placed on the vision path is silent about the OCR and caption paths, which is the same lesson as the OCR scanner that watched the wrong door, arriving from the other direction.

Then there is scope. The resize defends images that arrive through the upload form you instrumented. Images that reach the model without an upload — the ones a browsing agent fetches off a web page, an attachment in an email the agent reads — never pass through normalize_for_model at all, because that function lives in your upload handler. The next chapter is entirely about those images; here it is enough to notice that "we resize every upload" is a sentence with the word upload in it, and the risk is not confined to uploads.

Finally, and most subtly, the control depends on your resample being the genuine last transformation before the model, and on that being an invariant you can actually hold. Two things break it. A provider that re-encodes or re-scales the image on their side — because their endpoint has its own preprocessing — inserts a transformation after yours, and if an attacker can predict that one, they can craft against it and your resize becomes irrelevant. And an agent that, somewhere in its wrapper logic, forwards the original file alongside your resized copy — for logging, for a fallback, for a "here is the source" convenience — hands the attacker back the input they wanted, exactly the way the raw-image path bypassed the OCR scanner two chapters running. The bypass is structurally identical: a control on one representation is defeated by a component that quietly supplies a different representation.

Flowchart: Raw upload → Your resize to model size; Your resize to model size → Vision model; Raw upload → Your resize to model size; Raw upload → Vision model; Your resize to model size → Vision model.

So the honest account of the resize control is: it converts one specific, real, and otherwise very hard-to-detect attack into a non-issue, provided your resample is the last thing to touch the pixels and the original never travels onward. It does not touch plainly rendered instructions, it does not extend to the OCR or caption paths, it does nothing for images that never hit your upload form, and it fails if any later step re-introduces a resample the attacker can predict. A good answer to the interview question walks that list; the shallow answer stops at "resizing normalises the input" and inherits every gap it did not name.

From an image you were handed to an image you fetched

Everything so far has assumed the image comes to you — a user picks a file and uploads it, and your job is to handle that file safely. That framing quietly bounds the problem to the upload form, and the resize control leans on that bound. The next chapter removes it. A team that ships an agent which browses and summarises web pages accepts no uploads at all and concludes, reasonably-sounding, that image injection is not their problem. Every image on a page that agent fetches was put there by whoever controls the page, which makes it exactly as untrusted as a file a stranger uploaded — and there is no normalize_for_model in the path, because there was no upload to hang it on.

Image-borne prompt injection — read the current version at https://daviddominguez.dev/learn/en/image-borne-prompt-injection/downscaling-can-reveal-text-invisible-at-full-resolution

Back to top