Archive
TR

Same JPEG, different pixels

Decoding only part of a JPEG does not give the same pixels as decoding the whole image and cutting that part out.

CSINT-NEWS-2026-011Open in archive

Documents

The yellow highlights were added later. The original image is unchanged.

574 pixels differ, all on the edges

The same file was decoded whole and as a region. x=200 was requested, and the library started the region at 192 because of the block boundary.

  1. Requested and returned region
  2. Differences only on the edges

What it does not show. The counts hold for this test pattern and this region. In other images the number and size of the differences change.

The test's terminal recordingRaw recording 1, 0:5517 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

One position, two colours

From the rows where the colour changes sharply, the region's first column differs on the two paths; the columns beside it match.

  1. Full decode, x=192
  2. Partial decode, x=192

What it does not show. The crop shows the left edge only. The 244 differences on the right edge are not in it.

The test's decoded outputs, run 2main_simd/r_full.ppm and r_B_crop.ppm17 October 2026

Output by CSINT. The crop was enlarged 40 times with nearest-neighbour; pixel values are unchanged. A plain grey band separates the two crops.

Colour is stored once per two pixels

A generated test pattern was turned into a 4:2:2 JPEG with libjpeg-turbo's own cjpeg.

  1. 4:2:2 sampling

The test's terminal recordingRaw recording 1, 0:3017 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

Both paths read the same file

Both decode paths take one and the same file.

  1. The compared JPEG

The test's terminal recordingRaw recording 1, 0:4517 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The documentation says so

The libjpeg-turbo 2.1.5 documentation describes this behaviour, so it is not a new flaw.

  1. Region edge treated as image edge

The test's terminal recordingRaw recording 1, 0:1417 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The colour row drops from 384 to 154

The colour-filling function and the row length it sees were read at run time.

  1. 384 in the full decode
  2. Colour functions
  3. 154 after cropping

What it does not show. This shows which pointer is selected. Each call was not traced with a debugger.

The test's terminal recordingRaw recording 2, 0:1017 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The DLL opened with REA

The jpeg62.dll of the build without SIMD was analysed by Ghidra 12.1.4 through REA.

  1. Load address

The test's terminal recordingRaw recording 2, 1:2117 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The function at 0x3f2a0

In the build without SIMD, the probe's address is one 249-byte function.

  1. The colour function

The test's terminal recordingRaw recording 2, 1:2517 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The first sample is copied as is

The first colour of a row is written without a neighbour, and the row length is read from offset 0x28.

  1. First output, no neighbour
  2. Length from field 0x28

The test's terminal recordingRaw recording 2, 1:3917 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

Mixed inside, copied at the end

The function treats both ends of the row as image edges; it cannot know whether the image continues.

  1. Mixed with neighbour
  2. Last sample copied

The test's terminal recordingRaw recording 2, 1:5317 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

x is moved to a block boundary

The crop function rounds the start and targets the colour row's length field.

  1. 200 becomes 192
  2. The same field 0x28

The test's terminal recordingRaw recording 2, 2:1717 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The shorter length is written

This is where the length read by the colour function comes from.

  1. New width, 154

The test's terminal recordingRaw recording 2, 2:2917 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The C function by default

The selector uses the C function when the SIMD check returns zero.

  1. The choice

The test's terminal recordingRaw recording 2, 3:1717 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

No SIMD in this build

In the build without SIMD the check always returns zero, so the C function always runs.

  1. Always zero

The test's terminal recordingRaw recording 2, 3:4717 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The fast path reads the same field

In the SIMD build used for the measurement, the row length also comes from field 0x28.

  1. The shortened width
  2. AVX2 choice

What it does not show. The SSE2 path at 0x18000bb00 was not examined.

The test's terminal recordingRaw recording 2, 4:5517 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

AVX2 copies the last sample next to itself

The last sample becomes its own right neighbour, so the formula returns the same value.

  1. Last sample in the neighbour's place

The test's terminal recordingRaw recording 3, 1:2817 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

The same formula's constants

The vector code applies the C function's x3 and its +1 and +2 rounding to 16 values at once.

  1. 1, 2 and 3

The test's terminal recordingRaw recording 2, 5:2817 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

No difference with fancy off

When colour is not mixed with a neighbour, the two paths are byte-identical.

  1. Fancy off
  2. 0 pixels

The test's terminal recordingRaw recording 1, 1:1017 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

Three builds, the same result

The count was redone in Python from the raw PPM files and matches the C program.

  1. 574 with fancy on
  2. 0 in the controls

The test's terminal recordingRaw recording 1, 1:4317 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

djpeg does the same

libjpeg-turbo's own command-line tool gives the same edge pixels.

  1. Partial decode = djpeg -crop

The test's terminal recordingRaw recording 3, 0:1117 October 2026

Screen recording by CSINT. The frame was cropped; its pixels are unchanged.

Bottom line

574 of 133,056 pixels differ, and every one is on the first or last column of the region. jpeg_crop_scanline shortens the colour row to the region, and the colour-filling code treats the ends of that row as image edges, copying the edge sample instead of mixing in its neighbour.

Limits

This is a test I ran on my own computer. The behaviour is described in the libjpeg-turbo documentation; it is not a new flaw or a bug report.

Full account and source transcriptMethod, timeline and full text. The findings are in the documents above.

The claim reviewed

Decoding a region of a 4:2:2 JPEG with jpeg_crop_scanline can change the colour at the region's edges. The question is where and why.

Limits of the test

  • One version (2.1.5), one Windows build made with MSVC, one test pattern and only 4:2:2 were tested.
  • 4:2:0, vertical cropping with jpeg_skip_scanlines, newer versions and other decoders were not measured.
  • Calls were not traced with a debugger. The run-time probe, the code that was read and byte-identical outputs support the result together.
  • This is not a security flaw. The differences are 1 to 55 per channel and only on the edge columns.

Light and colour

A JPEG stores each pixel as light (Y) and colour (Cb, Cr). The eye sees fine detail in light much better than in colour, so colour is often stored less. In this file light is stored for every pixel and colour once for every two pixels side by side. This is called 4:2:2.

When decoding, the missing colour is filled in. By default libjpeg-turbo takes 3 parts of the nearest colour sample and 1 part of the neighbouring sample, then divides by 4. At the very edge of the image there is no neighbour, so the edge sample is copied as is.

When a region is decoded, the library shortens the colour row to the width of the region. The colour-filling code cannot see past the row, so it treats the region's edge as the image's edge.

The experiment

A 768 x 432 test pattern was generated with code. It has smooth gradients on top and sharp colour stripes at the bottom. It was turned into a JPEG with libjpeg-turbo's own cjpeg, using -quality 90 -sample 2x1 -dct int.

A small C program decoded the file two ways. It first decoded the whole image, cutting out x 192 to 499, then only that region with jpeg_crop_scanline. The two outputs were compared pixel by pixel, and the count was redone in Python from the raw PPM files.

x=200 was requested. Because of the 16-pixel block boundary, the library returned x=192 and a width of 308, so the same columns were compared in both outputs.

The largest difference is at x=192, y=216. The stored Cb samples are 215 and 91. The full decode calculates (3 x 91 + 215 + 1) >> 2 = 122. In the partial decode 215 is outside the region, so 91 is copied. Light is 127 on both paths, and the pixel becomes RGB 27, 190, 61 instead of 44, 171, 116.

On the right edge at x=499 the real next sample is 177. The full decode gives (3 x 180 + 177 + 2) >> 2 = 179. The AVX2 path writes a copy of 180 into the next cell, so the result is 180.

How the code was read

The SIMD build, the same build with JSIMD_FORCENONE=1, and the build without SIMD were compared. All three gave byte-identical output. The build without SIMD was the easiest to read.

A small probe printed the address of the colour function the library selected and the row length it sees. The DLL was opened with Ghidra 12.1.4 through REA, an MCP server. No debug symbols were loaded, so internal functions are named FUN_ followed by the address.

The REA tools used were open_binary, binary_overview, resolve_containing_procedure, procedure_pseudo_code, xrefs, procedure_assembly, inspect_native_instruction, read_bytes and export_evidence_bundle. Every request and response was kept as a separate JSON file.

Why it matters

Tests, caches or hash checks that make thumbnails or tiles with partial decoding and compare them with full decodes can fail on the edges.

In this test every pixel away from the region's edges matched. Decoding one extra block on each side and trimming it, turning fancy upsampling off, or accepting the difference are all possible options.

What went wrong on the way

libjpeg-turbo 2.1.5 only built with CMake 4.1 after adding -DCMAKE_POLICY_VERSION_MINIMUM=3.5. Ghidra's headless analyser did not start because the user folder name has the letter ö, and it ran through short 8.3 paths. The first two measurement scripts failed on PowerShell's stderr handling and a JSON escaping error. The failed attempts were kept.

Versions and hashes

Windows 11 Home 26300, Intel Core Ultra 7 155H. libjpeg-turbo 2.1.5 (git 3b19db4e), MSVC 19.29.30159, NASM 2.16.03. REA rea-agents 6.5.0, Ghidra 12.1.4, Python 3.10.11.

test_422.jpg SHA-256 72721589ae3fa7c665080f1334544cba0c6d69394cffcb0d931fe34c711630c2. The examined jpeg62.dll without SIMD starts with 8d8fd33f, the SIMD jpeg62.dll used for the measurement with 02057234. The frame hashes are in docs/research/jpeg-kenar-pikselleri.json.

Sources