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
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). |
noisgate verdict.
Assessed HIGH because the decisive factor is blast radius amplification via NIF: a single unauthenticated request crashes the entire BEAM OS process, not just the request handler — every tenant and background worker on the node dies with it. Friction is minimal (no auth, no special position, payload is one shell line), and exposure tracks any Phoenix app that renders untrusted markdown.
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.
What to do — in priority order.
- Upgrade mdex to >= 0.13.2 and set
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. - Hard-cap markdown input length at the controller boundary — Reject any markdown body > 64KB (or whatever fits your product) BEFORE it reaches
MDEx.to_html/1. Wraps abyte_size/1guard in your Plug/controller. Deploy within 3 days as a belt-and-suspenders measure while rolling out the upgrade fleet-wide. - Add a WAF rule rejecting >1000 consecutive
>,*,-, 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. - Move markdown rendering into an isolated worker pool — If feasible, render markdown inside an Oban/Broadway worker rather than the request-serving node. A SIGSEGV in the worker still kills its BEAM, but the user-facing nodes survive. Architectural change — pursue in parallel with the patch, not as a blocker.
- Tighten systemd / k8s restart policy and add SIGSEGV alerting — Ensure
Restart=alwayswithRestartSec=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.
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 $?
If you remember one thing.
mdex in mix.lock and queue mix deps.update mdex to land 0.13.2 with document_depth_limit: 100 configured at every call site. Per the noisgate mitigation SLA for HIGH (≤ 30 days), deploy a WAF rule blocking >1000 consecutive > characters and a controller-level 64KB body cap within 3 days as a stop-gap — the payload is a one-liner and you do not want a half-rolled fleet exposed. Per the noisgate remediation SLA for HIGH (≤ 180 days), the upgrade itself is trivial; finish the rollout in days, not months. This is not KEV and there is no active exploitation yet, so you are not on emergency-change footing — but the single-packet-kills-the-node failure mode means do not let it slip into next quarter's hygiene backlog.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.