Skip to content

Plan capture no longer trusts a plan header found anywhere in the log, only one at a real line start (#4008) - #4015

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/4008-plan-capture-spoofing
Sep 23, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/4008-plan-capture-spoofing

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #4008.

Why

Found by #3996's round-2 security review. PgPlanLogParser.s_planBlock (the C# parser, RDS log-API
route) and PgPlanCaptureCollector's self-hosted regexp_matches both matched auto_explain's plan
header - [pid] queryid LOG: duration: ... plan: - anywhere in the log tail, with no requirement
that a real timestamp precede it. PostgreSQL echoes a rejected statement back verbatim in its
STATEMENT: companion after a syntax error, tab-indented continuation lines included, so a user who
can run SQL on a monitored target could write the header into their own query text (in a comment)
and have PostgreSQL's own echo read back as a genuine auto_explain block - choosing the query id and
duration the forged plan attaches to, and the plan JSON itself. Confirmed live on a throwaway rig:
the pre-fix SQL returned Row { QueryId = 7, ..., PlanJson = {"Plan":{"Node Type":"Seq Scan","Relation Name":"HUNTER2_LEAK","Alias":"card 4111111111111111"}} } from exactly
this PoC.

What changes

  • PgPlanLogParser.s_planBlock and PgPlanCaptureCollector's SQL both now anchor to the start of a
    genuine physical log line (RegexOptions.Multiline / SQL's 'n' flag) and require a real
    \d{4}-\d\d-\d\d ... timestamp there. Forged text is never the first character of a raw physical
    line - a real line always opens with log_line_prefix and a continuation always opens with a tab.
  • The managed-prefix branch (colon-separated %t:%r:%u@%d:[%p]:) excludes [ from the gap before the
    pid bracket ([^\[\n]*) instead of a lazy [^\n]*?. The lazy form still lets a REAL bracket earlier
    on the same line be backtracked past to reach a forged one later on it, once the tail pattern fails
    to match right after the real bracket - a distinct sub-case, pinned by its own regression test
    (AForgedHeaderBehindARealColonPrefixedBracket_StillYieldsNoPlan), which fails if that hardening is
    reverted to the lazy form while the plain PoC test stays green.
  • Self-hosted's SQL keeps supporting only the space-separated (PostgreSQL default) prefix family, same
    as before this fix - it never supported the colon family, and this is a security fix, not a
    capability addition.
  • Darling/Darling.Tests/PgLogEventsPipelineTests.cs's TheTailerExtraction_LeftBothSiblingsSqlByteIdentical
    pin is updated to the fixed SQL text (the deadlock and log-events SQL it also pins are untouched by
    this issue).
  • New Darling/Darling.Tests/PgPlanCaptureLiveTests.cs: a live test against a real target with
    auto_explain actually writing to a real log file, proving the FIXED SQL still captures a real plan
    (correct query id, duration, top node type) while the exact forged PoC yields no row at all. Gated on
    DARLING_TEST_PG_AUTOEXPLAIN (a target started with logging_collector = on and
    log_line_prefix = '%m [%p] %Q '), separate from the shared DARLING_TEST_PG store, because
    logging_collector is postmaster-context and the shared store must not carry session chatter from
    every other live test into a log this test reads.

Lite parity: N/A. Lite does not capture PostgreSQL plans (SQL Server DMVs only); PgPlanLogParser
and PgPlanCaptureCollector live in the shared PerformanceMonitor.Collectors project and are tested
from Lite.Tests only because that is where this shared library's unit tests live, not because Lite
runs this collector.

Filed, not fixed here: #4014 - PgDeadlockLogParser/PgDeadlocksCollector share the anchoring
this PR adds, but their [^\n]*ERROR: deadlock detected gap is a wildcard that can cross a real
field-label boundary (a different, wider hole than anything #4008 closes), and their managed-prefix
branch has the same lazy-gap backtracking issue this PR fixes for plan capture. Out of scope here:
different collector, needs its own PoC verification and regression tests.

Test plan

  • Lite.Tests full suite: 5170/5170 passed.
  • Darling.Tests full suite (DARLING_TEST_PG against a scratch rig): see CI / latest run below.
  • Targeted PgPlanLogParserTests + PgPlanCaptureCollectorDefinitionTests (14 tests) pass.
  • Regression proof by revert: both new unit tests fail against the original unanchored regex;
    AForgedHeaderBehindARealColonPrefixedBracket_StillYieldsNoPlan alone fails against an
    anchored-but-lazy-gap intermediate version, proving it pins a distinct sub-case.
  • Live proof by revert: PgPlanCaptureLiveTests fails against the original SQL, surfacing the
    forged HUNTER2_LEAK / query id 7 row read straight off a real target's log file; passes
    against the fix, with a real plan still captured correctly.
  • TheTailerExtraction_LeftBothSiblingsSqlByteIdentical (byte-identity pin) updated and green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

… a statement can no longer forge a plan onto another query (#4008)

Both PgPlanLogParser.s_planBlock (the C# parser, RDS log-API route) and PgPlanCaptureCollector's
self-hosted regexp_matches matched auto_explain's "[pid] queryid LOG:  duration: ... plan:" header
anywhere in the log tail, with no timestamp check. PostgreSQL echoes a rejected statement back
verbatim in its STATEMENT: companion, tab-indented continuations included, so a user who could run
SQL on a monitored target could plant that header inside their own query text and have it read back
as a real auto_explain block - choosing the query id and duration it attaches to, and forging the
plan JSON itself.

Both patterns now anchor to a genuine physical line start (RegexOptions.Multiline / SQL's 'n' flag)
and require a real timestamp there, matching the log_line_prefix shape auto_explain actually renders.
The managed-prefix branch also excludes '[' from the gap before the pid bracket rather than using a
lazy wildcard, closing a backtracking path that would otherwise let a real bracket earlier on the
same forged line be skipped in favor of a later, forged one.

Found by #3996's round-2 security review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
@erikdarlingdata
erikdarlingdata merged commit 02a0fa4 into dev Sep 23, 2026
15 of 16 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4008-plan-capture-spoofing branch September 23, 2026 10:55
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…ntries in their sections (#4080)

Adds 42 entries and 42 link refs (#3992, #3995, #3996, #3998, #4001, #4002, #4003, #4007, #4010, #4011, #4013, #4015, #4020, #4022, #4025, #4029, #4030, #4031, #4036, #4038, #4039, #4040, #4044, #4047, #4048, #4049, #4050, #4051, #4055, #4061, #4063, #4064, #4065, #4066, #4067, #4068, #4069, #4070, #4071, #4073, #4074, #4078). Each PR's entry was buffered, and this lands every entry whose PR was merged on origin/dev when it ran.

#3989 left 26 entries under bare 'Changed' and 'Fixed' lines above '### Added'. They move into '### Changed' and '### Fixed', below the new entries, and one blank line stays under [Unreleased].


Claude-Session: https://claude.ai/code/session_01Ua31ugERL5DmhFVRtf6keQ

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant