From b0eb4f7bfac085ac9cfd2513b04a3cb493a98a5d Mon Sep 17 00:00:00 2001 From: Lucas Jeffrey Date: Mon, 5 Oct 2026 09:27:04 -0300 Subject: [PATCH v6 2/2] Fix use-after-free of RI query plans in reentrant RI checks RI checks can be reentered while running: for example, with an ON DELETE CASCADE foreign key on a self-referencing table and a BEFORE DELETE trigger that deletes from the same table, the cascade's DELETE fires the trigger, whose own DELETE fires the cascade again, which runs the same cached plan. If that plan has been invalidated in the meantime, the nested call's ri_FetchPreparedPlan() sees it as invalid and frees it with SPI_freeplan(), while the outer call is still executing it. The outer call then crashes in _SPI_execute_plan(). To fix, add a backend-local hash table, ri_query_plan_cache_executing_refcount, keyed by the plan pointer, that counts the executions in progress of each RI plan. ri_PerformCheck() creates the plan's entry when it starts executing a plan that has none, or increments its count otherwise, and decrements the count when the execution finishes. When ri_FetchPreparedPlan() finds an invalid plan that has an entry, it removes it from the query hash table as before, but defers freeing it. When the last execution of a plan finishes: - If freeing the plan was not deferred, its entry is removed, and the plan stays in the query hash table as usual. - If freeing it was deferred and the execution finished normally, the entry is removed and the plan is freed right away. - If freeing it was deferred and the execution was aborted by an error, the plan is not freed while the aborted (sub)transaction's resources are being released. Its entry is kept with a zero count, and the plan is freed by the next RI query execution, or at the latest by AtEOXact_RI(), both of which scan the table for such entries. The plan is no longer in the query hash table, so nothing can be using it. To decrement the count when the query throws an error, each execution is registered with the current resource owner, using a new ResourceOwnerDesc, rather than wrapping it in PG_TRY(). When a (sub)transaction aborts, its resource owner decrements the count of the executions that were running in it. If the error is caught by a PL/pgSQL exception block in a trigger, only the executions inside that block's subtransaction are released, and those in the outer levels keep their count. On the normal path this only adds and removes an element of the resource owner's array. ri_query_plan_cache_executing_refcount only has entries while RI queries are being executed, so it is small, and it is empty outside of nested RI checks, in which case the scan done by each RI query execution is skipped. Signed-off-by: Lucas Jeffrey --- src/backend/utils/adt/ri_triggers.c | 244 +++++++++++++++++++++++++++- src/tools/pgindent/typedefs.list | 1 + 2 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c index c3ea7db72ff..4b986e57825 100644 --- a/src/backend/utils/adt/ri_triggers.c +++ b/src/backend/utils/adt/ri_triggers.c @@ -58,6 +58,7 @@ #include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/rel.h" +#include "utils/resowner.h" #include "utils/rls.h" #include "utils/ruleutils.h" #include "utils/snapmgr.h" @@ -71,6 +72,7 @@ #define RI_INIT_CONSTRAINTHASHSIZE 64 #define RI_INIT_QUERYHASHSIZE (RI_INIT_CONSTRAINTHASHSIZE * 4) +#define RI_INIT_PLANEXECHASHSIZE 16 #define RI_KEYS_ALL_NULL 0 #define RI_KEYS_SOME_NULL 1 @@ -231,12 +233,39 @@ typedef struct RI_CompareHashEntry FmgrInfo cast_func_finfo; /* in case we must coerce input */ } RI_CompareHashEntry; +/* + * RI_QueryPlanCacheExecutingRefCountEntry + * + * Tracks a saved RI plan while ri_PerformCheck() is executing it. RI checks + * can nest: a trigger fired by an RI query may run another RI query using the + * same plan (e.g. ON DELETE CASCADE on a self-referencing table, with a BEFORE + * DELETE trigger that deletes from that table). If the inner call finds the + * plan invalid, it must not free it while an outer call is still executing + * it; instead it defers freeing it, and the last execution frees it. + * + * An entry exists only while the plan is being executed, plus, for a plan + * whose freeing was deferred and whose last execution was aborted by an + * error, until the next RI query execution or the end of the transaction. A plan is never + * freed while it has an entry, so its address cannot be reused by another + * plan in the meantime. + * + * Each execution is also registered with the current resource owner, so that + * the count is decremented even if the query throws an error. + */ +typedef struct RI_QueryPlanCacheExecutingRefCountEntry +{ + SPIPlanPtr plan; /* hash key */ + bool freeDeferred; /* free when refcount reaches 0 */ + uint32 refcount; /* # of executions in progress */ +} RI_QueryPlanCacheExecutingRefCountEntry; + /* * Local data */ static HTAB *ri_constraint_cache = NULL; static HTAB *ri_query_cache = NULL; static HTAB *ri_compare_cache = NULL; +static HTAB *ri_query_plan_cache_executing_refcount = NULL; static dclist_head ri_constraint_cache_valid_list; /* @@ -280,6 +309,22 @@ static SPIPlanPtr ri_FetchPreparedPlan(RI_QueryKey *key); static void ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr plan); static RI_CompareHashEntry *ri_HashCompareOp(Oid eq_opr, Oid typeid); +static void ri_PreparedPlanExecutionStarted(SPIPlanPtr plan); +static void ri_PreparedPlanExecutionFinished(SPIPlanPtr plan); +static void ri_PreparedPlanDecrementRefCount(SPIPlanPtr plan, bool canFree); +static void ri_PreparedPlanFreeOrDefer(SPIPlanPtr plan); +static void ri_PreparedPlanFreeDeferred(void); +static void ResOwnerReleaseRIPlanExecution(Datum res); + +static const ResourceOwnerDesc ri_plan_execution_resowner_desc = +{ + .name = "RI plan execution", + .release_phase = RESOURCE_RELEASE_AFTER_LOCKS, + .release_priority = RELEASE_PRIO_FIRST, + .ReleaseResource = ResOwnerReleaseRIPlanExecution, + .DebugPrint = NULL /* the default message is fine */ +}; + static void ri_CheckTrigger(FunctionCallInfo fcinfo, const char *funcname, int tgkind); static RI_ConstraintInfo *ri_FetchConstraintInfo(Trigger *trigger, @@ -2726,11 +2771,17 @@ ri_PerformCheck(const RI_ConstraintInfo *riinfo, * Set fire_triggers to false to ensure that AFTER triggers are queued in * the outer query's after-trigger context and fire after all RI updates * on the same row are complete, rather than immediately. + * + * Triggers fired by the query may reach ri_FetchPreparedPlan() for the + * same query key and find the plan invalid, so tell it that the plan is + * in use while it runs. */ + ri_PreparedPlanExecutionStarted(qplan); spi_result = SPI_execute_snapshot(qplan, vals, nulls, test_snapshot, crosscheck_snapshot, false, false, limit); + ri_PreparedPlanExecutionFinished(qplan); /* Restore UID and security context */ SetUserIdAndSecContext(save_userid, save_sec_context); @@ -3652,6 +3703,13 @@ ri_InitHashTables(void) ri_compare_cache = hash_create("RI compare cache", RI_INIT_QUERYHASHSIZE, &ctl, HASH_ELEM | HASH_BLOBS); + + ctl.keysize = sizeof(SPIPlanPtr); + ctl.entrysize = sizeof(RI_QueryPlanCacheExecutingRefCountEntry); + ri_query_plan_cache_executing_refcount = + hash_create("RI plan execution refcount", + RI_INIT_PLANEXECHASHSIZE, + &ctl, HASH_ELEM | HASH_BLOBS); } @@ -3698,11 +3756,13 @@ ri_FetchPreparedPlan(RI_QueryKey *key) /* * Otherwise we might as well flush the cached plan now, to free a little - * memory space before we make a new one. + * memory space before we make a new one. An RI check further up the + * stack may still be executing it, though, in which case it's freed only + * once that execution finishes. */ entry->plan = NULL; if (plan) - SPI_freeplan(plan); + ri_PreparedPlanFreeOrDefer(plan); return NULL; } @@ -3737,6 +3797,174 @@ ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr plan) } +/* + * ri_PreparedPlanExecutionStarted - + * + * Record that ri_PerformCheck() is about to execute a saved plan, so that it + * is not freed under us. The execution is also registered with the current + * resource owner, which undoes this if the execution is aborted by an error. + * + * This is also where plans left over by aborted executions are freed, if any. + * The plan about to be executed cannot be one of them: those are no longer in + * the query hashtable, and their addresses cannot have been reused yet. + */ +static void +ri_PreparedPlanExecutionStarted(SPIPlanPtr plan) +{ + RI_QueryPlanCacheExecutingRefCountEntry *entry; + bool found; + + /* The plan came from ri_FetchPreparedPlan() or ri_PlanCheck() */ + Assert(ri_query_plan_cache_executing_refcount != NULL); + + ri_PreparedPlanFreeDeferred(); + + ResourceOwnerEnlarge(CurrentResourceOwner); + + entry = (RI_QueryPlanCacheExecutingRefCountEntry *) + hash_search(ri_query_plan_cache_executing_refcount, + &plan, HASH_ENTER, &found); + if (!found) + { + entry->freeDeferred = false; + entry->refcount = 0; + } + entry->refcount++; + + ResourceOwnerRemember(CurrentResourceOwner, PointerGetDatum(plan), + &ri_plan_execution_resowner_desc); +} + +/* + * ri_PreparedPlanExecutionFinished - + * + * Record that an execution started by ri_PreparedPlanExecutionStarted() has + * finished normally. If it was the last execution of a plan whose freeing + * was deferred in the meantime, the plan is freed. + */ +static void +ri_PreparedPlanExecutionFinished(SPIPlanPtr plan) +{ + ResourceOwnerForget(CurrentResourceOwner, PointerGetDatum(plan), + &ri_plan_execution_resowner_desc); + ri_PreparedPlanDecrementRefCount(plan, true); +} + +/* + * ResOwnerReleaseRIPlanExecution - + * + * ResourceOwner callback, for an execution aborted by an error. We only drop + * the count here: a plan whose freeing was deferred and whose last execution + * this was is freed later by ri_PreparedPlanFreeDeferred(), called by the + * next RI query execution or at the end of the transaction, rather than while + * releasing the aborted (sub)transaction's resources. + */ +static void +ResOwnerReleaseRIPlanExecution(Datum res) +{ + ri_PreparedPlanDecrementRefCount((SPIPlanPtr) DatumGetPointer(res), false); +} + +/* + * ri_PreparedPlanDecrementRefCount - + * + * Drop one execution of a plan, removing its entry once no execution is left. + * If freeing the plan was deferred, it is freed at that point if canFree; + * otherwise its entry is left in place, with a zero count, for + * ri_PreparedPlanFreeDeferred() to find. + */ +static void +ri_PreparedPlanDecrementRefCount(SPIPlanPtr plan, bool canFree) +{ + RI_QueryPlanCacheExecutingRefCountEntry *entry; + bool freeDeferred; + + entry = (RI_QueryPlanCacheExecutingRefCountEntry *) + hash_search(ri_query_plan_cache_executing_refcount, + &plan, HASH_FIND, NULL); + Assert(entry != NULL); + Assert(entry->refcount > 0); + + entry->refcount--; + if (entry->refcount > 0) + return; + + freeDeferred = entry->freeDeferred; + if (freeDeferred && !canFree) + return; + + hash_search(ri_query_plan_cache_executing_refcount, + &plan, HASH_REMOVE, NULL); + if (freeDeferred) + SPI_freeplan(plan); +} + +/* + * ri_PreparedPlanFreeOrDefer - + * + * Free a saved plan that has just been removed from the query hashtable, or, + * if it is being executed, defer freeing it until the last execution + * finishes. + */ +static void +ri_PreparedPlanFreeOrDefer(SPIPlanPtr plan) +{ + RI_QueryPlanCacheExecutingRefCountEntry *entry; + + /* Called from ri_FetchPreparedPlan(), so the hashtables exist */ + Assert(ri_query_plan_cache_executing_refcount != NULL); + + entry = (RI_QueryPlanCacheExecutingRefCountEntry *) + hash_search(ri_query_plan_cache_executing_refcount, + &plan, HASH_FIND, NULL); + if (entry == NULL) + { + SPI_freeplan(plan); + return; + } + + Assert(entry->refcount > 0); + Assert(!entry->freeDeferred); + entry->freeDeferred = true; +} + +/* + * ri_PreparedPlanFreeDeferred - + * + * Free the plans whose freeing was deferred and whose last execution was + * aborted by an error, which ri_PreparedPlanDecrementRefCount() left in the + * hash table with a zero count. + * + * This is called for every RI query execution, but the hash table only has + * entries while RI queries are being executed, so outside of nested RI checks + * it is empty and there is nothing to scan. + */ +static void +ri_PreparedPlanFreeDeferred(void) +{ + HASH_SEQ_STATUS status; + RI_QueryPlanCacheExecutingRefCountEntry *entry; + + if (ri_query_plan_cache_executing_refcount == NULL || + hash_get_num_entries(ri_query_plan_cache_executing_refcount) == 0) + return; + + hash_seq_init(&status, ri_query_plan_cache_executing_refcount); + while ((entry = hash_seq_search(&status)) != NULL) + { + SPIPlanPtr plan = entry->plan; + + if (entry->refcount > 0) + continue; + Assert(entry->freeDeferred); + + if (hash_search(ri_query_plan_cache_executing_refcount, + &plan, HASH_REMOVE, NULL) == NULL) + elog(ERROR, "hash table corrupted"); + SPI_freeplan(plan); + } +} + /* * ri_KeysEqual - * @@ -4023,6 +4251,9 @@ RI_FKey_trigger_type(Oid tgfoid) * other AtEOXact_* routines but is not used: the release is the same on * the commit and the abort path. * + * It also frees the RI plans whose freeing was deferred and that are still + * pending; see RI_QueryPlanCacheExecutingRefCountEntry. + * * There is no AtEOSubXact_RI() counterpart. Nothing here is scoped to a * subtransaction: a detached FastPathMeta stays reachable from the dead * list whichever subtransaction detached it, and a check holding a pointer @@ -4040,4 +4271,13 @@ AtEOXact_RI(bool isCommit) MemoryContextDelete(dead->scratch_cxt); pfree(dead); } + + /* + * Likewise, free plans whose freeing was deferred and whose last + * execution was aborted. All subtransactions and resource owners have + * been released by now, so the hash table must end up empty. + */ + ri_PreparedPlanFreeDeferred(); + Assert(ri_query_plan_cache_executing_refcount == NULL || + hash_get_num_entries(ri_query_plan_cache_executing_refcount) == 0); } diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index d001f56efae..bea4c4d5848 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -2502,6 +2502,7 @@ RI_ConstraintInfo RI_FastPathState RI_QueryHashEntry RI_QueryKey +RI_QueryPlanCacheExecutingRefCountEntry RTEKind RTEPermissionInfo RWConflict -- 2.34.1