Pakowanie i rozpakowywanie ZIP bezpiecznie
- Pliki i katalogi
- poziom: zaawansowany
- aktualizacja: 16 sierpnia 2026
ZIP wygląda niewinnie, dopóki nie rozpakujesz cudzego archiwum. Nazwa wpisu może zawierać ../../evil.txt, a istniejące dowiązanie w katalogu celu potrafi wyprowadzić zapis poza niego mimo poprawnej normalizacji. Drugi kłopot to bomba: niewielki plik skompresowany może rozwinąć się do gigabajtów. Także pakowanie ma pułapki — link do pliku ujawni dane spoza drzewa, a archiwum zapisane we własnym źródle zacznie pakować samo siebie. Ten skrypt pomija linki i junctions, wymaga nowego katalogu rozpakowania, kontroluje każdą ścieżkę i liczy rzeczywiście odczytane bajty.
import static java.nio.charset.StandardCharsets.UTF_8;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
/**
* Pakowanie i rozpakowywanie ZIP bezpiecznie — skryptyJAVA.pl
* Zapisz jako: ArchiwumZip.java
*/
public class ArchiwumZip {
public static void spakuj(Path katalog, Path archiwum) throws IOException {
Path baza = katalog.toRealPath();
if (!Files.isDirectory(baza, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Źródło ZIP musi być katalogiem.");
}
Path wynik = archiwum.toAbsolutePath().normalize();
Path rodzic = wynik.getParent();
if (rodzic == null || !Files.isDirectory(rodzic)) {
throw new IOException("Katalog nadrzędny archiwum musi już istnieć.");
}
Path rzeczywistyWynik = rodzic.toRealPath().resolve(wynik.getFileName());
if (rzeczywistyWynik.startsWith(baza) || Files.isSymbolicLink(wynik)) {
throw new IOException("Archiwum musi leżeć poza pakowanym katalogiem.");
}
Path tymczasowy = Files.createTempFile(rodzic, "archiwum-", ".tmp");
try {
try (ZipOutputStream zip =
new ZipOutputStream(Files.newOutputStream(tymczasowy), UTF_8)) {
Files.walkFileTree(baza, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(
Path dir, BasicFileAttributes attrs) throws IOException {
if (!dir.equals(baza) && (attrs.isSymbolicLink() || attrs.isOther())) {
return FileVisitResult.SKIP_SUBTREE;
}
if (!dir.equals(baza)) {
zip.putNextEntry(new ZipEntry(nazwaWZipie(baza, dir) + "/"));
zip.closeEntry();
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(
Path plik, BasicFileAttributes attrs) throws IOException {
if (!attrs.isRegularFile()) { return FileVisitResult.CONTINUE; }
zip.putNextEntry(new ZipEntry(nazwaWZipie(baza, plik)));
try (var dane = Files.newInputStream(plik, LinkOption.NOFOLLOW_LINKS)) {
dane.transferTo(zip);
}
zip.closeEntry();
return FileVisitResult.CONTINUE;
}
});
}
Files.move(tymczasowy, wynik, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(tymczasowy);
}
}
private static String nazwaWZipie(Path baza, Path sciezka) {
return baza.relativize(sciezka).toString().replace(File.separatorChar, '/');
}
/** Rozpakowuje, odrzucając ucieczki poza katalog docelowy i licząc FAKTYCZNIE odczytane bajty. */
public static List<String> rozpakuj(Path archiwum, Path katalogDocelowy,
int limitWpisow, long limitBajtow) throws IOException {
if (limitWpisow < 0 || limitBajtow < 0) {
throw new IllegalArgumentException("Limity nie mogą być ujemne.");
}
Path cel = katalogDocelowy.toAbsolutePath().normalize();
Path rodzic = cel.getParent();
if (rodzic == null) { throw new IOException("Katalog celu musi mieć rodzica."); }
Files.createDirectories(rodzic);
try {
Files.createDirectory(cel);
} catch (FileAlreadyExistsException zajetyCel) {
throw new IOException("Odmowa: katalog docelowy musi być nowy.", zajetyCel);
}
cel = cel.toRealPath();
List<String> wypakowane = new ArrayList<>();
long lacznie = 0;
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archiwum), UTF_8)) {
ZipEntry wpis;
while ((wpis = zip.getNextEntry()) != null) {
if (wypakowane.size() >= limitWpisow) {
throw new IOException("Odmowa: archiwum ma ponad " + limitWpisow + " wpisów.");
}
Path docelowy = bezpiecznyCel(cel, wpis.getName());
Files.createDirectories(wpis.isDirectory() ? docelowy : docelowy.getParent());
if (!wpis.isDirectory()) { lacznie += zapiszZLimitem(zip, docelowy, limitBajtow - lacznie); }
wypakowane.add(wpis.getName());
}
}
return List.copyOf(wypakowane);
}
private static Path bezpiecznyCel(Path cel, String nazwa) throws IOException {
try {
Path docelowy = cel.resolve(nazwa).normalize();
if (docelowy.startsWith(cel) && !docelowy.equals(cel)) { return docelowy; }
} catch (InvalidPathException zlaNazwa) {
throw new IOException("Odmowa: „" + nazwa + "” to nazwa nielegalna w tym systemie.");
}
throw new IOException("Odmowa (Zip Slip): „" + nazwa + "” nie wskazuje pliku w katalogu celu.");
}
private static long zapiszZLimitem(ZipInputStream zrodlo, Path plik, long limit) throws IOException {
byte[] bufor = new byte[8192];
long zapisane = 0;
var wyjscie = Files.newOutputStream(plik, StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
try (wyjscie) {
int odczyt;
while ((odczyt = zrodlo.read(bufor)) > 0) {
if ((zapisane += odczyt) > limit) {
throw new IOException("Odmowa: limit danych przekroczony na " + plik.getFileName());
}
wyjscie.write(bufor, 0, odczyt);
}
} catch (IOException blad) {
try {
Files.deleteIfExists(plik);
} catch (IOException sprzatanie) {
blad.addSuppressed(sprzatanie);
}
throw blad;
}
return zapisane;
}
private static void probuj(Path archiwum, Path cel, long limitBajtow) throws IOException {
try {
System.out.println("Rozpakowano: " + rozpakuj(archiwum, cel, 50, limitBajtow));
} catch (IOException odmowa) {
try (var zostalo = Files.walk(cel)) {
System.out.println(odmowa.getMessage() + " Na dysku zostało: "
+ zostalo.filter(Files::isRegularFile).map(Path::getFileName).toList());
}
}
}
// ── PRZYKŁADY ───────────────────────────────────────────
public static void main(String[] args) throws IOException {
Path robocze = Files.createTempDirectory("zip-demo-");
Path zrodlo = robocze.resolve("projekt");
Path cel = robocze.resolve("rozpakowane");
Path pulapka = robocze.resolve("pulapka.zip");
try {
Files.createDirectories(zrodlo.resolve("raporty marcowe"));
Files.writeString(zrodlo.resolve("notatka.txt"), "Zażółć gęślą jaźń\n", UTF_8);
Files.writeString(zrodlo.resolve("raporty marcowe/wpływy.txt"), "1 234,50 zł\n", UTF_8);
Path archiwum = robocze.resolve("projekt.zip");
spakuj(zrodlo, archiwum);
probuj(archiwum, cel, 1_000_000);
System.out.println("Kopia notatki zgodna z oryginałem: " + (Files.mismatch(
zrodlo.resolve("notatka.txt"), cel.resolve("notatka.txt")) == -1));
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(pulapka), UTF_8)) {
for (String nazwa : List.of("duzy.bin", "dane.txt", "../../evil.txt")) {
zip.putNextEntry(new ZipEntry(nazwa));
zip.write(nazwa.equals("duzy.bin") ? new byte[200_000] : "treść\n".getBytes(UTF_8));
zip.closeEntry();
}
}
try (var podglad = new ZipInputStream(Files.newInputStream(pulapka), UTF_8)) {
System.out.println("Pułapka waży " + Files.size(pulapka) + " B, a nagłówek wpisu"
+ " duzy.bin deklaruje rozmiar: " + podglad.getNextEntry().getSize());
}
probuj(pulapka, robocze.resolve("cel-bomba"), 50_000);
probuj(pulapka, robocze.resolve("cel-slip"), 1_000_000);
} finally {
try (var drzewo = Files.walk(robocze)) {
drzewo.sorted((a, b) -> b.compareTo(a)).map(Path::toFile).forEach(File::delete);
}
}
}
}
Jak użyć tego skryptu
- Utwórz plik
ArchiwumZip.java— publiczna klasa dyktuje tu nazwę pliku — wklej do niego kod i wywołajjava ArchiwumZip.java. Demonstracja pracuje w katalogu tymczasowym: pakuje mały projekt, rozpakowuje go z powrotem, a potem dwa razy odbija się od tego samego archiwum-pułapki i na koniec kasuje wszystkie utworzone pliki. - Do spakowania katalogu wywołaj
ArchiwumZip.spakuj(katalog, archiwum). Metoda sama odrzuca wynik leżący wewnątrz źródła, zanim otworzy plik, pomija dowiązania i windowsowe junctions oraz buduje archiwum w unikalnym pliku roboczym. Katalog nadrzędny wyniku musi już istnieć. - Rozpakowanie to
rozpakuj(archiwum, katalogDocelowy, limitWpisow, limitBajtow), a zwróconaList<String>zawiera nazwy przyjętych wpisów. Limity dobierz do swojego zastosowania: liczba wpisów obejmuje także katalogi, a limit bajtów dotyczy sumy plików po rozpakowaniu, nie rozmiaru archiwum. - Rozpakowanie wymaga ścieżki, która jeszcze nie istnieje — metoda sama tworzy świeży katalog i odrzuca nawet pusty istniejący cel. Dzięki temu żadne zastane dowiązanie ani junction nie może przekierować zapisu. Jeśli zamiast strumienia potrzebujesz dostępu po nazwie, użyj
ZipFile, ale zachowaj tę samą kontrolę ścieżki i zasadę świeżego katalogu.
Warto wiedzieć
- Ochrona przed Zip Slip łączy dwie warstwy.
bezpiecznyCelwykonujeresolve,normalizei dopiero potemstartsWith, więc odrzuca.., ścieżki bezwzględne oraz wpis równy samemu katalogowi. Sama kontrola leksykalna nie zatrzymuje jednak istniejącego linku, dlategorozpakujwymusza całkiem nowy katalog, a każdy plik otwiera przezCREATE_NEWiNOFOLLOW_LINKS. - Nie ufaj
ZipEntry.getSize(). W demonstracji nagłówek wypisuje-1, boZipOutputStreamzapisał rozmiar w stopce wpisu, aZipInputStreamczyta go dopiero po przejściu przez dane. Gdy rozmiar jest podany — także ten z centralnego katalogu, który czytaZipFile— pochodzi od autora archiwum i może być zmyślony. Wiarygodne są tylko bajty policzone przy odczycie, dlatego limit sprawdzazapiszZLimitem. Osobny limit liczby wpisów blokuje archiwum z milionem pustych plików, które w bajtach jest niewielkie, a katalog docelowy potrafi zabić. - Odmowa przychodzi w trakcie rozpakowywania, nie przed nim. Wydruk pokazuje to wprost: po odrzuceniu wpisu
../../evil.txtw katalogu docelowym leżą jużdane.txtiduzy.bin, bo były wcześniej w archiwum. Kod kasuje sam tylko ten jeden plik, który przekroczył limit w połowie zapisu. Sprzątanie reszty należy do Ciebie — usuń cały katalog docelowy albo, lepiej, rozpakuj do katalogu tymczasowego i przenieś zawartość dopiero po sukcesie. - Nazwy wpisów są tekstem, nie ścieżkami systemu plików. Bez
replace(File.separatorChar, '/')ZIP z Windows dostałby backslashe zamiast wymaganych ukośników. Nazwa legalna na Linuksie może zaś wywołaćInvalidPathExceptionna Windows, dlategobezpiecznyCelzamienia ten wyjątek na zwykłą odmowę. Podczas pakowaniawalkFileTreepomija dowiązania oraz obiektyisOther(), w tym typowe junctions; twardych linków standardowe API nie odróżnia od zwykłych plików.
Podobne skrypty
Odczyt i zapis plików tekstowych
Komplet operacji na plikach tekstowych przez java.nio.file.Files z jawnym UTF-8: zapis, dopisywanie, odczyt całości i linia po linii, przetwarzanie strumieniowe, kopiowanie i usuwanie.
początkujący Zobacz kod → Pliki i katalogiListowanie i przeszukiwanie katalogów przez walkFileTree
Rekurencyjne przeglądanie drzewa katalogów przez Files.walkFileTree: limit głębokości, filtr rozszerzeń, strumieniowe szukanie treści w UTF-8 oraz jawne pomijanie dowiązań i windowsowych junctions.
średni Zobacz kod → Pliki i katalogiEksport i import CSV ze średnikiem
Samodzielny zapis i odczyt CSV w UTF-8 ze średnikiem: cytowanie, pola wielowierszowe, BOM i CRLF pod Excela, ścisły parser oraz jawny tryb chroniący arkusz przed wykonaniem formuł z niezaufanych danych.
zaawansowany Zobacz kod →