1. Podstawowe najlepsze praktyki dla bezpiecznej serializacji
Serializacja to jak pakowanie bagażu na lotnisku: jeśli nie wiesz, co jest w środku i komu powierzasz walizkę, możesz mieć niemiłą niespodziankę przy kontroli bezpieczeństwa. W Javie serializacja pozwala łatwo zapisywać i odtwarzać obiekty, ale jednocześnie otwiera furtkę do całego spektrum ataków, jeśli dane pochodzą z niezaufanych źródeł.
Klasyczne zagrożenie:
Serializacja w Javie może być niebezpieczna. Jeśli napastnik podsunie złośliwy strumień, to podczas deserializacji możliwe są bardzo przykre konsekwencje: od zmiany pól po wykonanie niepożądanego kodu. To nie straszak z podręcznika – w historii Javy rzeczywiście były przypadki ataków opartych właśnie na tym mechanizmie.
Dlaczego tak się dzieje?
Chodzi o to, że deserializacja to nie tylko odtworzenie wartości pól. W trakcie powstaje pełnoprawny obiekt: mogą zostać wywołane specjalne metody (na przykład readObject, readResolve), a czasem także podatne miejsca w kodzie przez refleksję. Szczególnie niebezpieczne są klasy z zewnętrznych bibliotek: niektóre z nich wykonują działania już na etapie deserializacji. Dlatego nigdy nie ufaj serializowanym danym pozyskanym z zewnątrz.
Używaj transient dla danych wrażliwych
Jeśli w twojej klasie są pola zawierające hasła, tokeny, klucze prywatne lub inne wrażliwe informacje, oznacz je jako transient. Te dane nie trafią do strumienia serializowanego.
import java.io.Serializable;
public class User implements Serializable {
private String username;
private transient String password; // nie podlega serializacji
// ...konstruktory, gettery, settery...
}
Co się stanie przy deserializacji? Pole password będzie miało wartość domyślną (null dla String). To dobrze: hasła nie będą zapisywane w plikach ani przesyłane przez sieć.
Jawnie definiuj serialVersionUID
Zawsze jawnie określaj serialVersionUID. To zmniejsza ryzyko błędów zgodności i minimalizuje ryzyko podmiany klas podczas deserializacji.
private static final long serialVersionUID = 1L;
Dlaczego to ważne dla bezpieczeństwa? Jeśli nie podasz serialVersionUID, kompilator wygeneruje go automatycznie na podstawie struktury klasy. Może to prowadzić do nieoczekiwanych rozbieżności i, teoretycznie, do nadużyć przy podmianie klas o tej samej nazwie, lecz innej strukturze.
Sprawdzaj typy obiektów podczas deserializacji
Nie ufaj temu, co przyszło z sieci lub z pliku. Po deserializacji zawsze sprawdzaj, czy zwrócony obiekt ma oczekiwany typ, zanim zaczniesz z nim pracować.
Object obj = objectInputStream.readObject();
if (obj instanceof User) {
User user = (User) obj;
// bezpiecznie pracujemy z user
} else {
// nieoczekiwany typ – rzuć wyjątek lub obsłuż błąd
}
Po co to robić? Złośliwy strumień może zawierać obiekt innej klasy, który implementuje Serializable, ale nie odpowiada twojej logice biznesowej.
Ograniczaj klasy, które można deserializować (ObjectInputFilter)
Począwszy od Java 9 używaj filtrów – ObjectInputFilter, aby ograniczyć zbiór klas dozwolonych do deserializacji. To jak selekcja przy wejściu.
Przykład: ustawienie filtra
import java.io.*;
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.User;com.example.Address;!*"
);
ObjectInputStream in = new ObjectInputStream(inputStream);
in.setObjectInputFilter(filter);
Object obj = in.readObject(); // teraz deserializowane są tylko User i Address
Ten filtr zezwala wyłącznie na klasy User i Address twojej aplikacji. Wszystkie pozostałe zostaną zablokowane – zostanie rzucony wyjątek. To znacząco zmniejsza ryzyko wstrzyknięcia złośliwego obiektu.
Nie deserializuj danych z niezaufanych źródeł
Złota zasada: jeśli nie masz pewności co do źródła danych – nie deserializuj. Preferuj formaty, które nie wykonują kodu podczas parsowania (na przykład JSON, XML z bezpiecznymi parserami).
Przykład złej praktyki:
// Nigdy tak nie rób z danymi z Internetu!
ObjectInputStream in = new ObjectInputStream(socket.getInputStream());
Object obj = in.readObject(); // niebezpieczne!
Co robić lepiej?
- Używaj parserów JSON (np. Gson/Jackson) lub parserów XML z walidacją.
- Jeśli binarna serializacja jest konieczna – filtruj klasy przez ObjectInputFilter i sprawdzaj typy (instanceof).
Używaj alternatywnych formatów do wymiany z systemami zewnętrznymi
Do integracji używaj formatów, które nie wykonują kodu podczas parsowania: JSON, XML, Protocol Buffers itd. To niemal eliminuje ataki przez deserializację.
// Zamiast ObjectInputStream użyj parsera JSON
User user = gson.fromJson(jsonString, User.class);
Nie przechowuj serializowanych obiektów w publicznie dostępnych miejscach
Pliki z serializowanymi obiektami mogą zawierać wrażliwe dane. Nie przechowuj ich w publicznie dostępnych katalogach i ograniczaj uprawnienia na poziomie systemu plików.
Nie polegaj na serializacji w kwestii integralności
Serializacja nie gwarantuje integralności ani autentyczności danych. Używaj podpisów cyfrowych, sum kontrolnych lub szyfrowania, jeśli zmiany są niedopuszczalne.
2. Praktyka: przykład ObjectInputFilter i demonstracja podatności
Przykład filtrowania klas
Załóżmy, że mamy klasę User:
import java.io.Serializable;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private transient String password;
// ...konstruktory, gettery, settery...
}
Filtr zezwala tylko na User:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.User;!*"
);
in.setObjectInputFilter(filter);
Teraz, jeśli ktoś spróbuje podłożyć obiekt innej klasy, deserializacja zakończy się błędem.
Demonstracja potencjalnej podatności
Złośliwa klasa:
// Wyobraźmy sobie, że ktoś podłożył taką klasę
public class Evil implements java.io.Serializable {
static {
System.out.println("Złośliwy kod został wykonany!");
// tutaj może być cokolwiek...
}
}
Jeśli nie będziesz filtrować klas, podczas deserializacji może zostać utworzony obiekt Evil, a inicjalizator statyczny wykona się przy ładowaniu klasy – to już realny atak.
4. Typowe błędy przy zapewnianiu bezpieczeństwa serializacji
Błąd nr 1: Deserializacja bez filtrowania i sprawdzania typu. Często programiści odczytują obiekt ze strumienia i od razu rzutują go na potrzebny typ. To otwiera drzwi dla ataków. Używaj ObjectInputFilter i sprawdzaj typ przez instanceof.
Błąd nr 2: Przechowywanie danych wrażliwych bez transient. Jeśli zapomnisz oznaczyć hasła/klucze jako transient, trafią do strumienia i mogą wyciec wraz z plikiem.
Błąd nr 3: Brak serialVersionUID. Bez jawnego serialVersionUID możliwe są nieoczekiwane błędy zgodności i ryzyka związane z podmianą klas.
Błąd nr 4: Użycie serializacji do wymiany z systemami zewnętrznymi. Binarna serializacja jest wygodna wewnątrz aplikacji (np. cache), ale niebezpieczna dla zewnętrznej wymiany. Preferuj JSON/XML/Proto z bezpiecznymi parserami.
Błąd nr 5: Ignorowanie integralności danych. Zmiana bajtów serializowanego pliku może pozostać niezauważona. Stosuj podpisy cyfrowe, sumy kontrolne lub szyfrowanie.
GO TO FULL VERSION