Gartner’s How to Achieve the Minimum Viable AI Governance
CVE-2026-81983: Integer Overflow Vulnerability Enables Out-of-Bounds Write in Adobe Acrobat
CVE-2026-81983
Critical
CVSS 7.8
This research targeted Adobe Acrobat's image pipeline, an old, always-on surface that runs the moment a PDF is opened and spends its time parsing data the file fully controls. We built a custom, LLM-generated fuzzer for it (more on that below). Rather than mutating whole PDFs blindly, the fuzzer wraps a single image XObject in a minimal, valid document and mutates the fields that actually drive the decoders: width and height, bit depth, color space and component count, the filter chain, and the raw sample bytes.
The fuzzer surfaced many bugs, but the most impactful finding was a one-click out-of-bounds write inside Acrobat.dll, reachable by simply opening a PDF that embeds a crafted 16-bit image. This post walks from that crash down to the root cause and the patch.
The crash
Here is the crash as the fuzzer first handed it over: an access violation reading an unmapped address inside Acrobat.dll.
(1a2c.1b40): Access violation - code c0000005Acrobat.dll+0xfbb65b:00007ff8`69f4b65b 0fb702 movzx eax, word ptr [rdx]ds:00000188`91f20000=????
The register state is a forward walk that ran far too long. rdx, the pointer being read, sits well above r12, the buffer it started from, and r15 holds an enormous width:
0:000> rrbx=0000018895fe5430 rdx=0000018891f20000r12=0000018891efcea0 r15=00000000200000000:000> ? @rdx - @r12Evaluate expression: 143712 = 00000000`00023160 ; ~144 KB past the buffer0:000> dd @rbx+8 L100000188`95fe5438 00000004; 4 components (CMYK)
And the buffer it walked off is tiny, a 16-byte low-fragmentation-heap chunk:
0:000> !heap -x @r12UserPtrUserSizeStateHeap bucket00000188`91efcea0 0x10busy (LFH)16-byte source buffer
So a 16-byte allocation, read-modify-written two bytes at a time, ran roughly 144 KB past its end before it stepped onto an unmapped page. The faulting instruction is the read half of that loop, which we return to below. After minimizing, the trigger reduced to a single embedded image inside a 745-byte PDF.
The vulnerable function
The function is short, and the bug sits in plain sight. param_1 is the image context, param_2 is the 16-bit sample buffer, param_4 is the width, and param_5 is a caller flag:
This is the version before LLM:
void FUN_60fbb590(longlong param_1, ushort *param_2, longlong param_3,int param_4, short param_5){...if ((DAT_649903b0 != 0) &&(*(byte *)(param_1 + 0xc7c) < 9) &&(*(char *)(param_1 + 0x20) == '\x10') &&(*(int *)(param_1 + 0xc) == 0) &&(*(longlong *)(param_1 + 0x10) == *(longlong *)(param_1 + 0x18)) &&((iVar6 = *(int *)(param_1 + 8), iVar6 == 2 || 4 < iVar6))) {...return;}if (param_5 == 0) {puVar2 = param_2;for (iVar6 = param_4 * *(int *)(param_1 + 8); iVar6 != 0; iVar6 = iVar6 + -1) {*puVar2 = *puVar2 >> 8 | *puVar2 << 8;puVar2 = puVar2 + 1;}}...}
And this is how it looks after:
void Normalize16BitRow(ImageContext *img, uint16_t *src,uint8_t *dst, int width, short keepEndian){int components = img->numComponents; // from /ColorSpacebool fastPath = g_featureEnabled&& img->subFormat < 9&& img->bitsPerComponent == 16&& img->flags == 0&& img->srcBuf == img->dstBuf // in-place&& (components == 2 || components > 4);if (fastPath) {int count = width * components; // 32-bit multiplyif (count < 1) return;// Narrow each 16-bit sample to 8 bits.// dst is sized to the same count: safe.for (int i = 0; i < count; i++)dst[i] = (uint8_t)src[i];return;}if (!keepEndian) {int count = width * components; // 32-bit, never checked// against the size of src// Swap the byte order of every 16-bit sample, in place.for (int i = 0; i < count; i++)src[i] = (src[i] >> 8) | (src[i] << 8); // BUG: overruns src}}
The narrowing copy is safe because its destination is sized to the same product. The important loop is the in-place byte swap at the bottom, which iteratesparam_4 * [ctx+8] (width times components) times over param_2, and nothing compares that count against the size of param_2.
Root cause: the 32-bit count
iVar6 = param_4 * *(int *)(param_1 + 8) is a 32-bit int multiply. For any legitimate image the product is a few thousand. But param_4, the width, comes straight from the file, and the byte-swap loop trusts the product as its loop bound with no check against the buffer.
The buffer is sized one level up, by Acrobat's "is there enough data in the stream?" validator, which works out the image size in bytes as about Width * components * 2 (two bytes per 16-bit sample) and rejects the file if the stream is shorter. That validator uses the same 32-bit arithmetic. There is the divergence: the validator carries the extra factor of 2; the loop does not. Choose a width where Width * components * 2 crosses 2^32 and wraps to something tiny, while Width * components stays just under 2^32 and remains enormous:
uint32_t samples = width * components; // swap-loop count: huge, still < 2^32if (stream_len < width * components * 2) // size check: the *2 wraps to ~0return insufficient_data; // -> a tiny stream is acceptedfor (uint32_t i = 0; i < samples; i++) // loop runs the full countbuf[i] = bswap16(buf[i]); // off the end of a tiny buffer
The size check approves a buffer the loop then overruns.
At the assembly level
The byte-swap loop is:
Acrobat.dll+0xfbb650:mov ecx, r15d ; ecx = width (x height)imul ecx, dword ptr [rbx+8] ; ecx = width * components <- 32-bit, no range checktest ecx, ecxje doneswap:movzx eax, word ptr [rdx] ; <- faulting read at +0xfbb65bror ax, 8 ; byte-swap the 16-bit samplemov word ptr [rdx], axlea rdx, [rdx+2]sub ecx, ebp ; ebp = 1jne swapdone:
The only guard on the path is test ecx, ecx, a check that the count is non-zero. rdx advances two bytes per iteration for ecx iterations, with nothing to stop it at the end of the buffer.
Reaching the vulnerable code
That function does not run for every image. It sits on the image-render path, where AGM, the Adobe Graphics Manager, hands a decoded image to this Acrobat.dll preprocessor to normalize 16-bit samples before compositing, and it fires only for a specific, fully attacker-controlled image shape. Its entry gates are:
[ctx+0xC7C] < 9, sub-format byte[ctx+0x20] == 0x10, bits-per-component equals 16[ctx+0x0C] == 0[ctx+0x10] == [ctx+0x18], in-place mode (source equals destination)[ctx+0x08]is 2, or greater than 4, the component count
Every one of these maps to a field an attacker sets in the image XObject dictionary: /BitsPerComponent 16, the /ColorSpace (which fixes the component count), and so on.
Triggering the bug
A minimal trigger is a one-row image with an enormous declared width:
/Type /XObject /Subtype /Image/Width 536870912 % 0x20000000/Height 1/BitsPerComponent 16/ColorSpace /DeviceCMYK % 4 components/Length 8
With four CMYK components, components * width = 4 * 0x20000000 = 0x80000000. The size check multiplies that by two (0x100000000), which wraps to 0 in 32 bits, so the 8-byte stream is accepted. The swap loop uses the un-doubled count and runs 0x80000000 iterations, walking straight off the 16-byte buffer.
The patch
Adobe has since fixed it. The recompiled function adds a 64-bit range check on the count, at entry, before either loop:
movsxd rdi, dword ptr [rcx+8] ; components, sign-extended to 64-bitimul rdi, r12 ; components * (width x height) -- 64-bit, cannot wrapcmp rdi, 0x7fffffff ; > INT_MAX ?ja error ; -> raise 0x2007001e, before any memory is touched
Computing the product in 64 bits removes the wrap, and rejecting anything above INT_MAX closes exactly the [2^31, 2^32) window the trigger relied on. For the canonical input, 4 * 0x20000000 = 0x80000000, which is greater than 0x7FFFFFFF, so the file is now rejected before the swap loop runs.
Building the fuzzer with an LLM
We kept the fuzzer simple: a Python script, mostly LLM-written, since Python's flexible PDF libraries make a harness quick to build. The model was connected to the PDF specification, so its documents were valid and its mutators hit the right fields. Crashes were caught under a debugger and bucketed automatically. What would normally take days was running in a fraction of that time, leaving us free to focus on crash analysis. We may share more on this process in a future post.
Conclusion
This is a textbook integer-overflow-to-out-of-bounds-write: a file-controlled count, computed in 32-bit arithmetic and trusted as a loop bound, paired with an upstream size check that shared the same wrapping arithmetic and so validated nothing. The fix is a single 64-bit range check, which is also the lesson: validate sizes derived from file input in arithmetic wide enough that it cannot wrap, before the first byte is touched.
We reported this to Adobe via HackerOne on 31 May 2026. Adobe accepted our report and assigned it CVE-2026-81983 (NVD), with the fix published in Adobe bulletin APSB26-141 on 8 September 2026.
#Adobe #Acrobat #Research #MemoryCorruption #CVE-2026-81983