public inbox for [email protected]  
help / color / mirror / Atom feed
From: Ayush Tiwari <[email protected]>
To: [email protected]
To: [email protected]
Subject: Re: BUG #19525: In `contrib/dict_int`, handling a token whose first byte is a null byte causes `pnstrdup()` .
Date: Thu, 18 Jun 2026 20:11:32 +0530
Message-ID: <CAJTYsWW7+aVAFFV3dxg1s-RrtwioYPj1qQ9o2oAM40MReTXzAg@mail.gmail.com> (raw)
In-Reply-To: <[email protected]>
References: <[email protected]>

Hi,

On Thu, 18 Jun 2026 at 18:54, PG Bug reporting form <[email protected]>
wrote:

> The following bug has been logged on the website:
>
> Bug reference:      19525
> Logged by:          Yuelin Wang
> Email address:      [email protected]
> PostgreSQL version: 19beta1
> Operating system:   Linux (Ubuntu 24.04, x86_64)
> Description:
>
> **Component**: `contrib/dict_int/dict_int.c`, function `dintdict_lexize()`
> (line 109)
>
> Requires a `SQL_ASCII`-encoded database (to bypass null-byte encoding
> checks) and superuser to install the extension and create a helper function
> that passes a `bytea` token directly to the lexize callback. Once the
> dictionary is created, any role granted `EXECUTE` on the helper can trigger
> the crash.
>
> ```sql
> -- 1. Create SQL_ASCII database (null bytes are not rejected)
> CREATE DATABASE vuln_ascii ENCODING 'SQL_ASCII' TEMPLATE template0;
> \c vuln_ascii
>
> -- 2. Install extension and create an intdict dictionary with
> REJECTLONG=false
> CREATE EXTENSION dict_int;
> CREATE TEXT SEARCH DICTIONARY intdict_test (
>     TEMPLATE = intdict_template,
>     MAXLEN = 8192,
>     REJECTLONG = false
> );
>
> -- 3. Create a C helper (raw_lexize.so) that invokes the lexize callback
> with
> --    a raw bytea token, bypassing the text encoding layer.
> CREATE FUNCTION raw_lexize(dict regdictionary, token bytea)
>     RETURNS text[] AS 'raw_lexize', 'raw_lexize' LANGUAGE C STRICT;
>
> -- 4. Trigger: null byte at position 0 causes pnstrdup to allocate 1 byte,
> --    but txt[8192] = '\0' writes 8191 bytes past the end of the
> allocation.
> SELECT raw_lexize('intdict_test',
>     decode('00' || repeat('78', 10000), 'hex'));
> -- Server closes connection; ASan reports heap-buffer-overflow WRITE of
> size
> 1
> -- at dict_int.c:109 in dintdict_lexize.
> ```
>
> ASan confirmation (server killed the backend; connection dropped):
> ```
> ==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x525000052880
> WRITE of size 1 at 0x525000052880 thread T0
>     #0 in dintdict_lexize
> /data/ylwang/Projects/postgres/contrib/dict_int/dict_int.c:109
>     #1 in FunctionCall4Coll .../src/backend/utils/fmgr/fmgr.c:1215
>     #2 in raw_lexize /tmp/raw_lexize.c:37
> SUMMARY: AddressSanitizer: heap-buffer-overflow
> .../contrib/dict_int/dict_int.c:109 in dintdict_lexize
>

Thanks for the report and repro!

`pnstrdup(ptr, len)` uses `strnlen(ptr, len)` internally, so when the token
> begins with a null byte it allocates only 1 byte. The variable `len` is not
> updated to reflect this and retains the original token length, so the guard
> at line 98 (`if (len > d->maxlen)`) passes, and line 109 writes `'\0'` at
> offset `d->maxlen` (e.g., 8192) into a 1-byte allocation.
>
> The fix is to recompute the effective length from the allocated buffer
> after
> the `pnstrdup` call, for example by replacing the `if (len > d->maxlen)`
> check with `if (strlen(txt) > d->maxlen)`. This ensures the truncation
> offset is always within the bounds of what `pnstrdup` actually allocated.
>

Your analysis seems right to me.

While looking around I think dict_xsyn may have a related issue: in
dxsyn_lexize() the token is copied with pnstrdup() and the original
length is then handed to str_tolower(), which reads that many bytes and
so could read past the shorter copy.

Attaching a patch that fixes both the above issues.

Regards,
Ayush


Attachments:

  [application/x-patch] 0001-Fix-out-of-bounds-access-on-embedded-null-tokens-in-.patch (2.5K, ../CAJTYsWW7+aVAFFV3dxg1s-RrtwioYPj1qQ9o2oAM40MReTXzAg@mail.gmail.com/3-0001-Fix-out-of-bounds-access-on-embedded-null-tokens-in-.patch)
  download | inline diff:
From 382c736b599292470b050e112b66945c46a5a7e7 Mon Sep 17 00:00:00 2001
From: Ayush Tiwari <[email protected]>
Date: Thu, 18 Jun 2026 19:54:54 +0530
Subject: [PATCH] Fix out-of-bounds access on embedded null tokens in dict_int
 and dict_xsyn

pnstrdup() stops at the first null byte, so a token beginning with a null
is copied as a 1-byte string.  Both dictionaries then reused the original
token length against that shorter copy.

dintdict_lexize() (dict_int) used it for the "len > maxlen" check and then
wrote txt[maxlen] = '\0' past the end of the 1-byte allocation; recompute
len from the copy.  dxsyn_lexize() (dict_xsyn) passed the original length
to str_tolower(), which then read past the copy; pass the token directly
instead, dropping the needless copy.

Reaching either path needs a token with a null byte, which the normal
text-search input path does not produce (it requires SQL_ASCII and a
caller passing raw bytes to the lexize method).

Reported-by: Yuelin Wang <[email protected]>
Bug: #19525
---
 contrib/dict_int/dict_int.c   |  6 ++++++
 contrib/dict_xsyn/dict_xsyn.c | 15 +++++++--------
 2 files changed, 13 insertions(+), 8 deletions(-)

diff --git a/contrib/dict_int/dict_int.c b/contrib/dict_int/dict_int.c
index aafce894977..b4413cfde99 100644
--- a/contrib/dict_int/dict_int.c
+++ b/contrib/dict_int/dict_int.c
@@ -95,6 +95,12 @@ dintdict_lexize(PG_FUNCTION_ARGS)
 	else
 		txt = pnstrdup(in, len);
 
+	/*
+	 * pnstrdup() stops at the first null byte, so recompute len from the copy;
+	 * otherwise the truncation below could write past the allocation.
+	 */
+	len = strlen(txt);
+
 	if (len > d->maxlen)
 	{
 		if (d->rejectlong)
diff --git a/contrib/dict_xsyn/dict_xsyn.c b/contrib/dict_xsyn/dict_xsyn.c
index 9e3784e0f47..b375e8f7387 100644
--- a/contrib/dict_xsyn/dict_xsyn.c
+++ b/contrib/dict_xsyn/dict_xsyn.c
@@ -211,14 +211,13 @@ dxsyn_lexize(PG_FUNCTION_ARGS)
 	if (!length || d->len == 0)
 		PG_RETURN_POINTER(NULL);
 
-	/* Create search pattern */
-	{
-		char	   *temp = pnstrdup(in, length);
-
-		word.key = str_tolower(temp, length, DEFAULT_COLLATION_OID);
-		pfree(temp);
-		word.value = NULL;
-	}
+	/*
+	 * Pass the token directly to str_tolower(); copying it first with
+	 * pnstrdup() and reusing the original length would read past the copy if
+	 * the token contains a null byte.
+	 */
+	word.key = str_tolower(in, length, DEFAULT_COLLATION_OID);
+	word.value = NULL;
 
 	/* Look for matching syn */
 	found = (Syn *) bsearch(&word, d->syn, d->len, sizeof(Syn), compare_syn);
-- 
2.34.1



view thread (4+ messages)  latest in thread

reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: [email protected]
  Cc: [email protected], [email protected], [email protected]
  Subject: Re: BUG #19525: In `contrib/dict_int`, handling a token whose first byte is a null byte causes `pnstrdup()` .
  In-Reply-To: <CAJTYsWW7+aVAFFV3dxg1s-RrtwioYPj1qQ9o2oAM40MReTXzAg@mail.gmail.com>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox