CVE-2026-87585
Overview
Background
- PDFium
- Chromium’s embedded PDF rendering engine, which bundles the
libopenjpegJPEG 2000 decoder to parse image streams inside PDF documents. - `opj_j2k_copy_default_tcp_and_create_tcd`
- an OpenJPEG J2K function that copies the default tile-coding parameters (
tcp) into a per-tile structure and creates the tile-coder/decoder. - `m_mct_records` / `m_mcc_records`
- dynamically allocated arrays on an
opj_tcp_tholding Multiple Component Transform (MCT) and MCC records that must be freed exactly once during cleanup. - `m_nb_mct_records` vs `m_nb_max_mct_records`
- the count field tracking how many records are currently populated versus the field tracking allocated capacity, either of which cleanup code may consult to decide how much to free.
Root Cause Analysis
When opj_j2k_copy_default_tcp_and_create_tcd begins populating a fresh l_tcp, it first memcpys the entire default tcp over the current tile’s tcp, which copies the default’s m_mct_records and m_mcc_records pointers and their count fields verbatim. The reset block that follows was clearing the capacity fields (m_nb_max_mct_records, m_nb_max_mcc_records) and the pointers but was not clearing the population counts m_nb_mct_records and m_nb_mcc_records, so on an early out-of-memory return those stale counts still described records that belonged to the default tcp, causing cleanup to free memory the tile did not own β a double free. Additionally, while copying records the code incremented m_nb_max_mct_records (capacity) rather than m_nb_mct_records (population count) per pass, so the invariant “the count field names exactly the records this tile has allocated and owns” was violated in both directions.
The fix zeroes m_nb_mct_records and m_nb_mcc_records in the reset block, sets m_nb_max_mct_records once to the source capacity after the allocation succeeds, and increments the population count m_nb_mct_records per copied record so that on any early error return the cleanup frees exactly what this tile allocated and owns, and nothing more.
m_nb_mct_records/m_nb_mcc_records pointing at records owned by the default tcp after the memcpy; the fix restores the invariant by zeroing those counts on reset and incrementing the correct population field as each record is truly allocated for the tile.Attack Path
- Craft a malicious JPEG 2000 stream
An attacker embeds a specially formed J2K image inside a PDF whose MCT/MCC parameters drive
opj_j2k_copy_default_tcp_and_create_tcd. - Deliver to PDFium The victim opens or previews the PDF, and PDFium hands the J2K stream to the bundled OpenJPEG decoder.
- Trigger the early OOM return
The stream is shaped so that a subsequent allocation fails (out-of-memory) partway through copying default
tcprecords, taking the early-error return path. - Cleanup double-frees
Because the stale
m_nb_mct_records/m_nb_mcc_recordsstill describe the defaulttcp’s records, the cleanup frees pointers already owned/freed elsewhere, producing a double free. - Leverage allocator corruption The attacker uses controlled heap state to turn the double free into a exploitable memory-corruption primitive within the decoder.
Impact Assessment
Files Changed
third_party/libopenjpeg/0051-opj_j2k_copy_default_double_free.patchthird_party/libopenjpeg/README.pdfiumthird_party/libopenjpeg/j2k.c
Audit Directions
- Struct-copy then partial-resetAfter any
memcpyof a whole struct that contains owning pointers plus their count/capacity fields, verify every ownership-tracking field (both pointers and counts) is reset, not just the pointers or the capacity. - Count vs. capacity confusionAudit allocation loops that increment a
nb_max_*capacity field where anb_*population field governs cleanup (or vice versa), since mismatched fields cause under- or over-free on error paths. - Early-error / OOM cleanup pathsFuzz and review decoder error returns (especially OOM) that free per-object arrays, ensuring counts reflect only memory the current object actually allocated and owns before the failure point.
Patch
From f1d22ed2e44393d0a16577ce941d5c002f2b378e Mon Sep 17 00:00:00 2001 From: Lei Zhang <[email protected]> Date: Fri, 31 Jul 2026 14:19:56 -0700 Subject: [PATCH] Fix an OpenJPEG double-free on early OOM return In opj_j2k_copy_default_tcp_and_create_tcd(), zero out m_nb_mct_records and m_nb_mcc_records when resetting tcp fields after the initial memcpy(), and increment m_nb_mct_records instead of m_nb_max_mct_records when copying MCT records. TAG=agy CONV=f83c340a-9268-4995-b1c0-b8bddd9c38c8 Bug: 540817065 Change-Id: I85b02da08a1a598d1ad5406004314b11f309acca Reviewed-on: https://pdfium-review.googlesource.com/c/pdfium/+/153973 Reviewed-by: Tom Sepez <[email protected]> Commit-Queue: Lei Zhang <[email protected]> --- diff --git a/third_party/libopenjpeg/0051-opj_j2k_copy_default_double_free.patch b/third_party/libopenjpeg/0051-opj_j2k_copy_default_double_free.patch new file mode 100644 index 0000000..a0c8b26 --- /dev/null +++ b/third_party/libopenjpeg/0051-opj_j2k_copy_default_double_free.patch @@ -0,0 +1,32 @@ +diff --git a/third_party/libopenjpeg/j2k.c b/third_party/libopenjpeg/j2k.c +index fc5ce62df..1fb83f8b5 100644 +--- a/third_party/libopenjpeg/j2k.c ++++ b/third_party/libopenjpeg/j2k.c +@@ -9246,8 +9246,10 @@ static OPJ_BOOL opj_j2k_copy_default_tcp_and_create_tcd(opj_j2k_t * p_j2k, + /* Remove memory not owned by this tile in case of early error return. */ + l_tcp->m_mct_decoding_matrix = 00; + l_tcp->m_nb_max_mct_records = 0; ++ l_tcp->m_nb_mct_records = 0; + l_tcp->m_mct_records = 00; + l_tcp->m_nb_max_mcc_records = 0; ++ l_tcp->m_nb_mcc_records = 0; + l_tcp->m_mcc_records = 00; + /* Reconnect the tile-compo coding parameters pointer to the current tile coding parameters*/ + l_tcp->tccps = l_current_tccp; +@@ -9270,6 +9272,7 @@ static OPJ_BOOL opj_j2k_copy_default_tcp_and_create_tcd(opj_j2k_t * p_j2k, + return OPJ_FALSE; + } + memcpy(l_tcp->m_mct_records, l_default_tcp->m_mct_records, l_mct_records_size); ++ l_tcp->m_nb_max_mct_records = l_default_tcp->m_nb_max_mct_records; + + /* Copy the mct record data from dflt_tile_cp to the current tile*/ + l_src_mct_rec = l_default_tcp->m_mct_records; +@@ -9290,7 +9293,7 @@ static OPJ_BOOL opj_j2k_copy_default_tcp_and_create_tcd(opj_j2k_t * p_j2k, + ++l_src_mct_rec; + ++l_dest_mct_rec; + /* Update with each pass to free exactly what has been allocated on early return. */ +- l_tcp->m_nb_max_mct_records += 1; ++ l_tcp->m_nb_mct_records += 1; + } + + /* Get the mcc_record of the dflt_tile_cp and copy them into the current tile cp*/ diff --git a/third_party/libopenjpeg/README.pdfium b/third_party/libopenjpeg/README.pdfium index 9006106..2823e66 100644 --- a/third_party/libopenjpeg/README.pdfium +++ b/third_party/libopenjpeg/README.pdfium @@ -36,6 +36,7 @@ 0048-dwt_neon_9-7_idwt.patch: Cherry-pick https://github.com/uclouvain/openjpeg/pull/1629 0049-dwt_neon_5-3_idwt.patch: Cherry-pick https://github.com/uclouvain/openjpeg/pull/1630 0050-opj_pi_initialise_encode_overflow.patch: Cherry-pick https://github.com/uclouvain/openjpeg/pull/1619 +0051-opj_j2k_copy_default_double_free.patch: Fix a double free. Note: diff --git a/third_party/libopenjpeg/j2k.c b/third_party/libopenjpeg/j2k.c index fc5ce62..1fb83f8 100644 --- a/third_party/libopenjpeg/j2k.c +++ b/third_party/libopenjpeg/j2k.c @@ -9246,8 +9246,10 @@ /* Remove memory not owned by this tile in case of early error return. */ l_tcp->m_mct_decoding_matrix = 00; l_tcp->m_nb_max_mct_records = 0; + l_tcp->m_nb_mct_records = 0; l_tcp->m_mct_records = 00; l_tcp->m_nb_max_mcc_records = 0; + l_tcp->m_nb_mcc_records = 0; l_tcp->m_mcc_records = 00; /* Reconnect the tile-compo coding parameters pointer to the current tile coding parameters*/ l_tcp->tccps = l_current_tccp; @@ -9270,6 +9272,7 @@ return OPJ_FALSE; } memcpy(l_tcp->m_mct_records, l_default_tcp->m_mct_records, l_mct_records_size); + l_tcp->m_nb_max_mct_records = l_default_tcp->m_nb_max_mct_records; /* Copy the mct record data from dflt_tile_cp to the current tile*/ l_src_mct_rec = l_default_tcp->m_mct_records; @@ -9290,7 +9293,7 @@ ++l_src_mct_rec; ++l_dest_mct_rec; /* Update with each pass to free exactly what has been allocated on early return. */ - l_tcp->m_nb_max_mct_records += 1; + l_tcp->m_nb_mct_records += 1; } /* Get the mcc_record of the dflt_tile_cp and copy them into the current tile cp*/
Original Bug Report
Double-free in Chrome's bundled OpenJPEG (opj_j2k_copy_default_tcp_and_create_tcd) reachable from PDF rendering when PartitionAlloc returns nullptr
Report description
Double-free in Chrome’s bundled OpenJPEG (opj_j2k_copy_default_tcp_and_create_tcd) reachable from PDF rendering when PartitionAlloc returns nullptr
Bug location
Where do you want to report your vulnerability?
Chrome VRP β Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://pdfium.googlesource.com/pdfium/+/refs/heads/main/third_party/libopenjpeg/j2k.c
The problem
Please describe the technical details of the vulnerability
Summary
The OpenJPEG copy Chrome bundles (third_party/libopenjpeg, 2.5.4 rev 6c4a29b0) contains a double-free reachable from a single JPX image inside a PDF. The trigger is one opj_malloc() returning nullptr β which PDFium deliberately allows, so this is a designed code path rather than an anomaly.
The defect is also unfixed upstream (OpenJPEG master 402ef586, 2026-07-07).
All j2k.c line numbers below refer to the file Chrome ships, third_party/libopenjpeg/j2k.c at bundle 2.5.4 rev 6c4a29b0. The corresponding upstream lines are 9 lower in opj_j2k_copy_default_tcp_and_create_tcd() and 10 lower in the teardown functions.
Root cause
opj_j2k_copy_default_tcp_and_create_tcd() clones the default TCP once per tile. It memcpys the whole struct, then clears “memory not owned by this tile” β but it clears only the pointer and capacity fields and misses the count fields:
/* j2k.c:9236-9251 */
for (i = 0; i < l_nb_tiles; ++i) {
l_current_tccp = l_tcp->tccps;
memcpy(l_tcp, l_default_tcp, sizeof(opj_tcp_t)); /* copies m_nb_mct_records = N too */
...
/* Remove memory not owned by this tile in case of early error return. */
l_tcp->m_mct_decoding_matrix = 00;
l_tcp->m_nb_max_mct_records = 0; /* capacity IS cleared */
l_tcp->m_mct_records = 00;
l_tcp->m_nb_max_mcc_records = 0;
l_tcp->m_mcc_records = 00;
/* m_nb_mct_records is NOT cleared -> stays at N */
The record array is then shallow-copied, so every entry’s m_data aliases the default TCP’s buffer:
/* j2k.c:9266-9272 */
l_mct_records_size = l_default_tcp->m_nb_max_mct_records * sizeof(opj_mct_data_t);
l_tcp->m_mct_records = (opj_mct_data_t*)opj_malloc(l_mct_records_size);
if (! l_tcp->m_mct_records) { return OPJ_FALSE; }
memcpy(l_tcp->m_mct_records, l_default_tcp->m_mct_records, l_mct_records_size);
A loop then gives each entry its own buffer, clearing the alias one entry at a time. If that allocation fails at index j, entries j..N-1 are left aliasing the default TCP:
/* j2k.c:9278-9294 */
for (j = 0; j < l_default_tcp->m_nb_mct_records; ++j) {
if (l_src_mct_rec->m_data) {
l_dest_mct_rec->m_data = (OPJ_BYTE*) opj_malloc(l_src_mct_rec->m_data_size);
if (! l_dest_mct_rec->m_data) {
return OPJ_FALSE; /* <-- entries j..N-1 remain aliases */
}
memcpy(l_dest_mct_rec->m_data, l_src_mct_rec->m_data, l_src_mct_rec->m_data_size);
}
++l_src_mct_rec;
++l_dest_mct_rec;
/* Update with each pass to free exactly what has been allocated on early return. */
l_tcp->m_nb_max_mct_records += 1; /* <-- bumps CAPACITY, not COUNT */
}
This is the crux. The author did implement per-pass rollback tracking, and wrote the intent in the comment β “Update with each pass to free exactly what has been allocated on early return.” β but incremented m_nb_max_mct_records (capacity), while the destructor iterates m_nb_mct_records (count):
/* j2k.c:9525-9536 opj_j2k_tcp_destroy() */
if (p_tcp->m_mct_records) {
opj_mct_data_t * l_mct_data = p_tcp->m_mct_records;
for (i = 0; i < p_tcp->m_nb_mct_records; ++i) { /* iterates N times */
if (l_mct_data->m_data) {
opj_free(l_mct_data->m_data); /* frees the aliases too */
l_mct_data->m_data = 00;
}
++l_mct_data;
}
So the default TCP’s m_data is freed twice. opj_j2k_destroy() runs the two teardowns in this order:
j2k.c:9374β the default TCP. First free.j2k.c:9417βopj_j2k_cp_destroy()βj2k.c:9574β the per-tile TCP array, which is what still holds the aliases. Second free.
The intended guard does nothing because of one wrong field name. This is not intended behaviour β the comment states the intent that the code fails to implement.
Why only the MCT records
Every field opj_j2k_tcp_destroy() frees was cross-checked. Only m_mct_records has all three properties at once and is reachable:
FIELD COUNT PER-ELEMENT DTOR ITERATES
STALE OWNED PTR THE COUNT RESULT
--------------------------------------------------------------------------
m_mct_records yes yes yes (:9529) DOUBLE-FREE
m_mcc_records yes no - safe
array freed whole (:9519); members are offsets into m_mct_records (:9316)
m_mct_decoding_matrix no - - safe
cleared at :9247
m_mct_coding_matrix (:8242) yes - - safe
mct_norms (:8270) yes - - safe
both encoder-only allocations, NULL when decoding
tccps no - - safe
reattached at :9253
m_data / m_data_size - - - safe
inlined opj_j2k_tcp_data_destroy; tile payload, only set during tile
decoding, so default_tcp never holds one
ppt_buffer YES yes - LATENT
opj_j2k_merge_ppt() (:4240). Unreachable today: PPT is a tile-part-
header-only marker, so default_tcp never holds one
ppt_markers / YES yes YES LATENT
ppt_markers_count
m_data per entry, freed in a loop over the count at :9488. Same shape as
m_mct_records, unreachable for the same reason as ppt_buffer
The last two rows are not exploitable today, but they are the same mistake β the copy function clears neither the pointer nor the count.
Why nullptr is reachable in Chrome, and not a theoretical condition
PDFium replaces OpenJPEG’s allocator (third_party/libopenjpeg/0034-opj_malloc.patch) and documents that it may fail:
// core/fxcrt/fx_memory.h:16
// For external C libraries to malloc through PDFium. These may return nullptr.
void* FXMEM_DefaultAlloc(size_t byte_size);
// core/fxcrt/fx_memory_pa.cpp:38-49
void* Alloc(size_t num_members, size_t member_size) {
...
return GetGeneralPartitionAllocator()
.root()
->Alloc<partition_alloc::AllocFlags::kReturnNull>(total.ValueOrDie(),
"GeneralPartition");
}
So OpenJPEG receiving nullptr is by design, not an edge case.
And the affected allocation runs in bulk. The attached poc.pdf is 14,143 bytes and declares 65,025 tiles with 10 MCT records per tile β record 0 being 4096 bytes. Because the copy is per tile, rendering that one page performs the 4096-byte allocation 65,026 times (65,025 tiles + the original in the default TCP), and peak renderer RSS reaches ~1.42 GiB.
The tile count, the MCT record count and each record’s size are all read from the codestream, so an attacker scales the demand at will β this is input-induced memory pressure, not an incidental OOM. Any one of those 65,026 nullptr returns produces the double-free; no race to win and no offset to guess.
What was observed
Chrome 150.0.7871.187 x64, Windows 11, renderer process, with a Job Object ProcessMemoryLimit of 1200 MB applied to the browser (children inherit the job). No modification to Chrome, and no command-line flag.
INPUT RUNS RESULT
------------------------------------------------------------------------
poc.pdf 3 renderer crash, 3/3
control PDF (ordinary image, otherwise identical) 2 no crash, 0/2
The three crashes are identical:
exception 0xC0000005 ACCESS_VIOLATION
access write
RIP chrome.dll+0x3870B70 (inside PartitionAlloc's free path)
faulting movq $0x0,(%rdi) 8-byte store; RDI equals the faulting address
addresses 0x000012E000679C00 / 0x00006EA000678000 / 0x00001E1800678000
This is not Chrome’s deliberate out-of-memory abort, which is 0xE0000008. The faulting addresses share their low bits while the ASLR-dependent upper bits differ.
The renderer stack for all three, recovered from the minidumps plus the shipped chrome.dll:
#0 chrome.dll+0x3870B70 PartitionRoot::Free
#1 chrome.dll+0x972E0DC opj_j2k_tcp_destroy j2k.c:9531
#2 chrome.dll+0x972DC20 opj_j2k_destroy j2k.c:9574
#3 chrome.dll+0x9739368 opj_destroy_codec
#4 chrome.dll+0x96EBD4A fxcodec::CJPX_Decoder::~CJPX_Decoder
#5 chrome.dll+0x96EB1C1 fxcodec::CJPX_Decoder::Create [Init() returned false]
#6 chrome.dll+0x9625559 CPDF_DIB::LoadJpxBitmap cpdf_dib.cpp:634
#7 chrome.dll+0x96230BB CPDF_DIB::CreateDecoder cpdf_dib.cpp:474
#8 chrome.dll+0x9623ACE CPDF_DIB::StartLoadDIBBase cpdf_dib.cpp:227
Two things in that stack are worth noting. Frame #2 is j2k.c:9574, the per-tile teardown β the second free β and not j2k.c:9374, the default-TCP teardown, which has already returned. And frame #5 shows CJPX_Decoder::Create running the destructor on its Init() == false branch, i.e. header parsing failed, which is exactly what opj_j2k_copy_default_tcp_and_create_tcd() returning OPJ_FALSE after a failed opj_malloc produces. A successful decode never reaches this code.
Independently, an ASAN build of pdfium_test in Chrome’s own allocator configuration, given the same input, reports the defect by name:
ERROR: AddressSanitizer: attempting double-free on 0x7d8ffbe50e80
#2 opj_j2k_tcp_destroy third_party/libopenjpeg/j2k.c:9531
#3 opj_j2k_destroy third_party/libopenjpeg/j2k.c:9574 <- second free
freed by thread T0 here:
#2 opj_j2k_tcp_destroy third_party/libopenjpeg/j2k.c:9531
#3 opj_j2k_destroy third_party/libopenjpeg/j2k.c:9374 <- first free
previously allocated by thread T0 here:
#2 opj_j2k_read_mct third_party/libopenjpeg/j2k.c:5988
ASAN splits the two frees the same way the Chrome stack does β 9374 under freed by, 9574 under attempting double-free β and attributes the allocation to opj_j2k_read_mct, the default TCP’s MCT data. On a 32-bit build of upstream OpenJPEG the same codestream aborts with double free or corruption (!prev) under no artificial constraint at all, because the 4 GB address space is the natural limit.
Stated up front so it is not discovered later: opening poc.pdf in a default Chrome does not deterministically crash. The trigger needs one allocation to fail, so the reproduction constrains renderer memory. On 64-bit Linux the OOM killer intervenes before malloc returns nullptr; Windows has no OOM killer and a commit failure returns nullptr, which is why the browser reproduction is on Windows.
Suggested fix
l_tcp->m_mct_decoding_matrix = 00;
+ l_tcp->m_nb_mct_records = 0;
l_tcp->m_nb_max_mct_records = 0;
l_tcp->m_mct_records = 00;
+ l_tcp->m_nb_mcc_records = 0;
l_tcp->m_nb_max_mcc_records = 0;
l_tcp->m_mcc_records = 00;
@@
memcpy(l_tcp->m_mct_records, l_default_tcp->m_mct_records, l_mct_records_size);
+ l_tcp->m_nb_max_mct_records = l_default_tcp->m_nb_max_mct_records;
@@
- l_tcp->m_nb_max_mct_records += 1;
+ l_tcp->m_nb_mct_records += 1;
Three added lines and one changed line: clear the count fields alongside the capacity fields; set the capacity right after the array allocation (mirroring what the MCC path already does at j2k.c:9305); and make the copy loop increment the count β what the existing comment intends.
It deliberately does not touch ppt_buffer or ppt_markers. Both are the same mistake but are unreachable today, and keeping the fix minimal keeps it reviewable; clearing those two as well is a reasonable belt-and-braces addition, which we have not regression-tested.
Applied and regression-checked: the PoC ends on the normal error path (exit=1, no crash), and three ordinary inputs decode byte-identically before and after. Mechanism controls behave as the analysis predicts β a codestream with a single MCT record, or with the failing record last, does not reproduce at any memory limit, because nothing is left aliased past the failure point.
Impact analysis
Who can exploit it
Displaying an image is the whole attack. The victim opens a page or a PDF, Chrome’s built-in viewer decodes the embedded JPX image while laying the page out, and the renderer’s heap is corrupted before anything is drawn. There is no click to make, no dialog to accept, no plugin to have installed, and no non-default setting involved. Rendering is the trigger.
So any party that can put the image in front of a Chrome user reaches it, through every ordinary delivery path: a link, a PDF embedded via <iframe> or <embed>, an email attachment opened in Chrome, a drive-by navigation, an ad frame. No prior access, no credentials, and no same-origin relationship are needed. The PoC is 14,143 bytes.
What they gain
The same heap block is passed to PartitionRoot::Free twice in the renderer process.
What we demonstrated is a renderer crash: 0xC0000005 write access violation inside PartitionAlloc’s free path, 3/3 runs, with an otherwise identical control PDF crashing 0/2 under the same conditions. Under AddressSanitizer, in Chrome’s own allocator configuration, the same input is diagnosed as AddressSanitizer: attempting double-free.
We did not attempt to develop that further, so we cannot speak to it from experience. What we can say is that the conditions a skilled attacker would need in order to control the corruption appear to be present, because several of them are read straight from the file:
- The size of the double-freed block is chosen in the file. Each MCT record’s data length is
Lmct - 8, anywhere from 1 to 65,527 bytes, which determines the PartitionAlloc bucket the duplicated slot belongs to. - The number of double-freed blocks is chosen in the file. With N MCT records and the allocation failing at index j, entries j..N-1 are all left aliasing, and all of them are freed twice. N can be up to 256, and placing the largest record first makes the failure land early, which maximises N-j.
- It is deterministic rather than a race. There is no timing window and no offset to guess; the PoC gives the allocator 65,026 chances to return nullptr during one page render, and any one of them produces the same state.
- It can be retried freely. A page can navigate an
<iframe>to the PDF repeatedly, varying record sizes and counts between attempts. - It happens on a clean teardown path. The double-free occurs inside
opj_j2k_destroy()on the decoder’s normal error path, not on an already-corrupted heap.
Given that, our honest reading is that a competent exploit developer would have a realistic chance of stabilising this into something more than a crash, possibly renderer code execution. We are stating that as an assessment, not a result β we have no exploit, and we did not test whether PartitionAlloc’s hardening (encoded freelist entries, slot-span metadata checks, partition isolation) blocks it. That is the main open question in this report, and we are not in a position to answer it from outside.
For classification, this is CWE-415: Double Free β memory corruption in the renderer process, reached from web content without user interaction.
The precondition, stated plainly
The trigger requires one opj_malloc() to return nullptr β renderer memory pressure. That is not something the attacker has to hope for:
- The file controls the demand. The tile count, the MCT record count, and each record’s data size are all read from the codestream, so the file dictates how much memory decoding it requires.
- The amplification is large. Our 14,143-byte PoC performs the affected 4096-byte allocation 65,026 times during a single page render and drives peak renderer RSS to 1.42 GiB (1,490,864 KB measured). A variant demanding roughly 127 GiB is attached as
poc_huge.j2k. - Any single failure suffices. No race to win, no offset to guess, no specific one of those 65,026 allocations to hit.
- The pressure can also come from the attacker. A page can consume memory in another frame β large canvases,
ArrayBuffers, its own decoded images β before navigating the victim frame to the PDF. - Windows returns nullptr rather than killing the process. There is no OOM killer, so a commit failure surfaces as the nullptr this code mishandles. On 64-bit Linux the OOM killer usually intervenes first, which is why our browser reproduction is on Windows.
- PDFium designed the nullptr contract.
core/fxcrt/fx_memory.h:16says “For external C libraries to malloc through PDFium. These may return nullptr.”, andcore/fxcrt/fx_memory_pa.cpp:47passesAllocFlags::kReturnNull. Returning nullptr is intended behaviour, not a fault condition.
Our browser reproduction makes the pressure explicit with a Job Object process commit limit (1200 MB), so that the result is deterministic and the control comparison is clean.
We have not demonstrated the allocation failing on an unconstrained desktop, and we are not claiming it. We were also unable to test several situations where it seems more likely β low-memory Android and ChromeOS devices, memory-capped or throttled tabs, and sessions with several large tabs already resident β and we have no visibility into how often real renderers reach allocation failure.
Also relevant: 32-bit Chrome is still shipped, and there the address space is 4 GB, where this PoC’s ~1.42 GiB of demand should fail far more readily. We reproduced the defect with no artificial constraint at all on a 32-bit build of upstream OpenJPEG (double free or corruption (!prev), SIGABRT), which supports that reasoning, but we did not verify it in 32-bit Chrome itself.
Scope
- Chrome 150.0.7871.187 x64 β verified. The bundled code is byte-identical to upstream at all three affected sites, and none of the 22 local patches listed under
Local Modificationsinthird_party/libopenjpeg/README.pdfiumtouches them (0023-opj_j2k_read_mct_records.patchfixes a different bug inopj_j2k_read_mct). third_party/libopenjpeg/README.pdfiumrecordsSecurity Critical: yes,Shipped: yes, andUpdate Mechanism: Manual, so the bundle would need its own roll even after upstream lands a fix.- Unfixed upstream (OpenJPEG master
402ef586, 2026-07-07); no released version contains a fix. Other consumers of the library are affected too, which is why the defect was also reported to the OpenJPEG project with the same patch.
Mitigating factors
- The renderer is sandboxed; this is renderer-process corruption, not a sandbox escape.
- It requires memory pressure, so it is not a plain “open a link and the tab dies” bug on a well-provisioned desktop.
- PartitionAlloc’s hardening may blunt or detect the corruption before it becomes useful. We did not test that boundary, and it is the biggest unknown in the assessment above.
The cause
What version of Chrome have you found the security issue in?
150.0.7871.187 (Official Build) (64-bit) - stable - Windows 11
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption
How would you like to be publicly acknowledged for your report?
Jeongkihyun