Write-UP AeroCTF 2021: BashD00r (500 pts)

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.

Bash backdoor, Pt. I

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.

Chiffrement "custom" et réseau de Feistel

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.

Analyse du "second stage"

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.

Bash backdoor Pt. II

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.

Final flash

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 ! :þ