Reaction

GoAhead loaded the revocation list and never checked it. The test that passes either way is the problem

GoAhead's critical TLS bug loaded a CRL but never checked it. We reproduced the trap in OpenSSL and Python: the setup test passes and the revoked cert gets in.

Published Updated 4 min read

On October 26, FoxIO published three TLS bugs in GoAhead, the embedded web server inside routers, printers, firewalls and industrial controllers. The one rated critical is the plainest: GoAhead "loads the configured CRL into the certificate store but never enables revocation checking". A revoked client certificate still completes the mutual TLS handshake. Our view: this is a testing bug before it is a crypto bug. Every check you would naturally write for "we support CRLs" passes on the broken build. Only a test of the effect, a revoked certificate being refused, can fail.

What happened

GoAhead calls X509_STORE_load_locations() with the revocation list. That succeeds, so startup logs nothing. But OpenSSL only consults a CRL when X509_V_FLAG_CRL_CHECK is set, and the OpenSSL docs say that flag is what "enables CRL checking for the certificate chain leaf certificate". GoAhead never set it. FoxIO's write-up quotes the OpenSSL verifier returning success before revocation is examined when neither CRL nor OCSP checking is enabled.

The fix in 6.1.1 is one line: X509_STORE_set_flags(store, X509_V_FLAG_CRL_CHECK). Versions 3.4.5 through 6.1.0 with the OpenSSL-compatible backend and a CRL configured are affected.

We reproduced the trap

The trap is not GoAhead's alone; it is how OpenSSL behaves by default. We built a test CA, issued a client certificate, revoked it, and generated a CRL with OpenSSL 3.6.0.

$ 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
CN=revoked-client
error 23 at 0 depth lookup: certificate revoked
error client.pem: verification failed

The CRL is passed in both commands. Only the second one reads it. Error 23 is X509_V_ERR_CERT_REVOKED, the error FoxIO says GoAhead's callback would have rejected, had the verifier ever produced it.

Python's ssl module sits on the same library, and its docs say it plainly: under the default flags, "certificate revocation lists (CRLs) are not checked." So we wrote the server a reviewer would approve. It requires client certificates, loads the CA, and loads the CRL, with and without VERIFY_CRL_CHECK_LEAF. Then we wrote the two tests a team would write.

Server config (Python 3.13.7, OpenSSL 3.0.16)test_crl_loadstest_revoked_client_rejected
CRL loaded, no flagPASSFAIL: handshake accepted CN=revoked-client
CRL loaded, VERIFY_CRL_CHECK_LEAF setPASSPASS: CERTIFICATE_VERIFY_FAILED

The first test, the one that checks the setup, cannot tell the two builds apart. It passes on the vulnerable one. The second test checks the effect, and it is the only one that notices.

Why the setup test gets written

Configuration code invites configuration tests. The feature is "support a revocation list", so the test loads one and asserts nothing blew up. The function returned success, so did the feature. A code review sees the same thing: a REVOKE setting, a load call, an error path for a missing file. All of it is correct, and none of it enforces anything.

The same shape shows up well outside TLS. A rate limiter that is configured and never consulted. A permission check registered on the wrong route. A feature flag read at startup and cached forever. Each has a setup that succeeds and an effect that never happens. When agents write the code and the tests together, the setup test is the easier one to write and the easier one to make pass.

The fair counterpoint

FiloSottile, who maintains Go's cryptography libraries, called the ratings "some serious severity inflation": "Skipping CRL checks is not a Critical." His bar for critical is "whoops we were not checking the signature on the certificates". He has a point. Another commenter noted that "revocation checking is mostly nonexistent, because it's hard to do and doesn't work in most cases."

That argues about severity, not about the lesson. If revocation is rarely checked in practice, a setting that claims to check it is worse than none. An operator who configured REVOKE and revoked a stolen device certificate believes the device is locked out. The access stayed open, and the config said otherwise.

What to do

For anything that denies access, write the negative test first: a revoked certificate, an expired token, a user without the role. Assert that it is refused, by the real handshake or the real request, not by the function that loads the policy. Keep a test fixture that should always fail to get in, and run it on every build. If you run GoAhead with the OpenSSL backend and a CRL, upgrade to 6.1.1.

Caveats. We reproduced the OpenSSL default and its Python binding, not GoAhead itself. FoxIO's own test was against GoAhead 6.1.0 with OpenSSL 3.6.2.

Sources

  1. 01FoxIO: Choose Your Backend, Three TLS Bugs in GoAheadfoxio.io
  2. 02GitHub: embedthis/goahead releasesgithub.com
  3. 03OpenSSL 3.6: X509_VERIFY_PARAM_set_flagsdocs.openssl.org
  4. 04OpenSSL 3.6: openssl-verification-optionsdocs.openssl.org
  5. 05Python docs: ssl, VERIFY_DEFAULT and VERIFY_CRL_CHECK_LEAFdocs.python.org
  6. 06Hacker News: Three TLS Bugs in GoAheadnews.ycombinator.com
  7. 07Hacker News: FiloSottile on severitynews.ycombinator.com
  8. 08Hacker News: pseudohadamard on revocation checkingnews.ycombinator.com
Reticleverdict

// The agent clicked "Pay". Did the right things actually happen?

{verified: "no"failureReason: "POST /api/order returned 500, expected 200"source: "src/checkout/PayButton.tsx:42"}

Example from the README

Your agent says it works. Reticle checks the network, state and console inside your running app, and names the line to fix.

85 of 88 seeded bugs caught in the public benchmark