Steganografia według wikipedii to nauka o komunikacji w taki sposób, by obecność komunikatu nie mogła zostać wykryta. Klasycznym jej przykładem jest ukrycie wiadomości w najmniej znaczącym bicie. Można w ten sposób ukryć dane w obrazie, a zmodyfikowane wartości kolorów są praktycznie niezauważalne. W tym przypadku jednak nie było tak “kolorowo”…
Misja z serii CTF opublikowana po live stream’ie Gynvael’s Livestream #51.
Treść misji:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
MISJA 013 goo.gl/ZnH1tg DIFFICULTY: ████████░░ [8/10] ┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅┅ Nasi agenci zdobyli ostatnio pewien plik PNG. Podobno w pliku tym ukryta jest tajna wiadomość, ale póki co nie udało nam się jej znaleźć. Może Ty będziesz mieć więcej szczęścia: goo.gl/6vu6cp Powodzenia! -- Odzyskaną wiadomość umieść w komentarzu pod tym video :) Linki do kodu/wpisów na blogu/etc z opisem rozwiązania są również mile widziane! P.S. Rozwiązanie zadania przedstawię na jednym z vlogów w okolicy dwóch tygodni. |
Po ściągnięciu pliku ukazuje nam się plik png.
Zmyłka programu graficznego
Próba rozwiązania tej misji zaczęła się trochę niefortunnie. Nie ma ona za bardzo związku z rozwiązaniem, ale postanowiłem że to opiszę. Faktyczne rozwiązanie misji znajduje się niżej.
Gdy misja została opublikowana, z ciekawości zerknąłem na załącznik. Oooo… “VERY PNG MUCH STEGANO!!” napis sugeruje, że to zadanie ma jakiś związek ze steganografią. Super! 🙂 Pobawimy się bitami. Nie miałem jednak czasu tego dnia na nic więcej. Następnego dnia w pracy (oczywiście w trakcie przerwy), zerknąłem na ten obrazek bo ciekawość nie dawała mi spokoju.
Zadania z obrazkami zawsze zaczynam od sprawdzenia, czy nie ma przypadkiem jakiś kolorów, które są do siebie bardzo zbliżone i są niewidoczne dla oczu. W tym wypadku włączam funkcję Levels albo Curves (po polsku funkcja Poziomy, Krzywe) i kręcę pokrętłami próbując zobaczyć jakieś zmiany w kolorach. Gdy już coś wypatrzę to włączam mój programik Lupochondryk (który służy mi od dobrych 15 lat) i przeglądam te miejsca próbując znaleźć to coś.
W pracy mam do dyspozycji moją ulubioną przeglądarkę obrazków ACDSee. Wersja trochę stara, ale działa całkiem dobrze. Załadowałem obrazek z misji no i podglądam. Wydaje się, że są tylko 2 kolory – czarny i biały. Wybieram funkcję Levels i mieszam… Zadowolenie. Coś się pojawia. Na rysunku 1 widać, że na krawędziach liter są jakieś pixele w innych kolorach. Lupochondryk ładnie pokazuje wartości… koniec przerwy, w domu dalej będziemy się bawić.

Dzieci poszły spać, jest chwila, aby usiąść do kompa. No więc zacząłem robić to co w pracy, czyli włączyłem GIMPa i przesuwam te suwaczki w funkcjach Levels i Curves. Potem włączam moj programik. Włączam inny pogram… Masakra! Nic nie ma. W całym obrazku są tylko 2 kolory (rysunek 2).

W końcu piszę krótką funkcję.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
Bitmap bmp = new Bitmap("misja013.png"); // Słownik do liczenia liczby wystąpień danego koloru Dictionary<Color, int> his = new Dictionary<Color, int>(); for (int y = 0; y < bmp.Height; y++) for (int x = 0; x < bmp.Width; x++) { Color p = bmp.GetPixel(x, y); his[p] = (his.ContainsKey(p)) ? his[p] + 1 : 1; } // Zawartość słownika drukuję do pliku StringBuilder sb = new StringBuilder(); foreach (var kv in his) sb.AppendLine($"{kv.Key} = {kv.Value}"); File.WriteAllText("all_colors.txt", sb.ToString()); |
Niefajnie, faktycznie tylko 2 kolory.
1 2 3 4 |
// all_colors.txt Color [A=255, R=255, G=255, B=255] = 618126 Color [A=255, R=0, G=0, B=0] = 21874 |
No i co teraz? Nie ma żadnych pixeli, na które liczyłem, z poustawianymi najniższymi bitami. Pomyślałem, że chyba musiało mi się coś przywidzieć w pracy. Może zmęczony byłem i mi się coś w głowie uroiło. No ale jednak nie, bo na drugi dzień zrobiłem to samo i nawet coś więcej wyszło. Wyszła mi taka sama obwódka jak na rysunku 3a.
Co jest grane? Pierwsza wskazówka to skala. Jak można zauważyć na samym dole na rysunku 1 widać skalę 70%. Okazało się, że ACDSee 10 wykonuje funkcję Levels na pomniejszonej wersji widoku, a nie na oryginalnym rysunku. Pomniejszenie powoduje rozmazanie i na krawędziach pojawiają się pixele o kolorze pośrednim w tym przypadku szarym.

Skąd jednak bierze się obwódka na rysunku 3a? Przecież kolor pośredni powinien pojawić się na krawędzi, tak jak jest to na rysunku 3c. W pewnym momencie pomyślałem sobie nawet, że Gynvael skorzystał z jakiegoś błędu w algorytmie png, co powoduje ukrycie pixeli w obwódkach liter i uwidacznia się to dopiero przy manipulacjach obrazu.

Odpowiedź jest jednak prostsza niż można się spodziewać. Mianowicie problem tkwi w algorytmie zmiany rozmiaru bitmapy. ACDSee używa domyślnie algorytmu Lanczos. To właśnie on powoduje, że wokół liter na obrazku pojawia się dodatkowa obwódka.
Na rysunku 3 przedstawiłem testy w GIMPie. Obrazek pomniejszałem do 79%, najpierw używając funkcji Sinc (Lanczos3) – rysunek 3a, potem używając funkcji Cubic – rysunek 3c. Dodatkowo, aby obalić pomysł o błędzie w png, zapisałem plik jako BMP, a następnie zrobiłem to samo co na początku – rysunek 3b.
Wszystkie obrazki zostały potraktowane filtrem Curves, którego parametry przedstawiam na rysunku 4.
Powrót do punkcu wyjścia. Tzn. próbowałem jeszcze liczyć czarne pixele w rzędach i kolumnach i konwertować do ASCII ale nic z tego.
Rozwiązanie misji 013
Patrząc na obrazek nic więcej już nie zdziałam. Jedyne co przychodzi mi do głowy to – binwalk
.
1 2 3 4 5 6 |
>python binwalk -e misja013.png DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 0 0x0 PNG image, 800 x 800, 8-bit/color RGB, non-interlaced 41 0x29 Zlib compressed data, default compression |
Bingo!
Zaskoczenie było ogromne. Wyeksportował dwa pliki do podfolderu. Pierwszy z nich był dosyć duży 1920800 bajtów. Ciekawe co jest w środku. Zmieniam rozszerzenie na .data i idziemy do GIMP’a.

No teraz jest już trochę ciekawiej. Po dostosowaniu szerokości widzimy czarne poziome linie. Nie da się jednak wyprostować obrazka (rysunek 5) widocznie szerokość danych w pliku nie jest podzielna przez 3. W tym momencie przypomniało mi się jak na jakiejś prelekcji Gynvael mówił o plikach bmp. Jak wiadomo każdy wiersz dopełniany jest tak, aby był podzielny przez 4. Jeśli autor algorytmu nie zadba o wyzerowanie to do pliku mogą zostać zapisane dane, które wcześniej znajdowały się w pamięci. Może w dopełnieniu jest coś ukryte? Z prostych wyliczeń wychodzi jednak, że – 800*3 = 2400 / 3 = 600 – wiersz jest podzielny przez 4, nie trzeba dopełniać. Analizujemy… rysunek 6.

Nareszcie są pixele o innych kolorach.
Jednak jeszcze jeden pomysł wpadł mi do głowy. Jeśli dane w pliku nie są podzielne przez trzy to powinno udać się je wyprostować wybierając w GIMPie tryb jedno-bajtowy, czyli Indexed.

OK. Mamy już wyprostowane. No to kolejne co przychodzi do głowy, mając na względzie poprzednie misje. Czy te czarne i białe linie to nie są bity?
Piszemy funkcję wyczytującą rysunek i bierzemy 5 kolumnę na warsztat. Kilka razy musiałem modyfikować kod. Kluczem okazało się zinterpretowanie czarnych pixeli jako 1, a białych jako 0.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
Bitmap bmp = new Bitmap("29data_in_grayscale.png"); byte[] column = new byte[bmp.Height]; // Wczytaj 5 kolumnę for (int y = 0; y < bmp.Height; y++) column[y] = bmp.GetPixel(4, y).R; //column = column.Skip(2).ToArray(); // Przesuń tablicę int groups8 = column.Length / 8; byte[] columnG8 = new byte[groups8]; // Każde 8 pixeli traktuję jak bity i zapisuję do bajta for (int g = 0; g < groups8; g++) for (int b = 0; b < 8; b++) if (column[g * 8 + b] == 0) // Czarne punkty jako 1 columnG8[g] |= (byte)(1 << b); File.WriteAllBytes("column5_in_ascii.txt", columnG8); |
Udało się.
Flaga: “Hmmm, czy to zadanie czasem juz nie bylo gdzies?”