Zip Slip in xslweb: een ontbrekende padcheck in een ingebouwde unzip-functie
De ingebouwde unzip-functie van het Java XSLT-framework xslweb pakte ZIP-bestanden uit zonder te controleren of een entry buiten de doelmap belandde. Die fout zat sinds 2015 in elke release en is inmiddels door de maintainer verholpen. Hieronder de technische anatomie van de bug, de fix, en waarom dit patroon zo hardnekkig blijft terugkomen.
Wat is xslweb?
xslweb is een open source webframework voor ontwikkelaars die liever in XSLT en XQuery werken dan in een traditionele taal. Een applicatie bestaat uit stylesheets die een XML-representatie van het HTTP-request omzetten naar een XML-representatie van de response. Daarbovenop levert het framework een bibliotheek met XPath- en XQuery-extensiefuncties, voor HTTP-aanroepen, database-toegang, bestandstoegang, en ook voor het uitpakken van een ZIP-bestand. Die laatste, xslweb:unzip($source, $target), staat in dit artikel centraal.
Het is geen grote naam in de Java-wereld, maar xslweb wordt al sinds 2015 onderhouden en draait, net als veel vergelijkbare frameworks, als WAR-bestand in een servlet-container zoals Tomcat.
Zip Slip in een notendop
Zip Slip is een kwetsbaarheidsklasse die Snyk in 2018 voor het eerst breed documenteerde, nadat het die in tientallen populaire Java-, JavaScript-, Go- en .NET-projecten had aangetroffen. Het probleem zit in iets simpels. Een ZIP-bestand mag een entry-naam dragen als ../../../etc/cron.d/evil. Plakt de uitpakcode die naam blind achter een doelmap, dan komt het bestand niet in die map terecht, maar ergens daarbuiten, overal waar het uitpakkende proces toevallig schrijfrechten heeft.
Acht jaar na dat onderzoek duikt dezelfde fout nog altijd op, en xslweb is het meest recente voorbeeld dat ik tegenkwam.
Waar het misging
De functie
xslweb biedt stylesheets de extensiefunctie xslweb:unzip($source, $target), gedefinieerd in Unzip.java. $source mag een lokaal bestandspad of een file:-URI zijn. De functie slikt trouwens ook een http(s)://-URL, en haalt het bestand dan zelf op. $target is de map waar de inhoud belandt. De echte uitpaklogica zit in ZipUtils.unzipStream(), en die zag er vóór de fix zo uit:
while ((entry = zis.getNextEntry()) != null) {
File file = new File(extractTo, entry.getName());
if (entry.isDirectory()) {
if (!file.exists()) {
file.mkdirs();
}
} else {
...
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file));
...
}
}
new File(extractTo, entry.getName()) is de hele kwetsbaarheid, in één regel. entry.getName() komt regelrecht uit het ZIP-bestand en wordt nergens genormaliseerd of gecontroleerd. Een entry die ../../../../opt/tomcat/webapps/ROOT/shell.jsp heet, wordt netjes naar precies dat pad geschreven, ver buiten extractTo.
Waarom dit uitvoerbaar is, niet alleen theoretisch
De functie is bedoeld voor ontwikkelaars die bijvoorbeeld een door een bezoeker geüploade ZIP naar een werkmap willen uitpakken, denk aan een themapakket, een content-import of een set afbeeldingen:
<xsl:value-of select="xslweb:unzip($uploaded-zip-path, $target-dir)"/>
De ontwikkelaar vertrouwt erop dat xslweb:unzip() dat veilig afhandelt. Daar gebruik je een frameworkfunctie tenslotte voor, in plaats van het zelf te bouwen. Maar de functie deed geen enkele validatie. Elke xslweb-applicatie die deze ingebouwde functie inzette om door bezoekers aangeleverde ZIP-content uit te pakken, kreeg de kwetsbaarheid er gratis bij. Niet door een fout van de ontwikkelaar, maar door een bouwsteen die het framework zelf onveilig aanbood.
Nog iets. $source accepteert ook een URL die de server zelf ophaalt. Geeft een stylesheet een door de aanvrager gestuurde parameter ongefilterd door aan xslweb:unzip(), dan combineer je Zip Slip met SSRF. De server haalt dan een ZIP op van een plek die de aanvaller bepaalt, en pakt die vervolgens onveilig uit.
Het effect
Wat een aanvaller precies kan overschrijven, hangt af van de deployment. Denk aan de schrijfrechten van het servlet-containerproces, de mappenstructuur, en welke bestanden de applicatie zelf weer inleest. Maar het patroon is bekend. Een JSP-bestand in de webapps-map van Tomcat droppen levert meestal remote code execution op. En zelfs zonder dat geeft willekeurige schrijftoegang vrijwel altijd een opstapje naar meer, of het nu gaat om het overschrijven van configuratiebestanden, het vervangen van stylesheets of het knoeien met logbestanden.
Dit soort kwetsbaarheden vindt u niet met een scanner, maar door de code en het gedrag met de blik van een aanvaller te bekijken. Dat is precies wat een handmatige pentest doet.
Plan een gratis intake →De fix
De maintainer van xslweb mergede op 26 mei 2026 een herschreven unzipStream() (commit 518c9ed). De kern van de fix is één validatiestap, uitgevoerd vóór elke schrijfactie:
private static Path resolveEntryPath(Path destRoot, ZipEntry entry) throws IOException {
// Normalize ensures that any ../ segments are removed before validation
Path resolved = destRoot.resolve(entry.getName()).normalize();
if (!resolved.startsWith(destRoot)) {
throw new IOException("Zip Slip detected for entry: " + entry.getName());
}
return resolved;
}
Elke entry wordt eerst tegen de doelmap opgelost en genormaliseerd, waardoor de ../-segmenten verdwijnen. Pas daarna volgt de controle: ligt het resultaat nog steeds onder de doelmap? Zo niet, dan gooit de functie een exception in plaats van te schrijven. De rest van de implementatie is overgezet naar de java.nio.file-API's, met try-with-resources en een nette fout bij een leeg archief.
Gevonden en gemeld door: Sofyan Aarrass (Resync).
Fix: gemerged op 26 mei 2026 door de maintainer (commit 518c9ed).
Gepubliceerde release met de fix: nog geen. De laatste getagde release is v4.2.0 (januari 2022).
Elke gepubliceerde release, ook de huidige v4.2.0, bevat de kwetsbare code. Er is simpelweg nog niets uitgebracht ná de fix. Bouwt u zelf vanaf de laatste master-branch, dan heeft u de patch al binnen. Draait u op een gepubliceerde release en verwerkt uw applicatie ZIP-bestanden van bezoekers via xslweb:unzip()? Pas dan de validatie uit de fix hierboven lokaal toe totdat er een nieuwe release is.
Een kwetsbaarheid die al tien jaar meereist
Het loont om even stil te staan bij hoe lang dit onopgemerkt bleef. unzipStream() verscheen in november 2015. Ik heb het nagetrokken, en elke gepubliceerde release sindsdien, van v2.0.0 tot en met de huidige v4.2.0, draagt de kwetsbare versie mee. Niet omdat niemand naar de code keek, maar omdat een onveilige unzip-implementatie nergens uit springt totdat iemand er gericht naar zoekt. Hij compileert, hij draait, en wie hem op de normale manier gebruikt merkt nooit iets. Zip Slip zit niet verstopt in de zelden gebruikte hoek van een codebase, maar gewoon in de grote berg code die nooit met kwaadaardige input wordt getest.
De bredere les
Zip Slip is geen exotische kwetsbaarheid. Het patroon new File(parent, entry.getName()) staat in talloze tutorials en Stack Overflow-antwoorden, en dus ook, niet toevallig, in de trainingsdata van zowat elke AI-codeassistent. (Dat raakt aan iets wat we ook bij vibe-coded apps beschrijven: code die werkt wordt overal overgenomen, code die echt veilig is moet je er bewust bovenop bouwen.) Een ZIP uitpakken is in vrijwel elke taal en bibliotheek zo'n functie die "gewoon werkt", zonder ingebouwde padbescherming. Die bescherming voegt u er zelf aan toe, of u pakt een bibliotheek die het al voor u doet.
Hoe u dit zelf voorkomt
Vertrouw nooit blind op entry.getName()
Resolve het pad tegen de doelmap, normaliseer het, en controleer pas daarna of het er nog binnen valt, vóórdat u ook maar iets wegschrijft. Dat is exact het patroon van de fix hierboven, en het zijn drie regels die de hele kwetsbaarheidsklasse de das om doen.
Of gebruik een bibliotheek die het al afdwingt
Apache Commons Compress en recentere versies van zip4j bieden veilige extractie-varianten. Kunt u die inzetten in plaats van een zelfgeschreven uitpak-loop, doe dat dan.
Behandel "uitpakken" als onvertrouwde input, ook als de aanroep vertrouwd is
De kwetsbaarheid zat niet in wie de functie mocht aanroepen. Dat was een vertrouwde partij, een ontwikkelaar die een stylesheet schrijft. Ze zat in de inhoud van het bestand dat werd uitgepakt. En of die inhoud nu door een bezoeker is geüpload of ergens van een URL is gehaald, zodra uw applicatie een ZIP, TAR of vergelijkbaar archief van buiten verwerkt, is die inhoud onvertrouwd. Hoe vertrouwd de aanroepende code ook is.
Conclusie
Een unzip-functie die er al tien jaar in zit, in een framework dat nog altijd in productie draait, met een fout die in drie regels te verhelpen blijkt. Daarom blijft Zip Slip, jaren na het oorspronkelijke onderzoek van Snyk, opduiken. Niet omdat het lastig te vinden is, maar omdat niemand ernaar kijkt, totdat iemand het wél doet.
Voor xslweb is het beeld helder: verholpen in de code, nog niet in een release. Voor de rest van ons is het een goede aanleiding om elke "uitpak"-functie in de eigen stack één keer kritisch tegen het licht te houden.
Dit soort bugs vindt u niet met een scanner.
Padvalidatie, autorisatie, businesslogica. Het soort kwetsbaarheid dat een handmatige pentest blootlegt en een geautomatiseerde scan voorbijloopt.
Naar webapplicatie-pentest →