Hunting GoAhead’s TLS Bugs Across the Internet, and the Fix That Wasn’t

Hunting GoAhead’s TLS Bugs Across the Internet - trace blog
João Cruz headshot
Written by João Cruz
Principal Security Research Scientist

In late October 2026, FoxIO published Choose Your Backend: Three TLS Bugs in GoAhead, disclosing three vulnerabilities in the GoAhead Embedded Web Server: revoked client certificates that are still accepted, a Diffie-Hellman (DH) modulus truncated to a single byte, and a certificate chain-depth limit that can be bypassed. Each bug only appears when GoAhead is built against a particular TLS library (OpenSSL, Mbed TLS, or LibreSSL), hence the title. We read it, we liked it, and then we did what we always do: we went on a hunt for vulnerable instances of this web server on the internet.

Citing Embedthis (the company behind it):

GoAhead is the world's most popular tiny embedded web server and is deployed in hundreds of millions of devices. GoAhead is simple, compact and ideal for the efficient hosting of embedded web applications. GoAhead is only 115K of code, yet provides a powerful and comprehensive set of features for secure web applications.

Three vulnerabilities and “deployed in hundreds of millions of devices” looks like a scary landscape, so the disclosure got wide coverage. But how many of these can we find on the internet? And how many are susceptible to the vulnerabilities FoxIO described?

Questions like these are what large-scale internet scanning is for. At Bitsight, our scanning data lets us check a claim against what’s actually out there, instead of taking the numbers on faith.

This post answers both questions. It also covers a detour we didn’t plan: along the way, we found that the fix for one of these vulnerabilities doesn’t work at all. We reported it to the Embedthis security team, and we’ll get to that story later in this post.

tl;dr

  • FoxIO disclosed three TLS bugs in GoAhead, an embedded web server that Embedthis says runs on hundreds of millions of devices. We can test two of them from the outside: the one-byte Diffie-Hellman modulus and the chain-depth bypass.
  • We observed 249K GoAhead servers on the internet, and only 65K of them speak HTTPS, which all three bugs require.
  • Very few servers sent the broken one-byte modulus, and none asked for a client certificate, so the depth bug had nothing to work with. On the public internet, it’s not as bad as initially suggested.
  • While testing our scanner in a lab, we found that GoAhead 6.1.1, the release the advisory says fixes the depth bug, still accepts over-depth client chains on LibreSSL’s legacy verifier. The vulnerability is not fixed yet.
  • We disclosed it to Embedthis, which acknowledged it the following day and will fix it in the next release.
  • Don’t judge it by our numbers alone: If you have GoAhead in your infrastructure, patch it anyway. The external exposure we found is modest, but the bugs are very real.

Three bugs, two we can see from the outside

FoxIO’s post covers three separate GoAhead advisories, each affecting builds that use a particular TLS backend:

AdvisoryCVSS v3BackendWhat goes wrongCan we test it remotely?
GOAHEAD-SA-2026-0004 (CVE-2026-115884)7.4 (high)LibreSSLOver-depth client certificate chains are acceptedYes
GOAHEAD-SA-2026-0005
(no CVE assigned)
5.9 (moderate)Mbed TLSThe Diffie-Hellman modulus is truncated to one byte (0xff)Yes
GOAHEAD-SA-2026-0006 (CVE-2026-115881)9.1 (critical)OpenSSLA CRL is loaded but never enforcedNo

The third one needs a word of explanation, because we’re not skipping it out of laziness. Testing it requires a client certificate that a Certificate Authority (CA) the server trusts has issued and then revoked, which we’d then present to see whether the server still accepts it. From the outside, we can see that a server asks for client certificates, but not whether it has a Certificate Revocation List (CRL) configured, so we won’t report a number for it. The other two are different: both show up in how the server behaves during the TLS handshake, so they’re worth checking at scale.

The one-byte key nightmare (GOAHEAD-SA-2026-0005)

Tiny numbers in cryptography were a theme this October. A week before FoxIO's write-up, we learned that eight intermediate CA keys issued by eMudhra were absurdly small. The values were 4, 56, 66, 115, 155, 156, 230, and 237. A CA’s private key should be a huge random number, and these were small enough that anyone could work them out from the public certificates in under a second.

Yes, the old xkcd joke about a random number generator that returns 4 became real in 2026.

This one is a little different, though. But first, a quick primer on Diffie-Hellman.

Diffie-Hellman in two minutes

Diffie-Hellman solves a funny problem: two parties who have never met, talking over a line anyone can listen to, need to end up with the same secret without ever sending it. It works with some clock math.

The setup. Before anything else, both sides agree on two public numbers:

  • p, a big prime that works like the number of hours on a clock. Every calculation wraps around when it passes p (also known as mod, or modular arithmetic), so p sets the size of the playground.
  • g, the base (also called the generator): a small public number that both sides build on.

Then, each side picks a private number and never shares it. Each then computes g raised to its private number, wrapped around the clock, and sends the result. Here’s a toy run with p = 23 and g = 5:

 

Client

Server

Private number (priv)

6

15

Sends: gpriv mod p

56 mod 23 = 8

515 mod 23 = 19

Raises the received value to its own private number

196 mod 23 = 2

815 mod 23 = 2

Both ended up with the same shared secret, 2, and neither ever sent it. An eavesdropper saw 23, 5, 8, and 19, but never the private numbers 6 and 15, so they can’t simply compute 2 from what they saw.

Why does it hold up? Well, going forward is easy, and going backward is hard. Given 8, finding the private number that produced it means trying exponents until one matches. That’s called the discrete logarithm problem. With a 2048-bit p there are far too many candidates to try, so the eavesdropper is stuck.

Where does it break? All of that depends on p being huge. If p is tiny, the eavesdropper just tries every exponent until one matches. That’s what happened here.

The typo

GoAhead’s Mbed TLS backend ships with a well-known 2048-bit group1, where p is 256 bytes long and g is just 2, a single byte. When the code hands that group to the library, it has to say how many bytes p takes up. It passes the size of g (1 byte) instead of the size of p (256 bytes). The library trusts it, keeps the first byte of p, and throws the other 255 away:

Figure 1: sizeof(dhm_g) should have been sizeof(dhm_p) - source
Figure 1: sizeof(dhm_g) should have been sizeof(dhm_p) - source

What’s left is p = 0xFF = 255, which isn’t even prime2. The server then announces this group in the clear on every TLS 1.2 DHE-RSA3 handshake. The clock now has 255 hours, so the discrete logarithm problem we just covered is trivial. An eavesdropper who recorded the handshake needs at most 254 guesses to recover the server’s private number, derive the session keys, and decrypt the traffic. In fact, it’s even simpler: with g = 2, the powers of 2 wrap around to 1 after only eight steps (28 = 256, one past 255), so the shared secret can only be one of eight values: 1, 2, 4, 8, 16, 32, 64, or 128 (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, or 0x80). The eavesdropper just tries each one.

The never-ending chain (GOAHEAD-SA-2026-0004)

Normally only the server shows a certificate. With mutual TLS (mTLS), the client has to show one too. A client certificate usually arrives as a chain: your certificate, signed by an intermediate, signed by another intermediate, and so on up to a root the server trusts. GoAhead caps this chain at 10 certificates, so nobody can hand it an endless pile.

The depth cap lives in the short piece of code in the figure below. It says: if the chain is longer than 10 certificates, refuse it, but only if the TLS library hasn’t already reported a problem. LibreSSL’s legacy verifier reports a different error (20, “unable to get local issuer”) for an over-long chain, so the length check is skipped. With issuer checking off, GoAhead tolerates that error and the handshake completes.

Figure 2: A depth check with a condition: if the library already complained about something else, the check never runs
Figure 2: A depth check with a condition: if the library already complained about something else, the check never runs - source

You might be thinking: if issuer checking is off, doesn’t the server already accept any client certificate anyway? Pretty much, yes. FoxIO makes the same point: self-signed certificates are allowed by policy in that mode, so the bug only sidesteps the depth cap, and servers that require a trusted client CA reject the chain. That makes it a narrow bug, and we won’t pretend otherwise.

Gearing up for the hunt

FoxIO gave us a good kickstart. Their proof-of-concept code showed us what each bug looks like on the wire, and we started from there. But a PoC and a scanner do different jobs. A PoC proves a bug exists on a server you control. A scanner, especially one that scans the whole internet and does it ethically, as we do, has to answer the same question about servers it doesn’t control, over and over, without breaking any of them. So we wrote our own toolkit.

Reading the DH modulus: look, don’t touch

FoxIO’s PoC goes all the way: it completes the handshake, sends a real request, and breaks the key offline. We stop earlier. Our scanner reads the modulus the server announces and disconnects, so nothing is decrypted and no application data is ever sent.

What we read is the start of the server’s key exchange message during the TLS handshake. Its first field is the length of the modulus p, followed by p itself. A healthy server sends 256 bytes. A vulnerable one sends 00 01 ff: a length of 1 and a value of 0xFF.

Figure 3: The start of the server’s key exchange message. A healthy server sends a 256-byte prime. GoAhead on Mbed TLS sends one byte, 0xFF
Figure 3: The start of the server’s key exchange message. A healthy server sends a 256-byte prime. GoAhead on Mbed TLS sends one byte, 0xFF

To make sure the server shows us that message, we offer a few more DHE-RSA cipher suites than FoxIO’s PoC, which offers only one. Whichever the server picks, the modulus is the same, so any choice gives us the answer.

Reading the depth limit: infer, don’t peek

FoxIO could see this bug directly. Their lab runs a patched GoAhead that logs the error code the verifier reports, and a successful run ends with the protected page in hand. We can’t see any of that on a server we don’t own, so the scanner works it out from behavior with up to three probes:

  • No client certificate. If the server accepts the connection anyway, no certificate is required, and we stop there.
  • A short self-signed chain. A server that checks issuers rejects a chain nobody trusts. One that accepts it has the issuer checking off.
  • A 17-certificate chain, well past GoAhead’s limit of 10, sent only to servers that accepted the short chain. If one accepts it, the depth cap isn’t holding.

Hundreds of millions on paper, hundreds of thousands in sight

We started with the number everyone quotes. Embedthis says GoAhead is deployed in “hundreds of millions of devices”, and that may well be true. But that’s a count of devices that exist, and most of them sit inside routers, printers, and industrial gear that (ideally) never faces the internet. What matters for exposure is how many of them we can actually reach.

So how big is big? To put GoAhead in perspective, we compared it with two giants of web serving: nginx and Apache. They aren’t the only big names out there, but they make a useful yardstick. The chart below shows how many times we observed4 each one in a recent full internet scan (note the log scale):

Figure 4: Number of observations of each web server in a recent full internet scan, on a log scale. nginx and Apache are two of the largest web servers and serve only as reference points for scale. Many others exist and aren’t shown
Figure 4: Number of observations of each web server in a recent full internet scan, on a log scale. nginx and Apache are two of the largest web servers and serve only as reference points for scale. Many others exist and aren’t shown

GoAhead comes out as a tiny fraction of what we see for other major web servers. Even so, GoAhead servers are spread all around the world. The map below shows GoAhead observations by country, and we found exposure in almost every country with meaningful internet connectivity.

Figure 5: GoAhead observations by country, based on IP geolocation. Darker shades mean more observations, and the ten countries with the most are listed on the left
Figure 5: GoAhead observations by country, based on IP geolocation. Darker shades mean more observations, and the ten countries with the most are listed on the left

It’s global but concentrated: the United States leads with 28%, followed by South Korea (12%) and China (7%), and the ten countries listed on the map account for about two thirds of all observations.

That makes 249K observations in total, but not yet the number that matters. All three bugs FoxIO found live in GoAhead’s TLS code, so a server that only speaks plaintext HTTP can’t be affected by any of them (plaintext HTTP isn’t a good idea either, but that’s a topic for another post).

So we split the 249K GoAhead observations by how they answer:

Figure 6: Share of GoAhead servers by how they answer: over TLS (HTTPS) or in plaintext only (HTTP)
Figure 6: Share of GoAhead servers by how they answer: over TLS (HTTPS) or in plaintext only (HTTP)

Only about a quarter (26%, or 65K) answer over TLS. The other 74% (184K) are plaintext only, and out of reach of these bugs. So 65K is the real hunting ground, and that’s the population we’ll work with from here on.

A word of caution before we go on: a small internet footprint doesn’t make these bugs any less real. If you run GoAhead anywhere in your organization, you should patch regardless of what the internet-wide numbers say. A revocation list that’s loaded but never checked means a revoked certificate still gets in, which is why the vendor rates it critical. A one-byte DH modulus can break the confidentiality of sessions that rely on it. And a client certificate check that doesn’t hold up under scrutiny is a control you think you have and don’t. Our goal here is to measure how much of GoAhead’s real attack surface (i.e., the part reachable over HTTPS) is potentially vulnerable to FoxIO’s findings. Or at least to two of them, since, as we said earlier, there is no way to tell from the outside whether a server has a CRL configured.

Back from the hunt empty-handed… or almost

With our 65K hunting ground in hand, we went hunting and verified whether the broken Diffie-Hellman and client certificate bugs were actually out there.

Broken Diffie-Hellman: three hits

The chart below follows all 65K GoAhead HTTPS endpoints through the DH check:

Figure 7: How GoAhead’s 65K HTTPS endpoints flowed through the Diffie-Hellman check. Only three sent the one-byte 0xFF modulus
Figure 7: How GoAhead’s 65K HTTPS endpoints flowed through the Diffie-Hellman check. Only three sent the one-byte 0xFF modulus

We got a TLS answer from 40K of them (62%). On the remaining 25K we couldn’t get that far, for a variety of reasons, from plain timeouts to hosts that can’t speak TLS 1.2 at all. Of the 40K, over 90% (36K) never offered a DHE-RSA suite, so there was no modulus to read. Only 3K negotiated one, and apart from the three at the top, every modulus was at least 512 bits, with most at 10245.

Then there are those three. Three servers out of 65K sent the one-byte 0xFF modulus, roughly 0.005% of the HTTPS population. That’s about what we’d expect. The bug needs three things to line up: the Mbed TLS backend, TLS 1.2, and a DHE-RSA cipher suite. Start with the backend. GoAhead uses OpenSSL by default, and few integrators would bother going through the burden of replacing it with Mbed TLS. So there simply aren’t many servers in the pool. The exposure is real, but tiny.

Client certificates: nobody’s asking

This one doesn’t need a chart. Not one GoAhead HTTPS server we reached asked for a client certificate. The depth bug (and the CRL bug) need mutual TLS, so on the exposed internet neither has anything to work with.

So the scan came back with three DH hits and nothing for the depth bug. That could have been the end of this post. But building the scanner gave us something more interesting.

The fix that wasn’t: CVE-2026-115884 is not fixed (yet)

During the process of developing our scanner that we used to go for the hunt of these two bugs, we built a lab of GoAhead instances from upstream sources: different versions and different TLS backends, with vulnerable builds and patched builds that should never trigger. Run the scanner against that lab and every check has to come back right, in both directions.

We’re in the AI era, so we did this with the help of an AI coding assistant, which built the test targets, ran the checks, and told us when something looked off. Most of it was routine.

Then one target didn’t behave. GoAhead 6.1.1, the release the advisory says fixes the depth bug, running on LibreSSL 3.3.6, accepted our 17-certificate chain. Our AI assistant concluded that the fix doesn’t work on LibreSSL:

GoAhead 6.1.1 with LibreSSL 3.3.6 still accepted the 17-cert chain, even though I expected the new depth check to block it. I'll inspect the verify trace to determine if this is a build issue on my end or an upstream fix gap.

(...)

Confirmed: with LibreSSL 3.3.6 capping chain depth at 10, binary 6.1.1's guard VERIFY_DEPTH < depth (10 < 10) never triggers, so error 20 slips through unflagged. I'll now double-check the compiled source to rule out a build mistake on my end.

(...)

Confirmed: the 6.1.1 fix is compiled in and doesn't stop this trigger. That's a lab finding, not an error in the script

We didn’t believe it at first. A confident model telling you what looks plausible is exactly how you end up as a meat proxy, copying commands between terminals and nodding along, and we didn’t want to be that. So we treated the claim as a hypothesis and tried to break it by hand.

It reproduced every single time.

But why? The fix seemed correct:

Figure 8: The patch that shipped in GoAhead 6.1.1. Red lines are removed, green lines are added
Figure 8: The patch that shipped in GoAhead 6.1.1. Red lines are removed, green lines are added - source

On paper, this is exactly the right fix. The old check only ran if nothing else had gone wrong. The patch removes that condition, so the chain is rejected based on its depth, whatever error LibreSSL reports. The new comment even mentions the LibreSSL quirk it’s designed to handle.

The fix removed the guard's conditions. The problem is its comparison.

VERIFY_DEPTH < depth can only be true if LibreSSL reports a depth greater than the limit it was given. LibreSSL's legacy verifier cannot report one. It caps the depth it reports at exactly the limit, for every chain, however long.

Here’s why. The verifier builds the chain one certificate at a time, starting with the client’s own certificate and pulling issuers from the pile the client sent. Before each addition, it checks whether the chain it’s building has already outgrown the limit, and if so it stops (figure below). With a limit of 10, it stops at 11 certificates, and every depth it reports comes from that count, so the highest it can ever report is 10. GoAhead’s check therefore ends up asking 10 < 10, which is never true. Anything left in the pile is never added to the chain, so it has no position, and no depth number can refer to it.

Figure 9: The limit check in LibreSSL’s legacy chain builder. The loop stops as soon as the chain reaches the limit
Figure 9: The limit check in LibreSSL’s legacy chain builder. The loop stops as soon as the chain reaches the limit - source

LibreSSL developers saw this coming. A FIXME comment right above the check says that when the chain is too long, the code should make a note of it and later report the “chain too long” error. That note was never acted on: in our tests, the over-long chain comes out as a plain “can’t find the issuer” error. So a 12-certificate chain, a 17-certificate chain, and a 200-certificate chain all reach GoAhead looking the same: depth 10, error 20. They’re even indistinguishable from a legitimate 11-certificate chain whose root simply isn’t trusted. GoAhead’s new check compares the limit against itself, so it can never fire. With issuer checking off, that error is expected and allowed, so the chain goes through, exactly as it did in 6.1.0.

We reported this to Embedthis on October 29, with a lab that reproduces it. The next day they confirmed it: the depth check added in 6.1.1 doesn’t work as intended on LibreSSL’s legacy verifier. The fix will come in the next release.

Remember that this bug only applies when issuer checking is off, and in that mode self-signed client certificates are already accepted. A longer chain therefore gives an attacker nothing extra: all it gets past is the depth cap. Servers that check issuers reject the chain anyway. So there’s no new impact here.

The only twist is that a CVE was declared fixed when it wasn’t, and we caught it right after the advisory went public.

Timeline

Date

Event

Aug 2 2026

GoAhead 6.1.1 released (device builders)

Oct 12 2026

Advisories published, with 6.1.1 as the fix

Oct 26 2026

FoxIO publishes Choose Your Backend: Three TLS Bugs in GoAhead

Oct 29 2026

We report the incomplete fix to Embedthis, with a lab and a suggested fix

Oct 30 2026

Embedthis reproduces it, confirms a bugfix in the next release

Nov 10 2026

This post. As of publication, no fixed release is available

Patch, bypass, repeat

Incomplete fixes aren’t new. Log4Shell is the famous example: the fix for CVE-2021-44228 was incomplete in certain non-default configurations, and a second CVE followed (CVE-2021-45046). More recently, the infamous PHP2Shell was patched on October 21, and two days later PHP published an advisory for another unpatched code execution path in the same session upload progress feature. In both cases, patching once wasn’t the end of the story. Our GoAhead finding is yet another case of a fix that wasn’t.

Wrapping up

Given the media coverage, these bugs looked pretty bad. But headlines are claims, and claims are only as good as the data behind them. Large-scale internet scanning data, like ours at Bitsight, lets us measure instead of guess, and what it shows is that it’s not so bad, at least on the public internet. Even so, if you run GoAhead anywhere in your organization, including internal networks, patch it.

Also keep an eye out for Embedthis’s next release, which will carry the full fix for GOAHEAD-SA-2026-0004 (CVE-2026-115884). Until then, if you run GoAhead on LibreSSL with client certificates required and issuer checking off, turn issuer checking on or build against OpenSSL or a newer LibreSSL.

Acknowledgments

Thanks to Sébastien Féry at FoxIO, who found and disclosed these bugs. We just built on his work.

A note on disclosure: Embedthis reviewed our report, acknowledged the issue, and will fix it in the next release. As they see no new vulnerability, they told us we’re free to publish our findings. We want to thank Embedthis for their quick response and for being open to us sharing this work.

 

 

1In Diffie-Hellman, a “group” is just the pair of public numbers p and g. A “2048-bit group” is one whose p is 2048 bits long, like the one defined in RFC 3526.

2Why 0xFF? The 2048-bit prime in RFC 3526 is stored as 256 bytes. These standard primes are framed by 64 bits of 1’s at each end, so the very first byte is always FF. Keep only that byte and you get 0xFF, which is 255. The typo didn’t pick a random value. It cut off everything after the first byte.

3DHE-RSA is a family of TLS cipher suites. DHE means the key exchange is an ephemeral Diffie-Hellman (i.e., with fresh numbers for every session) and RSA means the server signs those parameters with its certificate to prove who it is.

4Yes, we know web servers sometimes go stealth. Many omit the hints we use to identify them, such as (but not limited to) the HTTP Server header, and GoAhead is no stranger to this. So the counts for all three are lower bounds: they show only the servers we can reliably confirm.

5Yes, 512 and 1024 bits are too small. These are most likely legacy devices, and they’re exposed to attacks like Logjam, which showed that 512-bit groups can be broken outright and that 1024-bit ones are within reach of well-resourced attackers. A curious honorable mention goes to the four servers using a 2236-bit modulus. It’s such an odd size that some developer probably ran the example command from the OpenSSL wiki word for word: openssl dhparam -C 2236.