A markdown comment that doesn't just crash the page — it kills every Elixir process on the box
mdex is a popular Elixir wrapper around the Rust comrak Markdown parser, exposed via a NIF (Native Implemented Function). The vulnerability lives in two mutually recursive Rust functions that translate between the %MDEx.Document{} Elixir struct and Comrak's internal AST. Neither function enforces a depth limit, so an attacker who supplies a document with thousands of nested constructs (block quotes, lists, emphasis) drives the recursion off the native C stack. Affected versions: 0.8.3 through < 0.13.2. Fixed in 0.13.2, which adds a configurable document_depth_limit.
There is no vendor CVSS for this CVE — we are establishing the first severity. The instinct is to dismiss this as 'just a DoS,' but the failure mode is unusually severe: a NIF stack overflow raises an uncatchable SIGSEGV that the BEAM cannot trap. The entire OS process dies, taking every Erlang/Elixir process on the node with it — every tenant, every websocket, every background worker. For any Phoenix app that renders untrusted markdown (CMS comments, chat, AI-generated content, Beacon CMS pages), this is a single-packet kill switch. That is materially worse than a typical request-scoped DoS, and it justifies a HIGH assessment.
5 steps from start to impact.
Identify a markdown render endpoint
mdex are easy to fingerprint via Beacon CMS footers, HEEx error pages, or the comrak-derived HTML output structure.- Target runs Phoenix/Elixir with mdex 0.8.3-0.13.1
- Endpoint reachable by attacker (often unauthenticated)
- Internal-only apps are unreachable from the internet
- WAFs may rate-limit POSTs to comment endpoints
Craft a deeply nested Markdown payload
python3 -c "print('>'*50000 + ' x')" produces 50,000 nested block quotes. Lists, emphasis, and HTML blocks also recurse. No tooling needed — curl and a shell loop suffice. No public PoC repo published as of this writing, but the bug class is well-understood (similar to CVE-2023-44487 in markdown-it, comrak's own past advisories).- Ability to write the nesting character at scale (no input length cap, or cap > ~5,000 chars)
- Length-limited input fields (Twitter-sized comments) won't reach depth threshold
- HTML sanitizers that strip
>early may neutralize it
> or * or - appears >1000 consecutive times. Modsecurity CRL doesn't ship this by default.POST the payload to the renderer
curl -X POST https://target/comments -d "body=$(python3 -c 'print(">"*50000+" x")')". The request handler hands the string to MDEx.to_html/1, which crosses the NIF boundary into Rust, where the mutually recursive converters blow the C stack.- Endpoint accepts request synchronously before rendering
- No depth-limited markdown preprocessor in front
- Async rendering (Oban job that swallows crashes) limits blast radius to the worker pool
- Some apps render markdown client-side via marked.js —
mdexnever sees it
BEAM VM process terminates
- No fast-restart watchdog OR restart is slower than attacker's retry cadence
- systemd
Restart=alwaysbrings the node back in ~5-15s - Kubernetes liveness probes restart the pod automatically — but attacker keeps replaying
init_terminate absent (it crashed before logging).Sustained denial of service
- Attacker has bandwidth to sustain the loop (negligible)
- LB does not blacklist source IP
- Cloudflare/WAF rate limit on repeated 5xx/timeout responses
- Fail2ban-style IP banning after N crashes
document_depth_limit: 100 — The definitive fix. mix deps.update mdex, redeploy. Per noisgate mitigation SLA for HIGH this is ≤ 30 days; remediation ≤ 180 days. Treat this as the same step — there is no reason to delay the patch behind a mitigation.MDEx.to_html/1. Wraps a byte_size/1 guard in your Plug/controller. Deploy within 3 days as a belt-and-suspenders measure while rolling out the upgrade fleet-wide.>, *, -, or # characters — Cloudflare/Akamai/AWS WAF custom rule. Catches the obvious payload shape without parsing markdown. Costs nothing, deploys in minutes, buys you cover while the upgrade rolls.Restart=always with RestartSec=2s (systemd) or pod restartPolicy Always with fast liveness probes (k8s). Add a SIEM rule alerting on repeated BEAM SIGSEGVs from the same source IP — that's your exploitation signal.- HTML output sanitizers (ammonia, html_sanitize_ex). They run AFTER mdex renders. The crash happens during parse, before sanitization sees anything.
- Erlang process supervisors /
try/rescue. A SIGSEGV in a NIF is not an Elixir exception. Supervisors cannot trap it — the OS reaps the process first. - Rate limiting per-IP at low thresholds. One request crashes the node. By the time the rate limiter notices, you're already down. Useful for sustained replay, not for the first hit.
- Switching to comrak-direct bindings. Comrak has had similar nesting bugs; the depth-limit fix is what matters. Changing wrapper does not change the underlying parser's behavior unless you set a depth cap.
- Client-side markdown preview (marked.js). Doesn't help if the server still renders for persistence/display.
The supporting signals.
| In-the-wild status | No known active exploitation. Not KEV-listed. EPSS 0.00168 (~bottom 20th percentile). |
|---|---|
| Public PoC | No published exploit repo as of disclosure. PoC is one shell line — python3 -c "print('>'*50000)" — so absence of a repo is meaningless. |
| EPSS | 0.00168 — low predicted-exploitation signal, but EPSS chronically under-weights DoS and library-class bugs. |
| KEV status | Not listed. CISA KEV historically excludes pure DoS bugs. |
| CVSS vector | No vendor CVSS. Plausible CVSS:3.1 vector: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H → 7.5 base — matches noisgate assessed score. |
| Affected versions | mdex >= 0.8.3, < 0.13.2 (Hex package). Bug introduced when MDEx.Document struct converter was added. |
| Fixed version | mdex 0.13.2 — adds document_depth_limit option (default 100). |
| Exposure data | No Shodan/Censys signature (library-level bug, no network fingerprint). Hex.pm download stats show mdex with low-thousands weekly downloads — niche but growing, especially via Beacon CMS and Phoenix LiveView apps. |
| Disclosure | 2026-06-29. Coordinated via Erlang Ecosystem Foundation CNA. |
| Reporter | Credited via the Erlang Ecosystem Foundation CNA (see EEF CVE list). |
Sources.
Why this verdict
- Unauthenticated, single-packet kill. No prior compromise stage required — attacker only needs a render endpoint. That's a zero-friction precondition.
- NIF blast radius is the multiplier. A normal markdown DoS scopes to one request. This one scopes to the entire OS process, dropping every Erlang process on the node. That's why it sits above MEDIUM despite being 'just DoS'.
- Role multiplier: typical role (Phoenix web tier rendering user content). Chain succeeds; blast radius = node-wide outage, multi-tenant if multi-tenant app. Not fleet-scale, not identity-scale.
- Role multiplier: Beacon CMS / public-facing Phoenix CMS. Chain succeeds; blast radius = full site outage on every node behind LB via replay. Still availability-only.
- Role multiplier: internal admin tool rendering trusted markdown. Chain may or may not succeed depending on input source; blast radius limited to a low-criticality service. This is the role that keeps the verdict below CRITICAL.
- No code execution, no data confidentiality/integrity loss. Ceiling is availability. That prevents promotion to CRITICAL even with the NIF amplification.
- EPSS and KEV are low, but both systematically discount DoS — they describe exploitation prevalence, not impact-when-exploited. We weight them low here.
Why not higher?
CRITICAL is reserved for chains ending in code execution, credential theft, or supply-chain pivot. This caps at availability impact. There is no path from a stack overflow inside the comrak AST converter to memory corruption that the attacker can shape — the BEAM process simply dies. Without an RCE primitive or identity/secret exposure, CRITICAL overstates the impact.
Why not lower?
MEDIUM would be appropriate for a request-scoped markdown DoS where the framework catches the exception and returns 500. This isn't that — the SIGSEGV is uncatchable inside the NIF and kills the BEAM OS process, so one POST equals one dead node and every co-tenant on it. That blast-radius amplification, combined with zero authentication and a one-line payload, lifts it firmly above MEDIUM.
Crowdsourced verification payload.
Run on each Phoenix/Elixir host or in CI against your build artifact. Invoke as ./check_mdex.sh /path/to/app_release (the deployed release dir containing mix.lock or releases/*/). Needs read access to the lockfile only — no privileges. Alternatively run from your project root with no args.
#!/usr/bin/env bash
# noisgate verification: CVE-2026-54888 — mdex uncontrolled recursion DoS
# Vulnerable: mdex >= 0.8.3, < 0.13.2
# Patched: mdex >= 0.13.2
set -u
TARGET="${1:-.}"
LOCK=""
for candidate in "$TARGET/mix.lock" "$TARGET"/releases/*/runtime.exs "$TARGET"/releases/*/start.boot; do
[ -f "$candidate" ] && LOCK="$candidate" && break
done
if [ -z "$LOCK" ] && [ -f "$TARGET/mix.lock" ]; then
LOCK="$TARGET/mix.lock"
fi
if [ -z "$LOCK" ]; then
# Last resort: look for compiled beam metadata
BEAM=$(find "$TARGET" -name 'Elixir.MDEx.beam' 2>/dev/null | head -n1)
if [ -z "$BEAM" ]; then
echo "UNKNOWN: no mix.lock or MDEx beam under $TARGET"
exit 2
fi
echo "UNKNOWN: MDEx beam present at $BEAM but no mix.lock to read version"
exit 2
fi
VER=$(grep -E '"mdex"' "$LOCK" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
if [ -z "$VER" ]; then
echo "PATCHED: mdex not present in $LOCK"
exit 0
fi
# semver compare against 0.13.2 and 0.8.3
awk -v v="$VER" 'BEGIN {
split(v, a, ".");
cur = a[1]*10000 + a[2]*100 + a[3];
lo = 0*10000 + 8*100 + 3; # 0.8.3
fix = 0*10000 + 13*100 + 2; # 0.13.2
if (cur >= lo && cur < fix) {
printf "VULNERABLE: mdex %s (need >= 0.13.2)\n", v; exit 1;
} else {
printf "PATCHED: mdex %s\n", v; exit 0;
}
}'
exit $?