Technical Analysis: Reentrancy Guards in ri_triggers.c
The Core Problem
This thread addresses a use-after-free vulnerability in PostgreSQL's referential integrity trigger system (ri_triggers.c) that manifests as a segmentation fault during cascading deletes on self-referential tables.
Architectural Context
PostgreSQL's foreign key enforcement is implemented via system-generated triggers in ri_triggers.c. When a row is deleted from a table with cascading foreign keys, RI_FKey_cascade_del fires to delete dependent rows. These triggers use SPI (Server Programming Interface) to execute internally-generated plans, which are cached in the plan cache for performance.
The Bug Mechanism
The crash requires a specific but realistic configuration:
- Self-referential table: A table with a foreign key column referencing its own primary key (e.g., a tree/hierarchy structure like
parent_id REFERENCES self(id)). - BEFORE DELETE trigger: A user-defined trigger that itself performs DELETE operations on the same table.
The sequence of events leading to the crash:
- A DELETE is issued on the self-referential table.
- The RI cascade trigger (
RI_FKey_cascade_del) fires, acquiring a cached plan via SPI and beginning execution. - The cascade delete triggers the user's BEFORE DELETE trigger on a dependent row.
- The BEFORE DELETE trigger deletes another row from the same table, causing reentrant invocation of
RI_FKey_cascade_del. - During this reentrant execution, something causes
ResetPlanCache()to be called (e.g., a catalog invalidation, DDL in a concurrent session, or the trigger's own actions). - The plan cache invalidation frees the plan that the outer (first) invocation of
RI_FKey_cascade_delis still actively executing. - When control returns to the outer invocation, it attempts to continue executing a freed plan — resulting in a use-after-free and segfault in
_SPI_execute_plan.
Why This Matters Architecturally
This bug exposes a fundamental gap in the lifecycle management of RI trigger plans. The RI module maintains its own plan cache (via ri_PlanHashTable) separate from the general plan cache, but it lacks awareness of whether a plan is currently in-flight. The SPI layer expects plans to remain valid for the duration of execution, but the RI layer can free/replace plans underneath active execution frames during reentrancy.
Self-referential tables with cascading foreign keys are common in real-world schemas (organizational hierarchies, category trees, threaded comments), making this a practically exploitable crash scenario rather than a theoretical concern.
Proposed Solution
The patch introduces reentrancy guards — essentially a reference counting mechanism for RI plans that are currently being executed.
Design Approach
The solution maintains a reference count on each cached plan entry. The key invariant is:
- Before executing a plan: Increment the reference count.
- After execution completes: Decrement the reference count.
- When invalidation/reset occurs: Only free plans with a reference count of zero; plans with non-zero counts are marked for deferred cleanup.
This is analogous to how the general plan cache uses ResourceOwner references to pin plans during execution, but adapted to the RI module's private plan hash table.
Patch Structure
- Patch 1 (test case): Adds a regression test that reproduces the segfault by creating a self-referential table with a BEFORE DELETE trigger that performs additional deletes, then issuing a DELETE that triggers the reentrant cascade path.
- Patch 2 (fix): Modifies
ri_triggers.cto add reference counting guards around plan execution in the RI trigger functions (likelyRI_FKey_cascade_del,RI_FKey_cascade_upd, and possibly other RI entry points that execute cached plans).
Design Tradeoffs
- Reference counting vs. plan pinning: The approach uses simple reference counts rather than integrating with ResourceOwner, which keeps the change localized to
ri_triggers.cbut doesn't leverage existing infrastructure. - Performance: Adding increment/decrement operations on every RI trigger execution adds minimal overhead (single atomic or protected integer operation).
- Deferred cleanup complexity: Plans marked for deferred freeing need a mechanism to eventually be cleaned up — likely checked at the point where the reference count drops to zero.
Relationship to Existing Infrastructure
The general plan cache (plancache.c) already handles this scenario for user-prepared statements via CachedPlanIsValid checks and plan pinning. The RI module's private cache predates some of these mechanisms and has historically assumed non-reentrant execution — an assumption violated by self-referential FK + user triggers.
This is conceptually similar to past bugs in SPI plan management where nested SPI calls could invalidate outer frames' plans, which were addressed by the SPI_keepplan / SPI_saveplan mechanisms.