Safeguard
Supply Chain Security•

YAML Alias Bombs After CVE-2026-72923: Which Parsers Stop Them, and How to Guard the Ones That Don't

We ran YAML alias bombs against nine parsers. Four refused them; PyYAML, goccy and others didn't. CVE-2026-72923 fixes, limits that fail, and a guard.

Safeguard Security Team
Security Research
11 min read

A 359-byte OpenAPI file can make a .NET service copy more than half a million strings, and a few more lines can run it out of memory. That's CVE-2026-72923, a denial-of-service bug in Microsoft's OpenAPI.NET readers that reached the GitHub Advisory Database on 14 October 2026. The bug class is old, the "billion laughs" alias bomb, and the same file shape affects many other YAML stacks.

This guide is for teams that accept YAML from somewhere they don't control: uploaded OpenAPI specs, CI configuration from pull requests, Helm values, webhook payloads, user-defined workflows. We ran the same two alias bombs against nine YAML parsers in Python, JavaScript, Go, Ruby and Java to see which stop them by default. Below are the results, the one distinction that matters when you read a parser's "alias limit", and a small guard for the parsers that have none.

The short answer

If you parse untrusted YAML, check what your parser does with aliases, because the answers differ widely. In our tests, the npm yaml package, Go's gopkg.in/yaml.v3 and sigs.k8s.io/yaml, and Ruby's Psych refused both bombs by default. PyYAML, ruamel.yaml and goccy/go-yaml loaded them with no limit. js-yaml and SnakeYAML have limits that count aliases, and a bomb built with 48 aliases got past both. For .NET, upgrade Microsoft.OpenApi.YamlReader to 2.12.0 or 3.10.0, or Microsoft.OpenApi.Readers to 1.6.30.

How an alias bomb works

YAML lets you name a node with an anchor (&a) and reuse it with an alias (*a). A bomb nests them. The first line holds nine strings, the second holds nine aliases to the first, the third nine aliases to the second, and so on. Each level multiplies the total by nine.

The OpenAPI.NET advisory explains why the damage often comes after parsing, not during it. Its YAML library, SharpYaml, keeps each alias as a pointer to one shared node, so the parsed graph stays small. The reader then converts that graph to System.Text.Json nodes, which can't have two parents, so every alias has to be copied. With six levels of nine, that conversion produces 531,441 strings from a file of a few hundred bytes.

The same pattern shows up wherever a YAML library hands back shared objects and something later walks them as a tree. That walker could be json.dumps, a deep copy, a schema validator or a template engine. The load looks cheap, and the cost lands in the next step.

What we tested

We generated bombs with the script below. It writes a file of any depth and width and prints how many strings the document holds once every alias is copied.

#!/usr/bin/env python3
"""Write a YAML alias-bomb test file and print how big it gets once expanded.

Usage: python3 yaml_alias_bomb.py LEVELS [WIDTH] > bomb.yaml
"""
import sys

levels = int(sys.argv[1])
width = int(sys.argv[2]) if len(sys.argv) > 2 else 9

print("x0: &x0 [" + ", ".join(['"lol"'] * width) + "]")
for i in range(1, levels):
    print(f"x{i}: &x{i} [" + ", ".join([f"*x{i-1}"] * width) + "]")

total = sum(width ** i for i in range(1, levels + 1))
sys.stderr.write(f"{levels} levels x {width} wide, {width * (levels - 1)} aliases: "
                 f"{total:,} strings once every alias is copied\n")

Two files mattered most. The first is nine levels of nine: 504 bytes and 72 aliases, expanding to 435,848,049 strings. The second is 17 levels of three: 446 bytes and 48 aliases, expanding to 193,710,243 strings. Every test ran on 15 October 2026 on an arm64 Mac, with each library's default settings, using its usual "safe" load function.

Parser (version tested)Default resultWhat limits it
PyYAML 6.0.3 (safe_load, pure Python and libyaml)Loads both. json.dumps of an eight-level bomb wrote 351 MBNothing
ruamel.yaml 0.19.1 (safe and round-trip)Loads both, same output as PyYAMLNothing
js-yaml 5.4.3Loads bothmaxAliases, off by default
yaml (npm) 2.9.1Rejects both: "Excessive alias count indicates a resource exhaustion attack"maxAliasCount, default 100, weighted by expansion
gopkg.in/yaml.v3 3.0.1Rejects both: "document contains excessive aliasing"Ratio of alias-driven decodes to total decodes
sigs.k8s.io/yaml 1.6.0Rejects both, same messageSame ratio check, inherited from go.yaml.in/yaml/v2
goccy/go-yaml 1.19.2Loads both. An eight-level bomb became 303 MB of JSON, and the process took 1.7 GB from the OSNo alias limit we could find in the source
Psych 5.3.1 (Ruby 4.0.6)Rejects both: aliases are disabled by default in load and safe_loadaliases: false
SnakeYAML 2.7Rejects the 72-alias bomb, loads the 48-alias onemaxAliasesForCollections, default 50

PyYAML and ruamel.yaml finished loading in under a hundredth of a second, because they keep aliases as shared objects too. The expansion only happened when we serialised the result. That's safe right up until your code passes the data to something that walks it.

Go's results have a history behind them. In 2019, Kubernetes' API server could be exhausted by large or malformed YAML and JSON bodies (CVE-2019-11253). Today the Go YAML libraries under it carry the alias ratio check, and sigs.k8s.io/yaml, which Kubernetes tooling uses to turn YAML into JSON, inherits it.

Counting aliases isn't the same as bounding expansion

The most useful result is the difference between the two kinds of limit.

js-yaml 5.4.3 has a maxAliases option, documented as the maximum number of alias nodes per document and set to -1 (no limit) by default. SnakeYAML's maxAliasesForCollections defaults to 50. Both count how many aliases appear in the file. That stops our first bomb with SnakeYAML's default, since it has 72. But the damage depends on how the aliases are nested, not how many there are. Our second bomb uses 48 aliases, three wide and 17 deep. It loaded under SnakeYAML's defaults and under js-yaml with maxAliases: 50. Walking the loaded data in Java visited 193,710,243 strings, and serialising it would produce well over a gigabyte of JSON.

The libraries that stopped both files measure expansion instead. The npm yaml package multiplies the number of times an anchor is used by the number of aliases inside it. Go's check compares how many decode steps came from aliases with how many happened in total. OpenAPI.NET's fix counts nodes during conversion and stops at 5,000,000.

So for untrusted input, an alias count is only safe when it's very low. With js-yaml, maxAliases: 0 rejects every alias. Most YAML from outside your organisation, such as an uploaded spec or a webhook body, has no reason to use aliases.

A guard for parsers with no limit

When your parser loads aliases as shared objects, you can measure the expanded size without paying for it. Count each shared object once, remember its size, and add the remembered size each time it appears again. The script below does that for PyYAML. It also rejects recursive aliases such as a: &a [*a], which PyYAML loads as a self-referencing list.

#!/usr/bin/env python3
"""Load YAML safely and refuse documents that expand too far.

PyYAML keeps aliases as shared objects, so loading an alias bomb is cheap.
The cost arrives later, when json.dumps, a deep copy, a validator or a
templating step walks the data and visits every shared object again.
expanded_size() counts that cost without paying it, by remembering the
size of each shared object.
"""
import sys
import yaml

MAX_NODES = 1_000_000  # pick a ceiling well above your largest real document


class TooBig(ValueError):
    pass


def expanded_size(obj, limit=MAX_NODES):
    sizes, active = {}, set()

    def size(o):
        if not isinstance(o, (dict, list)):
            return 1
        key = id(o)
        if key in sizes:
            return sizes[key]
        if key in active:
            raise TooBig("recursive alias")
        active.add(key)
        items = list(o.keys()) + list(o.values()) if isinstance(o, dict) else o
        n = 1 + sum(size(i) for i in items)
        active.discard(key)
        if n > limit:
            raise TooBig(f"expands to more than {limit:,} nodes")
        sizes[key] = n
        return n

    return size(obj)


def load_untrusted(text, limit=MAX_NODES):
    data = yaml.safe_load(text)
    expanded_size(data, limit)
    return data


if __name__ == "__main__":
    for path in sys.argv[1:]:
        try:
            data = yaml.safe_load(open(path))
            print(f"{path}: ok, {expanded_size(data):,} nodes")
        except (TooBig, RecursionError) as e:
            print(f"{path}: rejected, {e}")

We ran it on a Compose-style file with a normal anchor and merge key, a six-level bomb, both large bombs and a recursive alias:

compose.yaml: ok, 43 nodes
bomb-6x9.yaml: ok, 672,610 nodes
bomb-9x9.yaml: rejected, expands to more than 1,000,000 nodes
bomb-17x3.yaml: rejected, expands to more than 1,000,000 nodes
recursive.yaml: rejected, recursive alias

The check took 0.02 seconds on the 435-million-string bomb, because it sizes each of the nine shared lists once. Set MAX_NODES well above your largest legitimate document. The six-level bomb passed at 672,610 nodes, which shows the limit is a ceiling to tune, not a detector.

The same approach works for js-yaml in JavaScript and for SnakeYAML in Java, keyed on object identity. For goccy/go-yaml, the simpler fix is to switch untrusted input to yaml.v3, or to check the size of the document's AST before decoding it.

Settings by language

  • Python (PyYAML, ruamel.yaml): keep using safe_load, which prevents code execution but doesn't limit expansion. Run a size check like the one above before the data goes anywhere else.
  • JavaScript: the yaml package is protected by default; leave maxAliasCount at 100 or lower it. With js-yaml, pass maxAliases: 0 for untrusted input.
  • Go: yaml.v3 and sigs.k8s.io/yaml refuse bombs by default. Treat goccy/go-yaml as unprotected for untrusted input.
  • Ruby: Psych's default of aliases: false is the right one. Don't pass aliases: true for input you don't control.
  • Java: lower SnakeYAML's maxAliasesForCollections, or set it to zero, for untrusted input. The default of 50 can still be beaten.
  • .NET (OpenAPI.NET): upgrade, then leave YamlConverter.MaxNodeCount and MaxDepth at their defaults unless you only read trusted specs.

CVE-2026-72923 in detail

PackageAffectedFixedFix released
Microsoft.OpenApi.YamlReader2.0.0-preview.11 up to 2.12.0; 3.0.0 up to 3.10.02.12.0, 3.10.012 August 2026
Microsoft.OpenApi.Readersbefore 1.6.301.6.3013 August 2026

The fixed readers fail with an OpenApiDiagnostic error and return no document once a limit is crossed. GitHub's advisory credits myDosGyarados with finding the bug. It's rated CVSS 3.1 7.5, availability only, with no claim of code execution or data exposure.

The advisory also suggests parsing untrusted OpenAPI documents outside the main application process where possible. That's good advice for any parser: a crash in a child process is a failed request, not an outage.

The timeline is worth noting. Microsoft published the repository advisory on 13 August, the day the last fix shipped. The CVE record followed on 8 September. The advisory only entered GitHub's reviewed database, which is what Dependabot and OSV-based scanners read, on 14 October. For two months after the fix, a scanner that relies only on GitHub's advisory data would have reported a vulnerable version as clean. If you use OpenAPI.NET, check the version you actually run, not just what your scanner says.

The fair view

Alias bombs are availability bugs. They need an attacker who can supply YAML, and they crash or stall a process rather than leak data or run code. Most YAML in a typical company is configuration written by its own engineers, where a bomb would be self-inflicted. Aliases also have real uses in Compose files, CI configuration and Helm charts, so "reject all aliases" is the wrong default for trusted config.

The risk sits in places that parse someone else's YAML inside a shared process: API gateways importing specs, documentation portals, CI services reading contributors' workflow files, and any multi-tenant service that accepts YAML. There, a parser with no limit, or a limit that only counts aliases, lets one small file take the process down for everyone. The fix is cheap: know which kind of limit your parser has, and add a size check where it has none. For the older XML version of this attack, see our post on XXE and billion laughs.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.