Home Posts Projects Tags RSS

[PL] CTF writeup: CaptchaFault 2024


Dec 6, 2024 - ctf crypto

Ostatnio miałem okazję storzyć kilka zadań na CTF organizowany przez koła SecFault i Captcha na Politechnice Śląskiej.

Starałem się krótko opisać kroki rozwiązania do każdego z zadań.

Sekretna wiadomość

Mój kolega zaczął niedawno pracę w dziedzinie kryptografii. Z tego powodu ostatnio spotykamy się rzadziej niż zwykle. Kiedy ostatni raz się widzieliśmy, przekazał mi pewną bardzo ważną wiadomość. Niestety zanim zdążył wyjaśnić mi jak ją odszyfrować, dostał pilny telefon z pracy i musiał tam wracać. Pamiętam tylko, że wspominał coś o obracającym się kluczu…

Wspomnienie o obracającym kluczu może sugerować, że użyto szyfrowania XORem z kluczem którszym niż zaszyfrowane dane. Znamy właściwość funkcji XOR: k ^ p = c <=> c ^ p = k. Jeśli znamy fragment zdeszyfrowanej wiadomości (w naszym przypadku początek: CaptchaFault), możemy poznać część klucza użytego do szyfrowania.

from pwn import xor

KNOWN_PLAINTEXT = b'CaptchaFault'

with open('data.bin', 'rb') as f:
    data = f.read()

# klucz odzyskamy przez zxorowanie danych ze znanym plaintextem (początek flagi)
keystream = xor(data, KNOWN_PLAINTEXT)[:len(KNOWN_PLAINTEXT)]

# deszyfrowanie z odzyskanym powtarzającym się kluczem
decrypted = xor(data, keystream)
print("Zdeszyfrowane:", decrypted.decode('utf-8'))

Standard w branży

Jeśli widziałeś zadanie z sekretną wiadomością pewnie kojarzysz już mojego znajomego kryptografa. Niedawno znowu udało nam się spotkać. Powiedział wtedy, że w ramach praktyk napisał niezwykle bezpieczny program szyfrujący. Używa on znanego szyfru blokowego zatwierdzonego w 2001 roku, ale czy na pewno w sposób bezpieczny? Znajomy poprosił Cię o przetestowanie jego programu.

from Crypto.Util.Padding import pad
from Crypto.Util import Counter
from Crypto.Cipher import AES
import os

# generuj klucz losowo za każdym uruchomieniem
# nie da się zrobić tego lepiej
KEY = os.urandom(16)

# super bezpieczna funkcja do szyfrowania wiadomości
def encrypt_message(plaintext):
    counter = Counter.new(128)
    cipher = AES.new(KEY, AES.MODE_CTR, counter=counter)
    ct = cipher.encrypt(pad(plaintext, 16))
    return ct

# wczytaj cytaty
with open('quotes.txt', 'rb') as f:
    quotes = f.readlines()

# wczytaj flagę (jako tester nie masz dostępu do tego pliku)
with open('flag.txt', 'rb') as f:
    flag = f.read()

quotes.append(flag)

for i, q in enumerate(quotes):
    ct = encrypt_message(q)
    print("zaszyfrowana wiadomość {}: {}".format(i, ct.hex()))

Wraz z kodem, dostajemy też 2 pliki: quotes.txt i output.txt. Ten pierwszy zawiera 3 cytaty (plaintext) a drugi 4 zaszyfrowane hexstringi na wyjściu programu (3 cytaty i 1 flaga).

Patrząc na dany kod, widać że dane szyfrowane są AESem w trybie CTR. Problem polega na tym, że za każdym razem używany jest ten sam klucz i początkowa wartość licznika.

Używanie AESa w ten sposób jest niebezpieczne, ponieważ keystream generowany przez funkcję AES będzie również taki sam dla każdego tekstu. Jeśli znamy tryb CTR, wiemy że operacja XOR łącząca plaintext z keystreamem wykonywana jest na samym końcu szyfrowania.

Podobnie jak w pierwszym zadaniu zatem, jesteśmy w stanie odzyskać keystream i wykorzystać go do odszyfrowania flagi. Szyfrowanie AES mimo, że normalnie bardzo bezpieczne jest tutaj całkowicie zredukowane.

from pwn import xor

flag_enc = bytes.fromhex('e330b5639340973005f43552836269a61c8c97d23d42a8b0de0160b24efc5b37c748cabe66e5a59cd9d4bf85542797a0')
quote_enc = bytes.fromhex('ee3ee564895b821309a13640d87d3bb85cc186943d6bfae9ed0332aa1eeb242d954edfbe41efe69dd287ac9e470df4ccdaa7e1d0d74df4a239f93a249b71027346d1fda8c5f3afc691f2d5c46f3a83f94eb619647fb4d23c13110a472449bc8b91ec70a7086110f0175d9df2801d82ef19d1a4c8dc32932f401fb8e39e7a61ea9e14585f776098053c9cb508f5b19dde')

with open('quotes.txt', 'rb') as f:
    known_plaintext = f.read(len(flag_enc))

ks = xor(quote_enc[:len(known_plaintext)], known_plaintext)
print('Flaga:', xor(flag_enc, ks))

Irytujący Elf

W okresie świątecznym Elfy to nie rzadkość. Niestety moje spotkanie z Elfami w tym roku nie było takie miłe. Przygotowałem prezent dla znajomego - prosty program do szyfrowania plików. Już miałem go pakować w świąteczny papier, gdy zauważyłem że funkcja do deszyfrowania nagle przestała działać! Niestety nie mam już kodu źródłowego mojego programu… Czy umiesz napisać funkcję deszyfrującą i odszyfrować sekretny plik?

Po otwarciu binarki w Ghidrze i nazwaniu zmiennych:

...
key = atoi(*(char **)(param_2 + 0x10));
ksb = (undefined)key;
while( true ) {
    nread = fread(pl_buf,1,0x400,__stream);
    if (nread == 0) break;
    ksb = encrypt(ct_buf,pl_buf,nread,ksb);
    fwrite(ct_buf,1,nread,__s);
}
fclose(__stream);
fclose(__s);
ret = 0;
...

Funkcja encrypt szyfruje dane XORując je z keystreamem generowanym przez funkcję GET_KSB. Użytkownik ma możliwość podania wartości początkowej “ziarna” (pierwszego bajtu keystreamu). Z racji tego, jest tylko 256 możliwych wartości klucza klucza.

int GET_KSB(byte ksb_last)
{
  return (uint)ksb_last * 0x59 + 0xc;
}

byte encrypt(long outbuf,long inbuf,ulong len,byte seed)
{
  byte ksb;
  ulong i;
  
  ksb = seed;
  for (i = 0; i < len; i = i + 1) {
    *(byte *)(i + outbuf) = *(byte *)(i + inbuf) ^ ksb;
    ksb = GET_KSB(ksb);
  }
  return ksb;
}

Program deszyfrujący może na przykład wyglądać tak:

from pwn import xor

# funkcja przetłumaczona ze zdekompilowanego kodu
def get_ksb(last_ksb):
    return ((last_ksb * 0x59) + 0x0c)&0xff

f = open('secret.enc', 'rb')
fc = f.read()
f.close()

# nie znamy jednobajtowego klucza (i), więc możemy użyć małego brute-force
for i in range(256):
    keystream = b''
    x = i
    for _ in range(len(fc)):
        keystream += bytes([x])
        x = get_ksb(x)
    out = xor(fc, keystream)
    if b'Captcha' in out:
        print(out.decode('utf-8'), i)

cr4ck wp42

W pewnym budynku biurowym działa tajna grupa leet hackerów pod przykrywką. Używają oni swojej szyfrowanej sieci WiFi do wymiany ważnych informacji operacyjnych. Nie przewidzieli jednak, że ich sieć ma zasięg nawet poza budynkiem. Najprawdopodobniej nie chciało się również im zmienić domyślnego hasła routera. Detektywi ustalili, że hasło składa się z 8 cyfr, zaczyna się od 13 (pierwsze dwie cyfry) a ostatnią cyfrą jest 3. Udało się im też podsłuchać zaszyfrowaną komunikację i zapisać ją w pliku pcap. Czy jesteś w stanie pomóc w jej rozszyfrowaniu?

Do oddzielenia przechwyconego WPA2 handshake możemy użyć narzędzia hcxpcapngtool, które stworzy plik kompatybilny z hashcatem.

Do hashcata musimy napisać regułę i ustawić odpowiedni rodzaj hasha (22000 = WPA-PBKDF2-PMKID+EAPOL).

$ hcxpcapngtool -o handshakes captured.pcapng
$ hashcat -m 22000 -a 3 handshakes '13?d?d?d?d?d3'
$ hashcat -m 22000 -a 3 handshakes --show '13?d?d?d?d?d3'
aef4d97cfae76535618d999fc251962c:3ccd579a2293:32700b9eb099:1337wifi:13872903

Jak widać po złamaniu hasha, poprawne hasło do WiFi “hackerów” to 13872903.

Wireshark umożliwia automatyczne deszyfrowanie ruchu WPA2 pod warunkiem, że podamy odpowiednie hasło. To hasło należy wpisać w Preferences > protocols > IEEE 802.11 > Enable decryption > Decryption keys.

Jeśli ruch zostanie poprawnie zdeszyfrowany, możemy aktywować filtr na ruch http wpisując http jako filtr. Następnie zapisujemy plik index.html i po otwarciu go w przeglądarce wyświetla się flaga (obrazek png w base64 embedowany na stronie).

Zrzut pamięci

Twój znajomy jest detektywem, ostatnio badał sprawę pewnego hackera. Podobno na jego komputerze znajdują się ściśle tajne plany operacyjne. Detektyw uzyskał dostęp do jego komputera. Niestety hacker okazał się być sprytniejszy i zaszyfrował swój dysk. Jedyne co mamy to zrzut pamięci i obraz tego dysku. Czy umiesz coś z tym zrobić?

W załączonym pliku zip znajdują się 2 pliki:

Dysk zaszyfrowany jest za pomocą LUKS2:

$ cryptsetup luksDump disk.img 
LUKS header information
Version:       	2
Epoch:         	3
Metadata area: 	16384 [bytes]
Keyslots area: 	1015808 [bytes]
UUID:          	bf4b829f-4ccb-4293-80a8-5a3b295cb01f
Label:         	(no label)
Subsystem:     	(no subsystem)
Flags:       	(no flags)

Data segments:
  0: crypt
	offset: 1048576 [bytes]
	length: (whole device)
	cipher: aes-xts-plain64
	sector: 512 [bytes]

Keyslots:
  0: luks2
	Key:        512 bits
	Priority:   normal
	Cipher:     aes-xts-plain64
	Cipher key: 512 bits
	PBKDF:      pbkdf2
	Hash:       sha256
	Iterations: 2716518
	Salt:       47 28 63 06 47 97 af f0 93 35 25 ad 9a d7 65 55 
	            8f b1 db 77 66 4c 4e d1 73 64 94 32 47 05 bd c3 
	AF stripes: 4000
	AF hash:    sha256
	Area offset:32768 [bytes]
	Area length:258048 [bytes]
	Digest ID:  0
Tokens:
Digests:
  0: pbkdf2
	Hash:       sha256
	Iterations: 167611
	Salt:       53 d4 a2 0c 5d 5f a1 a5 75 9f 0f 8b b9 05 36 5c 
	            56 a0 c9 76 f0 ec f3 1b ba 6f 36 73 f3 42 91 da 
	Digest:     3f 05 13 97 43 5d 1d 30 25 29 22 99 ee 0c 76 28 
	            d0 34 d0 45 0f 36 53 73 aa 79 cb dd 20 96 7a e8

Próba zdeszyfrowania partycji bez znajomości klucza mija się tutaj z celem. LUKS2 to bezpieczny standard szyfrowania partycji zabezpieczony przed metodami brute-force.

Zakładając, że zrzut pamięci pochodzi z systemu na którym ta partycja została kiedyś odblokowana, wiemy że klucz do AESa powinien znajdować się gdzieś w pamięci.

Możemy użyć narzędzia findaes, żeby znaleźć ten klucz:

$ findaes memory.bin
Searching memory.bin
Found AES-256 key schedule at offset 0x4da8f9: 
7b 75 99 b9 09 37 b6 2a cf 18 f2 6c 05 5c f0 4f 84 4a b1 e2 99 6d 50 87 14 2a 68 bc 15 be 66 f0 
Found AES-256 key schedule at offset 0x4daae9: 
bd a2 c0 26 9f 7a cb f5 6f 84 5d 69 53 ef 56 dd 71 f7 cd ae 3a c8 9e 4c 3f af 52 c0 b6 ee 2b ee 
Found AES-256 key schedule at offset 0xb641ce: 
2b e3 4b 07 c1 79 42 a1 85 dd 3f af e1 63 80 ec 0d da 87 89 02 48 35 bb a8 32 76 22 2d f1 94 37 
Found AES-256 key schedule at offset 0xb643be: 
2b e3 4b 07 c1 79 42 a1 85 dd 3f af e1 63 80 ec 0d da 87 89 02 48 35 bb a8 32 76 22 2d f1 94 37 
Found AES-256 key schedule at offset 0xb8830e: 
f2 dc 84 76 4f 4f 02 60 ca 91 bd 44 3b ea df 15 ac 54 e1 67 15 af 9b 15 c3 98 8f 63 02 9f cb 7a 
Found AES-256 key schedule at offset 0xb884fe: 
f2 dc 84 76 4f 4f 02 60 ca 91 bd 44 3b ea df 15 ac 54 e1 67 15 af 9b 15 c3 98 8f 63 02 9f cb 7a 
Found AES-256 key schedule at offset 0xb888de: 
00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 
Found AES-256 key schedule at offset 0xb88ace: 
02 15 3e ca 33 08 d8 11 06 2f 1e 23 d7 85 14 08 9c e5 d6 c4 15 f1 d9 31 82 96 ba 9d ed 2e 02 e4 
Found AES-256 key schedule at offset 0xc13176: 
02 15 3e ca 33 08 d8 11 06 2f 1e 23 d7 85 14 08 9c e5 d6 c4 15 f1 d9 31 82 96 ba 9d ed 2e 02 e4 

Każdy znaleziony tutaj klucz ma długość 32 bajtów (AES256), natomiast wiemy że nasz dysk zabezpieczony jest 512 bitowym AESem. Szukając trochę w internecie, ten blog post okazuje się bardzo pomocny. Wystarczy zapisać klucze jeden za drugim.

Nie wiemy które klucze są prawdziwe, a które to false-positive. To musimy już sprawdzić ręcznie. Możemy otworzyć dysk bez znajomości hasła (znając klucz AESa) używając opcji --master-key-file:

$ echo 'bd a2 c0 26 9f 7a cb f5 6f 84 5d 69 53 ef 56 dd 71 f7 cd ae 3a c8 9e 4c 3f af 52 c0 b6 ee 2b ee 7b 75 99 b9 09 37 b6 2a cf 18 f2 6c 05 5c f0 4f 84 4a b1 e2 99 6d 50 87 14 2a 68 bc 15 be 66 f0' | xxd -r -p | tee key.bin
$ sudo cryptsetup --master-key-file key.bin luksOpen disk.img dysk

Jeśli wzięliśmy dobre klucze, odszyfrowany dysk zostanie zamontowany pod /dev/mapper/dysk.

Teraz możemy go zamontować, żeby zobaczyć pliki użytkownika:

$ mkdir /tmp/mp
$ sudo mount /dev/mapper/dysk /tmp/mp
$ ls /tmp/mp
cat-hacker.jpg  lost+found/
$ file /tmp/mp/cat-hacker.jpg 
/tmp/mp/cat-hacker.jpg: JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, baseline, precision 8, 1600x1024, components 3

Jedynym dostępnym plikiem jest cat-hacker.jpg, natomiast na pierwszy rzut oka nie znajdziemy w nim flagi.

Jedną z CTFowych sztuczek w plikach JPG jest zmiana rozmiaru obrazka ręcznie. Musimy otworzyć plik w edytorze binarnym i spróbować zmienić jego wymiary. Pod offsetem 0xa3 znajduje się liczba 0x0400 = 1024 (co odpowiada wysokości naszego obrazka). Zmieńmy ją na na przykład 0x0480 = 1152.

Teraz na dole obrazka znajduje się flaga.