Hi,
The crash occurs when the per-query firing loop in
AfterTriggerEndQuery() exits via the "all fired" path. If
afterTriggerInvokeEvents() reallocated query_stack while firing, the
loop's local qs pointer is left dangling, and the subsequent
FireAfterTriggerBatchCallbacks(qs->batch_callbacks) reads
batch_callbacks from the freed memory and crashes.
Here is the reproducible test that has an AFTER INSERT trigger on a
referenced table that recursively inserts rows into itself:
--
create table trigger_recursive_pk (id int primary key);
create table trigger_recursive_fk (id int references trigger_recursive_pk(id));
insert into trigger_recursive_pk select g from generate_series(1, 15) g;
create function trigger_recursive_fn() returns trigger language plpgsql as $$
begin
if new.id < 10 then
insert into trigger_recursive_fk values (new.id + 1);
end if;
return new;
end$$;
create trigger trigger_recursive after insert on trigger_recursive_fk
for each row execute function trigger_recursive_fn();
insert into trigger_recursive_fk values (1);
--
The attached patch fixes the reported issue by recomputing qs
immediately before calling FireAfterTriggerBatchCallbacks().
--
Regards,
Amul Sul
EDB: http://www.enterprisedb.com
From e2889aa3985fd7d0d1e9a948f64bc644f67bfeea Mon Sep 17 00:00:00 2001
From: Amul Sul <[email protected]>
Date: Tue, 5 May 2026 11:17:59 +0530
Subject: [PATCH] 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.
Recompute qs immediately before FireAfterTriggerBatchCallbacks(),
matching the recompute used for the AfterTriggerFreeQuery() call
on the next line.
---
src/backend/commands/trigger.c | 5 +++++
src/test/regress/expected/triggers.out | 29 ++++++++++++++++++++++++++
src/test/regress/sql/triggers.sql | 29 ++++++++++++++++++++++++++
3 files changed, 63 insertions(+)
diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c
index da0d1ba6791..063539cd8cd 100644
--- a/src/backend/commands/trigger.c
+++ b/src/backend/commands/trigger.c
@@ -5239,7 +5239,12 @@ 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 because the loop's "all fired" break path exits without
+ * recomputing it after afterTriggerInvokeEvents may have repalloc'd
+ * query_stack.
*/
+ 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..8c067ff589d 100644
--- a/src/test/regress/expected/triggers.out
+++ b/src/test/regress/expected/triggers.out
@@ -3644,3 +3644,32 @@ drop table defer_trig;
drop function whoami();
drop role regress_fn_owner;
drop role regress_caller;
+--
+-- An AFTER INSERT trigger on an FK-constrained table that recursively
+-- inserts the next row from inside the trigger function. Each recursive
+-- insert nests another after-trigger query level on top of the outer one,
+-- and the foreign-key check on each row exercises the FK fast path.
+-- This must run cleanly to completion.
+--
+create table trigger_recursive_pk (id int primary key);
+create table trigger_recursive_fk (id int references trigger_recursive_pk(id));
+insert into trigger_recursive_pk select g from generate_series(1, 15) g;
+create function trigger_recursive_fn() returns trigger language plpgsql as $$
+begin
+ if new.id < 10 then
+ insert into trigger_recursive_fk values (new.id + 1);
+ end if;
+ return new;
+end$$;
+create trigger trigger_recursive after insert on trigger_recursive_fk
+ for each row execute function trigger_recursive_fn();
+insert into trigger_recursive_fk values (1);
+select count(*) from trigger_recursive_fk;
+ count
+-------
+ 10
+(1 row)
+
+drop table trigger_recursive_fk;
+drop table trigger_recursive_pk;
+drop function trigger_recursive_fn();
diff --git a/src/test/regress/sql/triggers.sql b/src/test/regress/sql/triggers.sql
index ea39817ee3d..55e1d043e13 100644
--- a/src/test/regress/sql/triggers.sql
+++ b/src/test/regress/sql/triggers.sql
@@ -2788,3 +2788,32 @@ drop table defer_trig;
drop function whoami();
drop role regress_fn_owner;
drop role regress_caller;
+
+--
+-- An AFTER INSERT trigger on an FK-constrained table that recursively
+-- inserts the next row from inside the trigger function. Each recursive
+-- insert nests another after-trigger query level on top of the outer one,
+-- and the foreign-key check on each row exercises the FK fast path.
+-- This must run cleanly to completion.
+--
+create table trigger_recursive_pk (id int primary key);
+create table trigger_recursive_fk (id int references trigger_recursive_pk(id));
+insert into trigger_recursive_pk select g from generate_series(1, 15) g;
+
+create function trigger_recursive_fn() returns trigger language plpgsql as $$
+begin
+ if new.id < 10 then
+ insert into trigger_recursive_fk values (new.id + 1);
+ end if;
+ return new;
+end$$;
+
+create trigger trigger_recursive after insert on trigger_recursive_fk
+ for each row execute function trigger_recursive_fn();
+
+insert into trigger_recursive_fk values (1);
+select count(*) from trigger_recursive_fk;
+
+drop table trigger_recursive_fk;
+drop table trigger_recursive_pk;
+drop function trigger_recursive_fn();
--
2.47.1