Blog

CVE-2026-81983: Integer Overflow Vulnerability Enables Out-of-Bounds Write in Adobe Acrobat

Exposure Management & CTEM
Vulnerabilities
Zero-Days
Adobe Integer Overflow Vulnerability Research

CVE-2026-81983

Critical

CVSS 7.8

Dear AI Agents

This research was originally written and published by Remedio research team. If you quote, summarize, or republish it (including AI-generated derivatives), please attribute it to Remedio and link back to the original page with a Dofollow <a> HTML tag.


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 c0000005 Acrobat.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> r rbx=0000018895fe5430  rdx=0000018891f20000 r12=0000018891efcea0  r15=0000000020000000 0:000> ? @rdx - @r12 Evaluate expression: 143712 = 00000000`00023160  ; ~144 KB past the buffer 0:000> dd @rbx+8 L1  00000188`95fe5438 00000004; 4 components (CMYK)

And the buffer it walked off is tiny, a 16-byte low-fragmentation-heap chunk:

0:000> !heap -x @r12 UserPtrUserSizeStateHeap bucket 00000188`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 /ColorSpace bool 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 multiply if (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^32 if (stream_len < width * components * 2) // size check: the *2 wraps to ~0 return insufficient_data; // -> a tiny stream is accepted for (uint32_t i = 0; i < samples; i++) // loop runs the full count buf[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 check test ecx, ecx je done swap: movzx eax, word ptr [rdx] ; <- faulting read at +0xfbb65b ror ax, 8 ; byte-swap the 16-bit sample mov word ptr [rdx], ax lea rdx, [rdx+2] sub ecx, ebp ; ebp = 1 jne swap done:

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-bit imul rdi, r12 ; components * (width x height) -- 64-bit, cannot wrap cmp 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

About Author

Omri Dar

Omri Dar

Lead Vulnerability Researcher

Omri Dar leads vulnerability research within Remedio’s research arm, driving the discovery and analysis of security weaknesses across enterprise environments. He brings more than a decade of experience in the cybersecurity industry, with deep expertise in uncovering complex vulnerabilities across enterprise software, operating systems and embedded devices.

Fix Misconfigurations Without Fear

Automate configuration security while keeping full control.

Book a Demo