Skip to content

pcre2_jit_match() does not release a PCRE2_COPY_MATCHED_SUBJECT copy on reuse, causing a leak and an invalid free #920

Description

@albertZhangTJ

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:

  1. Memory leak — the previously allocated subject copy is overwritten and never freed.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions