Misja Gynvaela 013 - Steganografia

argeento -
  • #javascript
  • #png
  • #writeup

Piątek 13-stego - misja nr 13 - JavaScriptem w świat binarny - czas rozłożyć pliki PNG na części pierwsze! - Ale… w JavaScripcie? - brzmi strasznie, na szczęście node udostępnia niezbędne struktury danych oraz narzędzia, dzięki którym będzie to dość przyjemne zadanie.

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:

  https://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.

Steganografia!

Nauka o komunikacji w taki sposób, by obecność komunikatu nie mogła zostać wykryta. W odróżnieniu od kryptografii steganografia próbuje ukryć fakt prowadzenia komunikacji - pl.wikipedia.org

Plik graficzny, w którym ukryta jest wiadomość - misja013.png

Grafika z ukrytą wiadomością

Wow! Very PNG! Much stegano! - i to chyba nie jest ona ; )

Budowa pliku PNG

Na samym początku warto włączyć dowolny hexedytor, aby zobaczyć, co w PNG piszczy oraz - korzystając z (natywnego modułu) fs - wczytać plik do zmiennej:

const fs = require('fs')
const buffer = fs.readFileSync('misja013.png')

Sygnatura

Pierwsze 8 bajtów to informacje o samym pliku (dokładny opis poszczególnych bajtów można znaleźć w dokumentacji), jednak w tym przypadku będą one zbędne - ważna jest tylko długość tego fragmentu.

Chunks

Reszta pliku podzielona jest na części (chunks), które budowane są na podstawie następującego wzorca:

4 Bajty  - Długość części
4 Bajty  - Nazwa części
n Bajtów - Dane
4 Bajty  - CRC32 z nazwy i danych

Części te dzielimy dwie grupy:

  • Critical chunks są wymagane, aby zbudować poprawny plik PNG, oraz
  • Ancillary chunks przechowują dodatkowe informacje takie jak metadane, czy domyślne tło

Tyle z teorii! Pora spojrzeć na nasz plik:

// Sygnatura
buffer.slice(0, 7) // <Buffer 89 50 4e 47 0d 0a 1a>
//                               P  N  G

Pierwszy chunk

Według specyfikacji, pierwszym chunkiem powinien być IHDR. Zawiera on podstawowe informacje o grafice (i jest chunkiem krytycznym)

4 Bajty - Szerokość
4 Bajty - Wysokosć
1 Bajt  - Głębia kolorów
1 Bajt  - Typ kolorów
1 Bajt  - Metoda kompresji
1 Bajt  - Metoda filtrowania
1 Bajt  - Interlace
IHDR - Chunk length

Następne 4 Bajty będą długością chunku (jak można policzyć, powinny wskazywać na sumę bajtów z zestawienia wyżej)

const chunkLenght = buffer.slice(8, 12) // <Buffer 00 00 00 0d>

Przydałby się helper do odczytywania wartości 4-bajtowych fragmentów

const intFromSlicedBuf = b => (from, to) => b.slice(from, to).readUIntBE(0, 4)
const intFromSlice = intFromSlicedBuf(buffer)

Mając do dyspozycji takie narzędzie, możemy odczytać długość chunku (w tym przypadku jest to armata na muchę - wszak odczytujemy tylko szesnastkową wartość d (13), jednak za moment zrobimy z niego użytek)

intFromBuf(chunkLength) // 13
IHDR - Chunk name

W całkiem prosty sposób można zamienić bufor…

const chunkName = buffer.slice(12, 16) // <Buffer 49 48 44 52>

…na ciąg znaków:

chunkName.toString() // IHDR

Bez niespodzianek - chunk IHDR nazywa sie IHDR.

IHDR - Chunk data

Korzystając z informacji o IHDR zawartych wyżej, można odczytać potrzebne dane:

const imageWidth = intFromSlice(16, 20) // 800
const imageHeihgt = intFromSlice(20, 24) // 800
const compressionMethod = intFromSlice(26, 27) // 0

Metoda kompresji: 0 - co to oznacza?

PNG compression method 0 (the only compression method presently defined for PNG) specifies deflate/inflate compression with a sliding window of at most 32768 bytes. Deflate compression is an LZ77 derivative used in zip, gzip, pkzip, and related programs - www.libpng.org

Dane skompresowane są za pomocą popularnego algorytmu deflate

IHDR - CRC32

Jeszcze tylko sprawdzić integralność danych. Do liczenia CRC posłuży buffer-crc32 + helper

const crc32 = require('buffer-crc32')
const crcFromSlicedBuf = b => (from, to) => crc32.unsigned(b.slice(from, to))

const crcFromSlice = crcFromSlicedBuf(buffer)

Wartość z (IHDR CRC32) powinna być równa CRC z (IHDR name + data)

intFromSlice(29, 33) === crcFromSlice(12, 29) // true

Wszystko się zgadza - nie ma co przynudzać

Odczyt danych

W pliku zostały jeszcze dwa chunki: IDAT, który nas interesuje, oraz IEND - jak sama nazwa wskazuje, to informacja o końcu pliku. (Obydwa chunki są krytyczne, co za tym idzie, mamy do czynienia z najprostszą możliwą budową PNG)

Analogicznie do IHDR można wyeksportować dane z IDAT

const IDAT_length = intFromSlice(33, 37)
const IDAT_name = buffer.slice(37, 41).toString()
const IDAT_data = buffer.slice(41, 37 + IDAT_length)
const IDAT_crc = intFromSlice(41 + IDAT_length, 41 + IDAT_length + 4)

(Mało to generyczne rozwiązanie, ale na ten moment musi wystarczyć)

Sprawdzenie CRC

crcFromSlice(37, 41 + IDAT_length) === IDAT_crc // true

Dekompresja

Deflate/inflate natywnie w module zlib

const zlib = require('zlib')
const inflatedBuffer = zlib.inflateSync(IDAT_data) // <Buffer 00 ff ff ... >

inflatedBuffer.byteLength // 1920800

Tajemnicze kreski

Dane, dużo danych… można by z tego złożyć prawie 2 miliony liter - zdecydowanie nie tędy droga. Może warto to wszystko narysować? Coś na szybko - imagejs

const ImageJS = require('imagejs')
const bitmap = new ImageJS.Bitmap({
    width: imageWidth,
    height: imageHeight,
    data: inflatedBuffer
})

bitmap.writeFile('out_1.jpg')

Próba pierwsza

Długo głowiłem się nad tym, o co tu chodzi, i dlaczego nie mogę wyrenderować png? Kluczem był zielony pasek na dole grafiki, który zajmuje 1/4 całego obszaru - to brak danych!

Okazuje się, że biblioteka z założenia przyjmuje kolory w 4 bajtowym formacie RGBA. Brakuje kanału alpha - bajty trzeba pogrupwoać po 3 i do każdej z grupy dodać brakujący bajt (lodash na ratunek)

const data = Array.from(inflatedBuffer)
const dataWithAlpha = _.chain(data)
    .chunk(3)
    .map(arr => [...arr, 0xff])
    .flatten()
    .value()

const bitmap = new ImageJS.Bitmap({
    width: imageWidth,
    height: imageHeight,
    data: Buffer.from(dataWithAlpha)
})

bitmap.writeFile('out_2.png')

Próba druga

Znacznie lepiej - i to nawet w png!

Maluje się tutaj kod kreskowy, z którego należy ułożyć zestaw jedynek i zer, tudzież na odwrót, zer i jedynek.

Włączając plik w edytorze graficznym, można znaleźć kilka kolumn pozbawionych zakłóceń:

Kolumny bez zakłóceń

Pozycja kolumny na osi X: 134px

Poszczególne piksele z kolumny

// Pierwszy   : 800 * 0 + 134
// Drugi      : 800 * 1 + 134
// Trzeci     : 800 * 2 + 134
// n-ty       : 800 * (n - 1) + 134

Na każdy piksel przypadają 4 bajty

const bits = []
for (let i = 134 * 4; i < dataWithAlpha.length; i += imageWidth * 4) {
    // Białe piksele zaczynają się od bajtu z wartością 0xff
    const bit = dataWithAlpha[i] === 0xff ? 0 : 1
    bits.push(bit)
}

bits.join('') // 00010010101101101011011010110...

Rozszyfrowanie

Pogrupowanie bitów w bajty:

const joinArr = arr => arr.join('')

const bytes = _.chain(bits)
    .chunk(8)
    .map(joinArr)
    .value()

/* bytes

00010010
10110110
10110110
10110110
00110100
00000100
11000110
01011110
10011110
00000100
00101110
11110110
00000100
01011110
10000110
00100110
10000110
01110110
10010110
10100110
00000100
11000110
01011110
10000110
11001110
10100110
10110110
00000100
01010110
10101110
01011110
00000100
01110110
10010110
10100110
00000100
01000110
10011110
00110110
11110110
00000100
11100110
00100110
01011110
10010110
10100110
11001110
11111100 <-- ostatni pasek
00000000
00000000
00100000
00000000
00000000
00000000
...

*/

Sytuacja wygląda znajomo - Misja 009

W oczy rzuca się ostatnia kolumna zer - niepotrzebne bity w oryginalnym 7-bitowym ASCII.

Kolejnośc bitów w bajtach jest odwrócona:

const byteToInt = byte => parseInt(byte.join(''), 2)
const arrToStr = (acc, int) => acc += String.fromCharCode(int)

const flag = _.chain(bits)
    .chunk(8)
    .slice(0, 48) // <- ostatni pasek
    .map(_.reverse)
    .map(byteToInt)
    .value()
    .reduce(arrToStr, '')

console.log(flag) // Hmmm, czy to zadanie czasem juz nie bylo gdzies?