Base64 poza kodowaniem – steganografia i kanoniczność (cz. 2)

Gynvael Coldwind

2024-08-19

Część pierwsza dotyczyła dwóch lub czterech nieużywanych bitów na samym końcu ciągu Base64. Bity te powinny być wyzerowane, ale w zasadzie nic tego nie sprawdza, więc można je użyć również w innych celach. W części drugiej (i zarazem ostatniej) skupimy się na tych bardziej oczywistych niekanonicznych formach Base64.

Alfabet Base64

Formalnie Base64 korzysta ze znaków z zakresów A-Z, a-z, 0-9, oraz + i /. Oba znaki specjalne mogą się różnić w wariantach Base64. Przykładowo, zarówno znak + jak i / mają specjalne znaczenie w URLach: + w parametrach (query) zastępuje spację (jest to krótsza forma %20), a / jest oczywiście separatorem ścieżki. Sprawia to, że Base64 w standardowej wersji nie nadaje się za bardzo do używania w adresach webowych. Ale z pomocą przychodzi "wersja webowa", tj. base64url, która wymienia + na - oraz / na _.

Niezależnie jednak od wariantu, wszystkie pozostałe znaki są niewykorzystywane. Nawet jeśli ograniczymy się do podstawowego 7-bitowego ASCII, daje nam to 31 nieużywanych znaków drukowalnych oraz 32 nieużywane znaki sterujące. Co, jeśli taki znak pojawi się w strumieniu Base64?

Wszytko trochę zależy od konkretnej specyfikacji. Podstawowa specyfikacja (RFC 4648) mówi, że implementacje powinny odrzucać ciągi, które zawierają jakikolwiek znak spoza alfabetu Base64... chyba, że nadrzędna specyfikacja mówi inaczej:

> Implementations MUST reject the encoded data if it contains
> characters outside the base alphabet when interpreting base-encoded
> data, unless the specification referring to this document explicitly
> states otherwise.

Sprawia to, że implementacje ogólnego przeznaczenia są postawione w trochę dziwnej sytuacji: z jednej strony specyfikacja mówi, żeby nadprogramowe znaki odrzucać, ale z drugiej użytkownicy pewnie będą chcieli używać dekoderów w bardzo różnych sytuacjach. Poszukując więc pewnego kompromisu, implementacje często domyślnie ignorują dodatkowe znaki:

// PHP
$s = ':S!G@V#4$Q%X^J&j*Y(W)5[h]';
base64_decode($s);  // "HexArcana"
base64_decode($s, /*strict=*/true);  // false

# Python
s = ':S!G@V#4$Q%X^J&j*Y(W)5[h]'
base64.b64decode(s);  # b"HexArcana"
base64.b64decode(s, validate=True);  # binascii.Error: Only base64 data is allowed

# Node.js
const s = ':S!G@V#4$Q%X^J&j*Y(W)5[h]';
Buffer.from(s, "base64");  // <Buffer "HexArcana">

Co za tym idzie, nic nie stoi na przeszkodzie, aby w ciąg Base64 wpleść drugi strumień danych, zakodowany za pomocą pozostałych znaków. W zasadzie wystarczą dwa znaki i kodowanie binarne.

Albo tylko jeden znak i kodowanie wykorzystujące odległość pomiędzy wystąpieniami (liczoną w znakach). Przykładowo, w specyfikacji kodowania załączników do emaili (MIME, RFC 2045) można znaleźć następujący zapis:

> The encoded output stream must be represented in lines of no more
> than 76 characters each.

Można więc dodatkowe dane zakodować za pomocą długości poszczególnych linii – w końcu "nie więcej niż 76 znaków w linii" nie wymusza, aby linie miały dokładnie 76 znaków (choć trzeba też wskazać, że ta sama specyfikacja mówi, żeby wszystkie znaki spoza alfabetu ignorować).

Zainteresowanych zachęcamy do przećwiczenia powyższych sposobów i odkrycia danych "ukrytych" w poniższym ciągu (hint: hex):

TG9y$ZW0gaXBz$dW0gZG$9sb3I$gc2l0IG$FtZXQsIG$Nvbn$N$lY3RldH$VyIGFkaXBpc$2Npb$mcgZWxpd$C4gUGV$sbGVudGVz$cXVlIH$NvZG$FsZXMg$YSBua$XNs$IGVnZX$QgY$WNjd$W1zY$W$4uIE1v$cmJpIGdyYXZpZG$EsIGVs$aXQg$YWMgZ$3Jh$dmlkYS$Bjb25$2YWxsa$XMsIG$1hZ25h$IGFudGUgdWx$0cm$ljZXMg$YXJ$jdSw$gdGVtcG$9yIHRyaXN0aXF$1ZSBlbmltIHB1cnVzIGVnZXQgdXJuYS4gU2VkIHRyaXN0aXF1ZSB0aW5jaWR1bnQgYXVndWUsIHZlbCBpbXBlcmRpZXQgZXJhdC4gUHJhZXNlbnQgY29uc2VjdGV0dXIgdWx0cmljaWVzIGVzdCBhdCBtb2xsaXMuIEFlbmVhbiBldCBwbGFjZXJhdCBudWxsYS4gU3VzcGVuZGlzc2UgdGluY2lkdW50IHRlbXBvciBxdWFtLCBzZWQgdGVtcHVzIHF1YW0gc2NlbGVyaXNxdWUgYS4gRG9uZWMgdmVzdGlidWx1bSwgZWxpdCBhdCBydXRydW0gc29kYWxlcywgdHVycGlzIGxlY3R1cyBoZW5kcmVyaXQgbmVxdWUsIHF1aXMgZWdlc3RhcyBsb3JlbSBxdWFtIG9ybmFyZSB2ZWxpdC4gTW9yYmkgbG9ib3J0aXMgYWNjdW1zYW4gcGVsbGVudGVzcXVlLg==

I jeszcze jedna losowa ciekawostka – implementacja Pythona, nawet w trybie validate=True, ignoruje nadmiarowe znaki dopełnienia (=) na końcu ciągu:

# Python
s = 'SGV4QXJjYW5h==========='
base64.b64decode(s);  # b"HexArcana"
base64.b64decode(s, validate=True);  # b"HexArcana"

Base64 jako klucz i postać kanoniczna

Na koniec chciałbym wskazać jeszcze jedną istotną rzecz. Używanie Base64 jako klucza (w sensie wyszukiwania, np. klucza w słowniku, elementu w secie itp.) jest trochę niebezpieczne, ponieważ w praktyce – poza bardzo restrykcyjną postacią kanoniczną – bardzo wiele różnych ciągów znaków może dekodować się do tego samego ciągu wyjściowego. Tj. te same możliwości, które sprawiają, że w ciągu Base64 można zaszyć dodatkowe dane (nieużywane bity, ignorowane znaki spoza alfabetu, ignorowane nadmiarowe znaki dopełnienia), powodują jednocześnie, że nierestrykcyjnie traktowany Base64 NIE JEST mapowaniem 1-do-1.

Przykładowo, każdy z poniższych ciągów dekoduje się do tego samego ciągu wyjściowego:

# Python
b64decode("SGV4QXJjYW5hIGlzIGNvb2w=")  # b'HexArcana is cool'
b64decode("SGV4QXJjYW5hIGlzIGNvb2x=")  # b'HexArcana is cool'
b64decode("SGV4QXJjYW5hIGlzIGNvb2w====")  # b'HexArcana is cool'
b64decode("@SGV$4QXJjYW5h%IGl*zIGNv(b2:w=")  # b'HexArcana is cool'

Na przykład banowanie kluczy publicznych w ich zakodowanej Base64 formie może nie zadziałać zgodnie z przewidywaniami – i to nawet nie zagłębiając się w niejednoznaczności wynikające z binarnych formatów używanych do kodowanie kluczy publicznych.

Podsumowanie

Kilka ostatnich losowych ciekawostek:

  • Ciągi Base64 musimy dekodować od pierwszego znaku, żeby dostać sensowny output. Niemniej jednak jeśli pierwsze znaki są pominięte lub nie wiemy który znak jest pierwszy, to wystarczy, że spróbujemy zdekodować ciąg 4 razy, zaczynając od pierwszego dostępnego znaku, potem drugiego, trzeciego i w końcu czwartego. Daje nam to 100% prawdopodobieństwa, że za którymś z czterech podejść "zsynchronizujemy się" z faktycznym strumieniem Base64.
  • Wszelkie uszkodzenia ciągu Base64 są miejscowe i nie wpływają na dekodowanie pozostałych, nieuszkodzonych danych. Konkretniej, każdy uszkodzony znak Base64 wpłynie co najwyżej na dwa zdekodowane bajty.
  • Hashe haseł zapisane w /etc/shadow również używają Base64, ale z innym alfabetem – konkretniej, kolejność znaków w alfabecie jest zupełnie inna. W klasycznym Base64 używamy A-Za-z0-9+/. Natomiast, stosowane pod *nixami do hashy kodowanie (zwane B64) używa alfabetu ./0-9A-Za-z.

Ostatecznie Base64 jest dość prostym kodowaniem, ale i tak kryje w sobie sporo ciekawostek.

Nie przegap nic od HexArcana! Dodaj nasz blog do swojego czytnika RSS/Atom lub zapisz się na nasz newsletter poniżej.