Buffer overflow - jak skutecznie zabezpieczyć kod i system Linux?

Jędrzej Czarnecki .

5 lutego 2026

Atakujący wysyła złośliwy pakiet, który powoduje buffer overflow w jądrze Androida, umożliwiając wykonanie kodu i przejęcie kontroli.

Przepełnienie bufora, czyli klasyczny buffer overflow, nadal potrafi kończyć się awarią usługi, uszkodzeniem danych albo przejęciem sterowania nad programem. W tym tekście pokazuję, jak taki błąd powstaje, po czym go rozpoznać i co realnie działa przy zabezpieczaniu aplikacji oraz systemów Linux. Skupię się na praktyce: kodzie, kompilacji, testach i hardeningu środowiska.

Najważniejsze fakty, które pomagają szybko ocenić ryzyko

  • Źródłem problemu jest zapis większej ilości danych niż pozwala na to przydzielony bufor.
  • Najbardziej narażone są aplikacje natywne w C i C++, zwłaszcza parsery plików, demony i stare biblioteki.
  • Skutki zaczynają się od awarii, a kończą na korupcji danych i przejęciu przepływu wykonania.
  • Najlepsza obrona to połączenie bezpieczniejszych API, ostrzeżeń kompilatora, sanitizera i hardeningu systemu.
  • Jedna poprawka w kodzie nie wystarcza, jeśli build i środowisko uruchomieniowe nadal ułatwiają atak.

Czym jest przepełnienie bufora i skąd bierze się ryzyko

Bufor to po prostu wydzielony fragment pamięci o stałym rozmiarze, który ma przechować określoną ilość danych. Problem zaczyna się wtedy, gdy program próbuje zapisać do niego więcej, niż on mieści, i nadpisuje sąsiednie komórki pamięci. W praktyce oznacza to uszkodzenie danych, nieprzewidywalne zachowanie programu albo błąd bezpieczeństwa, który da się wykorzystać.

Ja zwykle tłumaczę to bardzo prosto: kod zakłada, że wejście będzie krótkie, a rzeczywistość dostarcza dane dłuższe. Właśnie ten rozdźwięk między założeniem a faktem jest najczęstszym źródłem problemu. W aplikacjach natywnych ryzyko rośnie szczególnie tam, gdzie dane trafiają z zewnątrz do stałych tablic, struktur parsera, buforów stosu albo pamięci sterowanej ręcznie.

Ważne jest jeszcze jedno rozróżnienie. Nie każdy taki błąd od razu oznacza pełne przejęcie aplikacji. Czasem kończy się to tylko crashem, czasem wyciekiem informacji, a czasem dopiero po złożeniu kilku warunków da się doprowadzić do wykonania obcego kodu. To właśnie dlatego ten temat nie jest akademicki, tylko praktyczny. Z samej definicji dobrze widać już, gdzie szukać mechanizmu błędu, więc teraz warto zobaczyć, jak wygląda on w kodzie i pamięci.

Jak dochodzi do nadpisania pamięci w praktyce

Najprostszy scenariusz to kopiowanie danych do tablicy o stałym rozmiarze bez sprawdzenia długości wejścia. Jeśli bufor ma 32 bajty, a program kopiuje 80 bajtów, nadmiar ląduje poza jego granicą. W zależności od miejsca w pamięci skutki będą inne, dlatego w audycie zawsze patrzę na to, czy mamy do czynienia ze stosem, stertą czy tylko błędem o jeden znak za daleko.

Wariant Gdzie występuje Co zwykle psuje Dlaczego jest groźny
Przepełnienie stosu Lokalne tablice w funkcjach Ramkę wywołania, wskaźnik powrotu, zmienne sąsiednie Może zmienić przebieg wykonania albo natychmiast wywołać crash
Przepełnienie sterty Bloki przydzielone dynamicznie Dane obiektu, sąsiednie struktury, metadane alokatora Bywa trudniejsze do zauważenia i często ujawnia się dopiero po czasie
Off-by-one Każdy typ bufora Jeden bajt za granicą, na przykład znacznik końca tekstu Pojedynczy bajt potrafi wystarczyć do destabilizacji programu
Błąd w długości kopiowania Funkcje `memcpy`, `strcpy`, `sprintf` i podobne Zbyt duży rozmiar kopiowania względem miejsca docelowego Często powstaje nie w jednym miejscu, lecz w łańcuchu kilku założeń

W praktyce najczęściej nie chodzi o „złą” funkcję samą w sobie, tylko o złą kontrolę granic. Jedna część programu liczy długość, druga kopiuje dane, a trzecia zakłada, że wszystko zostało już sprawdzone. To właśnie takie rozdzielenie odpowiedzialności lubi rodzić błędy, zwłaszcza w starszym kodzie. Gdy rozumiemy mechanikę, łatwiej ocenić konsekwencje, a one bywają poważniejsze, niż sugeruje sam komunikat o błędzie.

Jakie skutki ma taki błąd dla aplikacji i serwera

OWASP opisuje ten problem jako błąd, który może skończyć się zarówno awarią aplikacji, jak i wykonaniem obcego kodu. Z mojej perspektywy warto myśleć o skutkach warstwowo: najpierw stabilność, potem integralność danych, a dopiero na końcu pełna kompromitacja procesu.

  • Crash i restart usługi - najczęstszy efekt, szczególnie gdy zadziała ochrona stosu albo system wykryje nieprawidłowy zapis.
  • Korupcja danych - cicha i podstępna, bo program może działać jeszcze chwilę, ale zapisuje błędne informacje.
  • Przejęcie procesu - jeśli usługa działa z wysokimi uprawnieniami, błąd przestaje być lokalny.
  • Wykonanie obcego kodu - trudniejsze, ale nadal realne przy nieuważnym projekcie i słabej ochronie środowiska.
  • Wyciek informacji - nadpisanie i odczyt z błędnym wskaźnikiem często idą w parze z ujawnieniem fragmentów pamięci.

Na Linuxie takie problemy często zostawiają czytelne ślady: segfaulty, core dumpy, losowe restarty demona albo komunikaty o wykrytym naruszeniu bezpieczeństwa pamięci. Ja zawsze sprawdzam też, czy błąd pojawia się po konkretnym typie wejścia, bo to od razu wskazuje na źle obsłużony parser, format pliku albo niewystarczającą walidację długości. Zanim jednak zacznie się poprawianie, trzeba mieć sposób na odtworzenie błędu i odróżnienie jednorazowego crasha od rzeczywistej podatności.

Jak wykryć problem zanim trafi na produkcję

Największy błąd, jaki widzę w zespołach, to próba wykrywania takich rzeczy wyłącznie na etapie ręcznego testu. To za mało. Lepsze podejście łączy ostrzeżenia kompilatora, build z sanitizerami i testy wejść granicznych, które celowo próbują „zepsuć” program.

Włącz ostrzeżenia i traktuj je serio

W praktyce zaczynam od zestawu ostrzeżeń, które wyłapują podejrzane kopiowanie i błędne rozmiary: `-Wall`, `-Wextra`, `-Wstringop-overflow=2`, `-Warray-bounds` oraz `-Wformat-overflow=2`. Warto też włączyć `-Werror` przynajmniej dla nowego kodu, żeby ostrzeżenie nie przechodziło dalej w pipeline. GCC ma mechanizmy, które pomagają wykryć część takich błędów już na etapie kompilacji, a to daje najlepszy stosunek wysiłku do efektu. Jeśli ostrzeżenie wygląda jak fałszywy alarm, najpierw sprawdzam założenia w kodzie, a dopiero potem myślę o jego wyciszeniu.

Uruchamiaj sanitizery w osobnym buildzie

Do testów dynamicznych bardzo dobrze działa AddressSanitizer (`-fsanitize=address`). Taki build łapie odczyty i zapisy poza buforem, często dokładnie w miejscu, w którym problem się wydarzył, a nie dopiero po kilku kolejnych instrukcjach. To ważne, bo w klasycznym debugowaniu sam crash bywa mylący. Ja traktuję sanitizery jako obowiązkowy etap CI dla kodu natywnego, zwłaszcza przy parserach, bibliotekach pomocniczych i kodzie obsługującym dane z sieci lub plików.

Dodaj testy wejść granicznych i fuzzing

Tu nie chodzi o losowe „strzelanie” danymi. Dobre testy graniczne sprawdzają bardzo długie ciągi, brak znaku końca tekstu, nietypowe długości pól, zagnieżdżone struktury i pliki uszkodzone na poziomie pojedynczego bajtu. Fuzzing jest po prostu zautomatyzowaną wersją tej idei: im więcej dziwnych przypadków przejdzie przez parser, tym większa szansa, że program sam pokaże słaby punkt. Najlepiej działa to wtedy, gdy testy są częścią zwykłego pipeline’u, a nie jednorazowym eksperymentem.

Gdy taki system wykrywania działa, poprawki stają się dużo prostsze, bo zamiast zgadywać, można odtworzyć dokładny scenariusz. Następny krok to już nie samo łapanie błędu, tylko zbudowanie realnej obrony w kodzie i w środowisku uruchomieniowym.

Jak skutecznie ograniczyć ryzyko w kodzie i na Linuksie

Ja patrzę na to warstwowo: najpierw bezpieczniejsze API i poprawna logika, potem hardening kompilatora, a na końcu ochrona systemu. Samo jedno zabezpieczenie rzadko wystarcza, bo atakujący szuka zawsze najsłabszego ogniwa.

W kodzie wybieraj konstrukcje, które znają rozmiar

W C i C++ najlepiej ograniczać ręczne operowanie buforami tam, gdzie to możliwe. Zamiast kopiować dane bez kontroli, lepiej używać funkcji, które wymagają jawnego rozmiaru i zwracają błąd przy jego przekroczeniu. Unikam też „bezpiecznych” zamienników użytych bezmyślnie, bo `strncpy` czy `strncat` nie rozwiązują problemu same z siebie, jeśli autor kodu nie rozumie ich ograniczeń.

  • Waliduj długość na wejściu przed kopiowaniem, alokacją i konkatenacją.
  • Trzymaj rozmiar i dane razem, żeby nie rozjechały się w różnych częściach kodu.
  • Używaj struktur o jawnej semantyce rozmiaru, na przykład `std::string`, `std::vector` albo `std::array`, zamiast surowych tablic, gdy to możliwe.
  • Nie zakładaj, że dane są zakończone zerem, jeśli pochodzą z sieci, pliku binarnego albo pola o jawnej długości.

W buildzie włącz ochronę kompilatora

GCC daje tu sensowny zestaw narzędzi: `-fstack-protector-strong` oraz `_FORTIFY_SOURCE=3` to rozsądny punkt startowy w nowoczesnych buildach GNU/Linux. Dodałbym do tego osobny wariant debugowy z sanitizerami i release z włączonym hardeningiem. W praktyce to często lepsze niż ręczne szukanie błędów, bo kompilator i libc szybciej sygnalizują problem przy pierwszym złym zapisie. W release często dokładam też `-fPIE -pie` oraz `-Wl,-z,relro -Wl,-z,now`; PIE sprawia, że binarka ładuje się pod innym adresem, a RELRO uszczelnia fragmenty związane z relokacją.

  • Debug - sanitizery, ostrzeżenia i możliwie mało optymalizacji utrudniającej diagnozę.
  • Release - stack protector, fortification, PIE oraz inne mechanizmy hardeningu.
  • CI - kompilacja z ostrzeżeniami traktowanymi jak błędy dla nowego kodu.

Przeczytaj również: Jak sprawdzić historię Wi-Fi - Logi routera i prawne aspekty

Na systemie utrudniaj skutki, a nie tylko sam błąd

Na Linuxie liczy się też środowisko uruchomieniowe. Losowanie adresów, brak wykonywania danych w pamięci, ograniczenie uprawnień procesu, sandboxowanie i izolacja usług sprawiają, że nawet jeśli błąd istnieje, jego wykorzystanie staje się trudniejsze. To nie jest magiczna tarcza, ale dobra warstwa drugiej linii obrony. Jeśli usługa działa z minimalnymi uprawnieniami, ryzyko po udanym exploitcie spada wyraźnie.

Taki zestaw obrony daje sensowną odporność, ale tylko wtedy, gdy nie psuje go kilka pozornie drobnych decyzji przy refaktoryzacji. I właśnie tam najczęściej pojawiają się błędy, które wyglądają dobrze w review, a po wdrożeniu dalej są podatne.

Najczęstsze błędy przy poprawkach i dlaczego nie działają

W poprawkach najłatwiej wpaść w pułapkę „zabezpieczyliśmy to”, choć realnie zmienił się tylko składnik, a nie mechanizm. Ja najczęściej widzę pięć powtarzających się błędów.

  • Użycie `strncpy` jak uniwersalnej protezy - funkcja nie zawsze kończy tekst znakiem `\0`, więc można naprawić jeden problem i wprowadzić inny.
  • Sprawdzenie długości tylko raz - dane mogą zmienić się między walidacją a kopią, zwłaszcza gdy są przekazywane przez kilka warstw.
  • Liczenie rozmiaru przez `sizeof` wskaźnika - to klasyczny błąd, przez który kod „widzi” 8 bajtów zamiast rzeczywistego bufora.
  • Akceptowanie obcięcia jako sukcesu - cicha utrata danych bywa groźniejsza niż jawny błąd, bo trafia na produkcję bez alarmu.
  • Opieranie się tylko na jednej warstwie ochrony - same canary, sam sanitizer albo same reguły kodowania nie wystarczą, jeśli reszta warstw jest słaba.

Najlepiej myśleć o poprawce jak o usunięciu przyczyny, a nie o przyklejeniu łatki do objawu. Jeśli dane mają przyjść spoza procesu, to granica zaufania musi być widoczna w kodzie, a nie tylko w dokumentacji albo komentarzu. To prowadzi do najbardziej praktycznej części: od czego zacząć, gdy utrzymujesz starszy projekt i nie masz luksusu przepisania wszystkiego od zera.

Od czego zacząć, gdy utrzymujesz starszy kod C lub C++

Jeśli mam przejąć stary projekt, zaczynam od rzeczy, które dają największy zwrot najszybciej. Nie próbuję od razu przepisywać całego kodu, bo to rzadko jest realne. Zamiast tego układam krótką kolejność działań, która stopniowo obniża ryzyko.

  1. Włącz ostrzeżenia i zrób z nich gate w CI dla nowego kodu, nawet jeśli starsza część projektu jeszcze nie jest idealna.
  2. Dodaj build z AddressSanitizerem i odpal go na parserach, importerach, endpointach oraz kodzie obsługującym pliki.
  3. Spisz wszystkie miejsca z surowymi buforami i klasyfikuj je według ryzyka: wejście z zewnątrz, kopiowanie, konkatenacja, formatowanie tekstu.
  4. Wymień najbardziej ryzykowne funkcje na rozwiązania, które wymagają jawnego rozmiaru i zwracają błąd zamiast milczącego obcięcia.
  5. Włącz warstwy hardeningu produkcyjnego, żeby błąd, który jeszcze został, był trudniejszy do wykorzystania.
  6. Monitoruj crashe i core dumpy, bo w starszym kodzie często pierwszy sygnał podatności pojawia się właśnie w logach, nie w raporcie z testu.

Jeśli miałbym zamknąć temat jednym zdaniem, powiedziałbym tak: największą różnicę robi połączenie poprawnego kodu, ostrzeżeń kompilatora, sanitizerów i hardeningu systemu, a nie pojedynczy trik. Gdy te warstwy działają razem, przepełnienie bufora przestaje być „niewidzialnym” problemem i staje się błędem, który można wykryć, ograniczyć i konsekwentnie usuwać.

FAQ - Najczęstsze pytania

To błąd polegający na zapisaniu w pamięci większej ilości danych, niż może pomieścić wyznaczony obszar (bufor). Prowadzi to do nadpisania sąsiednich struktur, co skutkuje awarią programu, uszkodzeniem danych lub przejęciem kontroli przez atakującego.
Największe ryzyko niosą funkcje, które nie kontrolują automatycznie długości kopiowanych danych, takie jak strcpy, gets czy sprintf. Zamiast nich należy używać bezpieczniejszych odpowiedników wymagających podania rozmiaru bufora, np. snprintf.
Najlepiej stosować AddressSanitizer (-fsanitize=address) podczas kompilacji oraz przeprowadzać testy typu fuzzing. Takie podejście pozwala automatycznie wykryć nieprawidłowe zapisy i odczyty pamięci, które mogłyby zostać pominięte w zwykłych testach.
Kluczowe mechanizmy to ASLR (losowanie adresów w pamięci) oraz DEP/NX (oznaczanie obszarów danych jako niewykonywalnych). Choć nie usuwają one samej przyczyny błędu w kodzie, to znacznie utrudniają atakującemu wykonanie złośliwego kodu.

Oceń ten artykuł

Średnia: 0.0 / 5 · 0 ocen

Tagi

buffer overflow przepełnienie bufora jak zapobiegać przepełnieniu bufora przepełnienie stosu a sterty różnice zabezpieczenia przed buffer overflow w linux
Autor Jędrzej Czarnecki
Jędrzej Czarnecki
Jestem Jędrzej Czarnecki, specjalizującym się w systemach Linux, bezpieczeństwie oraz oprogramowaniu. Od ponad dziesięciu lat analizuję rynek technologii informacyjnych, co pozwoliło mi zdobyć dogłębną wiedzę na temat najnowszych trendów oraz najlepszych praktyk w tych dziedzinach. Moje doświadczenie obejmuje również pracę jako redaktor, gdzie koncentruję się na uproszczeniu skomplikowanych zagadnień technologicznych, aby były one zrozumiałe dla szerokiego grona odbiorców. W mojej pracy dążę do dostarczania rzetelnych i aktualnych informacji, które pomagają czytelnikom podejmować świadome decyzje. Wierzę, że obiektywna analiza i dokładne sprawdzanie faktów są kluczowe w budowaniu zaufania wśród moich odbiorców. Celem moich publikacji jest nie tylko edukacja, ale również inspirowanie do eksploracji i korzystania z możliwości, jakie oferuje współczesna technologia.

Komentarze (0)

Dodaj komentarz