July 17, 2026
When -1 Crossed a Library Boundary: How libvips Hardened Its TIFF Loader
A routine read error exposed a dangerous contract mismatch with libtiff and showed why downstream defenses still matter

By Aleens-labs
6 min read
A routine read error exposed a dangerous contract mismatch with libtiff and showed why downstream defenses still matter
In libvips, -1 meant that a read had failed. In the libtiff callback receiving that value, however, a negative result was not a supported error signal.
That small mismatch mattered.
The value could reach a vulnerable libtiff function, alter an internal byte counter, and contribute to an out-of-bounds memset(). AddressSanitizer confirmed the result inside libtiff: a heap write beginning one byte before an allocated buffer.
This is the story of tracing that value across two open-source libraries, reporting the integration risk to libvips, and watching a focused defensive patch land on the same day.
It is also a story about reporting boundaries. The memory-corruption root cause belonged to libtiff. The libvips change was downstream hardening, not evidence of a separate libvips RCE or an automatic second CVE.
The Boundary Where the Problem Began
libvips supports TIFF input through libtiff. To let libtiff read from a VipsSource, libvips supplies a custom read callback named openin_source_read().
Before the patch, the important part of that callback behaved like this:
bytes_read = vips_source_read(source, data, size - total_read);
if (bytes_read == -1)
return -1;
if (bytes_read == 0)
break;
total_read += bytes_read;bytes_read = vips_source_read(source, data, size - total_read);
if (bytes_read == -1)
return -1;
if (bytes_read == 0)
break;
total_read += bytes_read;This is natural from the perspective of a general I/O API: -1 reports an error, while 0 reports end-of-file.
But libtiff's client read handler does not provide a separate error channel. Its callers expect a byte count, and a negative value must not be allowed to influence offsets or buffer calculations.
The two sides therefore disagreed about the meaning of one return value:
VipsSource
-1 = read error
libtiff read callback contract
0 = no bytes available / EOF
negative values must not enter size arithmeticVipsSource
-1 = read error
libtiff read callback contract
0 = no bytes available / EOF
negative values must not enter size arithmeticThat disagreement was enough to expose the vulnerable path in libtiff.
Following the -1
The value moved through a short but security-relevant chain:
vips_source_read()
-> openin_source_read()
-> libtiff client read callback
-> TIFFReadEncodedStrip()
-> TIFFFillStrip()
-> TIFFReadRawStripOrTile2()
-> TIFFReadAndRealloc()vips_source_read()
-> openin_source_read()
-> libtiff client read callback
-> TIFFReadEncodedStrip()
-> TIFFFillStrip()
-> TIFFReadRawStripOrTile2()
-> TIFFReadAndRealloc()Inside the vulnerable libtiff revision, TIFFReadAndRealloc() updated already_read before rejecting a negative read result. When the callback returned -1, the error path could effectively reach a calculation equivalent to:
memset(raw_buffer - 1, 0, raw_buffer_size + 1)memset(raw_buffer - 1, 0, raw_buffer_size + 1)AddressSanitizer reported the boundary violation clearly:
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1025
#0 __asan_memset
#1 TIFFReadAndRealloc libtiff/tif_read.c:133
#2 TIFFReadRawStripOrTile2 libtiff/tif_read.c:743
#3 TIFFFillStrip libtiff/tif_read.c:978
#4 TIFFReadEncodedStrip libtiff/tif_read.c:579
0x51900000007f is located 1 byte before a 1024-byte regionERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1025
#0 __asan_memset
#1 TIFFReadAndRealloc libtiff/tif_read.c:133
#2 TIFFReadRawStripOrTile2 libtiff/tif_read.c:743
#3 TIFFFillStrip libtiff/tif_read.c:978
#4 TIFFReadEncodedStrip libtiff/tif_read.c:579
0x51900000007f is located 1 byte before a 1024-byte regionThis output proves a heap out-of-bounds write in the vulnerable libtiff path. It is not merely a handled decoding exception.
What the Evidence Proved, and What It Did Not
Separating observation from inference is essential in memory-corruption research.
Confirmed
- A callback returning negative
tmsize_tcould reach the vulnerableTIFFReadAndRealloc()logic. - AddressSanitizer confirmed a heap out-of-bounds write in libtiff.
- The pre-fix libvips TIFF callback could propagate a negative result from
vips_source_read(). - libvips accepted and merged a defensive change that prevents negative values from being returned to libtiff.
- libtiff separately fixed the root state-update bug upstream.
Not demonstrated
- Remote code execution through libvips.
- Instruction-pointer control or a controlled function-pointer overwrite.
- A universally exploitable public libvips service.
- A separate libvips vulnerability deserving its own CVE.
The ASAN reproducer established the libtiff primitive using a client read callback and a natural failed read. The libvips report then established that its TIFF adapter had the relevant error-propagation behavior. Those are related facts, but they should not be presented as an end-to-end compromise of every application using libvips.
The Report and the Fast Upstream Response
I reported the integration concern as libvips issue #5089 on June 16, 2026. The report identified openin_source_read() as the boundary where a normal libvips read error could become an unsupported negative return in libtiff.
The libvips maintainers responded quickly. Pull request #5090, titled "tiffload: squash read errors," changed the callback so both EOF and read errors stop the loop without returning a negative value to libtiff:
bytes_read = vips_source_read(source, data, size - total_read);
/* libtiff does not allow error returns from read, so any -1s need to
* be squashed out as EOF.
*/
if (bytes_read <= 0)
break;
total_read += bytes_read;bytes_read = vips_source_read(source, data, size - total_read);
/* libtiff does not allow error returns from read, so any -1s need to
* be squashed out as EOF.
*/
if (bytes_read <= 0)
break;
total_read += bytes_read;The patch was small: four added lines and three removed lines in libvips/foreign/tiff.c, plus a changelog entry. It was reviewed, passed 12 checks, and was merged into the 8.18 branch on the same day.
The commit message captured the contract problem directly: libtiff read handlers cannot return error codes, so libvips must convert read failures into a zero-byte result.
Was This a Separate libvips Security Vulnerability?
After the patch merged, I asked whether the libvips side should receive a GitHub Security Advisory or CVE.
The maintainer's position was cautious: the libvips behavior appeared relatively minor, and it was not clear that it was even a denial-of-service issue by itself. They also left room for reassessment if concrete exploitation could be demonstrated.
That distinction was reasonable.
I clarified that the confirmed heap write was in libtiff's TIFFReadAndRealloc(), not in libvips. I also stated that I was not claiming code execution from the libvips side and that the CVE request would target libtiff. The libvips patch would be referenced only as downstream hardening evidence.
This is an important reporting lesson: finding a dangerous value at a dependency boundary does not automatically create two independent vulnerabilities. The component containing the faulty memory operation is the primary affected product. A consumer that can avoid supplying the dangerous value may still benefit from defense in depth, but that does not move the root cause.
Why the libvips Patch Still Matters
The absence of a separate CVE does not make the patch unimportant.
Image-processing systems often combine several libraries. One component reads from a file, socket, object store, virtual filesystem, or custom source. Another parses the image format. Security depends not only on each component internally, but also on their shared assumptions.
In this case:
ordinary I/O failure
-> valid -1 in one abstraction
-> invalid negative byte count in another abstraction
-> vulnerable state update in a dependency
-> heap out-of-bounds writeordinary I/O failure
-> valid -1 in one abstraction
-> invalid negative byte count in another abstraction
-> vulnerable state update in a dependency
-> heap out-of-bounds writeThe libtiff fix closes the root memory-safety flaw. The libvips fix makes its adapter obey libtiff's callback contract even when linked against an older or otherwise vulnerable libtiff build. Together, those changes form a stronger defense than either assumption alone.
This is especially valuable for long-lived distributions and applications where dependencies may not all be upgraded at the same time.
How to Validate the Fix Safely
The goal of validation is not to claim exploitation. It is to confirm that a negative source read can no longer cross the libvips-to-libtiff boundary.
- Build a pre-fix libvips revision with AddressSanitizer and link it to the vulnerable libtiff revision used for the original test.
- Use a controlled
VipsSourcewhose read operation fails during TIFF strip loading. - Confirm that the old
openin_source_read()path can return-1to libtiff. - Repeat with libvips commit
8591369or the merged commit10db2ed. - Confirm that
bytes_read <= 0stops the read loop and the callback returns a non-negative byte count. - Independently test the patched libtiff revision and confirm that negative read results are rejected before updating
already_read.
The expected post-fix behavior is a clean TIFF read failure, not an ASAN heap-buffer-overflow.
Root-Cause Fix Versus Downstream Hardening
The two patches protect different layers:
libtiff root fix
- Validates negative
TIFFReadFile()results before state updates. - Prevents negative values from affecting offsets, buffer pointers, and lengths.
- Fixes the actual heap out-of-bounds write in
TIFFReadAndRealloc().
libvips downstream fix
- Converts source read errors into EOF semantics expected by libtiff.
- Prevents
openin_source_read()from forwarding-1across the callback boundary. - Reduces exposure when libvips is used with older libtiff builds.
The root issue is tracked in libtiff work item #854 and was fixed through merge request !901. The libvips compatibility hardening is tracked in issue #5089 and pull request #5090.
Security Impact Without the Hype
The confirmed security impact belongs to the vulnerable libtiff path: heap out-of-bounds write and possible process corruption when a custom read callback returns a negative value.
For libvips, the confirmed finding was a callback-contract mismatch that could supply that value. The maintainers fixed it promptly, but an independent libvips RCE, information disclosure, or even universal DoS was not demonstrated.
That may sound less dramatic than declaring every downstream application remotely exploitable. It is also more accurate, more useful to maintainers, and more defensible during CVE review.
Good vulnerability research is not only about finding the most severe possible interpretation. It is about locating the precise trust boundary, reproducing the actual primitive, identifying which component owns the root cause, and stopping the claim exactly where the evidence stops.
Timeline
June 16, 2026: The libvips callback behavior was reported in issue #5089. June 16, 2026: Pull request #5090 was opened, reviewed, passed 12 checks, and merged into the 8.18 branch. June 16, 2026: The libtiff root-cause fix was proposed in merge request !901. Later upstream resolution: libtiff merged the validation fix for negative read results. CVE coordination: The request targets libtiff; CAN-2026-2032028 remains a candidate identifier pending final CVE assignment.
References
- libvips issue
#5089: https://github.com/libvips/libvips/issues/5089 - libvips pull request
#5090: https://github.com/libvips/libvips/pull/5090 - libvips hardening commit: https://github.com/libvips/libvips/commit/8591369
- Merged libvips commit: https://github.com/libvips/libvips/commit/10db2ed
- Maintainer discussion and impact clarification: https://github.com/libvips/libvips/pull/5090#issuecomment-4724164755
- libtiff root issue
#854: https://gitlab.com/libtiff/libtiff/-/work_items/854 - libtiff root fix
!901: https://gitlab.com/libtiff/libtiff/-/merge_requests/901
Closing Thoughts
The most important character in this story was not a complex payload. It was a single negative integer crossing an API boundary with a different meaning on each side.
libvips treated -1 as an ordinary read failure. Vulnerable libtiff code treated it as part of a byte-count calculation. The result was a confirmed heap write in the dependency and a useful reminder for every parser integration: callback contracts are security boundaries.
The strongest outcome was not an inflated severity label. It was two complementary fixes: libtiff repaired the vulnerable state update, and libvips stopped exporting an error representation that libtiff could not safely accept.
That is what responsible open-source security work should look like.