Eine Zipbombe (oder Archivbombe) ist eine komprimierte Datei, die zu einer extrem großen Datei entpackt wird. Normalerweise erstellt man Zipbomben direkt durch genaue Kenntnis des Kompressionsverfahrens ohne tatsächlich Daten zu komprimieren. So kann man zum Beispiel eine 42 kiB große Datei auf 4,5 Petabyte aufblähen.
Die billigere Variante ist, einfach sehr gut zu komprimierende Daten zu nehmen und sie zu komprimieren. Deutlich weniger elegant und 4,5 Petabyte wird man damit nicht hinkriegen. Dennoch ganz lustig. Ein Nutzen für eine solche Datei ist zum Beispiel, Scraper, die sich nicht an Regeln halten (z.B. and die robots.txt) halten, abzustrafen.
Meine eigene billige Zipbombe
Cool, so etwas will ich auch haben. Ich habe aber nicht genug Kenntnis über Kompressionsverfahren um so etwas zu basteln, möchte keine vorgefertigte Zipbombe nehmen (deren Hash könnte ja auf einer ignore-list stehen) und hätte gerne eine Zipbombe, in der gültiges HTML steckt. Womit auch erklärt wäre, warum ich neulich eine große HTML-Datei möglichst schnell nach stdout schreiben wollte (dazu gibt es übrigens unten noch ein Update).
Ich habe drei verschiedene Kompressionsverfahren ausprobiert, die alle im HTTP-content-encoding header erlaubt sind: Brotli, gz und Zstandard. Ich habe jeweils die höchste Kompressionsrate gewählt (für gz allerdings nicht Zopfli, das wäre mir dann doch zu langsam gewesen, also nur herkömmliches gzip). Ich habe dann wie beschrieben eine etwa 42 GiB große Datei erzeugt und diese on-the-fly zur Kompression gegeben.
Die Messungen sind nur mit time gemacht, könnten also Ausreißer enthalten. genbig ist hier das Rust-Programm, das die HTML-Daten erzeugt. Das sind die Ergebnisse:
gzip
$ time genbig | gzip --best --stdout > big.html.gz
real 1m15,029s
user 1m12,729s
sys 0m14,630s
Dauert eine Weile, und die Ergebnisdatei ist etwa 126 MiB groß. Eigentlich würde ich gz gerne mit reinnehmen, weil es am weitesten verbreitet ist (viele Bots unterstützen meiner Erfahrung nach überhaupt keine Transportkompression, ganz zu Schweigen von Brotli), aber 126 Megabyte sind mir zu groß, da werden auf meiner Seite zu viele Resourcen genutzt.
Brotli
$ genbig | brotli --force --best -o big.html.br
real 32m47,275s
user 32m36,105s
sys 0m16,348s
Das hat eine gute halbe Stunde gebraucht, also eine Größenordnung mehr als gzip. Autsch! Auf der positiven Seite: Die Datei ist angenehme 35 kiB groß. Damit kann ich arbeiten.
Zstandard
Und der Neueste im Bunde. Um hier die Performance vergleichen zu können, habe ich es einmal mit einem Kompressions-thread und einmal mit vier Kompressions-Threads laufen lassen. Es gab dabei jeweils immer noch einen separaten I/O-Thread. Zuerst die Version mit einem Thread:
$ time genbig | zstd -19 -T1 --no-content-size --size-hint=43009MiB -o big_single.html.zst
real 0m53,649s
user 1m4,932s
sys 0m19,570s
Dann die Version mit vier:
$ time genbig | zstd -19 -T4 --no-content-size --size-hint=43009MiB -o big.html.zst
real 0m20,540s
user 1m27,782s
sys 0m19,051s
Von der Zeit her also beides deutlich besser als gzip, ganz zu schweigen von Brotli. Das Ergebnis ist für beide Varianten gleich und etwa 3,7 MiB groß. Das kommt schon in eine brauchbare Richtung, ist aber Größenordnungen hinter der Brotli-Variante.
Fazit
Es war ganz lustig, das mal ausprobiert zu haben. Bisher habe ich diese Dateien noch nirgendwo im Einsatz, weil ich noch nicht genau weiß, wie ich sie einsetzen will. Ich möchte keine Suchmaschinencrawler abschrecken, und erst recht nicht, dass ein regulärer User aus Versehen auf diese Dateien stößt. Eher will ich LLM-Scraper bestrafen, die sich nicht an die robots.txt halten (die sie momentan aber noch nicht aus diesem Blog aussperrt) oder die ganzen Scanner, die bei mir nach Lücken suchen. Letztere können aber auch gutmütig sein, aber davon würde ich nicht unbedingt ausgehen.
Viele dieser Bots unterstützen wie gesagt sowieso keine Transportkompression, und die meisten, die es tun, kein Brotli. Und speziell die gzip-Datei ist mir zu groß für den Spaß. Trotzdem: War mal ganz lustig, das auszuprobieren.
Bonus: I/O-Optimierung
Zstandard ist wirklich schnell, besonders mit mehreren Threads. Tatsächlich war es so schnell, dass ich dafür ein Rust-Programm optimieren musste, so dass es schneller nach stdout schreibt. Dummerweise war das Rust-Programm danach zwar deutlich schneller als die Python-Variante, aber immer noch zu langsam. Mit vier Threads:
real 0m51,731s
user 2m8,343s
sys 0m22,070s
Wenn man sich den Prozess während der Laufzeit anschaut, stellt man fest, dass genbig 100 % eines Kerns auslastet, und zstd kommt gerade mal auf 190 % (von den mit 4 + 1 Threads möglichen 500 %). Das verfälscht natürlich die Messung. Außerdem: Verdammt soll ich sein wenn ich kein Programm schreiben kann, das nichts tut außer immer wieder das gleiche nach stdout zu schreiben und dabei schneller ist als ein Programm, dass dieselben Daten von stdin liest und komprimiert. Das wäre peinlich.
Aber ich kann ja sicher etwas verbessern. Zum Beispiel werden sehr viele kleine Schreiboperationen durchgeführt. Die gehen zwar in einen Buffer, trotzdem muss nach jeder Operation auf Fehler geprüft werden. Wenn man das eine Milliarde mal macht, kommt da richtig Rechenzeit zusammen. Also habe ich mir gedacht, ich schreibe einfach mehrere Einheiten von dem sich wiederholenden Teil in einem write-Aufruf:
fn gen_body_part() -> String {
// 8 kiB is the default buffer size of BufWriter
repeat(REPEATED).take((1024 * 8 ) / REPEATED.len() + 1).collect()
}
fn main() -> Result<()> {
let body_part = gen_body_part();
let body_bytes = body_part.as_bytes();
let mut out = BufWriter::new(stdout().lock());
writeln!(out, "{}", PREFIX)?;
let repeats = APPROX_SIZE / body_bytes.len();
for _ in 0..repeats {
out.write_all(body_bytes)?;
}
writeln!(out, "{}", SUFFIX)
}
Das Ergebnis ist besser, aber nicht wesentlich besser:
real 0m48,688s
user 2m4,117s
sys 0m20,982s
genbig ist immer noch bei 100 % Auslastung, zstd bei etwa 200 %. Aber ich habe ja auch einen Fehler gemacht: Ich habe das, was ich auf einmal schreibe, ein sehr kleines bisschen größer gemacht als den Buffer. Wenn ich also die Funktion oben ändere in
fn gen_body_part() -> String {
// 8 kiB is the default buffer size of BufWriter
repeat_n(REPEATED, (1024 * 8) / REPEATED.len()).collect()
}
Dann hat genbig zwischendurch endlich Zeit Luft zu holen, zstd rechnet auf allen Threads am Limit und die Laufzeit verringert sich auf… Trommelwirbel
real 0m20,540s
user 1m27,782s
sys 0m19,051s
Etwa 20 Sekunden. Eine deutliche Verbesserung.
Ich hätte natürlich auf die Buffergröße anpassen können. Vermutlich gibt es noch einen Haufen Optimierungsmöglichkeiten hier. Und das hier ist ein sehr kurzes Programm. Ich frage mich, wie viel Software ich geschrieben habe, die langsam ist, weil sie an irgendeinem dummen Flaschenhals feststeckt. Ich frage mich auch, wie viel Software da draußen viel langsamer ist, als sie es sein müsste, weil irgendjemand einen dummen Flaschenhals übersehen hat. Naja, jedenfalls habe ich wieder etwas gelernt.