Back to Blog

GoAhead Loaded the CRL and Never Checked It. Is Your mTLS Any Different?

GoAhead's mTLS loaded a CRL but never enforced it, so revoked client certificates got in. OpenSSL, Python and Apache make the same mistake easy. Here's how to test yours.

CertGuard TeamSSL Security Experts10 min read
Three stacked rack-mounted network switches with rows of blue patch cables plugged into their ports and looping off to the right, small green and yellow link lights glowing beside the ports

Revoked client certificates walked straight in

A laptop gets stolen, so you revoke its client certificate and publish a fresh CRL. The device on the other end has that CRL loaded, in memory, right next to its trust store. It lets the stolen certificate in anyway. Every time, for years.

That's the headline bug in the three GoAhead advisories that went public on 12 October and landed on oss-security on 27 October. GoAhead is the embedded web server behind the device-management pages of routers, switches, printers, gateways and industrial controllers. FoxIO's write-up from 26 October puts it on "hundreds of millions of devices". (HN discussion.)

Honestly, GoAhead is the boring part. The mistake comes down to one missing flag, and leaving that flag off is the default behaviour in OpenSSL and Python, and in Apache too. Run mTLS with a CRL and you should assume you have this bug until you've proved otherwise.

One missing flag

According to FoxIO, GoAhead's OpenSSL backend loaded the configured CRL into the certificate store in sslOpen():

X509_STORE_load_locations(store, ME_GOAHEAD_SSL_REVOKE, 0);

And then never called this:

X509_STORE_set_flags(store, X509_V_FLAG_CRL_CHECK);

Without X509_V_FLAG_CRL_CHECK, OpenSSL's revocation step returns success before it reads a single CRL. The list sits in the store, parsed and perfectly valid, and nothing ever asks it anything. The fix in GoAhead 6.1.1 is that one line.

The advisory is GOAHEAD-SA-2026-0006, CVE-2026-115881, covering GoAhead 3.4.5 through 6.1.0 with mTLS on and a CRL configured. The researcher reported it on 30 April. Version 6.1.1 went to device builders on 2 August.

Look at that gap. You can't apt upgrade the web server inside a printer. You wait for the vendor to rebuild firmware against 6.1.1, then you wait for somebody on your team to flash it. For plenty of GoAhead devices neither will ever happen.

The other two bugs, and whether "Critical" is fair

The same disclosure had two more. GOAHEAD-SA-2026-0005 hits the Mbed TLS backend from 4.0.2 to 6.1.0: somebody wrote sizeof(dhm_g) where sizeof(dhm_p) belonged, which shrank the 2048-bit DH prime to a single byte. TLS 1.2 DHE_RSA sessions become decryptable, as long as the client accepts parameters that absurd. Yet another reason finite-field DHE belongs in the bin, which is where RFC 10015 put it. GOAHEAD-SA-2026-0004 (CVE-2026-115884) lets an over-long client chain through on LibreSSL 3.2.x and 3.3.2 to 3.3.6, and only with VERIFY_ISSUER=0.

FoxIO scored the CRL bug 9.1. Filippo Valsorda pushed back on Hacker News, calling it "serious severity inflation" and saying skipping CRL checks "is not a Critical". On the number, he's right. A Critical is a library that forgets to verify signatures. This one needs a certificate that used to be valid.

I care more about how it stayed hidden. The operator did their part: configured the CRL, revoked the certificate. The device took the CRL file without a word of complaint. No error anywhere, so nobody had a reason to test. That's how a security control ends up existing only in a config file.

Reproduce it in two minutes

You don't need GoAhead to watch it happen. Plain openssl does the same thing out of the box. Build a throwaway client CA, issue a certificate, revoke it, publish a CRL:

mkdir crl-lab && cd crl-lab
mkdir -p ca/newcerts && touch ca/index.txt
echo 1000 > ca/serial && echo 1000 > ca/crlnumber
cat > ca.cnf <<'EOF'
[ ca ]
default_ca = lab
[ lab ]
dir              = ./ca
database         = $dir/index.txt
new_certs_dir    = $dir/newcerts
serial           = $dir/serial
crlnumber        = $dir/crlnumber
certificate      = ./ca.pem
private_key      = ./ca.key
default_md       = sha256
default_days     = 30
default_crl_days = 7
policy           = anything
[ anything ]
commonName = supplied
EOF

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout ca.key -out ca.pem -days 365 -subj "/CN=Lab Client CA"
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout client.key -out client.csr -subj "/CN=laptop-0042"
openssl ca -batch -config ca.cnf -in client.csr -out client.pem
openssl ca -config ca.cnf -revoke client.pem -crl_reason keyCompromise
openssl ca -config ca.cnf -gencrl -out crl.pem

Now verify the revoked certificate with the CRL loaded. Then do it again with checking switched on:

openssl verify -CAfile ca.pem -CRLfile crl.pem client.pem
# client.pem: OK

openssl verify -CAfile ca.pem -CRLfile crl.pem -crl_check client.pem
# error 23 at 0 depth lookup: certificate revoked

Same certificate, same CRL, two answers. The first one is the GoAhead bug.

A live handshake behaves the same way. Start a server that demands a client certificate and has the CRL loaded, and connect with the revoked one:

openssl s_server -accept 8443 -cert srv.pem -key srv.key -www \
  -Verify 1 -verify_return_error -CAfile ca.pem -CRL crl.pem

curl --cacert srv.pem --cert client.pem --key client.key https://localhost:8443/

curl gets a 200. Add -crl_check to the server, restart, and the same curl dies with sslv3 alert certificate revoked. I ran both on OpenSSL 3.0.22.

Which stacks load a CRL without checking it

My rule of thumb: when a product has one setting for "load a CRL" and a separate one for "check revocation", somebody out there has set the first and forgotten the second.

nginx gets it right. Setting ssl_crl turns on both X509_V_FLAG_CRL_CHECK and X509_V_FLAG_CRL_CHECK_ALL, in the same function that loads the file:

ssl_client_certificate /etc/nginx/mtls/client-ca.pem;
ssl_verify_client      on;
ssl_crl                /etc/nginx/mtls/client-ca.crl;

Apache httpd doesn't. SSLCARevocationFile loads the CRLs, but the mod_ssl docs give SSLCARevocationCheck none as the default. Set the file, skip the check, and you've rebuilt the GoAhead configuration by hand. You need both lines:

SSLVerifyClient      require
SSLCACertificateFile /etc/httpd/mtls/client-ca.pem
SSLCARevocationFile  /etc/httpd/mtls/client-ca.crl
SSLCARevocationCheck chain

Python's ssl module has the same split. load_verify_locations() will happily load a CRL, and the docs say that under VERIFY_DEFAULT CRLs "are not checked". You set ctx.verify_flags |= ssl.VERIFY_CRL_CHECK_LEAF, or VERIFY_CRL_CHECK_CHAIN, yourself.

HAProxy (crl-file on the bind line) and Envoy (crl in the validation context) enforce a CRL as soon as you give them one. Go's crypto/tls doesn't check CRLs at all. If you terminate mTLS in Go, revocation is whatever you wrote in VerifyConnection. Wrote nothing? Then you have nothing.

Switching it on has its own failure modes

Hold off on flipping every flag for a minute. Enforced CRL checking is a hard dependency, and it fails closed.

First trap: chain coverage. nginx sets CRL_CHECK_ALL, which wants a CRL from every CA in the chain, the root included. I tested it with a root and an issuing CA. Give OpenSSL only the issuing CA's CRL and the leaf passes a leaf-only check, then fails the full-chain one:

error 3 at 1 depth lookup: unable to get certificate CRL

HAProxy's docs say it bluntly: you need a CRL "for every certificate of your certificate authority chain". Concatenate the root's CRL and the issuing CA's CRL into one PEM file.

The second trap is nastier. A CRL has a nextUpdate date. Once that passes, OpenSSL rejects every client certificate, revoked or not:

error 12 at 0 depth lookup: CRL has expired

I checked with a perfectly good certificate and a CRL one day past nextUpdate. Rejected. In production that's an outage starting at whatever minute your CRL publishing job last failed to run, and that job tends to be a cron entry nobody has looked at since it was written. You know how those end.

Also: nginx and Apache read the CRL file when they load their configuration. Dropping a new file on disk does nothing until you reload. HAProxy can swap one in at runtime with set ssl crl-file and commit ssl crl-file, which is nice, and is still a step someone has to automate.

Monitor the CRL like it's a certificate

An enforcing CRL is a file with an expiry date that can take your mTLS endpoint down. So treat it as one. This is enough to start with:

#!/usr/bin/env bash
CRL=/etc/nginx/mtls/client-ca.crl
next=$(openssl crl -in "$CRL" -noout -nextupdate | cut -d= -f2)
hours=$(( ( $(date -d "$next" +%s) - $(date +%s) ) / 3600 ))
echo "$CRL: nextUpdate $next (${hours}h left)"
[ "$hours" -lt 48 ] && exit 1
exit 0

Alert well before it hits zero. Watch the file's modification time as well: a CRL that hasn't changed in two publishing cycles means the job that writes it is dead, even if the current file still has days to go.

Keep a revoked canary

Reading config won't find this. GoAhead's config looked fine. So does an Apache vhost with SSLCARevocationFile and nothing else. The one test that counts is a revoked certificate trying to get in.

So keep one around. Issue a client certificate from your real client CA, call it something obvious like canary-revoked, revoke it, and store the key in your monitoring system. Then point a synthetic check at every mTLS endpoint you run:

curl -sS -o /dev/null --cert canary-revoked.pem --key canary-revoked.key \
  https://api.internal.example.com/healthz \
  && echo "FAIL: revoked client certificate was accepted" \
  || echo "ok: rejected"

Here a pass is the alarm. Pair it with a non-revoked canary that has to succeed, so "revocation works" and "the endpoint is down" don't look identical. It costs you nothing. It also catches the expired-CRL outage and the day someone swaps the load balancer for one with different defaults. If you already rotate client certificates, fold the canaries into that process.

On the GoAhead boxes the canary has a second job. It tells you exactly which devices on your network still accept revoked certificates, and that's a far better firmware-update priority list than any vendor bulletin. Anything that will never see a 6.1.1 build should have its management interface pulled off every network a revoked credential can reach.

Browsers mostly gave up on revocation years ago. mTLS is where you own both ends and can actually make it work. Test the reject path, or you're trusting a file nobody reads.

Frequently asked questions

Am I affected if I use GoAhead without client certificates? Not by the CRL bug, since GOAHEAD-SA-2026-0006 only applies with mTLS enabled and a CRL configured, but 6.1.1 also fixes a batch of unrelated authentication, CGI and request-smuggling issues, so get it anyway.

Does OCSP stapling avoid this problem? Not in practice. Stapling exists for the server's own certificate, and while TLS 1.3 does allow a server to ask the client for a stapled OCSP response, mTLS deployments almost always handle client revocation with a CRL the server holds locally or with short-lived client certificates.

Should I use CRL_CHECK_ALL or only check the leaf? Check the full chain if you can publish a CRL for every CA in it, because a compromised issuing CA is exactly the event you most want to stop. If your root is offline and its CRL rarely gets refreshed, a leaf-only check plus a long-lived root CRL that you monitor closely is a defensible compromise.

What happens to existing connections when I revoke a client certificate? Nothing. Revocation is checked during the handshake, so long-lived connections and resumed sessions using that certificate stay up until they reconnect, which means you also need to cap session lifetimes or drop connections when a revocation is urgent.

How short should a client CRL's validity be? Short enough that a revocation lands while it still matters, and long enough that one missed publishing run doesn't take you down. Publishing daily with a 7-day nextUpdate, monitored as above, leaves several days of slack without letting a stale list linger for a month.

Related posts

Never miss an expiring certificate again

CertGuard watches your SSL certificates and domains, and alerts you long before anything expires. Free for your first five domains.