``` ============================================================================ 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.