From 3517bef1548beb94b9d5fb0f91e63f1d63c74a03 Mon Sep 17 00:00:00 2001
From: Xnerv Wang <xnerv@amazon.com>
Date: Thu, 22 Oct 2026 22:45:56 +0000
Subject: [PATCH 2/2] Fix use-after-free when flattening expanded records

ER_get_flat_size() detoasts out-of-line fields before flattening a
record, and replacing a field frees the old value if it isn't part
of the record's flat tuple.  But a caller may still be using a field
value it fetched earlier, as in a PL/pgSQL expression that references
both a field and the whole record.  So ER_get_flat_size() now leaves
the old value alone; ordinary field assignments still free it.
---
 src/backend/utils/adt/expandedrecord.c | 41 +++++++++++++++++++++-----
 1 file changed, 34 insertions(+), 7 deletions(-)

diff --git a/src/backend/utils/adt/expandedrecord.c b/src/backend/utils/adt/expandedrecord.c
index 13752db44e8..c0ce52649b2 100644
--- a/src/backend/utils/adt/expandedrecord.c
+++ b/src/backend/utils/adt/expandedrecord.c
@@ -45,6 +45,12 @@ static const ExpandedObjectMethods ER_methods =
 static void ER_mc_callback(void *arg);
 static MemoryContext get_short_term_cxt(ExpandedRecordHeader *erh);
 static void build_dummy_expanded_header(ExpandedRecordHeader *main_erh);
+static void expanded_record_set_field_worker(ExpandedRecordHeader *erh,
+											 int fnumber,
+											 Datum newValue, bool isnull,
+											 bool expand_external,
+											 bool check_constraints,
+											 bool free_old_value);
 static pg_noinline void check_domain_for_new_field(ExpandedRecordHeader *erh,
 												   int fnumber,
 												   Datum newValue, bool isnull);
@@ -706,13 +712,19 @@ ER_get_flat_size(ExpandedObjectHeader *eohptr)
 				VARATT_IS_EXTERNAL(DatumGetPointer(erh->dvalues[i])))
 			{
 				/*
-				 * expanded_record_set_field_internal can do the actual work
-				 * of detoasting.  It needn't recheck domain constraints.
+				 * expanded_record_set_field_worker can do the actual work of
+				 * detoasting.  It needn't recheck domain constraints.
+				 *
+				 * It mustn't pfree the old value, though: a caller may still
+				 * be using a field value it fetched earlier.  The old value
+				 * is just a TOAST pointer, so it can stay around until the
+				 * record is deleted.
 				 */
-				expanded_record_set_field_internal(erh, i + 1,
-												   erh->dvalues[i], false,
-												   true,
-												   false);
+				expanded_record_set_field_worker(erh, i + 1,
+												 erh->dvalues[i], false,
+												 true,
+												 false,
+												 false);
 			}
 		}
 
@@ -1113,6 +1125,21 @@ expanded_record_set_field_internal(ExpandedRecordHeader *erh, int fnumber,
 								   Datum newValue, bool isnull,
 								   bool expand_external,
 								   bool check_constraints)
+{
+	expanded_record_set_field_worker(erh, fnumber, newValue, isnull,
+									 expand_external, check_constraints,
+									 true);
+}
+
+/*
+ * Workhorse for expanded_record_set_field_internal
+ */
+static void
+expanded_record_set_field_worker(ExpandedRecordHeader *erh, int fnumber,
+								 Datum newValue, bool isnull,
+								 bool expand_external,
+								 bool check_constraints,
+								 bool free_old_value)
 {
 	TupleDesc	tupdesc;
 	CompactAttribute *attr;
@@ -1221,7 +1248,7 @@ expanded_record_set_field_internal(ExpandedRecordHeader *erh, int fnumber,
 	 * whatever we just allocated here is in the short-lived domain check
 	 * context.
 	 */
-	if (oldValue && !(erh->flags & ER_FLAG_IS_DUMMY))
+	if (oldValue && free_old_value && !(erh->flags & ER_FLAG_IS_DUMMY))
 	{
 		/* Don't try to pfree a part of the original flat record */
 		if (oldValue < erh->fstartptr || oldValue >= erh->fendptr)
-- 
2.47.3

