Blaze Labs /

Sixteen fixes we found too late to report: our GoAhead investigation

Security
Published · Updated
18min read
Forensic cyan and violet light reveals a repaired seam across a black ceramic surface, illustrating an investigation into already-fixed GoAhead flaws.

‍

We found bugs in GoAhead, built reproductions, and spent time checking what they actually proved. We did not get these findings to the maintainer before upstream fixed them. When we finally compared our audit snapshot with the released tree, sixteen findings had already been addressed.

This is the technical record of that investigation: the code paths we questioned, the tests that turned candidates into findings, and the upstream changes that closed them. It is also a record of a missed reporting window. Finding a bug and validating it are valuable, but the maintainer needs the result while it can still help.

Credit for implementing and releasing the fixes described here belongs to EmbedThis. We are documenting our independent investigation, not claiming that our private findings prompted those changes.

The same version number, two different trees

GoAhead is an embedded web server written largely in C. Its integration model makes it an interesting research target: the security behavior of a product depends on the server code, enabled modules, route configuration, operating system, and application handlers around it.

Our review covered HTTP parsing and framing, routing, Basic and Digest authentication, form sessions, CGI, Windows file serving, and TLS behavior. We pinned the target to development commit f5fdfbd31fc489c4749cb9b527385e49ffeb276b, dated April 26, 2026. That tree already called itself 6.1.1.

The final v6.1.1 tag was a different tree: 59ec3fe44, dated August 5 in our upstream-status record. Between July 30 and August 2, upstream landed a hardening series that addressed sixteen of our findings. A version string shared by a development build and a release did not make their security properties identical.

Date What happened
April 26 Commit used as our audit target
June 24 onward Source review, candidate adjudication, and reproduction work
July 16 Recorded Linux revalidation, including the CRL experiment below
July 30–August 2 Upstream hardening series
August 5 Final v6.1.1 tag in the upstream-status record
September 19–20 Further live runs against the audit pin and comparison with the release

These are investigation milestones, not a claim that every bug was discovered on the first day. They also do not establish priority of vendor reporting: we had not reported this fixed subset before the patches landed.

The vulnerable behaviors in this post were reproduced against the development snapshot. Our September patch check established the released fixes by inspecting source and commits. We have not replayed the full corpus against the final tag, so we distinguish those two forms of evidence throughout.

How we investigated

We used AI-assisted source review with adversarial cross-checking. A candidate did not survive simply because a reviewer could describe a worrying branch. Other review passes tried to disprove reachability, find configuration assumptions, and challenge the proposed severity. Runtime evidence settled the parts that source inspection alone could not.

The tests used freshly built GoAhead processes on Windows with MSVC, including AddressSanitizer builds where relevant, and WSL/Ubuntu with GCC and OpenSSL. Python drivers staged configurations, sent requests, collected responses, and recorded effects. Smaller harnesses helped isolate individual operations, but their results were kept separate from full-server behavior.

That separation changed our claims. For the Digest parser, an earlier narrative mixed a larger standalone-harness result with a live-server over-read. Revalidation narrowed the live result to one byte. For the CGI use-after-free findings, the executed outcome was a crash. We did not demonstrate remote code execution.

We also learned to inspect the proof itself. A script can print an impressive marker and still be a poor regression test. A useful test needs a working baseline, an observable effect, and a failure path when that effect is absent. The historical receipts show what happened in their recorded runs; they do not exempt the scripts from scrutiny.

Windows paths: one file, different security decisions

Three findings shared a boundary problem: GoAhead made routing or handler decisions using a path spelling, while Windows resolved file identity using different rules.

F-005 exposed JST/ASP template source through alternate spellings. F-006 bypassed protected route matching through case changes. F-012 used a trailing dot on an intermediate directory component to avoid a protected prefix.

The source-disclosure case makes the mismatch especially visible.

Choosing the wrong handler

The test configuration put a template handler for jst and asp extensions before the catch-all file handler. A normal request for /test.jst selected the template handler, which executed the server-side code and returned its output.

In the audited src/route.c, the relevant extension check was:

if (route->extensions >= 0 &&
        (wp->ext == 0 ||
         !hashLookup(route->extensions, &wp->ext[1]))) {
    trace(5, "Route %s doesn match extension %s",
          route->prefix, wp->ext ? wp->ext : "");
    continue;
}

This excerpt preserves the branch and trace text, with line wrapping adjusted. A route with extension restrictions was skipped when the extracted extension did not match its extension table.

Now consider /test.jst::$DATA. The suffix changed the extension spelling used for route selection. The request could fall through to static file serving, while Windows opened the unnamed data stream of the underlying template. The server returned the file's source instead of executing it.

A trailing dot produced the same kind of disagreement in the tested environment. /test.jst. failed the template-extension selection but resolved to the existing file when opened.

::$DATA here identifies the unnamed data stream. The demonstration does not establish unrestricted access to every named stream, every file on disk, or arbitrary paths outside the configured document tree.

A harmless fixture with an unmistakable result

The fixture contained a short template:

<html>
<body><%
    write("Hello ASP World");
%></body>
</html>

The normal response contained Hello ASP World, with the template evaluated. The alias responses contained the literal <% ... %> block and write(...) call. That is a much stronger source-disclosure signal than a status code alone.

The September 20 Windows receipt recorded:

baseline GET /test.jst -> HTTP/1.1 200 OK [executed]
ADS ::$DATA            GET /test.jst::$DATA -> 200 [RAW SOURCE]
trailing dot           GET /test.jst.       -> 200 [RAW SOURCE]
asp ADS                GET /test.asp::$DATA -> 200 [RAW SOURCE]

Windows template-source disclosure: selected lines from the September 20 receipt

Terminal-style rendering of selected recorded output, not a new execution or an original desktop screenshot. Local machine paths are omitted. The full receipt also contains the returned synthetic template source.

The request itself was simple:

GET /test.jst::$DATA HTTP/1.1
Host: 127.0.0.1
Connection: close

The important preparation was the Windows filesystem and route configuration. The same suffix on a different operating system, or behind a handler that rejects it first, need not have the same effect.

Turning the demonstration into a stricter PoC

The existing Windows driver builds a server, stages the bundled test configuration, executes the scripts, and saves receipts. The source-disclosure script classifies bodies using source markers. However, its “not reproduced” path still exits successfully, and the driver relies on that exit status.

For the article's expanded reproduction, we want explicit assertions. The following is an adapted local-lab example; it has not been executed as part of preparing this draft. It uses http.client to decode response framing, preserves the literal request targets, and checks the baseline before drawing a conclusion.

import http.client

HOST, PORT = "127.0.0.1", 8095
SOURCE = b'write("Hello ASP World")'

def get(path):
    conn = http.client.HTTPConnection(HOST, PORT, timeout=5)
    try:
        conn.request("GET", path, headers={"Connection": "close"})
        response = conn.getresponse()
        return response.status, response.read()
    finally:
        conn.close()

status, body = get("/test.jst")
assert status == 200, "Baseline template unavailable"
assert b"Hello ASP World" in body, "Baseline did not execute"
assert b"<%" not in body, "Baseline already returned source"

for path in ("/test.jst::$DATA", "/test.jst."):
    status, body = get(path)
    assert status == 200, f"Alias not served: {path}"
    assert b"<%" in body and SOURCE in body, \
        f"Raw source not observed: {path}"

This is a vulnerable-build test: it should fail when the disclosure is absent. A fixed-build test needs different assertions: the canonical template must still work, and alias requests must not expose source. A connection failure is an invalid experiment, not proof that the fix works.

The authorization variants

The same spelling-versus-identity mismatch affected protected prefixes. The audited route code compared paths with strncmp:

if (strncmp(wp->path, route->prefix, len) != 0) {
    continue;
}

On a case-insensitive filesystem, a protected /secure/ route could fail to match /SECURE/, allowing a catch-all route to select the same underlying file without the intended authentication policy. For F-012, an intermediate component such as /auth/basic./admin/index.html avoided the canonical protected prefix while Windows resolved the directory spelling.

The controls matter: a canonical protected request must actually be denied or challenged, and the alias response must contain the protected fixture's marker. “Different status” alone does not establish authorization bypass.

What upstream changed

Commit 44ab2dbf8 added canonical-path validation and case-aware route matching. Selected checks from validCanonicalPath() show the approach:


if (*sp == '/') {
    if (sp > segment && (sp[-1] == '.' || sp[-1] == ' ')) {
        return 0;
    }
    segment = ++sp;
    continue;
}
if (*sp == ':') {
    return 0;
}

The complete function also checks the last component, short-name-like suffixes, UTF-8 decoding, and combining-character ranges. These are rejection rules, not an attempt to guess every spelling that the filesystem might fold into another name.

Commit 2d8e31b39 further restricts static serving to regular files and rejects Windows reserved device names. Together, the changes bring path validation, route selection, and file serving into closer agreement.

A revocation list that was loaded but not enforced

F-017 concerned the OpenSSL backend. An operator could configure a certificate revocation list, GoAhead could load it successfully, and a revoked client certificate could still complete mutual TLS and receive a response.

The vulnerable branch in sslOpen() loaded the configured revocation file:

if (ME_GOAHEAD_SSL_REVOKE && *ME_GOAHEAD_SSL_REVOKE) {
    store = SSL_CTX_get_cert_store(sslctx);
    if (!X509_STORE_load_locations(store,
            ME_GOAHEAD_SSL_REVOKE, 0)) {
        error("Cannot load certificate revoke list: %s",
              ME_GOAHEAD_SSL_REVOKE);
        sslClose();
        return -1;
    }
}

The missing step was enabling CRL checking on the store. Successfully loading revocation data did not establish that certificate verification would consult it.

Three checks using the same revoked identity

The Linux PoC generated a disposable CA, a server certificate, a client certificate, and a CRL revoking that client. It enabled peer and issuer verification and configured the generated authority and revocation files. Then it compared three behaviors:

Test Recorded result What it established
OpenSSL's explicit CRL verifier Error 23: certificate revoked The certificate/CRL fixture was valid
Audited GoAhead build HTTP/1.1 200 OK That revoked identity was admitted
Otherwise equivalent hardened control Certificate-revoked TLS alert Enabling the missing check changed the result

The July 16 receipt contains:

RESULT: OPENSSL CRL BYPASS CONFIRMED
error 23 at 0 depth lookup: certificate revoked
stock_goahead=HTTP/1.1 200 OK
hardened_control=[SSL: SSLV3_ALERT_CERTIFICATE_REVOKED]

CRL experiment: selected lines from the July 16 Linux receipt

Terminal-style rendering of recorded output. The hardened-control line is shortened for display; the original receipt includes the full Python SSL exception. This was a local control build, not a replay of the final upstream release.

The experiment did more than observe a successful TLS connection. It first checked that the independent verifier rejected the certificate, then required the vulnerable server to serve a response, then required the hardened build to reject the same identity.

The driver's core assertions were:

if not stock_response.startswith(b"HTTP/1.1 200") or stock_error:
    raise RuntimeError("Stock build did not admit revoked client")

if hard_response or "REVOKED" not in hard_error.upper():
    raise RuntimeError("Hardened control did not reject revoked client")

Messages are shortened here; the conditions follow the recorded driver. For a fuller regression matrix, add a non-revoked client that must continue working, then repeat against the upstream release. That additional matrix is future work, not a result we claim in this draft.

The fix and its boundaries

Upstream commit da6e87424 enables the missing operation after loading the CRL:

X509_STORE_set_flags(store, X509_V_FLAG_CRL_CHECK);

This is a leaf-certificate revocation check. It should not be described as every possible certificate-chain revocation policy.

The attack also has substantial preconditions: the affected OpenSSL mTLS/CRL configuration, access to the listener, and possession of a revoked but otherwise relevant client certificate and its private key. The impact can be high for a deployment relying on revocation to remove a device or user. That does not make it a generic unauthenticated bypass on every GoAhead build.

HTTP limits, framing, and a parser that walks too far

F-001 — Chunked bodies bypassed postLimit. The configured request-body cap was enforced for Content-Length bodies but not equivalently for chunked input. In the recorded comparison, a 100,000-byte body received a 413 when length-framed and was accepted when chunk-framed. The implication was resource pressure beyond the intended limit; the test did not measure the behavior of every embedded product. Upstream 59f4fd398 applies per-chunk and cumulative limits.

F-004 — Ambiguous request framing was accepted. The parser tolerated conflicting or duplicate Content-Length/Transfer-Encoding combinations rather than rejecting the ambiguity. The security concern was disagreement with another HTTP component about request boundaries. We treat this as a smuggling substrate; accepting malformed framing alone is not a complete production-proxy exploit. Commits 1a9cf23bc and 9cb20d409 tighten transfer-coding rules and reject out-of-range Content-Length values.

F-024 — HEAD request-body handling enabled desynchronization. The problematic behavior was on the request side: bytes presented as a HEAD request body could be interpreted as another request on the connection. Our records include direct backend tests and a controlled test gateway. They do not establish exploitation of an arbitrary production proxy. The framing changes in 59f4fd398 and 1a9cf23bc address this behavior. A rule forbidding a HEAD response body is not, by itself, permission to ignore a HEAD request body's framing.

F-002 — Digest unescaping read past the allocation. A quoted authentication value containing a backslash reached an unsafe pointer-advance sequence before credentials were validated:

if (strchr(value, '\\')) {
    for (dp = sp = value; *sp; sp++) {
        if (*sp == '\\') {
            sp++;
        }
        *dp++ = *sp++;
    }
    *dp = '\0';
}

The source pointer advances in the loop expression, in the backslash branch, and in the copy. A trailing escape can move it through the terminating NUL into an invalid read. The live full-server ASan result supported a one-byte over-read. Larger harness measurements and a source-suggested write path must not be substituted for that result. Upstream 06470169d bounds the unescape operation and removes the extra advancement.

CGI: ownership must survive every exit path

F-009 — Timeout use-after-free. A CGI registry retained request state after the request was freed through a timeout path. Later polling accessed the stale state. The MSVC ASan reproduction ended in server death. The demonstrated outcome was a memory-safety crash, not controlled execution.

F-015 — Pipeline use-after-free. A keep-alive CGI sequence exposed another lifetime problem: a stale poll frame could free request state while a later CGI was still active. The Linux reproduction demonstrated a crash. Both findings are addressed by ce4fdca31, which detaches registry entries when the associated request is freed; 180a224fe corrects Windows CGI handle lifecycle.

These findings require a reachable CGI route. “Pre-authentication” describes the tested route and absence of valid credentials, not a guarantee that every application exposes CGI publicly.

F-019 — Predictable staging files allowed interference. Request-staging names came from predictable counters. Our live demonstration used same-UID processes with aligned counters to interfere with CGI input or PUT content. That is narrower than a demonstrated generic remote file overwrite or local privilege escalation. Upstream moved staging away from served content and introduced unpredictable names, private modes, and stronger lifecycle handling in 764ac0c79, 9f2da2675, and dcc48f638.

F-020 — One failed open leaked another descriptor. CGI launch opened input and output staging files. When the second open failed, the first descriptor was not closed. The recorded test converted 91 induced failures into 91 retained descriptors and eventually failed fresh CGI requests. The staging rework adds the missing partial-open cleanup and guarded private opens. The reachable failure setup belongs in the PoC instructions; an ordinary successful CGI request alone is not the trigger.

Session state and shared resources

F-014 — A supplied session ID persisted through login. On a cache miss, the server adopted a client-supplied cookie value as a session ID and did not rotate it at login. Exploitation requires getting that value into a victim's browser and the victim subsequently authenticating. Upstream rotates IDs at login and during Basic/Digest authentication in 27ccf1ffe and efe7b13a4.

F-025 — Failed logins consumed the session table. Unauthenticated activity and failed login handling could allocate sessions until new users could no longer obtain a login session. Existing sessions could remain usable, so the symptom differed from a total server outage. 9a0a6f7e0, together with login rework, avoids the allocation path and verifies credentials before session work.

F-016 — Connection churn leaked accepted sockets. At an internal allocation limit, the accept path could retain a newly accepted descriptor instead of closing it. Repeated wakeups exhausted available descriptors. The Linux test reached its configured descriptor ceiling and observed no HTTP response bytes afterward. ef7539b6a fixes failed-accept cleanup and introduces total/per-peer connection caps.

F-018 — A slow reader stalled the shared event loop. A dynamic response could enter a blocking flush without a sufficient deadline. In the recorded experiment, an attacker connection that stopped reading prevented an unrelated client from receiving response bytes until that connection was released. The relevant upstream sequence—2df9fd2ae, 0572c9795, and 12adfe4e3—bounds flushing and adds an absolute pre-header deadline. A timeout on one client would not prove this effect; the unrelated-client control is what shows the shared-progress failure.

A reproduction record for every finding

All sixteen findings have PoC entry points and receipts indexed in our research package. The table below identifies the scripts rather than promising an already released public package. The original build instructions refer to a security-review/ directory within a GoAhead source checkout; the archived research directory alone is not a standalone build environment.

Finding PoC script(s) Main recorded observation
F-001 poc_chunked.py, poc_memory.py Body-limit differential
F-002 poc_digest.py Full-server ASan over-read
F-004 poc_smuggle.py Ambiguous framing accepted
F-005 poc_jst_source_disclosure.py Template code returned literally
F-006 poc_case_authbypass.py, matrix/policy variants Protected fixture through case alias
F-009 poc_cgi_uaf.py, disconnect variants CGI lifetime error and server crash
F-012 poc_dotdir_authbypass.py, tier variants Protected fixture through directory alias
F-014 poc_session_fixation.py Supplied session ID retained through login
F-015 poc_cgi_pipeline_uaf.py Pipeline lifetime error under ASan
F-016 poc_socket_highwater_fd_leak.py Descriptor exhaustion and lost service
F-017 poc_openssl_crl_bypass.py Revoked-client three-way differential
F-018 poc_default_404_slowread_freeze.py Unrelated-client progress stops
F-019 CGI-stdin and PUT cross-process scripts Same-UID staging interference
F-020 poc_cgi_partial_open_fd_leak.py Retained descriptors after failed opens
F-024 poc_head_content_length_smuggling.py, gateway verifier HEAD request-body desynchronization
F-025 poc_session_table_lockout.py New-login failure after table fill

The receipts describe historical runs, primarily against the pinned development tree. Source comparison established the upstream remediation mapping. Fresh before/after runs against the released tag are still work to do, as is strengthening scripts whose failure behavior is weaker than their output suggests.

What we should have done sooner

We kept improving the corpus: checking reachability, narrowing claims, and rerunning tests. Those activities improved the evidence. They did not replace timely maintainer contact or regular comparison with upstream.

The missed reporting window is ours to acknowledge. We should have checked upstream deltas during the investigation and communicated defensible findings while the review continued. Waiting for the entire corpus to feel finished left us validating an old snapshot after fixes were already available.

The useful outcome now is a clear technical record. It shows independent observations of the affected paths, explains the preconditions, and connects each one to the corresponding remediation. It does not give us credit for upstream's patch work.

For teams that embed GoAhead

Identify the actual code compiled into the product, including vendor backports. Check the relevant fix hunks and configuration, rather than treating an older date or version string as proof that every finding applies.

Windows path handling, CGI exposure, session routes, and OpenSSL mTLS settings materially change the attack surface. A vendor can have one affected component without having the others. Integrating a fix also needs product-level validation: the canonical protected route must remain protected, legitimate certificates must still work, and cleanup must happen on failure as well as success.

For us, the investigation reinforced two standards. Runtime proof needs controls and explicit failure conditions. Reporting needs a clock of its own. Both are part of finishing the research.


Draft references: GoAhead source at audit commit f5fdfbd31fc489c4749cb9b527385e49ffeb276b (route.c, openssl.c, auth.c); upstream commits linked inline; Blaze's internal disclosure/status mapping, fixed PoC index, and recorded receipts. No CVE assignment is claimed here.

Do you have questions? Let's talk.

Get in touch with our cybersecurity experts

Read More