From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Yuriy Grigoryev <ju.grigorev@ftdata.ru>
Date: Fri, 4 Sep 2026 14:20:00 +0700
Subject: [PATCH] Reject WAL block images whose hole does not fit in the page.

DecodeXLogRecord() already rejects a BKPIMAGE_HAS_HOLE image when
hole_offset or hole_length is zero, or when bimg_len is BLCKSZ.  It
does not check that the hole lies inside the page.  hole_offset and
hole_length are uint16 values taken from the WAL record, so a
compressed image can claim a hole that starts near the end of the
page and extends well past BLCKSZ.

RestoreBlockImage() then uses those fields as memcpy/MemSet lengths
into a BLCKSZ buffer, and as BLCKSZ - hole_length for the
decompressor's output capacity.  A hole that does not fit causes
out-of-bounds writes and size_t underflow.

Extend the existing HAS_HOLE cross-check with hole_offset <= BLCKSZ
and hole_length <= BLCKSZ - hole_offset, using subtraction so the
two untrusted fields are never added together.  Re-check the same
bound at the start of RestoreBlockImage() before decompression or
memcpy.

This is not reachable from WAL produced by a healthy PostgreSQL
instance.  It requires a corrupt or crafted record that still has a
plausible structure.

Discussion: https://postgr.es/m/19599-8859c3822a831331@postgresql.org
---
 src/backend/access/transam/xlogreader.c | 24 ++++++++++++++++++++----
 1 file changed, 20 insertions(+), 4 deletions(-)

diff --git a/src/backend/access/transam/xlogreader.c b/src/backend/access/transam/xlogreader.c
index 5c26d33a603..b7d082d8f8b 100644
--- a/src/backend/access/transam/xlogreader.c
+++ b/src/backend/access/transam/xlogreader.c
@@ -1824,13 +1824,18 @@ DecodeXLogRecord(XLogReaderState *state,
 				datatotal += blk->bimg_len;
 
 				/*
-				 * cross-check that hole_offset > 0, hole_length > 0 and
-				 * bimg_len < BLCKSZ if the HAS_HOLE flag is set.
+				 * cross-check that hole_offset > 0, hole_length > 0,
+				 * bimg_len < BLCKSZ, and the hole fits in the page if the
+				 * HAS_HOLE flag is set.  Compare hole_length with
+				 * BLCKSZ - hole_offset so the two untrusted fields are never
+				 * added together.
 				 */
 				if ((blk->bimg_info & BKPIMAGE_HAS_HOLE) &&
 					(blk->hole_offset == 0 ||
 					 blk->hole_length == 0 ||
-					 blk->bimg_len == BLCKSZ))
+					 blk->bimg_len == BLCKSZ ||
+					 blk->hole_offset > BLCKSZ ||
+					 blk->hole_length > BLCKSZ - blk->hole_offset))
 				{
 					report_invalid_record(state,
 										  "BKPIMAGE_HAS_HOLE set, but hole offset %u length %u block image length %u at %X/%X",
@@ -2099,6 +2104,21 @@ RestoreBlockImage(XLogReaderState *record, uint8 block_id, char *page)
 	bkpb = &record->record->blocks[block_id];
 	ptr = bkpb->bkp_image;
 
+	/*
+	 * The hole must fit in the page.  DecodeXLogRecord() already enforces
+	 * this; re-check here before using the values as memcpy/MemSet lengths
+	 * or as the decompressor output capacity.
+	 */
+	if (bkpb->hole_offset > BLCKSZ ||
+		bkpb->hole_length > BLCKSZ - bkpb->hole_offset)
+	{
+		report_invalid_record(record,
+							  "could not restore image at %X/%X with invalid state, block %d",
+							  LSN_FORMAT_ARGS(record->ReadRecPtr),
+							  block_id);
+		return false;
+	}
+
 	if (BKPIMAGE_COMPRESSED(bkpb->bimg_info))
 	{
 		/* If a backup block image is compressed, decompress it */
-- 
2.39.2
