⭐Le grand écart
404CTF{you_spot_w3ll_the_differences}
Catégorie: Stegano Difficulté: medium Flag: -
Challenge
Description
En sortant de votre cours de gymnastique, vous tombez sur un étrange bouquin dans les vestiaires... À vous d'en trouver tous les secrets.
Découverte du challenge
Récupération du texte original
La première chose à remarquer est qu'il s'agit d'un texte, certes, mais certains caractères semblent bizarres. Plus précisément, certains octets sont différents de ce qu'ils devraient être.

Pour avoir une base de comparaison et commencer à chercher, il faut donc trouver le fichier original.
Rien de plus simple grâce à internet, on peut simplement faire une recherche Google sur une partie intacte du texte et on découvre alors qu'il s'agit de Moby-Dick or the whale. On peut maintenant faire une recherche grâce aux Google Dork sur le type txt pour obtenir le fichier qu'il nous faut.

Parmi les résultats, j'ai trouvé et utilisé celui-ci
Différences entre les fichiers
Maintenant que l'on a les deux versions, on peut s'amuser à regarder les différences

Bon, il y en a pas mal sur les premières lignes et sachant que le texte en fait 15 000, on va automatiser tout ça grâce à Python.
Tout d'abord, on peut voir que nos deux fichiers font la même taille, ça nous indique déjà qu'on a récupéré le bon texte.
import os
challenge = 'challenge.txt'
original = 'mobydick.txt'
print(os.stat(challenge).st_size)
print(os.stat(original).st_size)
# 643210
# 643210
À présent, on va itérer simultanément sur les deux fichiers caractère par caractère et les comparer. Dès qu'ils sont différents, on va afficher la position dans le fichier ainsi que le XOR entre les deux.
Pourquoi faire un XOR ? Car c'est une opération très courante dans le chiffrement et la stéganographie puisqu'elle est réversible. C'est donc un réflexe à avoir lorsque l'on a plusieurs valeurs à comparer.
import os
challenge = 'challenge.txt'
original = 'mobydick.txt'
size = os.stat(challenge).st_size
assert size == os.stat(original).st_size
# rb pour lire en mode octet
f_challenge = open(challenge, 'rb')
f_original = open(original, 'rb')
for i in range(size):
# Récupère le prochain octet
c_challenge = f_challenge.read(1)
c_original = f_original.read(1)
# Si ils sont différents
if c_challenge != c_original:
# XOR entre les deux octets récupérés
xored = bytes([c_challenge[0] ^ c_original[0]])
# Affiche où dans le fichier, le caractère du challenge
# celui de l'original et le résultat du xor entre les deux
print(f"{i:0>6} {c_challenge}\t{c_original}\t{xored}")
f_challenge.close()
f_original.close()
# 000150 b'\xa9' b' ' b'\x89'
# 000180 b'\x04' b'T' b'P'
# 000210 b'n' b' ' b'N'
# 000240 b'\x02' b'E' b'G'
# 000270 b'C' b'N' b'\r'
# 000300 b'^' b'T' b'\n'
# 000330 b':' b' ' b'\x1a'
# 000360 b'\x00' b'\n' b'\n'
# 000480 b'-' b' ' b'\r'
# 000510 b'i' b' ' b'I'
# 000540 b'\x1f' b'W' b'H'
# 000570 b'\x05' b'A' b'D'
# 000600 b'\x17' b'E' b'R'
Bingo, on voit dans nos print plusieurs choses intéressantes :
A partir de l'octet 150, on a une variation tous les 30 octets.
Le XOR entre cette variation et le texte original affiche PNG ... IHDR, on a donc un .png face à nous
On voit des "sauts" (300, 330, 360, 480), ils sont surement dus au fait que le fichier contient des octets nuls. Sauf que
0 XOR x = x
, il ne faut donc pas se faire avoir et bien prendre tous les 30 octets même s'ils sont identiques.
Ce qu'on va faire, c'est tout simplement rediriger le résultat du XOR dans un fichier tierce, que l'on nommera étonnement flag.png.
import os
challenge = 'challenge.txt'
original = 'mobydick.txt'
flag = 'flag.png'
size = os.stat(challenge).st_size
assert size == os.stat(original).st_size
f_challenge = open(challenge, 'rb')
f_original = open(original, 'rb')
f_flag = open(flag, 'wb')
for i in range(150, size, 30):
f_challenge.seek(i) # Se déplace dans le fichier
c_challenge = f_challenge.read(1) # 1 octet lu
f_original.seek(i) # Se déplace dans le fichier
c_original = f_original.read(1) # 1 octet lu
xored = bytes([c_challenge[0] ^ c_original[0]])
f_flag.write(xored) # Ecrit le résulat du xor dans le fichier
f_challenge.close()
f_original.close()
f_flag.close()

Suite du challenge
Maintenant que nous avons récupéré l'image, il nous faut trouver le flag. En bas à gauche, on voit des pixels de couleurs différentes, ce qui fait penser tout de suite à une technique de LSB un peu foireuse.
Façon simple
On se rend sur AperiSolve et l'on importe l'image. Le site lance plusieurs outils sur le fichier et parmi eux, Zsteg détecte bien un LSB avec le flag

Façon puriste
On commence par reverse le LSB que l'on a vu plus tôt en bas de l'image. Je dis LSB, mais il faut prendre tous les bits de l'octet de chaque canal de couleur (sauf l'alpha).
from PIL import Image
img = Image.open(flag)
data = ''
for x in range(15):
r, g, b, a = img.getpixel((x, img.height - 1))
data += chr(r) + chr(g) + chr(b)
print(data.strip('\x00'))
# https://www.youtube.com/watch?v=dQw4w9WgXcQ
Bien joué, vous vous êtes fait RickRoll...
En réalité, c'était un indice pour vérifier les autres pixels. Si l'on regarde les pixels au début de l'image, on peut voir que les canaux toujours varient entre deux valeurs alors que c'est un fond uni.
from PIL import Image
img = Image.open(flag)
data = b''
for x in range(15):
r, g, b, a = img.getpixel((x, 0))
print(r, g, b, a)
# 12 8 77 254
# 12 9 77 254
# 12 8 76 254
# 12 8 76 254
# 12 8 76 254
# 12 8 76 254
# 12 8 77 255
# 12 9 76 254
On en déduit qu'il faut regarder le dernier bit de chaque canal pour reconstruire le message
from PIL import Image
img = Image.open(flag)
data = ''
for x in range(128):
# Ajoute 0 ou 1 en fonction du dernier bit de chaque canal
data += ''.join([f'{n & 1}' for n in img.getpixel((x, 0))])
# Regroupe par 8 bits et interprête en caractère
print(''.join(chr(int(data[i:i+8], 2)) for i in range(0, len(data), 8)))
# &404CTF{you_spot_w3ll_the_differences}
Dernière mise à jour
Cet article vous a-t-il été utile ?