Skip to content
August 13, 20265 min read

Your Database Extensions Are Application Attack Surface

Four of the 13 August code-execution fixes land in contrib modules — C you chose to load into the process that holds your data

Ihor K

CEO

PostgreSQL
database security
extensions
least privilege

Most teams model the database as a sealed box behind the application: credentials in, rows out. This patch set is a reminder that the box runs your extensions' C code in the same process that holds your data, and that a login is all it takes to reach some of it.

Start with what CREATE EXTENSION actually does. It does not start a sidecar and it does not open a second socket. For a C extension it loads a shared library into the backend already serving your session — the process that maps the shared buffers, holds the write handle on the data directory, and runs as the operating system user that owns every file in it. Once that library is loaded, its code and the server's code are one program, with one address space and one set of privileges. And CREATE EXTENSION is only one of the two doors: anything named in shared_preload_libraries is mapped into every backend at startup, with no SQL statement anywhere and no row in any database catalogue to show for it.

Next comes the question of who can reach it. PostgreSQL 18.6, 17.11, 16.15, 15.19 and 14.24 shipped on 13 August with twenty-eight security fixes and more than a hundred and ten bug fixes. The project's record for CVE-2026-14676 describes a heap buffer overflow in pg_stat_statements that "allows the query author to execute arbitrary code as the operating system user running the database, via crafted queries containing array constants". It scores 8.8 on AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H — reachable over the network, low privileges, no user interaction — and it lands on PostgreSQL 18 alone: minor versions before 18.6 affected, everything before 18 unaffected. Read the attacker in that sentence carefully. Not a superuser, not the object owner, not a DBA. The query author is whoever your application connects as.

Then read the rest of the set for where the code lives, because "in an extension" is not a synonym for "worse". Two of the heaviest entries are in the server itself and need no extension at all: CVE-2026-14664, a regexp heap buffer overflow that executes arbitrary code, and CVE-2026-14669, the same thing in to_char, both scored 8.8. What the contrib entries add is provenance. CVE-2026-14671 is a type confusion in the plan cache of refint, which the project fixed by removing that cache — the commit arrived as an ordinary bug report, subject "refint: Remove plan cache.", before it had a CVE number. CVE-2026-14670 is a heap buffer overflow on a tied hash returned from a PL/Perl function. CVE-2026-14677 is integer wraparound in the allocation arithmetic of PL/Tcl and PL/Perl, and only on 32-bit builds. All three score 8.8, all three end at the operating system account, and unlike the pg_stat_statements bug all three affect every supported branch, 14 through 18. They also ask for more than a login: the attacker in each is an object creator or the function owner, not any client with a connection string. So the set separates into a surface you get by running PostgreSQL and a surface you got by loading something into it.

That second surface arrives differently from everything else in the stack. An application dependency comes in through a pull request: a version pin, a licence check, a scanner run, a reviewer who has to approve it. An extension comes in as one line in a migration, or a checkbox in a managed provider's console, and from that moment its C code runs in the backend with everything the server holds. The asymmetry continues after installation, because the record of what is loaded is not in one place either. pg_extension is a per-database catalogue, so "what runs inside our backends" is a question that has to be asked once per database — and then asked again of shared_preload_libraries in the server configuration, which has no catalogue behind it at all.

Two entries in the set are worse than a crash, because a crash pages someone. In pgcrypto, when OpenSSL rejected the requested cipher — under FIPS, or with the legacy provider not loaded, which the release notes tie to blowfish, twofish, cast5 and 3des — the code failed to notice and XOR'd the non-encrypted block with the plaintext, so the encryption was trivially breakable while every call reported success. That is CVE-2026-14663, scored 6.5: no privileges required at all, and the cost is confidentiality rather than control. CVE-2026-14666 is the row-security plan cache. Role membership, role attribute and database ownership changes did not invalidate cached plans, so a query kept using a policy the change had already replaced, until something else invalidated the cache or the session ended. Neither raises an error. Neither increments a counter. Nothing in your observability distinguishes the period in which either was happening from any other period, which is why "did this ever affect us" is not a question you can answer with a query.

The restart is the shortest part of the remediation. The 18.6 notes ask for a check of reltuples on the table behind every GIN index, because a bug in the GIN code could leave a bogus value there, Infinity and NaN among them; the notes carry the query that finds it, and a manual ANALYZE or the creation of another index resets it. They ask for a reindex of btree_gist indexes on float4 and float8 columns that may hold NaN and on bit and bit varying columns, and for a reindex of any btree index over ltree values with "more than approximately 14,653 labels". One fix has to be applied before the upgrade rather than after it: CVE-2026-6471 let a non-superuser holding REPLICATION dlopen any file visible to the server's account by naming it as a logical decoding plugin, and the answer is a new setting, output_plugin_libraries, whose default permits pgoutput and test_decoding and nothing else. On clusters coming from 17 or later, pg_upgrade --check fails when that setting does not permit the plugins the old cluster's replication slots were using. A minor-version failover costs seconds. A REINDEX on a large index costs hours of I/O, and run CONCURRENTLY it can leave an invalid index behind.

Which leaves the word patched carrying more than one word can. On a cluster that restarted into 18.6 on 13 August, three things are true at once: the binary is current; any btree_gist or ltree index built before it stays wrong until somebody rebuilds it; and pgcrypto messages written under a disabled cipher no longer decrypt at all unless a caller passes ignore-cipher-failure=1, with OpenSSL configured the way it was when those messages were written. The three surface in three different places — a version string on a dashboard, an invalid index in a query plan, an error landing on whoever stored the data. The account is the one thing none of it moved. The role your application connects as could reach pg_stat_statements before the restart and can reach it after; what changed is the C behind that reach, which is what it means for the real boundary to sit at the edge of the process rather than at the edge of a grant.