Billion Laughs Style Attacks Through YAML Aliases Tested
Build a 378-byte YAML document that expands to 34 MB, measure PyYAML, DuckDB and Go on it, and see which parsers fail early and which wait for the output.

The billion laughs attack is an old XML trick. Define an entity as ten copies of another entity, nest it nine times, and a few hundred bytes of input expand into gigabytes of text when a parser resolves them. YAML has the same shape in anchors and aliases. An anchor (&a) names a node and an alias (*a) reuses it, which is a feature for configuration and a lever for abuse when the input is not yours. This post builds the document, measures what happens in several parsers and shows the defenses that worked. It belongs to the cluster on YAML security pitfalls, and the reading of anchors themselves is in YAML anchors, aliases and merge keys.
The document
Level zero is a list of nine strings. Each higher level is a list of nine aliases to the level below:
a0: &a0 [lol, lol, lol, lol, lol, lol, lol, lol, lol]
a1: &a1 [*a0, *a0, *a0, *a0, *a0, *a0, *a0, *a0, *a0]
a2: &a2 [*a1, *a1, *a1, *a1, *a1, *a1, *a1, *a1, *a1]and so on up to the depth you want. The file grows by about 54 bytes per level, and the logical document grows by a factor of nine per level: level two holds 81 strings, level six holds 531,441 and level seven holds 4,782,969. A small script generated depths three to seven:
def bomb(depth, fanout=9):
lines = ["a0: &a0 [lol, lol, lol, lol, lol, lol, lol, lol, lol]"]
for i in range(1, depth):
refs = ", ".join([f"*a{i-1}"] * fanout)
lines.append(f"a{i}: &a{i} [{refs}]")
return "\n".join(lines) + "\n"Nothing in the file is invalid. It uses only anchors and aliases, which the YAML specification defines and every parser supports.
PyYAML: the load is cheap, the use is not
depth 3: yaml 162 bytes, safe_load 0.54 ms, json.dumps -> 5,283 bytes in 0.0 ms
depth 4: yaml 216 bytes, safe_load 0.56 ms, json.dumps -> 47,565 bytes in 0.3 ms
depth 5: yaml 270 bytes, safe_load 0.64 ms, json.dumps -> 428,103 bytes in 2.4 ms
depth 6: yaml 324 bytes, safe_load 0.78 ms, json.dumps -> 3,852,945 bytes in 22.6 ms
depth 7: yaml 378 bytes, safe_load 0.92 ms, json.dumps -> 34,676,523 bytes in 200.4 msLook at the second column: safe_load took under a millisecond at every depth. PyYAML does not copy an aliased node; it points the alias at the same Python list. Loading is a few hundred object references. The cost appears the moment something walks the structure as a tree. json.dumps writes every occurrence, and its output grows ninefold per level: 5 KB, 48 KB, 428 KB, 3.9 MB and 34.7 MB. Two more levels would reach 2.8 GB, and each level takes ten times longer.
So a PyYAML-based service can pass the load, pass a schema check that only inspects the top-level keys, and fall over when it serializes the result to JSON for a response, writes it to a database, logs it or deep copies it. The failure is far from the cause, which makes it hard to diagnose. The same document is harmless to code that only reads one key and catastrophic to code that traverses everything.
DuckDB: the cost arrives at read time
DuckDB's YAML reader materializes values into columns, so it pays the expansion. A depth-seven bomb with a SELECT that touched the biggest list:
49.85user 1.18system 0:50.97elapsed 100%CPU (0avgtext+0avgdata 1022436maxresident)kAbout 51 seconds and 1 GB of memory for a 378-byte file. The extension has a defense that needs to be turned on deliberately, a node budget. Setting yaml_set_max_expansion_nodes(1000) before reading the depth-seven file made the query fail at once:
Invalid Input Error: YAML expansion exceeded the maximum node budget (1000); the input may contain an alias/anchor expansion bomb. Raise the limit with yaml_set_max_expansion_nodes(N) if this is a legitimate document.The message says what happened and how to raise the limit. Whether the default budget is enough for untrusted input depends on your version, so set it explicitly for any query that reads files you do not control. Using DuckDB over YAML is covered in using DuckDB to query data stored as YAML.
Go and Java refuse early
Some parsers count aliases and refuse.
Go. gopkg.in/yaml.v3 has a built-in ratio check. Unmarshaling a nine-level bomb:
alias bomb: yaml: document contains excessive aliasingThe same library accepted a document with one anchor and two aliases, so ordinary use works. The check compares decoded size with the number of alias expansions, and rejects documents where aliases dominate.
Java. SnakeYAML limits the number of aliases for collections, 50 by default. A 60-level chain of doubling lists failed with:
alias bomb: YAMLException: Number of aliases for non-scalar nodes exceeds the specified max=50That limit is configurable through LoaderOptions.setMaxAliasesForCollections. A legitimate configuration with more than 50 reused collections needs the limit raised on purpose; a default that is low is a safe default.
Ruby. Psych's safe loading rejects aliases altogether unless you opt in. YAML.safe_load on a document with one alias raised Psych::AliasesNotEnabled: Alias parsing was not enabled, and aliases: true enabled them. That is the most conservative design: the feature is off until you decide you need it.
Defenses that worked
No single control is enough, and the combination is cheap.
Cap the input size before parsing. Our attack file was 378 bytes, so a size cap does not stop it, and it stops the other half of the problem, a very large file.
Disable aliases if you do not use them. In Ruby it is the default. In PyYAML you can detect aliases by scanning parse events for
AliasEventand rejecting them. Many configs never use anchors.Use a parser with a limit. Go's check and SnakeYAML's maximum are on by default, and DuckDB's budget is an explicit setting.
Measure the expanded size before you traverse. For PyYAML, compose the document into nodes and count the size with memoization, so shared nodes are counted in one pass. A guard that does this rejected the depth-seven bomb with
expands to more than 10000 nodes, while accepting a document with a legitimate anchor. The code is in handling YAML uploads from users.Bound the work. A timeout and a memory limit on the process that parses untrusted files turn an incident into a single failed job.
A schema check is not on this list. A JSON Schema validator traverses the whole tree, so it can be the thing that explodes. Cap first, validate second.
How big is too big
Pick limits from your use. A deployment manifest has a few thousand nodes at most. A configuration with 10,000 expanded nodes is already large. Our guard used 10,000 as a budget and a nesting depth of 20, and both are generous for configuration and tiny for data files. Set yours from the largest legitimate file you have, plus a margin, and log rejections so you can see if the limit is too tight.
When alias bomb defenses fall short
The limit is on the wrong thing. Limiting file size does not stop a small file. Limiting the alias count does not stop a few aliases of a huge anchor. A node budget on the expanded size handles both, and not every parser has one.
A downstream tool expands it. You may validate safely with one parser and then hand the document to a tool that converts it to JSON. Bound the expanded size at the boundary where the cost occurs.
Legitimate documents need many aliases. Kubernetes manifests rendered from templates and CI files with heavy anchor use can look suspicious. Raise the limit for trusted sources and keep it low for untrusted ones.
The attack is not aliases. Deep nesting, enormous scalars and many keys exhaust resources without a single alias. The same guard covers them.
The behavior of anchors is defined in the YAML 1.2.2 specification, and the XML ancestor of the attack is described on the Wikipedia article on the billion laughs attack. Parser limits are documented for Go yaml.v3 and SnakeYAML. Related: limiting alias expansion in a YAML parser.


