Przejdź do treści

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.

JAVA ArchiwumZip.java 194 linii Pobierz .java
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

  1. Utwórz plik ArchiwumZip.java — publiczna klasa dyktuje tu nazwę pliku — wklej do niego kod i wywołaj java 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.
  2. 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ć.
  3. Rozpakowanie to rozpakuj(archiwum, katalogDocelowy, limitWpisow, limitBajtow), a zwrócona List<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.
  4. 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. bezpiecznyCel wykonuje resolve, normalize i dopiero potem startsWith, więc odrzuca .., ścieżki bezwzględne oraz wpis równy samemu katalogowi. Sama kontrola leksykalna nie zatrzymuje jednak istniejącego linku, dlatego rozpakuj wymusza całkiem nowy katalog, a każdy plik otwiera przez CREATE_NEW i NOFOLLOW_LINKS.
  • Nie ufaj ZipEntry.getSize(). W demonstracji nagłówek wypisuje -1, bo ZipOutputStream zapisał rozmiar w stopce wpisu, a ZipInputStream czyta go dopiero po przejściu przez dane. Gdy rozmiar jest podany — także ten z centralnego katalogu, który czyta ZipFile — pochodzi od autora archiwum i może być zmyślony. Wiarygodne są tylko bajty policzone przy odczycie, dlatego limit sprawdza zapiszZLimitem. 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.txt w katalogu docelowym leżą już dane.txt i duzy.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ć InvalidPathException na Windows, dlatego bezpiecznyCel zamienia ten wyjątek na zwykłą odmowę. Podczas pakowania walkFileTree pomija dowiązania oraz obiekty isOther(), w tym typowe junctions; twardych linków standardowe API nie odróżnia od zwykłych plików.
#pliki #nio #path #zip #bezpieczeństwo

Podobne skrypty