Closing the aCropalypse loophole in Cloudflare Images
aCropalypse (CVE-2023-21036) is a flaw in image editors that don’t truncate files after cropping. When an image is made smaller, residual data from the cropped area remains in the file after the end-of-image marker. Most decoders ignore this trailer, but a determined attacker can reassemble a large part of the original image from it.
The vulnerability surfaces in editors like the Pixel Markup tool (PNG output) and Windows Snipping Tool (PNG and JPEG). Any format that doesn’t enforce strict end-of-stream semantics is theoretically at risk. For Cloudflare Images, the concern is concrete: a user could crop a screenshot of a sensitive document, share the cleaned version, and unknowingly leave enough residual data to reconstruct the original.
Why the proxy already gives partial cover
Cloudflare Images and Image Resizing sit in front of the upstream image source. The proxy fetches the original from customer storage or an upstream URL, applies any requested transformations from the variant definitions or URL/worker parameters, and serves a freshly encoded result. Because the proxy decodes the input with standard libraries, any trailing bytes in the original are silently dropped in the re-encoded output.
There is one exception: the proxy can decide to serve the original unfetched when two conditions are met. First, the original must satisfy the request — correct dimensions, no metadata that needs stripping, no overlays or sharpening, and the format must be compatible with what the client asked for. Second, the re-encoded image must be larger than the original. For images carrying an aCropalypse trailer, the original usually wins on size because the leaked data inflates the file. But “usually” isn’t a guarantee, so the engineering team added an explicit check.
Requiring clean file endings
If the original is a PNG or JPEG, the proxy now also demands that it contains no data after the end-of-image marker before passing it through. Trailer detection requires proper parsing rather than a simple marker match. Checking for the JPEG end marker (0xFF 0xD9) or the PNG IEND chunk is not enough; an affected file was once a valid image, so its trailer is the tail of a valid stream and can contain byte sequences that look like legitimate end markers. Variable-length chunks in both formats can also embed these markers anywhere, making a second-marker scan unreliable.
JPEG: leveraging libjpeg-turbo’s stateful API
The proxy decodes JPEGs with a Rust wrapper around libjpeg-turbo. That library exposes a low-level API that supports scanline-by-scanline decompression and re-compression, giving precise control over how much input has been consumed. The trailer check is straightforward: when the decoder reaches the end-of-image marker, verify that the in-memory input buffer has no bytes left. If any remain, the file has a trailer and the proxy refuses to serve the original. The implementation is compact, as shown below.
pub fn consume_eoi_marker(&mut self) -> bool {
// Try to consume the EOI marker of the image
unsafe {
(ffi::jpeg_input_complete(&self.dec.cinfo) == 1) || {
ffi::jpeg_consume_input(&mut self.dec.cinfo);
ffi::jpeg_input_complete(&self.dec.cinfo) == 1
}
}
}
pub fn has_trailer(&mut self) -> io::Result<bool> {
if self.consume_eoi_marker() {
let src = unsafe {
NonNull::new(self.dec.cinfo.src)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::Other,
"source manager not set".to_string()
)
})?
.as_ref()
};
// We have a trailer if we have any bytes left over in the buffer
Ok(src.bytes_in_buffer != 0)
} else {
// We didn't consume the EOI - we can't say if there is a trailer
Err(io::Error::new(
io::ErrorKind::Other,
"EOI not reached".to_string(),
))
}
}
PNG: parsing the chunk structure
PNG decoding uses the lodepng library, which offers a simpler all-at-once API: lodepng_decode returns the image but gives no byte count and no way to inspect leftover input.
The PNG format’s regularity makes a separate check easy. The file starts with an 8-byte prelude (0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A), followed by a sequence of chunks:
- 4-byte big-endian length N
- 4-byte chunk type
- N bytes of chunk data
- 4-byte CRC-32 checksum over type and data
The file ends with an IEND chunk that carries no data. A separate parser reads the prelude, walks the chunks until it sees IEND, and then checks whether any input remains. The checksum validation can be skipped because lodepng has already verified it during decode. The resulting function is small and runs after decoding:
const PNG_PRELUDE: &[u8] = &[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
enum ChunkStatus {
SeenEnd { has_trailer: bool },
MoreChunks,
}
fn consume_chunks_until_iend(buf: &[u8]) -> Result<(ChunkStatus, &[u8]), &'static str> {
let (length_bytes, buf) = consume(buf, 4)?;
let (chunk_type, buf) = consume(buf, 4)?;
// Infallible: We've definitely consumed 4 bytes
let length = u32::from_be_bytes(length_bytes.try_into().unwrap());
let (_data, buf) = consume(buf, length as usize)?;
let (_checksum, buf) = consume(buf, 4)?;
if chunk_type == b"IEND" && buf.is_empty() {
Ok((ChunkStatus::SeenEnd { has_trailer: false }, buf))
} else if chunk_type == b"IEND" && !buf.is_empty() {
Ok((ChunkStatus::SeenEnd { has_trailer: true }, buf))
} else {
Ok((ChunkStatus::MoreChunks, buf))
}
}
pub(crate) fn has_trailer(png_data: &[u8]) -> Result<bool, &'static str> {
let (magic, mut buf) = consume(png_data, PNG_PRELUDE.len())?;
if magic != PNG_PRELUDE {
return Err("expected prelude");
}
loop {
let (status, tmp_buf) = consume_chunks_until_iend(buf)?;
buf = tmp_buf;
if let ChunkStatus::SeenEnd { has_trailer } = status {
return Ok(has_trailer)
}
}
}
Outcome
Cloudflare Images and Image Resizing customers are now covered against aCropalypse by construction. The mitigation sits entirely in the proxy logic; no source images are modified, and there is no measurable latency increase or regression for normal traffic handling. Affected originals are always re-encoded, which strips the trailer as part of the normal pipeline.



