Blog Posts

{ }

The 904KB Minecraft Zipbomb

A tiny resource pack hides 400,000 filter patterns, turning ordinary resource lookups into an hours-long slog.

minecraftresource-packsdenial-of-servicezipbomb

The 904KB Minecraft Zipbomb

The strange thing about this file is how boring it looks at first. The ZIP is only about 904 KiB. There are no textures, sounds, or elaborate models inside it. Open the archive listing and you find just one file: pack.mcmeta.

That file expands to about 13.2 MiB of JSON. That is a noticeable jump, but it is not the wild billion-to-one expansion people usually imagine when they hear "zip bomb." The real trick is what the JSON asks Minecraft to do after it has been read.

One file, 400,000 entries

Inside pack.mcmeta is a filter.block list. It contains 400,000 entries, with paths running from zzz0 through zzz399999. Each entry is tiny. A few bytes here and there do not look alarming on their own, and compression makes the repeated JSON structure even smaller in the ZIP.

The filter is meant to be useful. Resource packs can use path filters to tell Minecraft which resources should be blocked when packs are layered together. In this pack, each object contributes a short path pattern such as zzz123. The path strings are only four to nine characters long, so the interesting variable is not the cost of one match. It is the number of patterns that need to be considered.

The data path has two costs. First, the JSON decoder has to allocate and decode 400,000 list entries, then the filter code compiles those paths into pattern objects. Those compiled objects and the list that holds them also take heap space beyond the 13.2 MiB JSON text. Second, while resources are being resolved, Minecraft tests candidate resource paths against the filters.

The expensive part happens again and again

The expensive part is that filtering is not just a one-time parse. ResourceFilterSection builds the block predicate, and FallbackResourceManager consults it in getResource, getResourceStack, and listResources. Those are ordinary resource lookup paths used while packs reload, causing repeated full scans of the filter list.

One reload can trigger roughly 100,000 predicate evaluations. A lookup that does not match any filter may have to scan the entire list before it can return false. With 400,000 patterns, the upper-bound shape is therefore 400,000 pattern tests per lookup. Across 100,000 full scans, that is about 40 billion tests.

We can describe the dominant work as O(L x M), where L is the number of filter patterns and M is the number of resource-manager predicate evaluations. That model assumes a full scan each time. Early matches, caching, and the actual number of lookups can reduce the total, but non-matching paths are the expensive case because the predicate has to reach the end of the list to know that nothing matched.

In a benchmark with 400,000 patterns in a separate 5 MiB test file, one isPathFiltered() call took 171 ms when it had no early match. If 100,000 calls all took that long, the multiplication is 17,100 seconds, or about 4 hours and 45 minutes of predicate time.

That is an extrapolation, not a stopwatch reading for this exact 904 KiB archive. The benchmark file was 5 MiB, and real timings depend on hardware, the resource pack stack, JIT warmup, and how many lookups hit the full-scan path. It is still enough to explain why this can look like a reload that never finishes rather than a brief hitch.

This is not just a slow first pass that eventually settles down. The reload keeps evaluating the filter, and the expensive full scans continue consuming CPU. In the tested case, the client stays stuck on the loading screen instead of recovering on its own. You may have to force-close Minecraft, then remove the pack from the resource packs folder before launching again. The exact time and CPU usage vary by machine, but waiting does not make the filter list smaller.

Why it is not classic regex ReDoS

It is tempting to call any slow regular-expression code "ReDoS," but that would miss what is happening here. The patterns in this pack are short strings like zzz123. They are not complicated expressions designed to make one match take exponentially longer.

When I tested this on JDK 25, even more than 20 deliberately nasty catastrophic-backtracking patterns ran in linear time. That only describes those tests on that JDK, not every Java regex on every runtime. This archive's problem is simpler: a large collection of individually cheap patterns gets compiled, then revisited over and over. There is no need for a single pattern to exhibit exponential behavior.

This distinction matters when choosing a fix. Replacing the regex engine or looking only for pathological expressions would miss the demonstrated bottleneck. The work comes from the product of list size and repeated scans.

Why the file is so small

ZIP compression is good at repeated structure. Every entry repeats the same JSON keys and punctuation, and the paths share a common prefix. So the archive stores that repetition compactly, while the game still has to reconstruct and process all 400,000 entries.

That is why the compressed size is a bit misleading. The ZIP does not expand by an absurd factor, but its contents trigger work that is much larger than reading 13 MiB of text. The cost comes from how the game uses the data, not just from how many bytes are written to disk.

The bug behind the stunt

In ResourceFilterSection, Codec.list(...) accepts the list without a maximum element count. In ExtraCodecs, the pattern is compiled without a cap on pattern length or complexity. That makes it possible to hand the rest of the pipeline a very large compiled collection, which FallbackResourceManager then consults in common lookup methods.

These are separate controls. A per-pattern length limit prevents one entry from being arbitrarily large, but does not stop 400,000 short entries. A list-size limit prevents that volume. A total compilation or evaluation budget can protect against combinations that pass either individual check. The key invariant is that untrusted metadata must not be able to create work proportional to an effectively unbounded list on every lookup.

A malicious server can force the pack

Minecraft Java servers can send resource packs to players and mark the pack as required. When a server requires its pack, the player has to accept it to stay connected. That gives a malicious server a direct delivery path for this kind of client-side denial of service: require the pack, then make remaining on the server conditional on loading it.

The resource manager work here is primarily CPU load, not GPU load. The client parses the large JSON list, compiles hundreds of thousands of patterns, and repeatedly scans them while resolving resources. That repeated matching is the main reason reload stalls and keeps a CPU core busy. Decoding and retaining all those entries and compiled patterns also uses extra RAM, but the benchmark measured the repeated CPU-side predicate scans. The pack does not contain graphics that would make it a GPU stress test.

Requiring a pack does not mean a server silently executes code on the player's computer (blog on that soon..). The client still processes the pack through Minecraft's resource-loading code, and the server's leverage is that refusing the required pack means disconnecting. Players should treat required packs from untrusted servers as untrusted input.

This POC declares pack_format: 88, so it targets the Minecraft Java 1.21.9-era pack format. That is the version to try first, not a guarantee that the issue affects every release using that format. Older versions may mark the pack incompatible, and newer versions may change how the metadata is interpreted or limit the expensive path. I have not verified the exact vulnerable version range, so I cannot claim that it works on every version.

If a pack leaves the client stuck reloading, do not keep retrying it and hoping the next reload will be different. Force-close the game and remove the pack from the resource packs folder before launching again.

What a real fix should do

The strongest first fix is at the input boundary. Reject filter lists above a reasonable maximum before compiling them. Validate individual pattern length and syntax there too, and make the failure a normal resource-pack load error rather than letting the client enter a huge amount of work.

There are also useful defense-in-depth options. The loader can track a total pattern-compilation budget, and the resource manager can index or cache filter decisions if its invalidation rules make that safe. Those optimizations reduce repeated work, but they do not replace an input limit. A cache still has to be populated, consumes memory, and must be correct across pack reloads.

This is a good reminder that a file does not need to be huge to cause a huge amount of work. Sometimes the dangerous part is not what the archive contains, but how often the game is asked to look at it.

Here's the POC. Use with caution.

Download the 904KB ZIP