From 0aab42ea099254a52f2da188280f84f67ee18dee Mon Sep 17 00:00:00 2001
From: shihao zhong <zhong950419@gmail.com>
Date: Wed, 21 Oct 2026 23:27:47 -0400
Subject: [PATCH v4 1/2] pageinspect: validate line pointers before using them

The GiST, B-tree and hash page_items() functions could read past the
page image when given a corrupt page.  Verify each line pointer first,
and for B-tree also the posting list of a posting tuple.
bt_page_items() also raises an error for a pivot tuple marked LP_DEAD
instead of failing an assertion.

Co-authored-by: Greg Burd <gregburd@protonmail.com>
---
 contrib/pageinspect/btreefuncs.c       | 38 ++++++++++++++++++++++++--
 contrib/pageinspect/expected/btree.out | 34 +++++++++++++++++++++++
 contrib/pageinspect/expected/gist.out  |  4 +++
 contrib/pageinspect/expected/hash.out  |  4 +++
 contrib/pageinspect/gistfuncs.c        | 35 +++++++++++++++++++++---
 contrib/pageinspect/hashfuncs.c        | 17 ++++++++++--
 contrib/pageinspect/sql/btree.sql      | 30 ++++++++++++++++++++
 contrib/pageinspect/sql/gist.sql       |  3 ++
 contrib/pageinspect/sql/hash.sql       |  3 ++
 9 files changed, 159 insertions(+), 9 deletions(-)

diff --git a/contrib/pageinspect/btreefuncs.c b/contrib/pageinspect/btreefuncs.c
index 9917663593b..5fc7dc993f7 100644
--- a/contrib/pageinspect/btreefuncs.c
+++ b/contrib/pageinspect/btreefuncs.c
@@ -500,11 +500,34 @@ bt_page_print_tuples(ua_page_items *uargs)
 
 	id = PageGetItemId(page, offset);
 
-	if (!ItemIdIsValid(id))
-		elog(ERROR, "invalid ItemId");
+	/* Check that the line pointer and tuple lie within the page. */
+	if (!ItemIdHasStorage(id) ||
+		ItemIdGetOffset(id) != MAXALIGN(ItemIdGetOffset(id)) ||
+		ItemIdGetLength(id) < sizeof(IndexTupleData) ||
+		ItemIdGetOffset(id) + ItemIdGetLength(id) > BLCKSZ)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("invalid line pointer at offset %u in btree page",
+						offset)));
 
 	itup = (IndexTuple) PageGetItem(page, id);
 
+	if (IndexTupleSize(itup) > ItemIdGetLength(id))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("invalid index tuple length at offset %u in btree page",
+						offset)));
+
+	/* The posting list must lie within the tuple. */
+	if (BTreeTupleIsPosting(itup) &&
+		(BTreeTupleGetPostingOffset(itup) > IndexTupleSize(itup) ||
+		 (IndexTupleSize(itup) - BTreeTupleGetPostingOffset(itup)) / sizeof(ItemPointerData) <
+		 BTreeTupleGetNPosting(itup)))
+		ereport(ERROR,
+				(errcode(ERRCODE_DATA_CORRUPTED),
+				 errmsg("invalid posting list at offset %u in btree page",
+						offset)));
+
 	j = 0;
 	memset(nulls, 0, sizeof(nulls));
 	values[j++] = Int16GetDatum(offset);
@@ -572,7 +595,16 @@ bt_page_print_tuples(ua_page_items *uargs)
 		values[j++] = BoolGetDatum(ItemIdIsDead(id));
 	else
 	{
-		Assert(!ItemIdIsDead(id));
+		/*
+		 * LP_DEAD is never set on a pivot tuple in a valid page, but this
+		 * function accepts an arbitrary page image, so report a corrupt one
+		 * rather than tripping an assertion.
+		 */
+		if (ItemIdIsDead(id))
+			ereport(ERROR,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("invalid pivot tuple at offset %u: line pointer marked dead",
+							offset)));
 		nulls[j++] = true;
 	}
 
diff --git a/contrib/pageinspect/expected/btree.out b/contrib/pageinspect/expected/btree.out
index 0aa5d73322f..5f12b01232a 100644
--- a/contrib/pageinspect/expected/btree.out
+++ b/contrib/pageinspect/expected/btree.out
@@ -211,6 +211,38 @@ SELECT bt_page_items(get_raw_page('test1', 0));
 ERROR:  input page is not a valid btree page
 SELECT bt_page_items(get_raw_page('test1_a_brin', 0));
 ERROR:  input page is not a valid btree page
+-- A corrupt line pointer must be reported, not read out of bounds.  All-ones is
+-- an invalid (out-of-range, unaligned) line pointer on any architecture.
+SELECT bt_page_items(set_byte(set_byte(set_byte(set_byte(get_raw_page('test1_a_idx', 1), 24, 255), 25, 255), 26, 255), 27, 255));
+ERROR:  invalid line pointer at offset 1 in btree page
+-- LP_DEAD is never set on a pivot tuple, so a page that has it is corrupt.
+-- Block 1 is the first leaf of a multi-page index, and its first item is the
+-- high key.  Byte 26 holds the lp_flags of that line pointer.
+CREATE TABLE test2 (a int);
+INSERT INTO test2 SELECT g FROM generate_series(1, 1000) g;
+CREATE INDEX test2_a_idx ON test2 (a);
+SELECT bt_page_items(set_byte(get_raw_page('test2_a_idx', 1), 26, 245));
+ERROR:  invalid pivot tuple at offset 1: line pointer marked dead
+-- A posting list must lie within its tuple.  Equal keys are deduplicated into
+-- posting list tuples, and block 1 is the only leaf, so its first line pointer
+-- is such a tuple.  First set both bytes of the posting list count to 0xFF,
+-- then both bytes of the low half of the posting list offset to 0x1F.  Both
+-- values are the same on little and big endian machines.
+CREATE TABLE test3 (a int);
+INSERT INTO test3 SELECT 1 FROM generate_series(1, 300);
+CREATE INDEX test3_a_idx ON test3 (a);
+SELECT bt_page_items(set_byte(set_byte(get_raw_page('test3_a_idx', 1),
+       (SELECT lp_off FROM heap_page_items(get_raw_page('test3_a_idx', 1))
+        WHERE lp = 1) + 4, 255),
+       (SELECT lp_off FROM heap_page_items(get_raw_page('test3_a_idx', 1))
+        WHERE lp = 1) + 5, 255));
+ERROR:  invalid posting list at offset 1 in btree page
+SELECT bt_page_items(set_byte(set_byte(get_raw_page('test3_a_idx', 1),
+       (SELECT lp_off FROM heap_page_items(get_raw_page('test3_a_idx', 1))
+        WHERE lp = 1) + 2, 31),
+       (SELECT lp_off FROM heap_page_items(get_raw_page('test3_a_idx', 1))
+        WHERE lp = 1) + 3, 31));
+ERROR:  invalid posting list at offset 1 in btree page
 \set VERBOSITY default
 -- Tests with all-zero pages.
 SHOW block_size \gset
@@ -219,3 +251,5 @@ SELECT bt_page_items(decode(repeat('00', :block_size), 'hex'));
 bt_page_items | 
 
 DROP TABLE test1;
+DROP TABLE test2;
+DROP TABLE test3;
diff --git a/contrib/pageinspect/expected/gist.out b/contrib/pageinspect/expected/gist.out
index 8502f9efb41..de2cf55aae1 100644
--- a/contrib/pageinspect/expected/gist.out
+++ b/contrib/pageinspect/expected/gist.out
@@ -80,6 +80,10 @@ SELECT gist_page_items_bytea(get_raw_page('test_gist', 0));
 ERROR:  input page is not a valid GiST page
 SELECT gist_page_items_bytea(get_raw_page('test_gist_btree', 0));
 ERROR:  input page is not a valid GiST page
+-- A corrupt line pointer must be reported, not read out of bounds.  All-ones is
+-- an invalid (out-of-range, unaligned) line pointer on any architecture.
+SELECT gist_page_items_bytea(set_byte(set_byte(set_byte(set_byte(get_raw_page('test_gist_idx', 0), 24, 255), 25, 255), 26, 255), 27, 255));
+ERROR:  invalid line pointer at offset 1 in GiST page
 \set VERBOSITY default
 -- Tests with all-zero pages.
 SHOW block_size \gset
diff --git a/contrib/pageinspect/expected/hash.out b/contrib/pageinspect/expected/hash.out
index ea387a68143..bedb746b9ed 100644
--- a/contrib/pageinspect/expected/hash.out
+++ b/contrib/pageinspect/expected/hash.out
@@ -193,6 +193,10 @@ SELECT hash_page_stats(get_raw_page('test_hash', 0));
 ERROR:  input page is not a valid hash page
 SELECT hash_page_type(get_raw_page('test_hash', 0));
 ERROR:  input page is not a valid hash page
+-- A corrupt line pointer must be reported, not read out of bounds.  All-ones is
+-- an invalid (out-of-range, unaligned) line pointer on any architecture.
+SELECT hash_page_items(set_byte(set_byte(set_byte(set_byte(get_raw_page('test_hash_a_idx', 3), 24, 255), 25, 255), 26, 255), 27, 255));
+ERROR:  invalid line pointer at offset 1 in hash page
 \set VERBOSITY default
 -- Tests with all-zero pages.
 SHOW block_size \gset
diff --git a/contrib/pageinspect/gistfuncs.c b/contrib/pageinspect/gistfuncs.c
index 8f127d41ec4..975ae24f73a 100644
--- a/contrib/pageinspect/gistfuncs.c
+++ b/contrib/pageinspect/gistfuncs.c
@@ -169,10 +169,24 @@ gist_page_items_bytea(PG_FUNCTION_ARGS)
 
 		id = PageGetItemId(page, offset);
 
-		if (!ItemIdIsValid(id))
-			elog(ERROR, "invalid ItemId");
+		/* Check that the line pointer and tuple lie within the page. */
+		if (!ItemIdHasStorage(id) ||
+			ItemIdGetOffset(id) != MAXALIGN(ItemIdGetOffset(id)) ||
+			ItemIdGetLength(id) < sizeof(IndexTupleData) ||
+			ItemIdGetOffset(id) + ItemIdGetLength(id) > BLCKSZ)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("invalid line pointer at offset %u in GiST page",
+							offset)));
 
 		itup = (IndexTuple) PageGetItem(page, id);
+
+		if (IndexTupleSize(itup) > ItemIdGetLength(id))
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("invalid index tuple length at offset %u in GiST page",
+							offset)));
+
 		tuple_len = IndexTupleSize(itup);
 
 		memset(nulls, 0, sizeof(nulls));
@@ -274,11 +288,24 @@ gist_page_items(PG_FUNCTION_ARGS)
 
 		id = PageGetItemId(page, offset);
 
-		if (!ItemIdIsValid(id))
-			elog(ERROR, "invalid ItemId");
+		/* Check that the line pointer and tuple lie within the page. */
+		if (!ItemIdHasStorage(id) ||
+			ItemIdGetOffset(id) != MAXALIGN(ItemIdGetOffset(id)) ||
+			ItemIdGetLength(id) < sizeof(IndexTupleData) ||
+			ItemIdGetOffset(id) + ItemIdGetLength(id) > BLCKSZ)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("invalid line pointer at offset %u in GiST page",
+							offset)));
 
 		itup = (IndexTuple) PageGetItem(page, id);
 
+		if (IndexTupleSize(itup) > ItemIdGetLength(id))
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("invalid index tuple length at offset %u in GiST page",
+							offset)));
+
 		index_deform_tuple(itup, tupdesc,
 						   itup_values, itup_isnull);
 
diff --git a/contrib/pageinspect/hashfuncs.c b/contrib/pageinspect/hashfuncs.c
index 30870329cc9..ddd4d8691f1 100644
--- a/contrib/pageinspect/hashfuncs.c
+++ b/contrib/pageinspect/hashfuncs.c
@@ -356,11 +356,24 @@ hash_page_items(PG_FUNCTION_ARGS)
 
 		id = PageGetItemId(uargs->page, uargs->offset);
 
-		if (!ItemIdIsValid(id))
-			elog(ERROR, "invalid ItemId");
+		/* Check that the line pointer and tuple lie within the page. */
+		if (!ItemIdHasStorage(id) ||
+			ItemIdGetOffset(id) != MAXALIGN(ItemIdGetOffset(id)) ||
+			ItemIdGetLength(id) < sizeof(IndexTupleData) ||
+			ItemIdGetOffset(id) + ItemIdGetLength(id) > BLCKSZ)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("invalid line pointer at offset %u in hash page",
+							uargs->offset)));
 
 		itup = (IndexTuple) PageGetItem(uargs->page, id);
 
+		if (IndexInfoFindDataOffset(itup->t_info) + sizeof(uint32) > ItemIdGetLength(id))
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("invalid index tuple length at offset %u in hash page",
+							uargs->offset)));
+
 		j = 0;
 		values[j++] = Int32GetDatum((int32) uargs->offset);
 		values[j++] = PointerGetDatum(&itup->t_tid);
diff --git a/contrib/pageinspect/sql/btree.sql b/contrib/pageinspect/sql/btree.sql
index 102ebdefe3c..805493c6332 100644
--- a/contrib/pageinspect/sql/btree.sql
+++ b/contrib/pageinspect/sql/btree.sql
@@ -53,6 +53,34 @@ SELECT bt_page_items('aaa'::bytea);
 CREATE INDEX test1_a_brin ON test1 USING brin(a);
 SELECT bt_page_items(get_raw_page('test1', 0));
 SELECT bt_page_items(get_raw_page('test1_a_brin', 0));
+-- A corrupt line pointer must be reported, not read out of bounds.  All-ones is
+-- an invalid (out-of-range, unaligned) line pointer on any architecture.
+SELECT bt_page_items(set_byte(set_byte(set_byte(set_byte(get_raw_page('test1_a_idx', 1), 24, 255), 25, 255), 26, 255), 27, 255));
+-- LP_DEAD is never set on a pivot tuple, so a page that has it is corrupt.
+-- Block 1 is the first leaf of a multi-page index, and its first item is the
+-- high key.  Byte 26 holds the lp_flags of that line pointer.
+CREATE TABLE test2 (a int);
+INSERT INTO test2 SELECT g FROM generate_series(1, 1000) g;
+CREATE INDEX test2_a_idx ON test2 (a);
+SELECT bt_page_items(set_byte(get_raw_page('test2_a_idx', 1), 26, 245));
+-- A posting list must lie within its tuple.  Equal keys are deduplicated into
+-- posting list tuples, and block 1 is the only leaf, so its first line pointer
+-- is such a tuple.  First set both bytes of the posting list count to 0xFF,
+-- then both bytes of the low half of the posting list offset to 0x1F.  Both
+-- values are the same on little and big endian machines.
+CREATE TABLE test3 (a int);
+INSERT INTO test3 SELECT 1 FROM generate_series(1, 300);
+CREATE INDEX test3_a_idx ON test3 (a);
+SELECT bt_page_items(set_byte(set_byte(get_raw_page('test3_a_idx', 1),
+       (SELECT lp_off FROM heap_page_items(get_raw_page('test3_a_idx', 1))
+        WHERE lp = 1) + 4, 255),
+       (SELECT lp_off FROM heap_page_items(get_raw_page('test3_a_idx', 1))
+        WHERE lp = 1) + 5, 255));
+SELECT bt_page_items(set_byte(set_byte(get_raw_page('test3_a_idx', 1),
+       (SELECT lp_off FROM heap_page_items(get_raw_page('test3_a_idx', 1))
+        WHERE lp = 1) + 2, 31),
+       (SELECT lp_off FROM heap_page_items(get_raw_page('test3_a_idx', 1))
+        WHERE lp = 1) + 3, 31));
 \set VERBOSITY default
 
 -- Tests with all-zero pages.
@@ -60,3 +88,5 @@ SHOW block_size \gset
 SELECT bt_page_items(decode(repeat('00', :block_size), 'hex'));
 
 DROP TABLE test1;
+DROP TABLE test2;
+DROP TABLE test3;
diff --git a/contrib/pageinspect/sql/gist.sql b/contrib/pageinspect/sql/gist.sql
index 85bc44b8000..9dd79ff9450 100644
--- a/contrib/pageinspect/sql/gist.sql
+++ b/contrib/pageinspect/sql/gist.sql
@@ -34,6 +34,9 @@ SELECT gist_page_opaque_info('aaa'::bytea);
 SELECT * FROM gist_page_opaque_info(get_raw_page('test_gist', 0));
 SELECT gist_page_items_bytea(get_raw_page('test_gist', 0));
 SELECT gist_page_items_bytea(get_raw_page('test_gist_btree', 0));
+-- A corrupt line pointer must be reported, not read out of bounds.  All-ones is
+-- an invalid (out-of-range, unaligned) line pointer on any architecture.
+SELECT gist_page_items_bytea(set_byte(set_byte(set_byte(set_byte(get_raw_page('test_gist_idx', 0), 24, 255), 25, 255), 26, 255), 27, 255));
 \set VERBOSITY default
 
 -- Tests with all-zero pages.
diff --git a/contrib/pageinspect/sql/hash.sql b/contrib/pageinspect/sql/hash.sql
index e4b9e975f8a..b5a3f2767a7 100644
--- a/contrib/pageinspect/sql/hash.sql
+++ b/contrib/pageinspect/sql/hash.sql
@@ -100,6 +100,9 @@ SELECT hash_metapage_info(get_raw_page('test_hash', 0));
 SELECT hash_page_items(get_raw_page('test_hash', 0));
 SELECT hash_page_stats(get_raw_page('test_hash', 0));
 SELECT hash_page_type(get_raw_page('test_hash', 0));
+-- A corrupt line pointer must be reported, not read out of bounds.  All-ones is
+-- an invalid (out-of-range, unaligned) line pointer on any architecture.
+SELECT hash_page_items(set_byte(set_byte(set_byte(set_byte(get_raw_page('test_hash_a_idx', 3), 24, 255), 25, 255), 26, 255), 27, 255));
 \set VERBOSITY default
 
 -- Tests with all-zero pages.

base-commit: f2edf5dd00d174c7b1396f0a777375368a26c752
-- 
2.37.1 (Apple Git-137.1)

