[PATCH] Harden two-phase GID handling and fork-number validation in WAL replay/decode paths

Started by Matt Suiche28 days ago3 messageshackers
Beta feature

Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.

won't retrysuccessCI history

This thread has been committed, so CI has stopped here. Anything below is the last result it produced.

You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:

docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t253210
psql -h localhost -U postgres

Built from patchset v1 (message #1), July 28, 2026 at 09:53 PM.

Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:

git clone --branch t253210_1 https://github.com/hackorum-dev/postgres.git

In a checkout you already have, add the fork once:

git remote add hackorum https://github.com/hackorum-dev/postgres.git

then, for this patchset and every later one:

git fetch hackorum t253210_1 && git checkout t253210_1

Patchset v1 (message #1) is on t253210_1

Jump to latest
#1Matt Suiche
matt@tolmo.com

Hello,

While fuzzing WAL replay and decode paths on master, we found that the
two-phase redo code and several fork-number consumers trust
record-supplied values that can, in crafted or corrupted WAL, exceed
the bounds the rest of the code assumes. The write-time path already
rejects oversized two-phase GIDs (twophase.c:371), but the replay and
restore paths do not re-validate, and `gxact->gid` (GIDSIZE bytes) is
filled with plain `strcpy`. Similarly, fork numbers from records index
the 4-entry `forkNames[]` without range checks on some paths.

Per a suggestion on the security list (where the WAL-trust posture was
noted, but hardening the two-phase replay code was encouraged), here
are two small patches:

- **0001-Validate-two-phase-GID-length-in-replay-paths.patch**
Reject `gidlen == 0 || gidlen >= GIDSIZE` in `PrepareRedoAdd()`,
`RecoverPreparedTransactions()` (state-file restore), and
`ParsePrepareRecord()` (the same pattern into a stack buffer in
xactdesc.c), and replace the `strcpy` calls with `strlcpy`.

- **0002-Validate-fork-numbers-in-WAL-decode-paths.patch**
Range-check the block-header fork nibble in `DecodeXLogRecord()`
(`report_invalid_record`, consistent with neighbouring checks), and
clamp defensively in `GetRelationPath()`, which is shared
frontend/backend code and the common sink for `forkNames[]`
indexing (e.g. from `xl_smgr_create.forkNum`).

Without these checks, a malformed `XLOG_XACT_PREPARE` record overflows
`gxact->gid` into neighbouring two-phase shared memory during redo
(reproduced: multi-KB overwrite, freelist corruption), and an
out-of-range fork number reads outside `forkNames[]`. With them, both
inputs are rejected cleanly at replay/decode time (in redo, the ERROR
is promoted to FATAL, aborting recovery rather than corrupting state).

Verified against master (38afc3dcb): the tree builds cleanly,
`PREPARE TRANSACTION` / `COMMIT PREPARED` / recovery of prepared
transactions work normally, and crafted records that previously
corrupted shared memory are now rejected with the new error messages.

Best regards,
--
*Matt Suiche*

*Tolmo, Inc.*

Attachments:

t253210_1
0001-Validate-two-phase-GID-length-in-replay-paths.patchapplication/octet-stream; name=0001-Validate-two-phase-GID-length-in-replay-paths.patchDownload+37-2
0002-Validate-fork-numbers-in-WAL-decode-paths.patchapplication/octet-stream; name=0002-Validate-fork-numbers-in-WAL-decode-paths.patchDownload+17-0
#2Michael Paquier
michael@paquier.xyz
In reply to: Matt Suiche (#1)
Re: [PATCH] Harden two-phase GID handling and fork-number validation in WAL replay/decode paths

On Mon, Jul 27, 2026 at 10:06:55AM +0200, Matt Suiche wrote:

Per a suggestion on the security list (where the WAL-trust posture was
noted, but hardening the two-phase replay code was encouraged), here
are two small patches:

Because WAL is trusted data. :)

- **0001-Validate-two-phase-GID-length-in-replay-paths.patch**
Reject `gidlen == 0 || gidlen >= GIDSIZE` in `PrepareRedoAdd()`,
`RecoverPreparedTransactions()` (state-file restore), and
`ParsePrepareRecord()` (the same pattern into a stack buffer in
xactdesc.c), and replace the `strcpy` calls with `strlcpy`.

I can get behind the two strcpy() -> strlcpy() switches in
MarkAsPreparingGuts() and PrepareRedoAdd() on the ground that it is
going to silence LLMs and static fuzzers. Once we do that, the gidlen
checks don't really matter; we can just drop them.

The ParsePrepareRecord() check is a bit more debatable to have, but
rather than having a check, I think that we should just switch the
strncpy() to a strlcpy() bounded by GIDSIZE and call it a day. This
would offer the same protection, keep all code paths in line, and
avoid including an invasive logging.h for the sake of a defensive
check (aka I really don't want this level of dependency, WAL desc
files gain in portability with less dependencies).

- **0002-Validate-fork-numbers-in-WAL-decode-paths.patch**
Range-check the block-header fork nibble in `DecodeXLogRecord()`
(`report_invalid_record`, consistent with neighbouring checks), and
clamp defensively in `GetRelationPath()`, which is shared
frontend/backend code and the common sink for `forkNames[]`
indexing (e.g. from `xl_smgr_create.forkNum`).

+            if (blk->forknum > MAX_FORKNUM)
+            {
+                report_invalid_record(state,
+                                      "invalid fork number %u at %X/%08X",
+                                      blk->forknum,
+                                      LSN_FORMAT_ARGS(state->ReadRecPtr));
+                goto err;
+            }

Hmm. Why not. We have similar checks.

+    /*
+     * WAL decode/display paths can reach here with unvalidated fork
+     * numbers; clamp as defense in depth so we never index forkNames[]
+     * out of bounds.  Callers that can ereport should validate first.
+     */
+    if (forkNumber < MAIN_FORKNUM || forkNumber > MAX_FORKNUM)
+        forkNumber = MAIN_FORKNUM;

The change is a bad idea to me. It means that an incorrect record
(which would not really happen due to CRC check anyway) would now rely
on an incorrect context.

Without these checks, a malformed `XLOG_XACT_PREPARE` record overflows
`gxact->gid` into neighbouring two-phase shared memory during redo
(reproduced: multi-KB overwrite, freelist corruption), and an
out-of-range fork number reads outside `forkNames[]`. With them, both
inputs are rejected cleanly at replay/decode time (in redo, the ERROR
is promoted to FATAL, aborting recovery rather than corrupting state).

Note: none of that is worth a backpatch. These are just additional
defenses. The strlcpy() changes and the DecodeXLogRecord() are OK,
but let's drop the rest.
--
Michael

#3Matt Suiche
matt@tolmo.com
In reply to: Michael Paquier (#2)
Re: [PATCH] Harden two-phase GID handling and fork-number validation in WAL replay/decode paths

Because WAL is trusted data. :)

Okay :)

Makes sense to me.
strcpy -> strlcpy is a pretty big win in general

On Tue, Jul 28, 2026 at 8:47 AM Michael Paquier <michael@paquier.xyz> wrote:

On Mon, Jul 27, 2026 at 10:06:55AM +0200, Matt Suiche wrote:

Per a suggestion on the security list (where the WAL-trust posture was
noted, but hardening the two-phase replay code was encouraged), here
are two small patches:

Because WAL is trusted data. :)

- **0001-Validate-two-phase-GID-length-in-replay-paths.patch**
Reject `gidlen == 0 || gidlen >= GIDSIZE` in `PrepareRedoAdd()`,
`RecoverPreparedTransactions()` (state-file restore), and
`ParsePrepareRecord()` (the same pattern into a stack buffer in
xactdesc.c), and replace the `strcpy` calls with `strlcpy`.

I can get behind the two strcpy() -> strlcpy() switches in
MarkAsPreparingGuts() and PrepareRedoAdd() on the ground that it is
going to silence LLMs and static fuzzers. Once we do that, the gidlen
checks don't really matter; we can just drop them.

The ParsePrepareRecord() check is a bit more debatable to have, but
rather than having a check, I think that we should just switch the
strncpy() to a strlcpy() bounded by GIDSIZE and call it a day. This
would offer the same protection, keep all code paths in line, and
avoid including an invasive logging.h for the sake of a defensive
check (aka I really don't want this level of dependency, WAL desc
files gain in portability with less dependencies).

- **0002-Validate-fork-numbers-in-WAL-decode-paths.patch**
Range-check the block-header fork nibble in `DecodeXLogRecord()`
(`report_invalid_record`, consistent with neighbouring checks), and
clamp defensively in `GetRelationPath()`, which is shared
frontend/backend code and the common sink for `forkNames[]`
indexing (e.g. from `xl_smgr_create.forkNum`).

+            if (blk->forknum > MAX_FORKNUM)
+            {
+                report_invalid_record(state,
+                                      "invalid fork number %u at %X/%08X",
+                                      blk->forknum,
+                                      LSN_FORMAT_ARGS(state->ReadRecPtr));
+                goto err;
+            }

Hmm. Why not. We have similar checks.

+    /*
+     * WAL decode/display paths can reach here with unvalidated fork
+     * numbers; clamp as defense in depth so we never index forkNames[]
+     * out of bounds.  Callers that can ereport should validate first.
+     */
+    if (forkNumber < MAIN_FORKNUM || forkNumber > MAX_FORKNUM)
+        forkNumber = MAIN_FORKNUM;

The change is a bad idea to me. It means that an incorrect record
(which would not really happen due to CRC check anyway) would now rely
on an incorrect context.

Without these checks, a malformed `XLOG_XACT_PREPARE` record overflows
`gxact->gid` into neighbouring two-phase shared memory during redo
(reproduced: multi-KB overwrite, freelist corruption), and an
out-of-range fork number reads outside `forkNames[]`. With them, both
inputs are rejected cleanly at replay/decode time (in redo, the ERROR
is promoted to FATAL, aborting recovery rather than corrupting state).

Note: none of that is worth a backpatch. These are just additional
defenses. The strlcpy() changes and the DecodeXLogRecord() are OK,
but let's drop the rest.
--
Michael

--
*Matt Suiche*
Calendly: https://www.calendly.com/msuiche
Signal: +1-415-466-5067
*Bloomberg Odd Lots - *Cyberwar in the Age of AI: *Spotify
<https://open.spotify.com/episode/08KYvhIBPmqxO37C1mmaph?si=6b5b363d86cc452f&gt;
| Apple
Podcasts
<https://podcasts.apple.com/us/podcast/legendary-hacker-matt-suiche-on-cyberwar-in-the-age-of-ai/id1056200096?i=1000754809995&gt;*