security@ received a report about a NULL pointer SIGSEGV in $SUBJECT. I'm
attaching the reporter's materials. Please credit "Reported-by: Anthropic OSS
program". If you use the reporter's patch, similarly, please credit it as
"Author: Anthropic OSS program".
(The usual reporting process is unchanged. Please use
https://www.postgresql.org/account/submitbug/ to report all NULL pointer
SIGSEGVs and all defects specific to beta versions, including those that would
be vulnerabilities after final release.)
# src/backend/libpq/be-secure-openssl.c
# sni_clienthello_cb() chose between 'SNI off: use default_host unconditionally' and 'SNI on: match the
# pg_hosts.conf table' by reading the live ssl_sni GUC, while the table it operates on (SSL_hosts) is only
# replaced when be_tls_init() succeeds; a SIGHUP that flips ssl_sni on->off but then fails to load the
# postgresql.conf certificates leaves ssl_sni=false paired with a pg_hosts.conf table whose default_host is
# legitimately NULL, so every subsequent ClientHello dereferences NULL in ssl_update_ssl(). The fix records
# the ssl_sni value in struct hosts when the tentative table is built in be_tls_init() (sni_enabled) and
# makes the callback consult that instead of the GUC, so the selection logic and the table it selects from
# can never disagree; this is the place the invariant ('SNI-off tables always have default_host') is
# established, and the callback now asserts it. I checked the other ssl_sni uses (init_host_context's
# init-hook handling and the load-path branching) — they run at build time inside be_tls_init() and are
# therefore consistent with the table by construction; the LibreSSL path (no client-hello callback) can only
# have ssl_sni off via the check hook and is unaffected. The patch also adds a TAP case to
# src/test/ssl/t/004_sni.pl (requires PG_TEST_EXTRA=ssl) that performs exactly this failed reload and checks
# the handshakes still behave as before; it fails with SIGSEGV on the unpatched tree and passes with the
# fix, and the full ssl suite passes. Behaviour change: after a failed reload the server keeps serving with
# the previously loaded SNI mode (matching the 'SSL configuration was not reloaded' message) instead of
# half-applying the new ssl_sni value; pgindent reports no changes for the C file.
# reviewer: good — Fixes the root cause (decision keyed to the live GUC instead of the installed table) by
# snapshotting ssl_sni into struct hosts at build time; complete (the only connection-time reader of
# ssl_sni), preserves 'old config stays in effect' semantics, adds a TAP case; applied here it stops the
# crash, control behaviour unchanged, ssl/001 and ssl/004 (106 subtests incl. the 4 new ones) pass.
# our check: FIXED · confidence high
#
diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c
index b7ded8a0250..fda00334151 100644
--- a/src/backend/libpq/be-secure-openssl.c
+++ b/src/backend/libpq/be-secure-openssl.c
@@ -125,6 +125,14 @@ static struct hosts
* matches the supplied hostname in the SNI extension.
*/
HostsLine *default_host;
+
+ /*
+ * Whether the configuration was loaded with ssl_sni enabled. The ssl_sni
+ * GUC can change on reload without the configuration being replaced, in
+ * case loading the new configuration fails, so connection handling must
+ * consult this rather than the GUC.
+ */
+ bool sni_enabled;
} *SSL_hosts;
static bool dummy_ssl_passwd_cb_called = false;
@@ -177,6 +185,7 @@ be_tls_init(bool isServerStart)
/* Allocate a tentative replacement for SSL_hosts. */
new_hosts = palloc0_object(struct hosts);
+ new_hosts->sni_enabled = ssl_sni;
/*
* Register a reset callback for the memory context which is responsible
@@ -1934,9 +1943,16 @@ sni_clienthello_cb(SSL *ssl, int *al, void *arg)
len;
HostsLine *install_config = NULL;
- if (!ssl_sni)
+ /*
+ * Use the SNI setting which the installed configuration was loaded with
+ * rather than the current value of the ssl_sni GUC, since the two can
+ * disagree after a failed reload. A configuration loaded with SNI
+ * disabled always has a default host.
+ */
+ if (!SSL_hosts->sni_enabled)
{
install_config = SSL_hosts->default_host;
+ Assert(install_config != NULL);
goto found;
}
diff --git a/src/test/ssl/t/004_sni.pl b/src/test/ssl/t/004_sni.pl
index f1a135bb3c8..1c0e19c404a 100644
--- a/src/test/ssl/t/004_sni.pl
+++ b/src/test/ssl/t/004_sni.pl
@@ -263,6 +263,26 @@ $node->connect_fails(
"pg_hosts.conf: connect to 'example' with sslmode=require",
expected_stderr => qr/unrecognized name/);
+# Turn off SNI while the postgresql.conf configuration cannot be loaded, such
+# that the reload fails to replace the SSL configuration. The pg_hosts.conf
+# configuration without a default host must remain in effect and connections
+# must behave as before the reload.
+my $log_offset = -s $node->logfile;
+$node->append_conf('postgresql.conf',
+ "ssl_sni = off\nssl_cert_file = 'nonexistent.crt'");
+$node->reload;
+$node->wait_for_log(qr/SSL configuration was not reloaded/, $log_offset);
+$node->connect_ok(
+ "$connstr sslrootcert=ssl/root+server_ca.crt sslmode=require host=example.org",
+ "pg_hosts.conf: connect to example.org after failed reload with ssl_sni off"
+);
+$node->connect_fails(
+ "$connstr sslrootcert=ssl/root+server_ca.crt sslmode=require sslsni=0",
+ "pg_hosts.conf: connect to default after failed reload with ssl_sni off",
+ expected_stderr => qr/handshake failure/);
+$node->append_conf('postgresql.conf',
+ "ssl_sni = on\nssl_cert_file = 'server-cn-only.crt'");
+
# Reconfigure with broken configuration for the key passphrase, the server
# should not start up
ok(unlink($node->data_dir . '/pg_hosts.conf'));
```
============================================================================
ANT-2026-7KU3OD1P [low] CWE-476 dos
NULL dereference in SNI callback after failed reload disabling ssl_sni
Location: src/backend/libpq/be-secure-openssl.c:1939 in sni_clienthello_cb()
Commit: 4dd037286e06116f6f4af70a291e215c0a111c24
============================================================================
```
After a failed configuration reload, a PostgreSQL server can be left in a state
where every incoming encrypted connection attempt crashes the server process
handling it. Each crash makes the server drop all sessions and run crash
recovery, and this repeats until the configuration is fixed. It only arises
when an operator who selects certificates by requested host name turns that
feature off and reloads, and the fallback certificate settings then fail to
load. Once in that state, any client that can reach the port can trigger the
crash without credentials, but so can every ordinary client, so an attacker
gains little beyond what the failed reload already causes.
The bug is in `sni_clienthello_cb()` in
`src/backend/libpq/be-secure-openssl.c`. It decides how to pick a certificate
from the live `ssl_sni` setting, but it picks from `SSL_hosts`, which may have
been built under the other value of that setting. A `pg_hosts.conf` containing
only named hosts (no `*` line) is accepted with `ssl_sni = on` and leaves
`SSL_hosts->default_host` NULL. If the operator then sets `ssl_sni = off` and
reloads, and `be_tls_init()` fails (for example because there is no
`server.crt`), the postmaster keeps the old host table and logs "SSL
configuration was not reloaded", but the setting stays off. From then on the
`!ssl_sni` branch passes the NULL `default_host` to `ssl_update_ssl()`, which
dereferences it at line 1837. This happens before authentication or any
`pg_hba.conf` check, and the dereference comes before the existing `Assert`, so
assert and non-assert builds behave the same. The bad state lives in the
postmaster, so it survives crash recovery. The crash is a NULL read only, with
no memory disclosure or corruption.
The attached script first shows a handshake succeeding with `ssl_sni = on`. It
then performs the failing reload and sends one SSLRequest plus one ClientHello.
The server log then shows the backend terminated by signal 11, followed by the
postmaster terminating all other server processes. The bug was introduced in
4f433025f666 ("ssl: Serverside SNI support for libpq"). It is present in
REL_19_BETA1 through REL_19_BETA3 and on master, and no GA release is affected.
## Details
`sni_clienthello_cb()` decides how to pick the per-connection certificate by
looking at the *current* value of the `ssl_sni` GUC, but the host table it
picks from (`SSL_hosts`) may have been built under a *different* value of that
GUC. After a SIGHUP in which `ssl_sni` goes `on` → `off` and `be_tls_init()`
then fails, the postmaster holds `ssl_sni == false` together with a
`pg_hosts.conf`-derived table whose `default_host` is NULL, and every TLS
handshake from then on dereferences that NULL:
```c
/* src/backend/libpq/be-secure-openssl.c:1937 */
if (!ssl_sni)
{
install_config = SSL_hosts->default_host; /* NULL here */
goto found;
}
…
found:
if (!ssl_update_ssl(ssl, install_config)) /* :2061 */
/* src/backend/libpq/be-secure-openssl.c:1835 */
ssl_update_ssl(SSL *ssl, HostsLine *host_config)
{
SSL_CTX *ctx = host_config->ssl_ctx; /* :1837, SIGSEGV */
```
Observed on a stock build of this commit: server started with `ssl_sni = on`
and `pg_hosts.conf` containing only `myhost myhost.crt myhost.key`; then
`ssl_sni = off` + `pg_ctl reload` (reload of the SSL configuration fails because
`server.crt` does not exist); then one `SSLRequest` + TLS ClientHello from an
unauthenticated client. The backend dies with SIGSEGV, the postmaster logs
`terminating any other active server processes` and runs crash recovery. Core
file:
```
#0 ssl_update_ssl (ssl=…, host_config=0x0) be-secure-openssl.c:1837
#1 sni_clienthello_cb (ssl=…, al=…, arg=0x0) be-secure-openssl.c:2061
#2,#3 libssl.so.3
#4 be_tls_open_server (port=…) be-secure-openssl.c:934
#5 secure_open_server be-secure.c:140
#6 ProcessStartupPacket backend_startup.c:622
(gdb) p ssl_sni → false
(gdb) p *SSL_hosts → {sni = 0x…, no_sni = 0x0, default_host = 0x0}
```
The dereference at :1837 precedes the `Assert(ctx != NULL)` at :1844, so
assert and non-assert builds behave identically.
### How the two halves get out of step
`be_tls_init()` builds a tentative `struct hosts` and only installs it as
`SSL_hosts` on success. Which fields get filled depends on `ssl_sni` at build
time:
- `ssl_sni = off` (`be-secure-openssl.c:334-357`): the `postgresql.conf`
settings become a single `HostsLine` and are stored as
`new_hosts->default_host` unconditionally. This is the invariant the
`!ssl_sni` branch of the callback relies on.
- `ssl_sni = on` with a non-empty `pg_hosts.conf` (`:246-327`): `default_host`
is set only if the file has a `*` line, `no_sni` only if it has a `/no_sni/`
line; named hosts go to `new_hosts->sni`. The only completeness check is
`:363`, which accepts a table with named hosts alone. So
`default_host == NULL` is a legal, loadable state — and with `ssl_sni = on`
the callback handles it correctly (`:2009`, `:2021-2044`, `:2051-2058` all
test for NULL and answer with a TLS alert).
`ssl_sni` is `PGC_SIGHUP`. On reload the postmaster does
(`src/backend/postmaster/postmaster.c:2036-2058`):
```c
ProcessConfigFile(PGC_SIGHUP); /* ssl_sni := off */
…
if (EnableSSL)
{
if (secure_initialize(false) == 0)
LoadedSSL = true;
else
ereport(LOG,
(errmsg("SSL configuration was
not reloaded")));
}
```
`secure_initialize(false)` → `be_tls_init(false)` now takes the `ssl_sni = off`
path and tries to load `ssl_cert_file`/`ssl_key_file` from `postgresql.conf`.
If that fails — in the reproducer because the deployment keeps all key material
in `pg_hosts.conf` and there is no `server.crt`; equally a key needing a
passphrase without `ssl_passphrase_command_supports_reload`, wrong key file
permissions, etc. — `be_tls_init()` logs at `LOG` level, jumps to `error:`, and
leaves the previous `SSL_hosts`/`SSL_context` in place, as designed. Nothing
rolls the GUC back, so the postmaster now has `ssl_sni == false` and
`SSL_hosts->default_host == NULL`.
Every backend forked afterwards inherits both. `be_tls_open_server()` installs
`sni_clienthello_cb` unconditionally (`:900`), OpenSSL invokes it from
`SSL_accept()` on the first ClientHello, the `!ssl_sni` branch passes NULL to
`ssl_update_ssl()`, and the backend segfaults before any authentication or
`pg_hba.conf` processing. The same path is reached through direct TLS
negotiation (`backend_startup.c:439`). Because the stale pair lives in
postmaster memory, it survives the crash-restart cycle: the next TLS
connection after recovery crashes again, until the operator fixes the
configuration and reloads successfully or restarts the postmaster.
## Impact
Precondition: a server using SNI (`ssl_sni = on`, `pg_hosts.conf` without a
`*` line) whose operator edits `postgresql.conf` to switch `ssl_sni` to `off`
and reloads while the `postgresql.conf` certificate settings cannot be loaded.
No client can bring this state about. The reload reports
`SSL configuration was not reloaded`, which reads as "old configuration still
in effect". From that moment any host that can open a TCP connection to the
server port — no credentials, no `pg_hba.conf` match, no valid SNI name
needed — kills a backend with SIGSEGV using one SSLRequest and one ClientHello,
which makes the postmaster terminate every session (including local and
non-TLS ones) and run crash recovery; repeating the 2-packet exchange keeps the
cluster in a restart loop. libpq's default `sslmode=prefer` sends exactly this
sequence, so ordinary clients trigger it too. No memory beyond the NULL page is
read or written; the consequence is loss of availability, not disclosure or
corruption. Only the trusted operator can create the enabling state, and once it
exists every ordinary TLS client crashes the server in the same way, so an
unauthenticated client gains little beyond what the failed reconfiguration
already causes. What this amounts to is a reload-robustness bug: a path meant to
degrade gracefully ("old configuration still in effect") instead leaves the
cluster in a crash loop for as long as TLS connections keep arriving.
The bug was introduced in `4f433025f666` ("ssl: Serverside SNI support for
libpq", 2026-03-18). It is present in REL_19_BETA1 through REL_19_BETA3 and on
master, and no GA release is affected.
## Reproducing
`repro` is a short bash script. Run it as root. It uses the stock build in
`/src/build/install`,
creates the OS user `pgtest` because the server refuses to run as root, and
runs a throwaway
cluster in `/tmp/sni-mini` on 127.0.0.1:5498. The TLS client is
`openssl s_client -starttls postgres`. It sends the 8-byte SSLRequest and then
a TLS ClientHello,
with no startup packet and no credentials, which is what libpq's
`sslmode=prefer` sends first.
1. Setup builds an SNI-only deployment. `ssl = on` and `ssl_sni = on`, with a
self-signed
`myhost.crt`/`myhost.key` and a one-line `pg_hosts.conf` (`myhost myhost.crt
myhost.key`).
There is no `*` line, so `SSL_hosts->default_host` is NULL, which
`be_tls_init()` accepts.
There is also no `server.crt`, so the `postgresql.conf` certificate settings
can't be loaded
by themselves.
2. Control: a handshake with `-servername myhost` succeeds (`New, TLSv1.3, …`).
So the NULL
`default_host` is harmless while `ssl_sni` is on.
3. The script sets `ssl_sni = off` with `sed` and runs `pg_ctl reload`. It then
prints the log
lines that show the mismatch: `parameter "ssl_sni" changed to "off"`,
`could not load server certificate file "server.crt"` and `SSL configuration
was not reloaded`.
At that point the postmaster has the GUC off but still holds the old
pg_hosts.conf table.
4. The same handshake is sent again. The client gets `unexpected eof`, and the
server log
shows `client backend (PID …) was terminated by signal 11: Segmentation
fault` and
`terminating any other active server processes`.
The script stops the server and prints `BUG` if the log contains `was
terminated by signal 11`.
Otherwise it prints `OK`, which also happens when the reload does not fail and
the precondition is
not met. With the attached patch applied and rebuilt, the script prints `OK`:
after the reload the
handshake succeeds just as it did in step 2.
## Suggested fix
Make the callback consult the SNI mode the installed host table was built
with, not the live GUC, so a failed reload cannot desynchronise them. The
attached patch does this in `src/backend/libpq/be-secure-openssl.c`. It adds a
`sni_enabled` field to `struct hosts`, sets it from `ssl_sni` in
`be_tls_init()` when the tentative table is allocated, and tests that field in
`sni_clienthello_cb()` in place of the GUC. In the SNI-off branch it also
asserts that a table built with SNI off has a `default_host`:
```diff
@@ static struct hosts
HostsLine *default_host;
+
+ /*
+ * Whether the configuration was loaded with ssl_sni enabled. The
ssl_sni
+ * GUC can change on reload without the configuration being replaced, in
+ * case loading the new configuration fails, so connection handling must
+ * consult this rather than the GUC.
+ */
+ bool sni_enabled;
} *SSL_hosts;
@@ be_tls_init(bool isServerStart)
new_hosts = palloc0_object(struct hosts);
+ new_hosts->sni_enabled = ssl_sni;
@@ sni_clienthello_cb(SSL *ssl, int *al, void *arg)
- if (!ssl_sni)
+ if (!SSL_hosts->sni_enabled)
{
install_config = SSL_hosts->default_host;
+ Assert(install_config != NULL);
goto found;
}
```
The other `ssl_sni` readers are `init_host_context()`'s init-hook handling and
the load-path branching. Both run inside `be_tls_init()` while the table is
being built, so they always agree with it and need no change. The LibreSSL
build has no client-hello callback, and its check hook keeps `ssl_sni` off, so
it is unaffected. After a failed reload the server keeps serving in the SNI mode
it loaded last, which matches the `SSL configuration was not reloaded` message,
instead of half-applying the new `ssl_sni` value. The patch also adds a case to
`src/test/ssl/t/004_sni.pl` (needs `PG_TEST_EXTRA=ssl`) that does exactly this
failed reload. It checks that a connection with a matching SNI name still
succeeds and one without SNI is still rejected. On the unpatched tree the case
fails with SIGSEGV.
A smaller alternative is a NULL check in the `!ssl_sni` branch that fails the
handshake with `SSL_AD_INTERNAL_ERROR`. That still switches certificate
selection to "SNI off" while the SNI table is loaded, so it does not replace
tying the decision to the table, though it could be added on top as a
belt-and-braces check.
## Running the reproducer
`ANT-2026-7KU3OD1P/repro`, next to this file. It was written by an automated
agent and our check ran it as root in a privileged container: run it INSIDE A
THROWAWAY VM, with the image `cos-4dd037286e06` built by `bash
build/build-image.sh`, from the archive's top directory:
docker run --rm -i --network=none --privileged -u 0 -w /src
cos-4dd037286e06 bash -c 'cat >/tmp/repro; chmod +x /tmp/repro; /tmp/repro' <
ANT-2026-7KU3OD1P/repro
It prints its evidence; the last line is `BUG` if the issue triggered, `OK`
otherwise.
#!/bin/bash
# Minimal: SNI-only pg_hosts.conf, ssl_sni on->off with a failing reload, then
one TLS handshake.
BIN=/src/build/install/bin; D=/tmp/sni-mini; L=/tmp/sni-mini.log; P=5498
id pgtest >/dev/null 2>&1 || useradd -m pgtest
su pgtest -c "$BIN/pg_ctl -D $D -m immediate stop" >/dev/null 2>&1; rm -rf $D $L
su pgtest -c "$BIN/initdb -D $D -A trust --no-locale -E UTF8 >/dev/null" ||
exit 1
cd $D || exit 1
su pgtest -c "openssl req -new -x509 -days 30 -nodes -subj /CN=myhost -keyout
myhost.key -out myhost.crt 2>/dev/null; chmod 600 myhost.key"
echo 'myhost myhost.crt myhost.key' > pg_hosts.conf # named host only,
no '*' line -> default_host == NULL
cat >> postgresql.conf <<EOC
ssl = on
ssl_sni = on
port = $P
listen_addresses = '127.0.0.1'
unix_socket_directories = '/tmp'
EOC
su pgtest -c "$BIN/pg_ctl -D $D -l $L -w start >/dev/null" || { cat $L; exit 1;
}
hs() { timeout 10 openssl s_client -connect 127.0.0.1:$P -starttls postgres
"$@" </dev/null 2>&1 | grep -E '^Protocol|^New,|alert|errno|unexpected eof' |
head -1; }
echo "--- before reload (ssl_sni=on): $(hs -servername myhost)"
sed -i 's/^ssl_sni = on/ssl_sni = off/' postgresql.conf # no server.crt
exists -> be_tls_init() fails on reload
su pgtest -c "$BIN/pg_ctl -D $D reload >/dev/null"
sleep 1
grep -E '"ssl_sni"|server.crt|not reloaded' $L
echo "--- after failed reload (ssl_sni=off): $(hs -servername myhost)"
sleep 1
grep -E 'signal 11|terminating any other' $L
su pgtest -c "$BIN/pg_ctl -D $D -m immediate stop" >/dev/null 2>&1
grep -q 'was terminated by signal 11' $L && { echo BUG; exit 0; }
echo OK