Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqMto-002AsF-1j for pgsql-bugs@arkaria.postgresql.org; Sun, 02 Aug 2026 03:30:04 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1wqMtl-0022pE-1G for pgsql-bugs@arkaria.postgresql.org; Sun, 02 Aug 2026 03:30:01 +0000 Received: from makus.postgresql.org ([2001:4800:3e1:1::229]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqKjL-001wnj-1P for pgsql-bugs@lists.postgresql.org; Sun, 02 Aug 2026 01:11:07 +0000 Received: from mahout.postgresql.org ([2001:4800:3e1:1::227]) by makus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.98.2) (envelope-from ) id 1wqKjJ-00000001TzY-00Kh for pgsql-bugs@lists.postgresql.org; Sun, 02 Aug 2026 01:11:06 +0000 DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=postgresql.org; s=20171124; h=Message-ID:Date:Reply-To:Cc:From:To:Subject: Content-Transfer-Encoding:MIME-Version:Content-Type:Sender:Content-ID: Content-Description:In-Reply-To:References; bh=qzQCH0Ek9Pr8jo5FO66Vis9+OmfKUe/XgGxP6a5pM9g=; b=h593zsbXB954suNLHB5itTEhz2 bpd77uk1LNFd+uQwRA/T0PvuZ7OMlfapUTufKtXe6JN+ZFnC54aNq/maGny53X6M35JAbExEOpCE/ Q4hO6Xcu4QTF1tmIys1qEG1m6q0zOXPggvRuJeX5y+CXjXEr++K53yvxq5ieQoNpVwAqDFdSZfC6k /DYzOb4YleSYsaiKIKn6SHIV9kk7dBgA4tQEe0cxJdPUrBNzap5YeUKsZ5NrOl4sfQlqfA7DRORYu m9PozXvOKP8OorxxKvezLNndj4zXDzOfP6Ah+AnV5QaisVmHX/S8hXkc/FiIc8WmLyR8sEaf5Cyfc VyGhPngw==; Received: from wrigleys.postgresql.org ([2a02:16a8:dc51::60]) by mahout.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqKjH-000GQi-30 for pgsql-bugs@lists.postgresql.org; Sun, 02 Aug 2026 01:11:04 +0000 Received: from localhost ([127.0.0.1] helo=wrigleys.postgresql.org) by wrigleys.postgresql.org with esmtp (Exim 4.98.2) (envelope-from ) id 1wqKjH-0000000ArTo-0exD for pgsql-bugs@lists.postgresql.org; Sun, 02 Aug 2026 01:11:03 +0000 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Subject: BUG #19595: Three memory-safety defects in src/backend/tsearch/spell.c (dictionary loader), PG 18.3 To: pgsql-bugs@lists.postgresql.org From: PG Bug reporting form Cc: michaelmalis2@gmail.com Reply-To: michaelmalis2@gmail.com, pgsql-bugs@lists.postgresql.org Date: Sun, 02 Aug 2026 01:10:18 +0000 Message-ID: <19595-7dc18b4e212c4757@postgresql.org> X-Auto-Response-Suppress: All Auto-Submitted: auto-generated List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk The following bug has been logged on the website: Bug reference: 19595 Logged by: Michael Malis Email address: michaelmalis2@gmail.com PostgreSQL version: 18.3 Operating system: MacOS Description: =20 (I initially filed this at security@ but because the dictionary is considered a trusted file Tom asked me to repost here) Three memory-safety defects in the ispell/hunspell dictionary loader, all reached by CREATE TEXT SEARCH DICTIONARY on a malformed dictionary file. BUG 1 -- out-of-bounds heap write in NISortAffixes() 1987: Conf->CompoundAffix =3D ptr =3D (CMPDAffix *) palloc(sizeof(CMPDAffix) * Conf->naffixes); ... /* loop over i < naffixes; ptr++ once per collected affix */ 2015: ptr->affix =3D NULL; 2016: Conf->CompoundAffix =3D repalloc(Conf->CompoundAffix, sizeof(CMPDAffix) * (ptr - Conf->CompoundAffix + 1)); The array holds exactly naffixes elements. When every affix is collected, ptr =3D=3D base + naffixes, so line 2015 writes one element (8 bytes) past = the end -- and the repalloc that would make room for the terminator is on the next line, after the write. CMPDAffix is 16 bytes, palloc rounds to power-of-two chunks, so the write escapes its chunk when naffixes is a power of two, and always escapes for naffixes > 512 (dedicated block), which covers typical dictionaries. The store then lands in the next chunk's header or in malloc metadata. Reproducer -- in $SHAREDIR/tsearch_data, oob.affix: compoundwords controlled Z suffixes flag ~Z: . > S oob.dict: foo/Z then: CREATE TEXT SEARCH DICTIONARY oob (TEMPLATE =3D ispell, DictFile =3D oob, AffFile =3D oob); SELECT ts_lexize('oob', 'foos'); This gives naffixes =3D=3D 1 with the one affix collected. The allocator de= tects the damage at the next allocation ("free list is damaged", aborting in palloc from mkANode from NISortAffixes). Fix: allocate naffixes + 1 at line 1987, or write the terminator after the repalloc. BUG 2 -- uninitialized stack buffer read in NIImportAffixes() 1426: char flag[BUFSIZ]; /* never initialized */ ... 1519: flag[0] =3D *s++; /* only written in the "flag" bra= nch */ 1520: flag[1] =3D '\0'; ... 1543: NIAddAffix(Conf, flag, flagflags, mask, find, repl, ...); /* unconditional */ flag is written only inside the "flag" directive branch but passed unconditionally to NIAddAffix, which does cpstrdup(Conf, flag) -- strlen + strcpy over uninitialized stack. If no "flag" line was parsed, this is an unbounded strlen (no guaranteed NUL in BUFSIZ) and stack contents are copied into a long-lived dictionary flag. Trigger: a .affix file with an old-format "prefixes"/"suffixes" section and a parseable affix entry but no "flag" directive, e.g.: COMPOUNDWORDS l 1 suffixes nlag Z: . > S with dict "foo/Z" ("nlag" is simply not "flag", so the directive is never seen while the entry still parses). Fix: initialize flag[0] =3D '\0' at declaration. BUG 3 -- NULL dereference on unfilled AF alias slots 1324: Conf->AffixData =3D (const char **) palloc0(naffix * sizeof(char *= )); 1336: Conf->AffixData[curaffix] =3D cpstrdup(Conf, sflag); /* one per AF line */ The alias table is zero-filled and only the AF lines actually present are filled. If "AF " declares more slots than the file fills, the tail stays NULL, and those NULL slots are dereferenced without a check: - MergeAffix() line 1576: if (*Conf->AffixData[a1] =3D=3D '\0') (the Assert at 1573 checks only the index, and asserts are off in production builds), reached from NISortDictionary() at line 1691; - getAffixFlagSet() (1156) -> getCompoundAffixFlagValue() (1120) -> getNextFlagFromString() (350), reached from inside NIImportOOAffixes(). Trigger: a .affix file whose AF table declares a larger count than the number of AF lines it provides, with a dictionary word referencing an unfilled index. Fix: reject an incompletely populated AF table, or treat a NULL slot as the empty flag set (VoidString) at the dereference sites. Reachability: CREATE TEXT SEARCH DICTIONARY requires CREATE on a schema, not superuser (DefineTSDictionary in src/backend/commands/tsearchcmds.c). The file path is restricted to [a-z0-9_] under $SHAREDIR/tsearch_data (get_tsearch_config_filename in src/backend/tsearch/ts_utils.c), so the crafted file must be placed there by other means -- most realistically a corrupt or hostile third-party hunspell dictionary. Minimized file pairs available on request.