| From: | Andrey Rachitskiy <pl0h0yp1(at)gmail(dot)com> |
|---|---|
| To: | michaelmalis2(at)gmail(dot)com, pgsql-bugs(at)lists(dot)postgresql(dot)org |
| Subject: | Re: BUG #19595: Three memory-safety defects in src/backend/tsearch/spell.c (dictionary loader), PG 18.3 |
| Date: | 2026-08-02 07:29:48 |
| Message-ID: | CAB8bMiun+cTqTnv-cTfxvTbnOLxWunRgyZeUZ88YMSLLXub4mg@mail.gmail.com |
| Views: | Whole Thread | Raw Message | Download mbox | Resend email |
| Thread: | |
| Lists: | pgsql-bugs |
Hi, Michael!
Patch attached.
1. NISortAffixes() — allocate CompoundAffix with naffixes + 1 so the
terminating NULL entry fits. The previous allocation of exactly
naffixes elements left the terminator one past the end when every
affix was collected.
2. NIImportAffixes() — initialize flag[0] = '\0' so NIAddAffix() does
not see an uninitialized buffer when an old-format affix entry
appears without a preceding "flag" directive.
3. Incomplete Hunspell AF tables — reject a NULL AffixData slot in
getAffixFlagSet() as an invalid affix alias (same error text as an
out-of-range alias), and reject an incompletely populated AF table
at the end of NIImportOOAffixes(), mirroring the existing "too many
aliases" check. The former covers a mid-parse reference to an
unfilled slot, the latter covers a truncated AF table with no such
reference.
Regression tests cover both AF cases. The former errors while
tsearch_readline() still has an error-context callback installed, so
the ERROR would otherwise include CONTEXT with the absolute path of
the affix file under $SHAREDIR. That path is install-dependent and
would make the expected file non-portable, so the test wraps that
CREATE in \set VERBOSITY terse (same pattern as elsewhere in the
regress suite). The incomplete-table case errors after
tsearch_readline_end(), so it needs no VERBOSITY tweak.
вс, 2 авг. 2026 г. в 08:30, PG Bug reporting form <noreply(at)postgresql(dot)org>:
> The following bug has been logged on the website:
>
> Bug reference: 19595
> Logged by: Michael Malis
> Email address: michaelmalis2(at)gmail(dot)com
> PostgreSQL version: 18.3
> Operating system: MacOS
> Description:
>
> (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 = ptr = (CMPDAffix *)
> palloc(sizeof(CMPDAffix) * Conf->naffixes);
> ... /* loop over i < naffixes; ptr++ once per collected affix */
> 2015: ptr->affix = NULL;
> 2016: Conf->CompoundAffix = repalloc(Conf->CompoundAffix,
> sizeof(CMPDAffix) * (ptr - Conf->CompoundAffix + 1));
>
> The array holds exactly naffixes elements. When every affix is collected,
> ptr == 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 = ispell, DictFile =
> oob, AffFile = oob);
> SELECT ts_lexize('oob', 'foos');
>
> This gives naffixes == 1 with the one affix collected. The allocator
> detects
> 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] = *s++; /* only written in the "flag"
> branch
> */
> 1520: flag[1] = '\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] = '\0' at declaration.
>
>
> BUG 3 -- NULL dereference on unfilled AF alias slots
>
> 1324: Conf->AffixData = (const char **) palloc0(naffix * sizeof(char
> *));
> 1336: Conf->AffixData[curaffix] = cpstrdup(Conf, sflag); /* one
> per AF line */
>
> The alias table is zero-filled and only the AF lines actually present are
> filled. If "AF <n>" 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] == '\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.
>
>
>
>
>
| Attachment | Content-Type | Size |
|---|---|---|
| 0001-Fix-ispell-memsafe-REL_19_STABLE.patch | text/x-patch | 6.9 KB |
| 0001-Fix-ispell-memsafe-master.patch | text/x-patch | 6.9 KB |
| From | Date | Subject | |
|---|---|---|---|
| Next Message | Hüseyin Demir | 2026-08-02 09:41:40 | Re: BUG #19483: pg_upgrade fails with orphan records in pg_init_priv catalog table |
| Previous Message | Zsolt Parragi | 2026-08-02 06:56:05 | Re: MERGE/SPLIT PARTITIONS issues/questions |