Introduction
Le point d’entrée du challenge est, cette année, un fichier pdf ( strange_sonnet.pdf ). La page du challenge nous indique également un premier indice :
PS: A little anti-frustration-stegaguess hint: for each new image you get for step0 (in the expected order, from the main support first two), there is a visual way to confirm you are on the right path. The final image should be cristal clear (with no need for URL bruteforce)
Qui sera plus tard suivie de :
Hint 1: They are 4
Hint 2: Entering pdf specification is required, you may need a good Netflix subscription
Le fichier fourni contient en première page une image assez explicite :

Ceci dit, le premier indice parle de deux images dans le support principal (le pdf), il nous manque donc un candidat.
Parsing de strange_sonnet
Un premier script python utilisant le paquet pymupdf permet d’obtenir un listing de l’ensemble des objets contenus par ce fichier :
import pymupdf
def dump_doc(doc):
#copie de la doc mupdf
xreflen = doc.xref_length()
for xref in range(1, xreflen):
print("")
print("object %i (stream: %s)" % (xref, doc.xref_is_stream(xref)))
print(doc.xref_object(xref, compressed=False))
doc_root = pymupdf.open("strange_sonnet.pdf")
dump_doc(doc_root)
La sortie nous informe :
-
que le pdf inclut deux autres fichiers :
-
secret.pdf (objet 39)
-
rfc.pdf (objet 42)
-
-
on peut également extraire du bloc 36 une image invisible :

J’ajoute le code suivant permettant de l’extraire et xor les deux images entre elles :
def xor_imgs(img1, img2):
data1 = img1.getdata()
data2 = img2.getdata()
out = []
for idx in range(len(data1)):
out.append(data1[idx] ^ data2[idx])
out_img = Image.new(img1.mode, img1.size)
out_img.putdata(out)
return out_img
xor_me_stream = doc_root.xref_stream(8)
xor_me_img = Image.new('L', (512, 512))
xor_me_img.putdata(xor_me_stream)
# xor_me_img.show()
key_stream = doc_root.xref_stream(36)
key_stream = key_stream[key_stream.index(b'(')+1:key_stream.rindex(b')')]
key_img = Image.new('L', (512, 512))
key_img.putdata(key_stream)
# key_img.show()
step1_img = xor_imgs(xor_me_img, key_img)
# step1_img.show()
On obtient :

RFC.pdf, RC4, stegano : So much for that
On peut récupérer le contenu de ce fichier en quelques lignes :
rfc_pdf_stream = doc_root.xref_stream(42)
rfc_doc = pymupdf.open(stream=rfc_pdf_stream)
dump_doc(rfc_doc)
Encore un fois, le fichier contient une série d’images invisibles :
-
les blocs 98, 99 et 101 contiennent 3 lobsters (leur taille de 256*256 ne correspondent pas à nos images de travail)
-
le bloc 100 contient lui une image en niveau de gris :
OK…
A ce moment, le troisième indice n’a pas été publié. Je sais donc qu’il me manque, à minima, 2 images de 512 par 512 pixels. Il y a bien une image qui correspondrait dans le second fichier (secret.pdf). Mais il est chiffré, je suppose qu’on finira par avoir la passphrase quelque part.
Commence une longue phase de recherche de l’image suivante. Des tentatives (en utilisant stegonline ) d’analyse des lobsterdogs (on a que ça) ne donne pas grand-chose (mais si là, en plissant les yeux, ne serait-ce pas un fragment de QRcode dans les bitplanes Red0 et Green0 ????). Recherches infructueuses.
Seconde hypothèse : nous avons trois images de 256*256 venant de rfc.pdf et 3 images de 256*256 dans un fichier chiffré. En se renseignant sur les algorithmes utilisés pour protéger des pdfs (ici on utilise la version 2, donc le schéma de chiffrement de pdf 1.4), j’apprends qu’il s’agit de RC4. RC4 étant un stream cipher reposant, au final, sur le xor entre le clair (ou le chiffré) et un PNRG, on peut très facilement imaginer une attaque par clair connu.
En xorant une image claire et une image chiffrée entre elles on obtient le flux de PNRG associé. Qu’on pourrait donc utiliser pour déchiffrer l’ensemble des autres images, dont les 256*256 premiers pixels de notre image cible. Ce ne serait pas complet, mais peut être mieux que rien.
Sauf que la clé de chiffrement de chaque bloc est dérivée à partir du mot de passe utilisateur, mais aussi de l’id de bloc. Les différents blocs ne partagent donc pas le même flux de PNRG….
RFC.pdf : une histoire de séparateur
Hint 2: Entering pdf specification is required, you may need a good Netflix subscription
Certes. En continuant à me frapper la tête contre les murs, je finis par me rendre compte que l’objet 100 de rfc.pdf est effectivement très gros. Et que peut être le "So much for that" indiquerait qu’il y a, effectivement, trop de données pour un si petite image. Pendant le challenge j’ai utilisé cyberchef, mais je continuerai en python pour cette solution :
En reprenant l’objet 100 on sait que les traitements appliqués seront :
/FlateDecode /ASCII85Decode /ASCIIHexDecode /ASCII85Decode
on a donc :
rfc_multi_stream = rfc_doc.xref_stream_raw(100)
# FlateDecode
rfc_multi_uncomp = zlib.decompress(rfc_multi_stream)
#ASCII85Decode
rfc_part1 = base64.a85decode(rfc_multi_uncomp)
Et l’erreur suivante :
ValueError: Non-Ascii85 digit found: ~
En ouvrant le fichier on trouve en fait le marqueur de fin de stream '~>' en plein milieu de nos données. Séparant donc deux flux de base85 :
rfc_part1_b85, rfc_part2_b85 = rfc_multi_uncomp.split(b'~>')
rfc_part1 = base64.a85decode(rfc_part1_b85)
rfc_part2 = base64.a85decode(rfc_part2_b85)
rfc_part2 est une citation, continuons sur rfc_part1 :
#ASCIIHexDecode
# rfc_part2 = binascii.unhexlify(rfc_part1)
Donne :
#Odd-length string
Ok, ajoutons un 0 ça ne coute rien :
#ASCIIHexDecode
rfc_part2 = binascii.unhexlify(rfc_part1+b'0')
#Non-hexadecimal digit found
Cette fois, on trouve le séparateur '>'
rfc_part2_1, rfc_part2_2 = rfc_part1.split(b'>')
rfc_part2_1 = binascii.unhexlify(rfc_part2_1)
# chunk 1: Almost There
rfc_part2_2 = binascii.unhexlify(rfc_part2_2)
rfc_c1_img = Image.new('L', (512, 512))
rfc_c1_img.putdata(rfc_part2_2)
On obtient (enfin!) une nouvelle image :
Ou plutôt une demi image… Continuons de creuser du coup. rfc_par2_1 se sépare à nouveau en deux flux de base85:
# ASCII85Decode
# rfc_part3 = base64.a85decode(rfc_part2_1)
# ValueError: Non-Ascii85 digit found: ~ ....
rfc_part3_1, rfc_part3_2 = rfc_part2_1.split(b'~>')
rfc_part3_1 = base64.a85decode(rfc_part3_1) # wrong image
rfc_part3_2 = base64.a85decode(rfc_part3_2)
rfc_c2_img = Image.new('L', (512, 512))
rfc_c2_img.putdata(rfc_part3_2)
rfc_c2_img.show()
On retrouve "So much for that" et une URL ! Un passage sur le site nous apprend que nous ne sommes pas au bout du chemin
What were you expecting at this location?? Go dig deeper …
Le xor de "On the right track" et d’une combinaison des deux fragments ne nous donne rien de plus que du bruit. On a trois images, il nous en manque une. Et le seul endroit restant est secret.pdf.
Secret.pdf : au final il reste plus que ça
Après un nmap de l’url (au cas où, on ne sait jamais), et une tentative de s’interfacer avec les ports ouverts (44544, 44546, 44546) et de parsing des messages émis (commençant par MFD), je me rappelle qu’on est au STEP0, que je commence à sortir du scope et qu’on n’attaque pas l’infrastructure. Retour à secret.pdf.
En désespoir de cause, je sors john, rockyou et lance un bruteforce sur secret.
Note
|
chez moi, les outils permettant de générer l’entrée de john à partir d’un pdf (pdf2john, que ce soit la version perl ou python) sortent des hashs dans un format non reconnu par john. J’ai beaucoup râlé, mais il suffit de remplacer fffffffc par -4 pour obtenir l’entrée valide suivante : |
$pdf$2*3*128*-4*1*32*3734306334623338316266313665323365313935393537363037633237303335*32*5826e181f11c7b175dfa91d99d73b6c528bf4e5e4e758a4164004e56fffa0108*32*d0573deb08a87d0a632421f2f2c884b2f172f5a66f37b19b009b573bc67183df
On obtient très rapidement le mot de passe, et une nouvelle image. Qu’on xor avec "On the right track" :
secret_doc = pymupdf.open(stream=secret_pdf_stream)
secret_doc.authenticate('lobsterpumpkin')
# dump_doc(secret_doc)
secret_stream = secret_doc.xref_stream(10)
secret_img = Image.new('L', (512, 512))
secret_img.putdata(secret_stream)
# secret_img.show()
step3_img = xor_imgs(step1_img, secret_img)
Ce qui nous donne :
Nous indiquant l’ordre de nos deux fragments, et enfin :