Java, Stringi i hasła

Java, Stringi i hasła

O przechowywaniu haseł w obiektach typu `String` vs tablicach znaków `char[]`

sobota, 30 grudnia 2017

Informatyka

Hasło jest ciągiem znaków i jednocześnie należy do kategorii "danych wrażliwych". Jego ujawnienie - przypadkowe bądź nie - może mieć duże konsekwencje. Gdy tworzymy program, który przetwarza w jakiś sposób hasła, musimy zachować szczególną ostrożność tak, aby np. przypadkiem nie zapisały się one do logów. Do dobrych praktyk należy też czyszczenie obszarów pamięci, w których zapisane było jakieś hasło, kiedy przestaje być ono potrzebne. Inaczej nawet pojedyncza dziura taka, jak przepełnienie bufora, może być wykorzystana do "odzyskania" go. W Javie sytuacja wygląda trochę inaczej. Po pierwsze, jednym z założeń języka jest automatyczna weryfikacja zakresów tablic, efektywnie eliminująca błędy przepełnienia bufora. Jednak w Javie cała pamięć zarządzana jest automatycznie, zaś obiekt typu String jest niezmienny. Oznacza to, że jeśli zapiszemy do niego hasło, nie mamy żadnej kontroli nad tym, kiedy zostanie ono usunięte z pamięci maszyny wirtualnej. Oczywiście oddzielna kwestia to dostanie się do tej pamięci - nie jest to łatwe, ale wykonalne. Wystarczy mieć system, który robi zrzuty pamięci procesów przerwanych z powodu błędu oraz... exploit pozwalający wykrzaczyć maszynę wirtualną.

Kilkakrotnie spotkałem się z rekomendacją, że do przechowywania haseł w Javie powinno się wykorzystywać zwykłe tablice znaków char[] zamiast Stringów, ponieważ po użyciu możemy je łatwo wyzerować. Rekomendacja pojawia się czasem podczas rozmów rekrutacyjnych o pracę, można na nią trafić w szkoleniach z zakresu bezpieczeństwa. Co więcej, w bibliotece standardowej Javy możemy nawet znaleźć praktyczną implementację. W pakiecie Swing metoda getText() z kontrolki JPasswordField zwraca właśnie tablicę znaków. Brzmi ciekawie, prawda? Tyle teoria, zatem przeszedłem do praktyki i postanowiłem przeprowadzić pewne ćwiczenie koncepcyjne. Wyobraziłem sobie aplikację webową, z backendem i API REST-owym, która pozwala użytkownikowi zmienić swoje hasło. Ćwiczenie polegało na tym, żeby dokładnie prześledzić drogę hasła w maszynie wirtualnej od momentu pojawienia się go w buforze połączenia TCP, do momentu przesłania go do bazy danych.

Pierwszy ogień: JPA

Gdy słyszymy "zapisuj hasło jako char[]", najprawdopodobniej pomyślimy w pierwszej kolejności o naszych encjach. Aby mieć w ogóle o czym rozmawiać, musimy ustawić odpowiedni typ naszego pola:

@Entity
@Data // Lomboku, dodaj mi gettery i settery
public class User {
    @Id
    @Column
    private long id;

    @Column
    private String username;

    @Column
    private char[] password;
}

W przypadku korzystania z Hibernate'a z takim zapisem nie ma problemu. Przeglądając tablicę mapowań typów widzimy, że typ char[] jest prawidłowo rozpoznawany jako CharArrayType i mapowany na bazodanowy typ VARCHAR. Cel osiągnięty... ale czy na pewno?

Drugi ogień: czyszczenie

Zauważmy, że stworzyliśmy sobie pole char[] password, ale nie powiedzieliśmy Javie, co ma w związku z tym zrobić. To my musimy pamiętać o tym, by pole z hasłem wyczyścić po użyciu. Wprowadzamy zatem dodatkową metodę:

@Entity
@Data //Lomboku, dodaj mi gettery i settery
public class User {
    @Column
    private char[] password;
    // ... i inne pola

    public void clearSensitive() {
        Arrays.fill(password, '\0');
    }
}

Przykładowy kod w Springu mógłby wyglądać następująco:

@RestController
@RequestMapping("/user")
@Transactional
public class UserResource {
    private final UserRepository userRepository;

    public UserResource(UserRepository userRepository) {
        this.somethingRepository = somethingRepository;
    }

    @PostMapping(consumes = "application/json")
    public ResponseEntity<String> addProduct(@RequestBody User user) throws URISyntaxException {
        userRepository.save(user);
        user.clearSensitive();
       return ResponseEntity.created(new URI("/user/" + user.getId())).build();
    }
}

O koniecznych zmianach musimy pamiętać we wszystkich miejscach, gdziekolwiek wrażliwe dane trafiają do encji. Tu ujawnia się problem z narzędziami - ponieważ w Javie koncepcja czyszczenia pamięci zasadniczo nie istnieje, frameworki również nie będą nas w tym za bardzo wspierać. Dla nas oznacza to napisanie dużej ilości dodatkowego kodu.

Trzeci ogień: JDBC

Zabezpieczyliśmy nasze hasło na poziomie naszej aplikacji. W encji jest ono reprezentowane jako tablica znaków, pamiętamy też o jej wyczyszczeniu wszędzie tam, gdzie hasło się pojawia. Moja praca w ramach ćwiczenia koncepcyjnego zaczęła się tak naprawdę w tym miejscu. Bowiem czy mam od Hibernate'a jakąkolwiek gwarancję, że owa tablica znaków pozostanie tablicą na zawsze i że w którymś miejscu w kodzie third-party nie zmieni się jednak na String? Hibernate nie zajmuje się gadaniem z bazą danych samodzielnie. Pod spodem ma standardowe API JDBC, które również jest zaimplementowane w Javie. Do wstawiania danych do zapytań SQL Hibernate korzysta z obiektu PreparedStatement. Posiada on szereg metod do podpinania danych różnych typów pod zapytanie. Gdy przyjrzymy się szczegółowo dostępnym metodom, zauważymy, że mamy tam metody w stylu setByte(), setInteger(), setString(), ale nie ma nic, co pozwalałoby przesłać do bazy danych tekst w postaci tablicy znaków. Najbliższa temu rozwiązaniu wydaje się być metoda setCharacterStream​(). Teoretycznie moglibyśmy naszą tablicę ukryć pod takim strumieniem znaków i w ten sposób przesłać go do bazy... wciąż mając nadzieję, że implementacja JDBC pod spodem nie zrobi nam numeru. Jednak sama obecność metody to za mało - pytanie czy Hibernate z niej korzysta.

Zobaczmy najpierw, jak zaimplementowana jest w Hibernate'cie klasa CharArrayType:

public class CharArrayType extends AbstractSingleColumnStandardBasicType<char[]> {
    public static final CharArrayType INSTANCE = new CharArrayType();

    public CharArrayType() {
        super( VarcharTypeDescriptor.INSTANCE, PrimitiveCharacterArrayTypeDescriptor.INSTANCE );
    }

    public String getName() {
        return "characters"; 
    }

    @Override
    public String[] getRegistrationKeys() {
        return new String[] { getName(), "char[]", char[].class.getName() };
    }
}

Widzimy, że opis typu nie wykonuje jeszcze żadnej konkretnej pracy na danych, lecz deleguje tę funkcjonalność do deskryptora typu bazodanowego, którym w tym przypadku jest VarcharTypeDescriptor. Ten sam, który jest używany przez typ String. W tym miejscu zapaliła mi się lampka ostrzegawcza. Przeskoczyłem do źródeł VarcharTypeDescriptora i one wyjaśniły wszystko:

@Override
protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options) throws SQLException {
    st.setString( index, javaTypeDescriptor.unwrap( value, String.class, options ) );
}

Cała nasza praca z obudowywaniem i czyszczeniem tablicy znaków poszła na marne, bowiem w ostateczności Hibernate i tak zmieni nasze hasło na obiekt typu String po to, by przesłać je do JDBC. A w tym momencie ponownie zostaje ono w pamięci maszyny wirtualnej przez nieznaną ilość czasu bez możliwości kontrolowanego jego wyczyszczenia.

Czwarty ogień: JSON

Idąc tym tokiem rozumowania, możemy przyjrzeć się naszemu stosowi oprogramowania od drugiej strony. Spring bardzo ładnie upraszcza nam życie, dostarczając nam gotowy obiekt User, jednak pod spodem wciąż mamy całą ogromną mechanikę do konwersji danych. W pewnym momencie nasze hasło i tak jest strumieniem bajtów reprezentującym jakiś dokument JSON, wczytywanym z gniazda sieciowego. Dokument JSON musi być przeparsowany, zanim Spring będzie mógł rozpocząć konwersję na obiekt encji. W tym celu parser buduje sobie model dokumentu w pamięci (ew. strumieniuje kolejne tokeny) i w trakcie tego procesu pojawiają nam się obiekty String reprezentujące nazwy atrybutów i ich wartości. Zwróćmy uwagę - parser nie zajmuje się semantyką dokumentu. Kompletnie nie obchodzi go, że wartością atrybutu password są wrażliwe dane, które powinny być wyczyszczone po użyciu. To znaczy... mógłby się o tym dowiedzieć, gdyby odpowiednia logika była wbudowana w API parsera, jednak osobiście nigdy się z czymś takim nie spotkałem. Oznacza to, że proces parsowania wejściowego JSON-a również najprawdopodobniej pozostawi nasze hasło w postaci obiektu klasy String niezależnie od naszych starań.

Wnioski

Choć idea reprezentowania haseł w postaci tablic znaków wydaje się w teorii znakomitym pomysłem, w praktyce okazuje się, że w wielu scenariuszach nie jesteśmy w stanie jej zaimplementować. Aby ona działała, prawidłowa obsługa czyszczenia wrażliwych danych powinna być zaimplementowana nie tylko w naszym, ale również w zewnętrznym kodzie, od samego początku, do samego końca. Inaczej cały wysiłek włożony w obsługę tablic znaków po prostu nie ma sensu. W wielu przypadkach próba dodania takiego czyszczenia jest problematyczna już na poziomie designu. Parsowanie wszelkiego rodzaju tekstów z założenia wykonuje się etapami: tokeny, później składnia, później model pamięciowy... Zauważmy, że aby kod dzielący tekst wejściowy na tokeny wyczyścił pamięć po naszym haśle, musiałby albo wiedzieć, że w tym miejscu konkretny token reprezentuje hasło (czyli znać z wyprzedzeniem składnię tego, co parsuje), albo... zapisywać wszystko w postaci tablic znaków, rezygnując całkowicie ze wsparcia biblioteki standardowej języka. Podobnie rzecz ma się z parserem.

Praktyczne porady

Zatem czy powinniśmy całkowicie olać temat? W opracowaniu SEI CERT Oracle Secure Coding Standard for Java mamy dwie wzmianki o przechowywaniu wrażliwych danych:

Jeśli chodzi o zapisywanie haseł bezpośrednio w kodzie źródłowym, to sprawa jest oczywista - nigdy nie powinniśmy czegoś takiego robić. Rekomendacja omawia kwestię czyszczenia pamięci przechowującej wrażliwe dane i oto, co musimy sobie na ten temat uświadomić:

  • wspomniane techniki służą jedynie ograniczeniu czasu istnienia wrażliwych danych w pamięci, ale te dane jednak przez chwilę muszą się w niej znaleźć.
  • nie mamy wpływu na to, co z obszarami pamięci robi system operacyjny, ani sama maszyna wirtualna.
  • zastosowanie w/w technik jedynie zmniejsza prawdopodobieństwo, że w momencie np. wystąpienia core dumpa w pamięci JVM znajdzie się hasło.

Zatem jak w praktyce je stosować? Mój pomysł jest następujący:

  • inwestować w tablice znaków tam, gdzie możemy zagwarantować, że pozostanie ona tablicą znaków od początku do końca (np. czytanie hasła z konsoli),
  • skupić się na tym, by nie dało się do pamięci procesu w ogóle dostać:
    • upewnijmy się, że porty diagnostyczne są zablokowane na produkcji,
    • upewnijmy się, że w przypadku awarii produkcyjnej maszyny wirtualnej system operacyjny nie będzie generował core dumpów.
  • zabezpieczyć hasło przed przypadkowym zapisaniem np. w logach bądź w danych audytowych.

W przypadku scenariuszy z JPA/JDBC/JSON-ami, pozostawienie Stringów niczego nie pogarsza, bo i tak one się nam pojawią w pamięci w procesie parsowania. Możemy jednak rozważyć użycie tablicy znaków z innego powodu - jako techniki programowania defensywnego przeciwko pojawieniu się hasła w logach. Przypadkowe wywołanie toString() jakimś automatem na obiekcie char[] sprawi, że zamiast tekstu z hasłem w logach zobaczylibyśmy jedynie nic niemówiący numerek opisujący referencję. Zastanawiałem się też nad stworzeniem dedykowanego typu SensitiveData, ale później uznałem, że nie byłby to dobry pomysł. Gdyby ktoś naprawdę uzyskał dostęp do pamięci maszyny wirtualnej (choćby nawet narzędziem jvisualvm), miałby po prostu idealny punkt zaczepienia do szybkiego znalezienia wszystkich krążących po pamięci instancji wrażliwych danych :). Tymczasem liczba obiektów char[] jest ogromna - pod spodem każdy String ma taką oto tablicę, więc tablica z zapisanym hasłem zginie w morzu innych tekstów. Po co włamywaczowi ułatwiać życie?

Tomasz Jędrzejewski

Programista Javy, lider techniczny. W wolnych chwilach podróżuje, realizując od kilku lat projekty długodystansowych wypraw pieszych.

Autor zdjęcia nagłówkowego: Jared Tarbell, CC-BY-2.0

zobacz inne wpisy w temacie

Informatyka

poprzedni wpis Przyszłość Javy, bliższa i dalsza następny wpis O paradygmatach programowania

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, anonimizowany po zatwierdzeniu komentarza.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.

Klikając "Wyślij komentarz" wyrażasz zgodę na przetwarzanie podanych w nim danych osobowych do celów moderacji i publikacji komentarza, zgodnie z polityką prywatności: polityka prywatności