FCSC 2026 scampi32

Apr 16, 2026 • 16:42 / 6 min read

1 - Première analyse

Ce challenge est basé sur un processeur implémenté en Python, nommé nCPU, mais reste un challenge purement déterministe car aucune seed n’est introduite dans le modèle.

La première étape est d’analyser le code fourni. On comprend que le flag va être découpé en 8 parties, qui seront ensuite dispatchées dans les registres de 0 à 7. Le code est désassemblé avant d’être donné au CPU, on va donc en profiter pour l’afficher. On obtient le code suivant :

ASM ///
LI R8, 0
MOVE R9, R0
LI R10, 1042199187
LI R11, 324508639
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 3
LI R13, 189364614
BNE R9, R13, 146
LI R13, -382013801
BNE R10, R13, 146
LI R8, 0
MOVE R9, R1
LI R10, 1255156435
LI R11, 610839776
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 21
LI R13, -1570814974
BNE R9, R13, 146
LI R13, 1190628912
BNE R10, R13, 146
LI R8, 0
MOVE R9, R2
LI R10, 1649624048
LI R11, 253635900
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 39
LI R13, 918377153
BNE R9, R13, 146
LI R13, 1189870101
BNE R10, R13, 146
LI R8, 0
MOVE R9, R3
LI R10, -1532158393
LI R11, -1985229329
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 57
LI R13, -1817064021
BNE R9, R13, 146
LI R13, -56834482
BNE R10, R13, 146
LI R8, 0
MOVE R9, R4
LI R10, -2087660728
LI R11, 270611011
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 75
LI R13, -1865150769
BNE R9, R13, 146
LI R13, 1485325267
BNE R10, R13, 146
LI R8, 0
MOVE R9, R5
LI R10, 1438293097
LI R11, 1432778632
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 93
LI R13, -1703343653
BNE R9, R13, 146
LI R13, 479882693
BNE R10, R13, 146# 1 - Première analyse
LI R8, 0
MOVE R9, R6
LI R10, 550440309
LI R11, -1515870811
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 111
LI R13, 78846657
BNE R9, R13, 146
LI R13, 1618336931
BNE R10, R13, 146
LI R8, 0
MOVE R9, R7
LI R10, -977009854
LI R11, 1515870810
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 129
LI R13, -370039574
BNE R9, R13, 146
LI R13, 596482869
BNE R10, R13, 146
LI R0, 1
HALT
LI R0, 0
HALT

On remarque immédiatement un bloc qui se répète. Il prend la valeur de Rn, la place dans R9, charge des constantes dans R10 et R11, puis effectue 1024 tours de boucle avec des XOR et des MOV, ainsi qu’une instruction SHRIMP.

2 - Analyse de l’instruction SHRIMP

J’ai beau chercher, je ne trouve pas cette instruction dans aucun standard d’assembleur. On va donc investiguer de ce côté. On va utiliser ce snippet pour tracer la réponse de cette instruction en fonction de l’entrée qu’on lui donne :

python ///
xs = list(range(1024))
ys_real = [cpu.registry.neural.neural_shrimp(i) for i in xs]

on trace la courbe et on obtien :

On observe une magnifique périodicité de 256, avec un offset. On trace donc la même courbe en avançant par pas de 256, et on obtient la courbe suivante :

Bon, on a une périodicité de 256 et une périodicité de 256². On vérifie à la puissance supérieure, et c’est toujours le cas, en tenant compte du fait que ce sont des entiers signés sur 32 bits.

On peut donc en déduire que le traitement du SHRIMP se fait byte par byte. Après quelques tests, on comprend qu’il s’agit en réalité d’une S-box.

3 - Conséquence

Du coup, si on revient à notre ASM :

text ///
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10

On remarque que, par conséquent, il n’y a aucune diffusion entre les bytes, ce qui va bien nous arranger, car cela nous permet de brute force caractère par caractère.

4 -Brute force

On reprend donc le code du challenge pour coder cet outil de brute force, un peu trop “CTF style” : j’ai géré les 8 rounds un par un à la main.

python ///
import re
from ncpu.model import CPU
from ncpu.model.scampi32_bytecode import disassemble_scampi32, assemble_scampi32

test = """
LI R8, 0
MOVE R9, R6
LI R10, 550440309
LI R11, -1515870811
XOR R11, R10, R11
XOR R11, R11, R8
SHRIMP R12, R11
MOVE R13, R9
MOVE R9, R10
XOR R10, R13, R12
LI R13, 1
ADDU R8, R8, R13
LI R13, 1024
BNE R8, R13, 3

LI R13, 78846657
LI R14, 1618336931

HALT
"""

for i in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789":
    flag = "FCSC{N3URA-LSHR1-MPNET-32FCS-C2026}"
    #            00001111222233334444
    if not re.fullmatch(r"FCSC\{[A-Z0-9]{5}(?:-[A-Z0-9]{5}){4}\}", flag):
        print("Invalid flag.")
        exit(1)

    flag = flag[5:-1].ljust(32, "\x00")
    flag = [ flag[i:i + 4] for i in range(0, 32, 4) ]
    flag = [ int.from_bytes(x.encode(), "little") for x in flag ]

    cpu = CPU(neural_execution = True, max_cycles = 250000, models_dir = "models")
    cpu.load_program(test)

    for idx, chunk in enumerate(flag):
        cpu.state = cpu.state.set_register(f"R{idx}", chunk)

    cpu.run()

    print(hex(cpu.get_register("R10")) , hex(cpu.get_register("R9")), hex(cpu.get_register("R13")), hex(cpu.get_register("R14")))

Chaque brute force de bloc prend grosso modo 2 à 5 minutes. On récupère une table avec toutes les valeurs prises par chaque octet en fonction de l’entrée. On peut donc se référer à cette table pour retrouver à quel moment l’octet était correct.

Il faut faire un peu attention au fait que certains octets sont inversés à cause du complément à 32 bits, mais malgré cette subtilité, on peut automatiser la technique. Une fois le script écrit, le flag tombe en 30 minutes malgré mon grille-pain.