From ac1ea05366d9cc749fd38e65a0bb67e034d51caf Mon Sep 17 00:00:00 2001
From: Amit Langote <amitlan@postgresql.org>
Date: Thu, 7 May 2026 17:55:04 +0900
Subject: [PATCH v2] Fix use-after-free of qs in AfterTriggerEndQuery.

afterTriggerInvokeEvents() may repalloc afterTriggers.query_stack
while firing trigger events, leaving any precomputed entry pointer
dangling.  The loop body in AfterTriggerEndQuery() recomputes qs
after each afterTriggerInvokeEvents() call for that reason, but the
"all fired" break path exits without the recompute, and the
subsequent FireAfterTriggerBatchCallbacks(qs->batch_callbacks)
dereferences the freed pointer.

Fix by recomputing qs immediately before
FireAfterTriggerBatchCallbacks(), as the loop body already does
after each afterTriggerInvokeEvents() call.

The hazard was introduced in 34a30786293, which added the
qs->batch_callbacks dereference at this site.

Reported-by: Amul Sul <sulamul@gmail.com>
Author: Amul Sul <sulamul@gmail.com>
Reviewed-by: Anthonin Bonnefoy <anthonin.bonnefoy@datadoghq.com>
Reviewed-by: Amit Langote <amitlangote09@gmail.com>
Discussion: https://postgr.es/m/CAAJ_b95p6-qiVpE2Gpr=bUsNAqTcejD_rPgLnfjx9m=fo3Rf3Q@mail.gmail.com
---
 src/backend/commands/trigger.c         |  6 ++++++
 src/test/regress/expected/triggers.out | 24 ++++++++++++++++++++++++
 src/test/regress/sql/triggers.sql      | 23 +++++++++++++++++++++++
 3 files changed, 53 insertions(+)

diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c
index da0d1ba6791..b122eec9b98 100644
--- a/src/backend/commands/trigger.c
+++ b/src/backend/commands/trigger.c
@@ -5239,7 +5239,13 @@ AfterTriggerEndQuery(EState *estate)
 	 * Fire batch callbacks before releasing query-level storage and before
 	 * decrementing query_depth.  Callbacks may do real work (index probes,
 	 * error reporting).
+	 *
+	 * Recompute qs first: the loop above refreshes it after each
+	 * afterTriggerInvokeEvents() call (see comment there), but the
+	 * "all fired" break exits without doing so, leaving qs potentially
+	 * stale here.
 	 */
+	qs = &afterTriggers.query_stack[afterTriggers.query_depth];
 	FireAfterTriggerBatchCallbacks(qs->batch_callbacks);
 
 	/* Release query-level-local storage, including tuplestores if any */
diff --git a/src/test/regress/expected/triggers.out b/src/test/regress/expected/triggers.out
index 511e7cfb6ce..8fcb33ac81a 100644
--- a/src/test/regress/expected/triggers.out
+++ b/src/test/regress/expected/triggers.out
@@ -3644,3 +3644,27 @@ drop table defer_trig;
 drop function whoami();
 drop role regress_fn_owner;
 drop role regress_caller;
+--
+-- Test a recursive AFTER ROW trigger that nests after-trigger query levels
+-- deeply enough to grow query_stack mid-fire.  Outer levels then resume their
+-- post-loop cleanup against the relocated stack.
+--
+create table trigger_recursive (id int);
+create function trigger_recursive_fn() returns trigger language plpgsql as $$
+begin
+    if new.id < 10 then
+        insert into trigger_recursive values (new.id + 1);
+    end if;
+    return new;
+end$$;
+create trigger trigger_recursive after insert on trigger_recursive
+    for each row execute function trigger_recursive_fn();
+insert into trigger_recursive values (1);
+select count(*) from trigger_recursive;
+ count 
+-------
+    10
+(1 row)
+
+drop table trigger_recursive;
+drop function trigger_recursive_fn();
diff --git a/src/test/regress/sql/triggers.sql b/src/test/regress/sql/triggers.sql
index ea39817ee3d..2285e90110e 100644
--- a/src/test/regress/sql/triggers.sql
+++ b/src/test/regress/sql/triggers.sql
@@ -2788,3 +2788,26 @@ drop table defer_trig;
 drop function whoami();
 drop role regress_fn_owner;
 drop role regress_caller;
+
+--
+-- Test a recursive AFTER ROW trigger that nests after-trigger query levels
+-- deeply enough to grow query_stack mid-fire.  Outer levels then resume their
+-- post-loop cleanup against the relocated stack.
+--
+create table trigger_recursive (id int);
+create function trigger_recursive_fn() returns trigger language plpgsql as $$
+begin
+    if new.id < 10 then
+        insert into trigger_recursive values (new.id + 1);
+    end if;
+    return new;
+end$$;
+
+create trigger trigger_recursive after insert on trigger_recursive
+    for each row execute function trigger_recursive_fn();
+
+insert into trigger_recursive values (1);
+select count(*) from trigger_recursive;
+
+drop table trigger_recursive;
+drop function trigger_recursive_fn();
-- 
2.47.3

