Skip to content

GHSA-r9hj-j2rw-4q3m

CVE Information

PCRE2 maintainer report

Summary

A regular expression can cause JIT-compiled matching to write outside the maximum memory area of a growable JIT stack. A sufficiently large single stack allocation may exceed the fixed amount by which the stack is grown, allowing subsequent writes below the allocated region.

An attacker must be able to supply a regular expression that is JIT compiled and matched by an application using a growable JIT stack. Patterns with very large numbers of capturing groups can trigger the issue.

Affected configurations

Affected Unaffected
PCRE2 versions 10.48 and earlier Fixed in 10.49
APIs JIT matching with a stack created by pcre2_jit_stack_create() and assigned using pcre2_jit_stack_assign() Interpreter matching and DFA matching
Matching mode JIT matching with a growable JIT stack JIT matching using the default machine stack
Code-unit widths 8, 16, and 32 None
Platforms All JIT-supported platforms None
Build options JIT support enabled Builds without JIT support

The issue is not a regression and affects all earlier PCRE2 releases containing this JIT stack implementation.

Impact

The demonstrated impact is an out-of-bounds write with data derived from JIT matching state. Consequences include process crash and corruption of adjacent memory.

Depending on memory layout, the write may land in another valid allocation and remain undetected until the corrupted data is subsequently used. Further exploitation, including arbitrary code execution, should be assumed to be possible.

Attack requirements

The application must enable JIT, create a growable JIT stack using pcre2_jit_stack_create(), and assign it to the match context using pcre2_jit_stack_assign().

An attacker must be able to provide, or otherwise cause the application to compile and match, a pattern requiring an unusually large single JIT stack allocation. This can be achieved with a sufficiently large number of capturing groups and recursive matching.

Applications that do not use JIT, use only the default machine stack, or accept only trusted regular expressions are not exposed.

Remediation

Upgrade to PCRE2 10.49 or later.

The fix is available in commit 2b4038298072684b0fae29b15bedfb1a75bda46d.

Backport patches for supported earlier releases are listed in SUPPORT-LIFECYCLE.md.

Workarounds

Do not JIT compile attacker-controlled regular expressions on affected versions.

Alternatively, do not assign a growable JIT stack and allow JIT matching to use the default machine stack, or disable JIT matching by passing PCRE2_NO_JIT to pcre2_match().

Increasing the maximum JIT stack size is not a reliable workaround.

Technical details

The JIT stack grows downwards. Before reserving a frame, generated code subtracts the required frame size from the current stack top and compares the result with the current stack limit. If the new stack top is below the limit, execution enters a shared stack-resize stub.

Before the fix, that stub lowered the stack limit by a fixed 8192 bytes. This was sufficient for ordinary allocations, but not for a single allocation larger than the growth amount. The resize operation could therefore succeed and return even though the already-adjusted stack top remained below the new limit. Generated matching code then wrote through that stack top outside the permitted stack area.

On POSIX systems, SLJIT commonly reserves the entire configured maximum stack with a writable mapping and treats the current start as a logical boundary. An escaped write may consequently remain inside that mapping, or reach another valid adjacent mapping, rather than causing an immediate fault. This explains why the corruption can first become visible later during allocator cleanup or other control flow.

The fix records during JIT compilation whether the pattern contains any large stack allocation. For such patterns, the resize target is calculated from the actual adjusted stack top, rounded to the stack growth boundary, with additional spare space. If the required target lies below the configured maximum stack extent, sljit_stack_resize() fails and matching returns PCRE2_ERROR_JIT_STACKLIMIT before any caller writes through the invalid stack top.

A regression test exercises a large recursive pattern with 1400 capturing groups. Memory checking confirms a direct out-of-bounds write before the fix and no memory errors after it.

Discovery and credits

Reported by Michael Allen.

The fix was developed by Zoltan Herczeg.

References

  • Fixed release: https://github.com/PCRE2Project/pcre2/releases/tag/pcre2-10.49
  • Fix commit: https://github.com/PCRE2Project/pcre2/commit/2b4038298072684b0fae29b15bedfb1a75bda46d
  • Supported-release backports: https://github.com/PCRE2Project/pcre2/blob/master/SUPPORT-LIFECYCLE.md

Text of original report

Summary

Maintainer note: This vulnerability is not specific to phpMyAdmin, and may affect other PHP software, and other software using PCRE2.

While testing phpMyAdmin 5.2.3, I developed a lab proof of concept that achieved command execution through phpMyAdmin's use of an attacker-controlled regular expression. Root-cause analysis of the memory corruption led to an independent vulnerability in the PCRE2 8-bit JIT.

An attacker-controlled pattern can make the JIT write below its stack mapping. The escaped writes can corrupt a separate allocation and include pointers into the attacker-controlled subject buffer.

I reproduced the issue with clean official PCRE2 10.48 builds on Linux/AArch64, macOS/ARM64, and macOS/x86_64 under Rosetta. The same stack boundary failure is also present in PCRE2 10.42 with an earlier trigger. I have not identified the first affected release or tested other JIT backends.

Technical Details

The attached standalone C proof of concept, pcre2_10_48_jit_stack_underwrite_poc.c, has a primary physical mode. It lets PCRE2 create its JIT stack normally with pcre2_jit_stack_create(32*1024, 192*1024, NULL). Physical mode reads the stack's address range without altering the PCRE2 or SLJIT objects. It places a separate 1-MiB test mapping immediately below the stack and fills that mapping with a known byte value. After arranging this layout, it runs the pattern and scans the test mapping for overwritten bytes.

Changed bytes prove that the JIT wrote below the mapping PCRE2 owns and into a separate allocation. In physical mode, the PoC leaves the JIT stack's requested 32-KiB initial size and 192-KiB maximum unchanged. It does not write to PCRE2/SLJIT internal pointers, patch generated code, or modify the library. Its fixed-address reservation calls fail if the requested range is already occupied instead of replacing an existing mapping.

The trigger is a recursive pattern with 1,400 distinct referenced captures. On the tested 64-bit builds, the JIT needs a 4,205-word (33,640-byte) frame for it, which is larger than PCRE2's 8,192-byte stack-growth increment. In allocate_stack(), the generated code first subtracts the complete frame size from STACK_TOP and then compares the result with STACK_LIMIT. If the result is below the limit, the shared slow path asks sljit_stack_resize() to lower the limit by exactly 8,192 bytes. After a successful resize, that path returns without comparing the already reduced STACK_TOP with the new limit. One increment can therefore leave part of this frame outside the permitted stack range, and the generated frame-initialization stores write through the bottom of the JIT stack mapping.

PCRE2 10.47 introduced capture-save de-duplication, which prevents the earlier trigger based on repeated saves of the same captures from producing the oversized frame. The 10.48 trigger uses 1,400 distinct referenced captures, so the de-duplication check does not collapse them and the measured 4,205-word frame is still generated.

The match eventually returns PCRE2_ERROR_JIT_STACKLIMIT (-46), but only after the escaped writes have occurred.

The attachment also has a secondary logical-bound mode for repeatable A/B testing. Unlike physical mode, it intentionally rewrites the four internal SLJIT stack fields. This places a 32-KiB-to-192-KiB logical stack range inside a larger diagnostic allocation, with a canary below the logical lower bound. A changed canary shows that the JIT crossed that bound. The physically separate victim mode remains the primary proof that writes escape PCRE2's actual mapping.

Reproduction Steps

Build the official PCRE2 10.48 release with 8-bit JIT support, then compile the attached reproducer:

cc -O2 -Wall -Wextra \
  $(pkg-config --cflags libpcre2-8) \
  pcre2_10_48_jit_stack_underwrite_poc.c \
  $(pkg-config --libs libpcre2-8) \
  -o pcre2_10_48_jit_stack_underwrite_poc

./pcre2_10_48_jit_stack_underwrite_poc --physical
./pcre2_10_48_jit_stack_underwrite_poc --physical --no-jit

The first command runs the JIT. The second runs the same pattern through the interpreter as a control. Physical mode reports the address ranges, the match result, how many victim bytes changed, the deepest escaped write, and how many changed words contain a pointer into the subject buffer.

Expected and observed results

The JIT should return the stack-limit error before writing below its stack mapping. The victim allocation should remain unchanged.

Observed with PCRE2 10.48 on macOS/ARM64:

victim mapping : 0x108168000 .. 0x108268000
jit stack mmap : 0x108268000 .. 0x108298000 (192 KiB, all PCRE2 owns)
PCRE2=10.48 2026-08-31 mode=physical engine=jit jit_compile=0 pattern_bytes=11237 subject_bytes=10
pcre2_match returned -46
victim bytes clobbered: 139959
deepest write: 139976 bytes BELOW the jit stack mapping
aligned words holding a pointer into the subject buffer: 15

The exact changed-byte count varies because a stored byte equal to the sentinel is not counted. Across repeated runs, the escaped depth remained 139,976 bytes. The interpreter control returned 2 with zero victim bytes changed and zero subject-buffer pointers. I reproduced the same JIT-only overwrite on Linux/AArch64 and x86_64 under Rosetta.

Security Impact

Maintainer note: Removed

Attachments and Hashes

File SHA-256
pcre2_10_48_jit_stack_underwrite_poc.c 2bda01bc0d70ac7888a8205aa9141e4bf8efcc0e7b48cc50eb23adca56756cd4

pcre2_10_48_jit_stack_underwrite_poc.c