Summary
When a match data block is reused, pcre2_jit_match() overwrites match_data->subject without freeing any existing PCRE2_COPY_MATCHED_SUBJECT copy and without clearing PCRE2_MD_COPIED_SUBJECT. pcre2_match() and pcre2_dfa_match() both free any existing copy and clear the flag before overwriting subject; pcre2_jit_match() is the only match entry point that does not.
This has two consequences:
- Memory leak — the previously allocated subject copy is overwritten and never freed.
- Invalid free — the stale
PCRE2_MD_COPIED_SUBJECT flag survives while match_data->subject now points at caller-owned memory. The next pcre2_match_data_free() therefore calls the allocator's free() on a pointer the library never allocated.
The second item is the significant one: it hands free() a pointer chosen by the caller's subject argument. In the reproducer below that is a stack buffer. If the subject lives on the heap it instead becomes a premature free of live application memory, i.e. a use-after-free / double-free primitive.
Reproduced on current master (ff92e0b, 10.48-DEV), 8-bit library, --enable-jit, x86-64 Linux, GCC + ASAN. The affected code is long-standing and not a recent regression.
Documented API usage
This is a documented guarantee being broken:
pcre2_jit_match() is a public entry point that "takes exactly the same arguments as pcre2_match()" (pcre2jit(3)).
- pcre2api(3), under PCRE2_COPY_MATCHED_SUBJECT, states the copy "is also automatically freed if the match data block is re-used for another match operation."
Reusing a match data block for a JIT fast-path match is exactly that case, and the copy is not freed.
Reproducer
/* gcc -g -O1 -fsanitize=address -I<pcre2_src> repro.c libpcre2-8.a -o repro */
#define PCRE2_CODE_UNIT_WIDTH 8
#include <pcre2.h>
#include <stdio.h>
#include <string.h>
int main(void) {
int errorcode; PCRE2_SIZE erroroffset;
pcre2_code *code = pcre2_compile((PCRE2_SPTR)"a", 1, 0, &errorcode, &erroroffset, NULL);
if (!code) { printf("compile failed\n"); return 1; }
if (pcre2_jit_compile(code, PCRE2_JIT_COMPLETE) != 0) { printf("jit_compile failed\n"); return 1; }
pcre2_match_data *md = pcre2_match_data_create_from_pattern(code, NULL);
/* Step 1: normal match WITH PCRE2_COPY_MATCHED_SUBJECT -> heap copy, flag set */
char subj1[64]; memset(subj1, 'a', sizeof subj1);
int rc1 = pcre2_match(code, (PCRE2_SPTR)subj1, sizeof subj1, 0,
PCRE2_COPY_MATCHED_SUBJECT, md, NULL);
printf("step1 pcre2_match(COPY_MATCHED_SUBJECT) rc=%d\n", rc1);
/* Step 2: JIT match reusing the SAME match_data, different subject, options=0.
Leaks the copy; leaves flag set with subject pointing at subj2. */
char subj2[128]; memset(subj2, 'a', sizeof subj2);
int rc2 = pcre2_jit_match(code, (PCRE2_SPTR)subj2, sizeof subj2, 0, 0, md, NULL);
printf("step2 pcre2_jit_match rc=%d (subject now points at stack %p)\n", rc2, (void*)subj2);
/* Step 3: frees md->subject because the flag is still set -> free(stack) */
printf("step3 calling pcre2_match_data_free...\n"); fflush(stdout);
pcre2_match_data_free(md);
printf("no crash\n");
pcre2_code_free(code);
return 0;
}
Observed output (unpatched)
The listing above is lightly condensed from the file ASAN was run against, so the line numbers in the report below do not line up with it; the frame offsets and buffer sizes do.
step1 pcre2_match(COPY_MATCHED_SUBJECT) rc=1
step2 pcre2_jit_match rc=1 (subject now points at stack 0x7f0977a000c0)
step3 calling pcre2_match_data_free...
=================================================================
==6645==ERROR: AddressSanitizer: attempting free on address which was not malloc()-ed: 0x7f0977a000c0 in thread T0
#0 free
#1 default_free src/pcre2_context.c:62
#2 pcre2_match_data_free_8 src/pcre2_match_data.c:105
#3 main repro.c:31
Address 0x7f0977a000c0 is located in stack of thread T0 at offset 192 in frame
#0 main repro.c:10
This frame has 4 object(s):
[96, 160) 'subj1' (line 19)
[192, 320) 'subj2' (line 25) <== Memory access at offset 192 is inside this variable
SUMMARY: AddressSanitizer: bad-free in free
Because ASAN aborts at the bad free, the leaked copy of subj1 is never reported; once the bad free is prevented, detect_leaks=1 surfaces the leak separately.
Expected
No diagnostic. Step 2 should free the copy made in step 1 and clear the flag, leaving step 3 to free only the match data block itself.
Root cause
pcre2_jit_match() (src/pcre2_jit_match_inc.h) unconditionally reassigns subject after running the compiled code:
match_data->code = re;
match_data->subject =
(rc >= 0 || rc == PCRE2_ERROR_NOMATCH || rc == PCRE2_ERROR_PARTIAL)? subject : NULL;
Nothing frees the prior copy and nothing clears PCRE2_MD_COPIED_SUBJECT. pcre2_match_data_free() (src/pcre2_match_data.c:104-105) then trusts the flag:
if ((match_data->flags & PCRE2_MD_COPIED_SUBJECT) != 0)
match_data->memctl.free((void *)match_data->subject,
match_data->memctl.memory_data);
so it frees whatever subject now points at — here, the caller's subj2.
Both sibling entry points already guard against this on entry, and pcre2_jit_match() simply lacks the equivalent block:
src/pcre2_match.c:7126
src/pcre2_dfa_match.c:3679
Note the internal pcre2_match() → pcre2_jit_match() path is unaffected, because the wrapper frees the copy, clears the flag and sets subject = NULL before dispatching. Only direct fast-path callers reusing a block are exposed.
Why the test suite doesn't catch this
pcre2test cannot express the required sequence, so there is no coverage:
jitfast is a pattern-level modifier (MOD_PAT), so every subject line under a pattern takes the fast path — a normal pcre2_match() with copy_matched_subject cannot be interleaved with a fast-path match on one pattern.
- With the default
oveccount == 0, pcre2test frees and recreates match_data for each subject line (src/pcre2test_inc.h:4640), so the flag never survives into a later match.
Additionally pcre2test_inc.h:5202 deliberately skips the copy_matched_subject verification when CTL_JITFAST is set ("not supported for fast JIT"), which is consistent with pcre2_jit_match() ignoring the option when passed directly.
Disclaimer
This bug is found by a fuzzer. The minimal bug-inducing test case, the proposed fix below, and this bug report are produced by Opus 4.8 with human proofread/verification. The proposed fix below should hence be used carefully.
Proposed fix
Free any existing copy and clear the flag on entry, mirroring pcre2_match() and pcre2_dfa_match(). Placed after the JIT_BADOPTION bail-out so an early return does not discard the copy.
diff --git a/src/pcre2_jit_match_inc.h b/src/pcre2_jit_match_inc.h
index 32d4c8a..4163cf6 100644
--- a/src/pcre2_jit_match_inc.h
+++ b/src/pcre2_jit_match_inc.h
@@ -125,6 +125,16 @@ else if ((options & PCRE2_PARTIAL_SOFT) != 0)
if (functions == NULL || functions->executable_funcs[index] == NULL)
return match_data->rc = PCRE2_ERROR_JIT_BADOPTION;
+/* If the match data block was previously used with PCRE2_COPY_MATCHED_SUBJECT,
+free the memory that was obtained. */
+
+if ((match_data->flags & PCRE2_MD_COPIED_SUBJECT) != 0)
+ {
+ match_data->memctl.free((void *)match_data->subject,
+ match_data->memctl.memory_data);
+ match_data->flags &= ~PCRE2_MD_COPIED_SUBJECT;
+ }
+
/* Sanity checks should be handled by pcre2_match. */
arguments.str = subject + start_offset;
arguments.begin = subject;
Freeing unconditionally on entry (rather than gating on match success) matches the other two entry points: a new match invalidates the previous copy regardless of outcome. This is a no-op on the pcre2_match() → pcre2_jit_match() path, where the flag is already clear, so there is no double-free risk.
Unlike pcre2_match(), the patch does not also set match_data->subject = NULL after freeing; it is not required here because pcre2_jit_match() unconditionally reassigns subject before returning. Happy to add it for consistency if preferred.
Verification
- Reproducer runs clean under ASAN with
detect_leaks=1 — no bad free, no leak.
- Full suite passes with the patch applied (
make check): RunTest, pcre2_jit_test, pcre2posix_test, RunGrepTest — 4 passed, 0 failed.
Summary
When a match data block is reused,
pcre2_jit_match()overwritesmatch_data->subjectwithout freeing any existingPCRE2_COPY_MATCHED_SUBJECTcopy and without clearingPCRE2_MD_COPIED_SUBJECT.pcre2_match()andpcre2_dfa_match()both free any existing copy and clear the flag before overwritingsubject;pcre2_jit_match()is the only match entry point that does not.This has two consequences:
PCRE2_MD_COPIED_SUBJECTflag survives whilematch_data->subjectnow points at caller-owned memory. The nextpcre2_match_data_free()therefore calls the allocator'sfree()on a pointer the library never allocated.The second item is the significant one: it hands
free()a pointer chosen by the caller's subject argument. In the reproducer below that is a stack buffer. If the subject lives on the heap it instead becomes a premature free of live application memory, i.e. a use-after-free / double-free primitive.Reproduced on current
master(ff92e0b, 10.48-DEV), 8-bit library,--enable-jit, x86-64 Linux, GCC + ASAN. The affected code is long-standing and not a recent regression.Documented API usage
This is a documented guarantee being broken:
pcre2_jit_match()is a public entry point that "takes exactly the same arguments aspcre2_match()" (pcre2jit(3)).Reusing a match data block for a JIT fast-path match is exactly that case, and the copy is not freed.
Reproducer
Observed output (unpatched)
The listing above is lightly condensed from the file ASAN was run against, so the line numbers in the report below do not line up with it; the frame offsets and buffer sizes do.
Because ASAN aborts at the bad free, the leaked copy of
subj1is never reported; once the bad free is prevented,detect_leaks=1surfaces the leak separately.Expected
No diagnostic. Step 2 should free the copy made in step 1 and clear the flag, leaving step 3 to free only the match data block itself.
Root cause
pcre2_jit_match()(src/pcre2_jit_match_inc.h) unconditionally reassignssubjectafter running the compiled code:Nothing frees the prior copy and nothing clears
PCRE2_MD_COPIED_SUBJECT.pcre2_match_data_free()(src/pcre2_match_data.c:104-105) then trusts the flag:so it frees whatever
subjectnow points at — here, the caller'ssubj2.Both sibling entry points already guard against this on entry, and
pcre2_jit_match()simply lacks the equivalent block:src/pcre2_match.c:7126src/pcre2_dfa_match.c:3679Note the internal
pcre2_match()→pcre2_jit_match()path is unaffected, because the wrapper frees the copy, clears the flag and setssubject = NULLbefore dispatching. Only direct fast-path callers reusing a block are exposed.Why the test suite doesn't catch this
pcre2testcannot express the required sequence, so there is no coverage:jitfastis a pattern-level modifier (MOD_PAT), so every subject line under a pattern takes the fast path — a normalpcre2_match()withcopy_matched_subjectcannot be interleaved with a fast-path match on one pattern.oveccount == 0,pcre2testfrees and recreatesmatch_datafor each subject line (src/pcre2test_inc.h:4640), so the flag never survives into a later match.Additionally
pcre2test_inc.h:5202deliberately skips thecopy_matched_subjectverification whenCTL_JITFASTis set ("not supported for fast JIT"), which is consistent withpcre2_jit_match()ignoring the option when passed directly.Disclaimer
This bug is found by a fuzzer. The minimal bug-inducing test case, the proposed fix below, and this bug report are produced by Opus 4.8 with human proofread/verification. The proposed fix below should hence be used carefully.
Proposed fix
Free any existing copy and clear the flag on entry, mirroring
pcre2_match()andpcre2_dfa_match(). Placed after theJIT_BADOPTIONbail-out so an early return does not discard the copy.Freeing unconditionally on entry (rather than gating on match success) matches the other two entry points: a new match invalidates the previous copy regardless of outcome. This is a no-op on the
pcre2_match()→pcre2_jit_match()path, where the flag is already clear, so there is no double-free risk.Unlike
pcre2_match(), the patch does not also setmatch_data->subject = NULLafter freeing; it is not required here becausepcre2_jit_match()unconditionally reassignssubjectbefore returning. Happy to add it for consistency if preferred.Verification
detect_leaks=1— no bad free, no leak.make check):RunTest,pcre2_jit_test,pcre2posix_test,RunGrepTest— 4 passed, 0 failed.