Skip to content

Wszystko o WordPressie, tworzeniu stron — i nie tylko

🔐 Steganografia obrazów w PHP: ukrywamy tekst w pikselach przez LSB

🔐 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

11001100

10010001

00101011

1

00101011

2

00011000

11110000

11111110

1

11111111

3

11100010

00100101

01010101

0

01010100

4

11111101

00001010

01000011

1

01000011

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
3function 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
20function 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
3require_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
12for ($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
28imagepng($img, 'encoded.png');
29
30echo "Закодировано {$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
3require_once 'functions.php';
4
5$src = 'encoded.png';
6$img = imagecreatefrompng($src);
7
8$bits = '';
9
10for ($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
19echo 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.jpg i encoded.png obok 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.php zapisuje 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) i steghide (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, i pateon/steganography, PHP 8.5+ z obsługą GD i Imagick. Jednak rozwiązań klasy produkcyjnej na poziomie steghide w 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.php i 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) i Stegano (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.