← Back to Feed CACHED · 2026-06-30 20:31:41 · CACHE_KEY CVE-2026-54888
CVE-2026-54888 · CWE-674 · Disclosed 2026-06-29

Uncontrolled Recursion vulnerability in leandrocp mdex

ASSESSED — NOISGATE V0.5
Vendor
Reassessed
Verdict:
Do you agree?
01 · The Real Story

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.

"One malicious markdown blob kills the entire BEAM node — not just the request, not just the tenant. Unauthenticated, single-packet DoS."
02 · The Attack Path

5 steps from start to impact.

STEP 01

Identify a markdown render endpoint

Attacker enumerates the target Phoenix/Elixir app for any route that accepts user-controlled text and renders it as Markdown — comments, forum posts, profile bios, Beacon CMS pages, LiveView chat, or AI prompt/response previews. Public Phoenix apps using mdex are easy to fingerprint via Beacon CMS footers, HEEx error pages, or the comrak-derived HTML output structure.
Conditions required:
  • Target runs Phoenix/Elixir with mdex 0.8.3-0.13.1
  • Endpoint reachable by attacker (often unauthenticated)
Where this breaks in practice:
  • Internal-only apps are unreachable from the internet
  • WAFs may rate-limit POSTs to comment endpoints
Detection/coverage: No CVE-specific scanner sig yet. SCA tools (Snyk, Dependabot, mix_audit, Sobelow) flag mdex < 0.13.2 once advisory is mirrored in deps.hex.pm and GHSA.
STEP 02

Craft a deeply nested Markdown payload

Trivial one-liner: 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).
Conditions required:
  • Ability to write the nesting character at scale (no input length cap, or cap > ~5,000 chars)
Where this breaks in practice:
  • Length-limited input fields (Twitter-sized comments) won't reach depth threshold
  • HTML sanitizers that strip > early may neutralize it
Detection/coverage: Trivial WAF rule: reject any POST body where > or * or - appears >1000 consecutive times. Modsecurity CRL doesn't ship this by default.
STEP 03

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.
Conditions required:
  • Endpoint accepts request synchronously before rendering
  • No depth-limited markdown preprocessor in front
Where this breaks in practice:
  • Async rendering (Oban job that swallows crashes) limits blast radius to the worker pool
  • Some apps render markdown client-side via marked.js — mdex never sees it
Detection/coverage: Suricata/Zeek can match on the repeating-byte pattern; APM (AppSignal, NewRelic) will show a node disappearing rather than an error.
STEP 04

BEAM VM process terminates

The NIF cannot trap SIGSEGV; the OS kills the BEAM. Every Elixir process — supervised GenServers, Phoenix endpoints, LiveView sockets, Oban workers, ETS state — dies with it. For a single-node deployment this is total outage. For a clustered deployment (libcluster), one node drops but the attacker simply replays against the next node behind the LB.
Conditions required:
  • No fast-restart watchdog OR restart is slower than attacker's retry cadence
Where this breaks in practice:
  • systemd Restart=always brings the node back in ~5-15s
  • Kubernetes liveness probes restart the pod automatically — but attacker keeps replaying
Detection/coverage: Visible as repeated SIGSEGV in dmesg/journalctl, repeated container restarts in k8s events, Erlang init_terminate absent (it crashed before logging).
STEP 05

Sustained denial of service

Attacker scripts a retry loop hitting every backend behind the LB. Each request costs the attacker ~50KB of bandwidth and kills a node. Cluster restart-loop becomes the steady state. No persistence, no data loss, no lateral movement — just availability gone.
Conditions required:
  • Attacker has bandwidth to sustain the loop (negligible)
  • LB does not blacklist source IP
Where this breaks in practice:
  • Cloudflare/WAF rate limit on repeated 5xx/timeout responses
  • Fail2ban-style IP banning after N crashes
Detection/coverage: On-call paging from uptime monitors. Look for correlated SIGSEGVs across nodes from the same source IP.
03 · Intelligence Metadata

The supporting signals.

In-the-wild statusNo known active exploitation. Not KEV-listed. EPSS 0.00168 (~bottom 20th percentile).
Public PoCNo published exploit repo as of disclosure. PoC is one shell linepython3 -c "print('>'*50000)" — so absence of a repo is meaningless.
EPSS0.00168 — low predicted-exploitation signal, but EPSS chronically under-weights DoS and library-class bugs.
KEV statusNot listed. CISA KEV historically excludes pure DoS bugs.
CVSS vectorNo vendor CVSS. Plausible CVSS:3.1 vector: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H7.5 base — matches noisgate assessed score.
Affected versionsmdex >= 0.8.3, < 0.13.2 (Hex package). Bug introduced when MDEx.Document struct converter was added.
Fixed versionmdex 0.13.2 — adds document_depth_limit option (default 100).
Exposure dataNo 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.
Disclosure2026-06-29. Coordinated via Erlang Ecosystem Foundation CNA.
ReporterCredited via the Erlang Ecosystem Foundation CNA (see EEF CVE list).
04 · The Call

noisgate verdict.

Final Verdict
= UNCHANGED to HIGH (7.5/10)

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.

HIGH vulnerability mechanics and patch state
HIGH blast radius (NIF SIGSEGV kills BEAM)
MEDIUM installed-base exposure — mdex adoption is growing but niche
LOW active exploitation likelihood near-term

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.

05 · Compensating Control

What to do — in priority order.

  1. 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.
  2. 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 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.
  3. 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.
  4. 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.
  5. Tighten systemd / k8s restart policy and add SIGSEGV alerting — Ensure 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.
What doesn't work
  • 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.
06 · Verification

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.

noisgate-verify.sh
BASHREAD-ONLYSAFE
#!/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 $?
07 · Bottom Line

If you remember one thing.

TL;DR
Monday morning: grep every Phoenix/Elixir codebase for 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

  1. leandrocp/mdex GitHub repository
  2. mdex CHANGELOG
  3. mdex Releases
  4. Erlang Ecosystem Foundation CNA — Issued CVEs
  5. CWE-674: Uncontrolled Recursion
  6. GitHub Advisory Database
  7. Hex.pm — mdex package
  8. Comrak (upstream Rust parser)
Peer Review

What defenders are saying.

Submit a review attribution: handle + country only
0 flags selected · stored anonymously
Validation Results

Crowdsourced verification outputs.

Results submitted by users who ran the verification payload against their environment.