
🔐 Steganografia obrazów w PHP: ukrywamy tekst w pikselach przez LSB
Klik i Pana encoded.png z zewnątrz nie różni się od container.jpg. Nikt nie podejrzewa, że w środku znajduje się ukryta wiadomość. Steganografia nie czyni danych nieczytelnymi, jak szyfrowanie, ona ukrywa sam fakt transmisji. A dla programisty PHP jest to jeden z tych trików, które warto rozumieć na poziomie bitów i pikseli.
Steganografia LSB w PHP z modułem GD to nie narzędzie produkcyjne, a poligon edukacyjny. Ale poligon, który uczy pracy z reprezentacją binarną, maskami bitowymi i formatami bezstratnymi lepiej niż tuzin zadań CRUD. Przejdzie Pan drogę od „jak zbudowany jest wewnątrz obrazek" do polecenia php decrypt.php, które wydobywa hello z pikseli, i zrozumie Pan, dlaczego JPEG się do tego nie nadaje.
💡 Szybki przegląd:
- Rozkładamy teorię: jak najmłodszy bit niebieskiego kanału ukrywa wiadomość i dlaczego oko tego nie widzi
- Piszemy trzy pliki PHP:
functions.php(narzędzia),encrypt.php(kodowanie),decrypt.php(wydobywanie) - Uruchamiamy na rzeczywistym kontenerze JPEG i otrzymujemy wizualnie nieodróżnialny wynik PNG
- Omawiamy ograniczenia: kompresja, wykrywalność, pojemność i powiązanie z szyfrowaniem AES
- Przeglądamy gotowe biblioteki PHP i decydujemy, kiedy GD wystarcza, a kiedy czas sięgnąć po Pythona
Jak działa steganografia LSB
LSB (Least Significant Bit) to metoda, w której tajna wiadomość jest zapisywana w najmłodszych bitach pikseli. Nazwa jest dosłowna: bierzemy najmniej znaczący bit kanału koloru i zastępujemy go bitem wiadomości.
Obraz cyfrowy to siatka pikseli. Każdy piksel kodowany jest trzema bajtami: czerwonym (R), zielonym (G) i niebieskim (B). Kombinacja trzech wartości od 0 do 255 określa końcowy kolor. Zmiana najmłodszego bitu przesuwa wartość kanału nie więcej niż o 1, na przykład RGB(100, 50, 200) przekształca się w RGB(100, 50, 201). Ludzkie oko nie rozróżnia różnicy.
Weźmy pomarańczowy piksel:
- Czerwony:
11001001(201) - Zielony:
11111000(248) - Niebieski:
00000011(3)
Najmłodszy bit niebieskiego kanału to jedynka. Zastąpmy go bitem tajnej wiadomości, a kolor wizualnie pozostanie ten sam. Przetwarzając piksele sekwencyjnie, zapisujemy całą wiadomość bit po bicie.
Załóżmy, że trzeba ukryć sekwencję 1101. Bierzemy 4 piksele i zmieniamy najmłodszy bit niebieskiego kanału:
Piksel | R | G | Początkowy B | LSB zastąpiony | Nowy B |
|---|---|---|---|---|---|
1 |
|
|
| → |
|
2 |
|
|
| → |
|
3 |
|
|
| → |
|
4 |
|
|
| → |
|
Dekodowanie to proces odwrotny: zbieramy najmłodsze bity niebieskiego kanału tych samych czterech pikseli i otrzymujemy 1101. Żadnej magii, tylko operacje bitowe.
W praktyce nie ograniczamy się do jednego niebieskiego kanału: można wykorzystać wszystkie trzy (R, G, B), potrajając objętość ukrywanych danych. W tym samouczku dla uproszczenia pracujemy tylko z niebieskim, tak wyraźniej widać, co dzieje się z każdym bitem.
Implementacja krok po kroku w PHP
Potrzebny będzie PHP z modułem GD, jest on preinstalowany praktycznie na wszystkich hostingach. Kod napisano pod PHP 8.0+ i sprawdzono aż do 8.4. Stworzymy trzy pliki w jednym folderze.
Krok 1: Funkcje pomocnicze, functions.php
Dwa narzędzia: konwersja ciągu znaków na reprezentację binarną i z powrotem.
1 <?php 2 3 function toBin(string $str): string 4 { 5 $result = ''; 6 $len = strlen($str); 7 8 while ($len--) { 9 $result = str_pad( 10 decbin(ord($str[$len])), 11 8, 12 '0', 13 STR_PAD_LEFT 14 ) . $result; 15 } 16 17 return $result; 18 } 19 20 function toString(string $binary): string 21 { 22 $result = ''; 23 foreach (str_split($binary, 8) as $byte) { 24 $result .= chr((int) base_convert($byte, 2, 10)); 25 } 26 27 return $result; 28 }
toBin() konwertuje każdy znak ciągu na 8-bitową reprezentację przez decbin(ord(...)) i dopełnia zerami z lewej do pełnego bajtu. toString() wykonuje operację odwrotną: tnie binarny ciąg na bloki po 8 bitów, przekształca każdy blok na liczbę dziesiętną i otrzymuje znak przez chr().
Przetwarzanie blokowe w toString() to nie kaprys. Popularne w samouczkach podejście przez pack('H*', base_convert($binary, 2, 16)) załamuje się na wiadomościach dłuższych niż 31 bitów: funkcja base_convert operuje na liczbach całkowitych i w przypadku przekroczenia PHP_INT_MAX zwraca śmieci. Podział na bajty rozwiązuje problem dla wiadomości o dowolnej długości.
Krok 2: Kodowanie, encrypt.php
Odczytuje kontener JPEG, wszywa wiadomość w najmłodsze bity niebieskiego kanału i zapisuje wynik w PNG. Dlaczego nie JPEG? Format bezstratny jest obowiązkowy: kompresja JPEG zniszczy ukryte bity, a wiadomość zamieni się w szum.
1 <?php 2 3 require_once 'functions.php'; 4 5 $message = 'hello'; 6 $binary = toBin($message); 7 $msgLen = strlen($binary); 8 9 $src = 'container.jpg'; 10 $img = imagecreatefromjpeg($src); 11 12 for ($x = 0; $x < $msgLen; $x++) { 13 $y = $x; 14 $rgb = imagecolorat($img, $x, $y); 15 16 $r = ($rgb >> 16) & 0xFF; 17 $g = ($rgb >> 8) & 0xFF; 18 $b = $rgb & 0xFF; 19 20 $blueBits = str_pad(decbin($b), 8, '0', STR_PAD_LEFT); 21 $blueBits[7] = $binary[$x]; 22 $newB = bindec($blueBits); 23 24 $newColor = imagecolorallocate($img, $r, $g, $newB); 25 imagesetpixel($img, $x, $y, $newColor); 26 } 27 28 imagepng($img, 'encoded.png'); 29 30 echo "Закодировано {$msgLen} бит — результат в encoded.png\n";
imagecolorat() pobiera kolor piksela o współrzędnych (x, y). Przesunięcia bitowe >>16 i >>8 wyodrębniają R, G, B. Niebieski kanał rozkładany jest na 8-bitowy ciąg, najmłodszy bit (indeks 7) zastępowany jest kolejnym bitem wiadomości, a nowy kolor zapisywany jest przez imagesetpixel().
Wiadomość kodowana jest po przekątnej (y = x), tak łatwiej prześledzić logikę na małym przykładzie. W rzeczywistym projekcie należy iterować po wszystkich pikselach wierszowo i dodać 32-bitowy nagłówek z długością wiadomości: bez niego dekoder nie wie, gdzie się zatrzymać.
Krok 3: Dekodowanie, decrypt.php
Wydobywa ukrytą wiadomość: przechodzi po tych samych pikselach i zbiera najmłodsze bity niebieskiego kanału.
1 <?php 2 3 require_once 'functions.php'; 4 5 $src = 'encoded.png'; 6 $img = imagecreatefrompng($src); 7 8 $bits = ''; 9 10 for ($x = 0; $x < 40; $x++) { 11 $y = $x; 12 $rgb = imagecolorat($img, $x, $y); 13 14 $b = $rgb & 0xFF; 15 $blueBits = str_pad(decbin($b), 8, '0', STR_PAD_LEFT); 16 $bits .= $blueBits[7]; 17 } 18 19 echo toString($bits) . "\n";
Tutaj 40 to sztywno ustawiona liczba bitów, równo 5 bajtów na słowo hello. W wersji bojowej należy zastąpić tę liczbę długością z nagłówka wiadomości, który zapisano pierwszymi 32 bitami podczas kodowania.
Krok 4: Uruchomienie i weryfikacja
Umieszczamy container.jpg w folderze ze skryptami. Kolejność działań:
php encrypt.php, w konsoli: „Zakodowano 40 bitów, wynik w encoded.png".php decrypt.php, w konsoli:hello.- Otwieramy
container.jpgiencoded.pngobok siebie, różnicy nie ma. Wizualnie są nieodróżnialne.
Porównać bajt po bajcie można przez ImageMagick: magick compare container.jpg encoded.png diff.png podświetli różnice w najmłodszych bitach, praktycznie niezauważalne dla oka. Ale najpewniejszy sposób weryfikacji to uruchomić decrypt.php i odczytać wiadomość.
Ograniczenia metody
Steganografia LSB to narzędzie edukacyjne, a nie ochrona kryptograficzna. Oto, co należy mieć na uwadze, zanim zastosuje Pan to podejście w rzeczywistym projekcie:
Kompresja zabija dane. JPEG przelicza bloki pikseli 8×8 z kwantyzacją, najmłodsze bity nie przetrwają. Dlatego
encrypt.phpzapisuje wynik wyłącznie w PNG (bezstratnie). Jedno przejście przez kompresję JPEG i ukryte bity zamienią się w szum. Alternatywne formaty: BMP, TIFF bez kompresji.Wykrywalność. Wypełnienie LSB pozostawia artefakty statystyczne: histogram niebieskiego kanału zmienia się w przewidywalny sposób. Analiza RS i test χ² wykrywają ukrytą wiadomość w ciągu sekund. Bez szyfrowania LSB to ukrywanie, a nie ochrona: fakt istnienia wiadomości jest obliczalny, nawet jeśli treść nie jest czytelna.
Objętość danych. Każdy piksel oddaje jeden bit w niebieskim kanale. Obraz 1000×1000 pikseli mieści do 125 KB ukrytego tekstu, taka jest granica matematyczna, wynikająca z definicji metody LSB. W praktyce po kompresji PNG użyteczna objętość jest mniejsza, plus część bitów idzie na nagłówek z długością.
To nie kryptografia. Steganografia nie zastępuje szyfrowania, lecz je uzupełnia. Prawidłowy pipeline: szyfruje Pan wiadomość (AES-256), następnie ukrywa szyfrogram w obrazie. Wtedy nawet wykrycie ukrytego kanału nie ujawnia treści.
PHP GD to podstawowe narzędzie. GD daje niskopoziomowy dostęp do pikseli i do nauki jest to więcej niż wystarczające. Ale ekosystem Pythona dla steganografii jest zauważalnie bogatszy: biblioteka
stegano(LSB + szyfrowanie),Stegano(zestaw metod) isteghide(C++, ale są bindingi Pythona). W czystym PHP istnieje kilka bibliotek:kzykhys/Steganography, minimalna implementacja LSB dla PNG,seba/steganography, nowoczesna biblioteka z manipulacją LSB, ipateon/steganography, PHP 8.5+ z obsługą GD i Imagick. Jednak rozwiązań klasy produkcyjnej na poziomiesteghidew PHP na rok 2026 nie ma.
⁉️🤔 Często zadawane pytania
Czy można ukryć tekst bezpośrednio w JPEG?
Nie. Kompresja JPEG dzieli obraz na bloki 8×8 i stosuje kwantyzację, najmłodsze bity nie przetrwają. Do ukrywania w JPEG używa się innego podejścia: ukrywa się dane we współczynnikach dyskretnej transformaty kosinusowej (DCT), a nie w pikselach. Algorytmy: F5, JSteg, OutGuess. Gotowych bibliotek PHP do tego praktycznie nie ma, łatwiej sięgnąć po Pythona.
Czym LSB różni się od steganografii w metadanych?
LSB zmienia dane pikseli, wiadomość jest ukryta „wewnątrz" obrazu. Metadane (EXIF, XMP, chunki tEXt PNG) leżą w nagłówkach pliku i są odczytywane przez dowolną przeglądarkę właściwości. Metadane łatwiej wydobyć, za to przetrwają kompresję JPEG, do czego LSB nie jest zdolne.
Jak upewnić się, że encoded.png rzeczywiście zawiera ukrytą wiadomość?
Wizualnie, w żaden sposób. Proszę porównać pliki bajt po bajcie:
magick compare container.jpg encoded.png diff.png(ImageMagick) pokaże różnice w najmłodszych bitach. Są one praktycznie niezauważalne dla oka, ale narzędzie podświetli zmienione piksele. Najpewniejszy sposób to uruchomićphp decrypt.phpi odczytać wiadomość.
Na ile jest to w ogóle bezpieczne?
Bez szyfrowania, niebezpieczne. LSB bez uprzedniego szyfrowania AES to ukrywanie, a nie ochrona. Fakt transmisji ukrytych danych jest wykrywany testami statystycznymi w ciągu sekund. Połączenie „szyfrowanie AES + ukrywanie LSB" daje akceptowalny poziom dla projektów hobbystycznych, ale nie dla danych wrażliwych.
Czy są gotowe biblioteki PHP do steganografii?
Tak, ale wszystkie są edukacyjne lub lekkie.
kzykhys/Steganography, minimalna implementacja LSB, działa tylko z PNG.seba/steganography, nowsza, z czystym API do encode/decode tekstu w obrazie.pateon/steganography, PHP 8.5+, obsługuje i GD, i Imagick. Do poważnych zadań proszę spojrzeć w stronę Pythona:stegano(LSB + szyfrowanie) iStegano(zestaw metod, w tym generator stego-obrazów).
Steganografia w PHP: baza do nauki czy gotowe narzędzie?
Steganografia LSB w PHP z GD to doskonały poligon do zrozumienia, jak dane są ukrywane na poziomie pikseli. Napisał Pan działającą pętlę encode/decode, rozpracował operacje bitowe i zobaczył w praktyce, dlaczego „niewidzialna" zmiana pozostawia ślady w statystyce obrazu.
Do demonstracji edukacyjnej, projektu hobbystycznego lub zadania CTF ten zestaw narzędzi jest więcej niż wystarczający. Do rzeczywistej ochrony danych proszę dodać szyfrowanie przed ukryciem i przyjrzeć się bibliotekom Pythona, które o głowę przewyższają odpowiedniki PHP pod względem możliwości i odporności na detekcję.
Jeśli chce Pan kopać głębiej, proszę zaimplementować kodowanie od razu w trzech kanałach kolorów i dodać 32-bitowy nagłówek z długością wiadomości. Powstanie mini-narzędzie, które jest 3 razy efektywniejsze od wersji podstawowej i już nadaje się na pracę zaliczeniową. Kod źródłowy proszę udostępnić na GitHubie, niech inni uczą się na Pana przykładzie.



