Choose Your Backend: Three TLS Bugs in GoAhead
Sébastien Féry
TLDR
Three independent bugs in GoAhead's TLS integrations can leave revoked client certificates accepted, encrypted sessions recoverable to an observer or a configured chain-depth limit unenforced. Exposure depends on the TLS library and the verification settings built into the device:
OpenSSL-compatible backend - Critical, CVSS 9.1 - CRL loaded but not enforced, revoked client certificates are silently accepted. GOAHEAD-SA-2026-0006.
LibreSSL legacy verifier - High, CVSS 7.4 - An over-depth client chain is accepted when client certificates are required but issuer checking is disabled. This bypasses the chain-depth restriction. GOAHEAD-SA-2026-0004.
Mbed TLS - Medium, CVSS 5.9 - A buffer-length typo reduces a 2048-bit DH modulus to one byte. TLS 1.2 DHE_RSA sessions are decryptable when the client accepts the broken parameters. GOAHEAD-SA-2026-0005.
TLS build | GoAhead versions | Required configuration | Fix or mitigation |
|---|---|---|---|
OpenSSL-compatible backend | >= 3.4.5, <= 6.1.0 | mTLS enabled with a CRL configured. Revoked client certificates remain accepted even with issuer verification enabled. | Upgrade to 6.1.1 or later. |
Mbed TLS backend | >= 4.0.2, <= 6.1.0 | TLS 1.2 DHE_RSA negotiated with a client that accepts the broken DH parameters. | Upgrade to 6.1.1 or later, disable finite-field DHE suites, or use the OpenSSL backend. |
LibreSSL legacy verifier — 3.2.x or 3.3.2–3.3.6 | <= 6.1.0 |
| Enable |
Background
GoAhead is an embedded web server used for device management: routers, switches, printers, gateways, and industrial controls and is deployed in hundreds of millions of devices.
Each finding below affects a specific subset of those deployments, depending on the build and configuration.
GoAhead supports two TLS backends: OpenSSL, the default, and MbedTLS. The OpenSSL backend also works with compatible libraries such as LibreSSL.
GoAhead takes a compile-time approach regarding certain configurations:
- ME_GOAHEAD_SSL_VERIFY_PEER=1 requires a client certificate.
- ME_GOAHEAD_SSL_AUTHORITY points at a PEM bundle of trusted CAs.
- ME_GOAHEAD_SSL_VERIFY_ISSUER=1 requires the certificate chain to establish issuer trust against that bundle.
- ME_GOAHEAD_SSL_REVOKE points at a PEM-encoded Certificate Revocation List.
- The OpenSSL backend sets a chain-depth limit of 10 (VERIFY_DEPTH).
OpenSSL-compatible builds

GoAhead's OpenSSL backend loads the configured CRL into the certificate store but never enables revocation checking. Revoked client certificates can therefore complete the mTLS handshake even when issuer verification is enabled.
The CRL is loaded in sslOpen():
OpenSSL requires X509_V_FLAG_CRL_CHECK to enable CRL checking. Loading the file into the store does not set that flag. Without CRL or OCSP checking enabled, check_revocation() returns success before checking revocation status:
GoAhead never sets either CRL flag. OpenSSL returns success without checking the loaded list. The application's callback would reject error 23, X509_V_ERR_CERT_REVOKED, through its default: branch, but the verifier never generates that error.
On GoAhead 6.1.0 with OpenSSL 3.6.2, a revoked CA-signed client certificate completed the mTLS handshake with issuer verification enabled. The verification callback reported error 0 throughout the chain:
X509_STORE_load_locations() succeeds, so startup produces no warning that CRL enforcement is inactive.
Affected: GoAhead 3.4.5 through 6.1.0 using the OpenSSL-compatible backend with a CRL configured. The security impact is clearest with VERIFY_PEER=1, a trusted CA bundle, and VERIFY_ISSUER=1: a previously trusted but revoked identity retains access. When issuer checking is disabled, the CRL defect remains but unknown issuers are already accepted by policy.
Fix: 6.1.1 now checks after loading the CRL:
Mbed TLS builds

GoAhead's mbed TLS backend configures the RFC 3526 2048-bit DH group through mbedtls_ssl_conf_dh_param_bin(). The modulus length passed to that function is taken from the generator buffer:
The size arguments tell mbed TLS how many bytes to read from each buffer. The call uses sizeof(dhm_g) for both, even though dhm_g is only one byte long. The length for dhm_p should be sizeof(dhm_p), or 256 bytes.
As a result, mbed TLS reads only the first byte of dhm_p: 0xFF (255). The remaining 255 bytes are discarded. It accepts this value and sends it to the client during the TLS 1.2 DHE_RSA handshake:
In ServerKeyExchange, 00 01 ff identifies a one-byte DH parameter with the value 255. It should contain the full 256-byte value.
The broken parameters leave just eight possible shared-secret values. An observer can try each candidate and identify the correct one by checking whether it decrypts the captured traffic successfully.
In the attached PoC, I managed to reproduce the shared secret after five attempts. It matched the client's secret, confirming that the same TLS session keys could be derived to decrypt the HTTP exchange.
A passive observer can decrypt captured sessions using the recorded handshake and encrypted application records. No MITM position, server private key, or modification of traffic is required.
Exploitation requires a client that accepts the broken DH parameters. The tested OpenSSL client rejected them with DH_check_params_ex: modulus too small. Permissive clients, including custom or embedded implementations without a minimum-size check, can complete a decryptable session. ECDHE sessions and TLS 1.3 are unaffected by this configured finite-field group.
Affected: GoAhead 4.0.2 through 6.1.0 using the mbed TLS backend with TLS 1.2 DHE_RSA suites, where the client accepts the parameters. The typo entered in 2018 during migration to the binary-parameter API and survived the April 2024 TLS refactor. Mbed TLS 4.x removed the TLS_DHE_* suites, so this DHE path isn't impacted.
If you see any of these suites in the JA4S logs of your environment, you might be impacted (eg. t120100_009f_1ca028f07214)
A sample query you might want to run (in Splunk or other) would look like:
replacing 0014 with the curves listed above.
Fix: 6.1.1 fixed the typo
LibreSSL builds

Essentially, an attacker can present a certificate chain longer than GoAhead’s limit and still complete the mTLS handshake on an affected build. LibreSSL reports an issuer error instead of a depth error. GoAhead accepts that issuer error when issuer checking is disabled.
GoAhead 6.1.0 sets a chain-depth limit of 10 using SSL_CTX_set_verify_depth(sslctx, VERIFY_DEPTH). Its verification callback also checks the depth:
That check only assigns a depth error when ok is true, the reported depth exceeds the limit, and no error is already present. The callback then classifies the error:
Error 20, UNABLE_TO_GET_ISSUER_CERT_LOCALLY, is tolerated when issuer checking is disabled. Error 22, CERT_CHAIN_TOO_LONG, is rejected.
LibreSSL 3.2.x and 3.3.2–3.3.6 use the legacy X.509 verifier by default for peer verification. Its chain-building loop contains a FIXME comment (which is what prompted a look at GoAhead in the first place):
The loop stops building the chain without recording the depth error. The failure later reaches GoAhead as error 20. With VERIFY_ISSUER=0, the callback accepts it.
On GoAhead 6.1.0 linked against LibreSSL 3.3.6, a 17-certificate chain rooted in an attacker-controlled, self-signed CA produced the following callback trace:
The handshake completed and the resource behind the client-certificate requirement returned HTTP 200. The same GoAhead source built against LibreSSL 4.3.1 received error 22 at depth 10 and rejected the chain.
With VERIFY_ISSUER=0, ordinary self-signed certificates are already allowed. The over-depth chain bypasses the separate chain-depth restriction because the verifier reports a tolerated issuer error. Deployments requiring a trusted client CA reject it.
Demonstrated configuration: GoAhead 6.1.0 with the LibreSSL legacy verifier, ME_GOAHEAD_SSL_VERIFY_PEER=1, and ME_GOAHEAD_SSL_VERIFY_ISSUER=0. Enabling issuer trust rejects error 20. OpenSSL, BoringSSL, AWS-LC, and LibreSSL 4.x builds are not exposed to this legacy-verifier trigger.
Conclusion
All three findings affect GoAhead's integration with its TLS libraries. CRL enforcement depends on a verification flag, DH parameters depend on the supplied buffer lengths, and chain-depth enforcement depends on how verification errors are handled. None of these defects prevents the affected handshake from completing.
The backend and build configuration determine which defects apply. LibreSSL builds can inherit both the CRL defect in the OpenSSL-compatible backend and the legacy verifier's depth issue. The mbed TLS backend uses a separate verification path but is affected by the DH truncation when a client accepts the parameters.
To find impacted publicly facing servers in your fleet, here are the JA4Scan-TLS fingerprints that map to the different backends:
Server (GoAhead 6.1.x) | JA4Scan-TLS |
OpenSSL 3.6.2 | 03100h0s00sn_1fe27f6025fb_d06dc4648d2f_sr00ss537461 |
LibreSSL 3.3.6 | 03100h0s00s0_9d52559f55bc_c64486648d2f_sr00sc24603c |
LibreSSL 4.3.1 (depth bypass unaffected) | 03100h0r00s0_9d52559f55bc_c64486206461_sr00scc98ba1 |
mbedTLS 3.6.6 | 23100h0s01s0_d7d3025021f4_0475c5cede0d_sr0ss02265cf |
Read the full blog post about JA4Scan here:
https://foxio.io/blog/introducing-ja4scan-active-server-fingerprinting-for-tls-and-quic
POC
Reproduction code is available at https://github.com/FoxIO-LLC/blog_poc/tree/main/chooseyourbackend
Timeline
- April 22 2026 - LibreSSL report
- April 27 2026 - MbedTLS report
- April 30 2026 - OpenSSL report
- Aug 2 2026 - GoAhead 6.1.1 released
- Oct 12 2026 - Advisories published
- Oct 26 2026 - Blog published
References
GOAHEAD-SA-2026-0004 / CVE-2026-115884: mTLS chain-depth bypass
GOAHEAD-SA-2026-0005: MbedTLS DH parameter truncation
GOAHEAD-SA-2026-0006 / CVE-2026-115881: OpenSSL CRL checking disabled
