Outils pour utilisateurs

Outils du site


misc:crackme_linux

C'est un mirroir d'un article très intéressant sur le reversing : Étude d'un crackme sous Linux

Un dossier de MISC a déjà été dédié au reverse engineering. Nicolas Brulez y avait notamment abordé quelques moyens de reverser un programme ainsi que différents angles pour protéger un logiciel d'une étude trop simpliste. Nous allons ici étudier un programme d'entraînement, un crackme. Nous verrons quelques bases théoriques et surtout pratiques d'une protection logicielle, ainsi que les moyens que nous avons à notre disposition pour les contourner.Dans cet article, nous nous concentrons sur un crackme dont je suis coupable, tiny-crackme, qui présente plusieurs aspects pratiques pour une étude, ainsi qu'une taille très réduite (< 800 octets). Nous nous y intéressons tout d'abord du point de vue d'un reverse engineer, puis nous présentons l'élaboration dudit crackme. Toute l'étude est menée sur un PC sous Linux (Debian but who cares ?).

I. Looking for the key...

On récupère tout d'abord le crackme et on le teste :

kaya$ wget http://nuxed.org/code/challenges/tiny-crackme &>/dev/null
kaya$ chmod +x tiny-crackme
kaya$ file tiny-crackme
tiny-crackme: ELF 32-bit LSB executable, Intel 80386, version 1, statically linked, corrupted section header size
yanisto@kaya$ ls -l tiny-crackme
-rwxr-x---  1 yanisto yanisto 795 2004-08-29 03:50 tiny-crackme
yanisto@kaya$ ./tiny-crackme
      Tiny_crackme - nisto's crackme #2
      ---------------------------------
 
This one has a particularly small size...
Hope u'll get some fun with it.
 
You can join me at yanisto@nuxed.org or on #secdev@freenode.net
 
Enter the Password :   1234567890
 
 Wrong password, sorry...
 
yanisto@kaya$ 567890
bash: 567890: command not found

On remarque en premier lieu la toute petite taille de l'exécutable… 795 octets. À première vue, le mot de passe comporte 4 caractères. On constate en effet que les suivants sont rejetés (cf. la répétition des caractères 567890 ci-dessus).
Ne connaissant pas le binaire, nous vérifions les symboles et autres informations contenues dans le header ELF. A priori, nous ne devrions pas en trouver beaucoup étant donné la petite taille de l'exécutable (qui en général se rapporte davantage à un binaire strippé, c'est-à-dire auquel on a enlevé toutes les informations non nécessaires à l'exécution, symboles et autres). La commande file nous révèle d’autant plus d'investigations que l'en-tête ELF a l'air corrompu, d'où un comportement possiblement étrange des différents outils reposant sur la librairie BFD (binary file descriptor) comme gdb (cf. binutils-dev pour plus de détails) ou elfsh (http://elfsh.devhell.org)

kaya$ nm tiny-crackme
nm: tiny-crackme: File format not recognized
kaya$ elfls tiny-crackme
tiny-crackme: warning: invalid section header table offset.
tiny-crackme* (Intel 386)
Program header table entries: 1 (20 - 40)
 0 P rws     0   31B 00200000
 
kaya$ elfsh
         Welcome to The ELF shell 0.51b3 .::.
 
         .::. This software is under the General Public License
         .::. Please visit http://www.gnu.org to know about Free Software
 
[ELFsh-0.51b3]$ load tiny-crackme
Segmentation fault
kaya$ objdump -x tiny-crackme
objdump: tiny-crackme: File format not recognized

Tentons maintenant les méthodes de debug usuelles :

kaya$ gdb tiny-crackme -q
"/home/yanisto/tiny-crackme/tiny-crackme": not in executable format:
File format not recognized
(gdb) r
Starting program:
No executable file specified.
Use the "file" or "exec-file" command.
(gdb) q
kaya$ strace ./tiny-crackme
execve("./tiny-crackme", ["./tiny-crackme"], [/* 67 vars */]) = 0
write(0, "   Tiny_crackme - nisto\'s cra"..., 244  Tiny_crackme - nisto's crackme #2
      ---------------------------------
 
This one has a particularly small size...
Hope u'll get some fun with it.
 
You can join me at yanisto@nuxed.org or on #secdev@freenode.net
 
Enter the Password :   ) = 244
ptrace(PTRACE_TRACEME, 0, 0x1, 0)       = -1 EPERM (Operation not permitted)
write(0, "Sorry but the process seems to b"..., 52Sorry but the process seems to be traced... Bye...
) = 52
_exit(0)                                = ?

Comme l'essai n'est pas plus concluant, tentons d'examiner d'éventuelles chaînes de caractères :

kaya$ strings -a ./tiny-crackme
{T?H
KxT?
ygg{t?
yT?AH

À ce point-là, on sait déjà que le binaire est :

  • un ELF dont les en-têtes sont corrompus ;
  • crypté (au moins les chaînes de caractères) ;
  • protégé contre le traçage ;
  • programmé en ASM (de bonnes chances vu la taille de l'exécutable) ;
  • protégé par un mot de passe de 4 caractères.


Ne disposant pas d'IDA (et de fait pas de son plug-in d'émulation), ni encore de The Perfect Emulator, le seul moyen qui nous reste pour étudier le crackme est le désassemblage.
La première chose à faire est donc de repérer le point d'entrée de l'exécutable :

kaya$ readelf -l tiny-crackme
Elf file type is EXEC (Executable file)
Entry point 0x200008
There are 1 program headers, starting at offset 32
 
Program Headers:
  Type           Offset     VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x000000 0x00200000 0x00000001 0x0031b 0x0031b RWE 0x1000

Voilà donc les informations qu'il nous fallait pour désassembler le programme à partir de la bonne adresse. Le point d'entrée du programme, c'est-à-dire la première instruction exécutée se trouvera à l'adresse mémoire 0x200008.
En revanche, le fichier sera, quant à lui, chargé à partir de l'adresse 0x00200000 (VirtAddr de l'unique en-tête de programme), soit un décalage de 8 octets (0x200008 - 0x00200000) :

 kaya$ ndisasm -V
NDISASM version 0.98.38 compiled Apr  6 2004
kaya$ ndisasm -au tiny-crackme -o 0x00200008 -e 0x08 |head -5
00200008  B32A              mov bl,0x2a
0020000A  E931000000        jmp 0x200040
0020000F  0002              add [edx],al
00200011  0003              add [ebx],al
00200013  0001              add [ecx],al

La première instruction positionne le registre BL à 0x2a, soit 42, puis la suivante effectue un saut vers l'adresse 0x200040. Allons donc voir à cette adresse :

kaya$ ndisasm -au tiny-crackme -o 0x00200040 -e 0x40 |head -3
00200040  E901000000        jmp 0x200046
00200045  B0E8              mov al,0xe8
00200047  A5                movsd

En premier lieu, nous remarquons un saut en milieu d'instruction : à l'adresse 0x200046, on ne voit aucune instruction pour le moment (on passe de l'adresse 0x200045 à 0x200047). Il s'agit juste d'une astuce utilisée par le code du crackme pour éviter que les instructions soient bien alignées et de fait que le code tombe tout cuit lors d'un désassemblage sauvage.

En examinant directement le code situé à l'adresse 0x200046, on voit alors apparaître une nouvelle instruction :

kaya$ ndisasm -au tiny-crackme -o 0x00200046 -e 0x46 |head -1
00200046  E8A5020000        call 0x2002f0
kaya$ ndisasm -au tiny-crackme -o 0x2002f0 -e 0x02f0 |head -11
002002F0  90                nop
002002F1  B84B002000        mov eax,0x20004b
002002F6  89C6              mov esi,eax
002002F8  89F7              mov edi,esi
002002FA  B9A5020000        mov ecx,0x2a5
002002FF  C1E902            shr ecx,0x2
00200302  AD                lodsd
00200303  35F179543F        xor eax,0x3f5479f1
00200308  AB                stosd
00200309  E2F7              loop 0x200302
0020030B  C3                ret

Après une série de sauts, nous nous retrouvons en 0x02002F0 (procédure Call). Ce dernier bloc d'instructions est caractéristique d'un déchiffrement de code/données contenu dans le binaire lui-même. En effet, nous voyons une boucle qui lit/modifie/réécrit la zone s'étendant de l'adresse 0x20004b à 0x20004b+(0x2a5»2) = 0x2000f4. La modification est par ailleurs toujours la même, c'est un XOR 0x3f5479f1.
Une fois ce (premier ?) layer de cryptage passé, le binaire aura modifié 0xA9 dwords, soit 169*4 = 676 octets, autant dire qu'il était presque entièrement chiffré.
Écrivons un petit outil destiné à passer ce layer pour progresser :

unciphered_layer1.c
void uncipher_dw(unsigned int *start, int length, unsigned int key)
{
    for (int i = 0; i < length; i++)
        *start++ ^= key;
}
 
void operate(int fd)
{
    void *img;
    struct stat filestat;
 
    fstat(fd, &filestat);
    img = mmap(0L, filestat.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
 
    /* + 0x4b : la boucle de déchiffrement commence en 0x20004b */
    uncipher_dw(img + 0x4b, 0x2a5 >> 2, 0x3f5479f1);
 
    if (munmap(0L, filestat.st_size) != 0) {
        perror("munmap");
        exit - 1;
    }
}
kaya$ gcc -Wall unciphered_layer1.c -o unciphered_layer1
kaya$ cp tiny-crackme tiny-crackme_work
kaya$ ./unciphered_layer1 tiny-crackme_work
Applying unciphering process on tiny-crackme_work.

Regardons maintenant le code qui est apparu après le call de l'adresse 0x200046. En effet, on remarque que le déchiffrement commence à l'adresse 0x20004B, qui correspond exactement à l'adresse de retour de la procédure, l'instruction située juste après le call :

kaya$ ndisasm -au tiny-crackme_work -o 0x00200046 -e 0x46 |head -15
00200046  E8A5020000        call 0x2002f0
0020004B  B89E012000        mov eax,0x20019e
00200050  BBF4000000        mov ebx,0xf4
00200055  C1EB02            shr ebx,0x2
00200058  8B1592022000      mov edx,[0x200292]
0020005E  E859020000        call 0x2002bc
00200063  B99E012000        mov ecx,0x20019e
00200068  BAF4000000        mov edx,0xf4
0020006D  E828020000        call 0x20029a
00200072  B81A000000        mov eax,0x1a
00200077  31C9              xor ecx,ecx
00200079  89CE              mov esi,ecx
0020007B  BA01000000        mov edx,0x1
00200080  CD80              int 0x80
00200082  29C3              sub ebx,eax

Après déchiffrement, nous désassemblons la même adresse que précédemment (le call). On peut se le permettre car le bytecode E8A5020000 n'est pas modifié par le déchiffrement. Dans l'absolu, il aurait fallu désassembler depuis 0x20004B seulement, c'est-à-dire l'adresse de retour de la procédure de déchiffrement.
Nous voyons deux appels à une procédure par call puis une interruption système (int 0x80) qui admet pour arguments (eax, ebx, ecx, edx, esi) = (0x1a, 0, 0, 1, 0), ce qui correspond à un appel à ptrace() (les détails viennent par la suite). Examinons d'abord la première procédure située en 0x2002bc :

kaya$ ndisasm -au tiny-crackme_work -o 0x00200046 -e 0x46 |head -6
00200046  E8A5020000        call 0x2002f0
0020004B  B89E012000        mov eax,0x20019e
00200050  BBF4000000        mov ebx,0xf4
00200055  C1EB02            shr ebx,0x2
00200058  8B1592022000      mov edx,[0x200292]
0020005E  E859020000        call 0x2002bc
 
kaya$ ndisasm -au tiny-crackme_work -o 0x2002bc -e 0x02bc |head -8
002002BC  89C6              mov esi,eax
002002BE  89F7              mov edi,esi
002002C0  89D9              mov ecx,ebx
002002C2  AD                lodsd
002002C3  31D0              xor eax,edx
002002C5  AB                stosd
002002C6  E2FA              loop 0x2002c2
002002C8  C3                ret
 
kaya$ hexdump tiny-crackme_work -s 0x292 -n 4
0000292 c0da beef

Comme précédemment, il s'agit d'un layer de cryptage avec une fonction xor de clef edx = [0x200292] (i. e. le double word contenu à l'adresse virtuelle 0x200292, soit 0xbeefc0da), qui s'exécute sur les adresses de 0x20019e (valeur de eax mise dans edi et esi) à 0x20019e+(0xf4»2)=0x2001a6 (car loop tant que le registre ecx n'est pas nul).
Nous n'aurions qu'à adapter le premier programme de déchiffrement, mais nous attendons de vérifier qu'il ne s'agit pas de données plutôt que de code, car dans ce cas, il n'est peut-être pas nécessaire de décoder ces données maintenant.
Poursuivons avec la procédure située en 0x20029a (29A, no comment ;)). Tout d'abord, un saut en pleine instruction (on connaît maintenant le truc). Il suffit de désassembler à l'adresse en question et tout se passe mieux.

kaya$ ndisasm -au tiny-crackme_work -o 0x20029a -e 0x029a |head -3
0020029A  E901000000        jmp 0x2002a0
0020029F  B031              mov al,0x31
002002A1  C089C3B004CD80    ror byte [ecx+0xcd04b0c3],0x80
 
kaya$ ndisasm -au tiny-crackme_work -o 0x2002a0 -e 0x02a0 |head -5
002002A0  31C0              xor eax,eax
002002A2  89C3              mov ebx,eax
002002A4  B004              mov al,0x4
002002A6  CD80              int 0x80
002002A8  C3                ret

La deuxième procédure débute en fait réellement en 0x2002a0 : il s'agit d'une interruption système (int 0x80) avec pour arguments (eax, ebx, ecx, edx) = (4, 0, 0x20019e, 0xf4), soit l'appel système write() (cf. /usr/include/asm/unistd.h).
On sait par ailleurs que le prototype de la fonction write est : ssize_t write(int fd, const void *buf, size_t count);
Cette procédure affiche le message déchiffré par la première procédure (celle qui déchiffre des octets à partir de 0x20019). Il s'agit en fait du message d'invite. Vérifions cela rapidement en modifiant quelques valeurs dans le programme :

unciphered_layer1.c :
kaya$ cp tiny-crackme_work tiny-crackme_work2
kaya$ cat unciphered_layer1.c | \
	sed -e "s/uncipher_dw(img.*/uncipher_dw(img + 0x19e, 0xf4 >> 2, 0xbeefc0da);/g" \
	> unciphered_layer2.c
kaya$ gcc unciphered_layer2.c -o unciphered_layer2
kaya$ ./unciphered_layer2 tiny-crackme_work2
Applying unciphering process on tiny-crackme_work2.
 
kaya$ hexdump -C tiny-crackme_work2 -s 0x19e -n 254
0000019e  20 20 20 20 20 20 54 69  6e 79 5f 63 72 61 63 6b  |      Tiny_crack|
000001ae  6d 65 20 2d 20 6e 69 73  74 6f 27 73 20 63 72 61  |me - nisto's cra|
000001be  63 6b 6d 65 20 23 32 0a  20 20 20 20 20 20 2d 2d  |ckme #2.      --|
000001ce  2d 2d 2d 2d 2d 2d 2d 2d  2d 2d 2d 2d 2d 2d 2d 2d  |----------------|
000001de  2d 2d 2d 2d 2d 2d 2d 2d  2d 2d 2d 2d 2d 2d 2d 0a  |---------------.|
000001ee  0a 54 68 69 73 20 6f 6e  65 20 68 61 73 20 61 20  |.This one has a |
000001fe  70 61 72 74 69 63 75 6c  61 72 6c 79 20 73 6d 61  |particularly sma|
0000020e  6c 6c 20 73 69 7a 65 2e  2e 2e 0a 48 6f 70 65 20  |ll size....Hope |
0000021e  75 27 6c 6c 20 67 65 74  20 73 6f 6d 65 20 66 75  |u'll get some fu|
0000022e  6e 20 77 69 74 68 20 69  74 2e 0a 0a 59 6f 75 20  |n with it...You |
0000023e  63 61 6e 20 6a 6f 69 6e  20 6d 65 20 61 74 20 79  |can join me at y|
0000024e  61 6e 69 73 74 6f 40 6e  75 78 65 64 2e 6f 72 67  |anisto@nuxed.org|
0000025e  20 6f 72 20 6f 6e 20 23  73 65 63 64 65 76 40 66  | or on #secdev@f|
0000026e  72 65 65 6e 6f 64 65 2e  6e 65 74 0a 0a 45 6e 74  |reenode.net..Ent|
0000027e  65 72 20 74 68 65 20 50  61 73 73 77 6f 72 64 20  |er the Password |
0000028e  3a 20 20 20 da c0 ef be  00 00 00 00 e9 01        |:   ÚÀï¾....é.|
0000029c

Les deux procédures situées avant l'appel à ptrace() étant maintenant expliquées, revenons justement à notre ptrace() situé à l'adresse 0x0200080 :

kaya$ ndisasm -au tiny-crackme_work2 -o 0x00200072 -e 0x72 |head -15
00200072  B81A000000        mov eax,0x1a
00200077  31C9              xor ecx,ecx
00200079  89CE              mov esi,ecx
0020007B  BA01000000        mov edx,0x1
00200080  CD80              int 0x80
00200082  29C3              sub ebx,eax
00200084  85C0              test eax,eax
00200086  7411              jz 0x200099
00200088  B902012000        mov ecx,0x200102
0020008D  B234              mov dl,0x34
0020008F  E806020000        call 0x20029a
00200094  E973020000        jmp 0x20030c
00200099  EB01              jmp short 0x20009c
0020009B  B053              mov al,0x53
0020009D  B996022000        mov ecx,0x200296

L'appel à ptrace() se fait avec les registres (eax, ebx, ecx, edx, esi) = (0x1a, 0, 0, 1, 0). La requête transmise à ptrace est donc PTRACE_TRACEME. Cela bloque tous les signaux, excepté SIGKILL, à destination du processus et un SIGTRAP est généré si le processus est ptracé() par un processus externe, genre strace ou gdb, (cf. ptrace(2)).
Un test est effectué sur eax, valeur de retour de ptrace(), pour voir si le programme est tracé. Si eax n'est pas nul, le processus est considéré comme tracé. On appelle alors la procédure d'affichage (call 0x20029a) vue auparavant en lui demandant d'afficher 0x34 caractères depuis l'adresse 0x102 :

kaya$ hexdump -C tiny-crackme_work2 -s 0x102 -n 52
00000102  53 6f 72 72 79 20 62 75  74 20 74 68 65 20 70 72  |Sorry but the pr|
00000112  6f 63 65 73 73 20 73 65  65 6d 73 20 74 6f 20 62  |ocess seems to b|
00000122  65 20 74 72 61 63 65 64  2e 2e 2e 20 42 79 65 2e  |e traced... Bye.|
00000132  2e 2e 0a 00

Enfin, on saute en 0x20030c, qui nous renvoie encore une fois au milieu d'une instruction :

kaya$ ndisasm -au tiny-crackme_work2 -o 0x0020030c -e 0x30c |head -3
0020030C  E901000000        jmp 0x200312
00200311  3231                xor dh,[ecx]
00200313  C089C3FEC0CD80    ror byte [ecx+0xcdc0fec3],0x80
kaya$ ndisasm -au tiny-crackme_work2 -o 0x00200312 -e 0x312 |head -15
00200312  31C0              xor eax,eax
00200314  89C3              mov ebx,eax
00200316  FEC0              inc al
00200318  CD80              int 0x80
0020031A  C3                ret

Cette suite d'instruction positionne les registres eax et ebx respectivement à 1 et 0, puis déclenche une interruption 80 : cela correspond en fait à l'appel système exit() (eax = 1) avec comme argument 0 (ebx = 0). On quitte misérablement le programme.
Revenons au cas où le processus n'est pas ptracé() et que le registre eax vaut alors 0. On saute en 0x200099 puis à l'intérieur d'une instruction (0x20009c). Notons que le registre ebx reste nul dans ce cas :

kaya$ ndisasm -au tiny-crackme_work2 -o 0x0020009c -e 0x9c |head -11
0020009C  53                push ebx
0020009D  B996022000        mov ecx,0x200296
002000A2  BA04000000        mov edx,0x4
002000A7  E8FE010000        call 0x2002aa
002000AC  E818020000        call 0x2002c9
002000B1  331D96022000      xor ebx,[0x200296]
002000B7  7413              jz 0x2000cc
002000B9  B9E5002000        mov ecx,0x2000e5
002000BE  B21D              mov dl,0x1d
002000C0  E8D5010000        call 0x20029a
002000C5  E942020000        jmp 0x20030c
 
kaya$ ndisasm -au tiny-crackme_work2 -o 0x002002aa -e 0x2aa |head -1
002002AA  E901000000        jmp 0x2002b0
kaya$ ndisasm -au tiny-crackme_work2 -o 0x002002b0 -e 0x2b0 |head -6
002002B0  31C0              xor eax,eax
002002B2  89C3              mov ebx,eax
002002B4  FEC3              inc bl
002002B6  B003              mov al,0x3
002002B8  CD80              int 0x80
002002BA  C3                ret
kaya$ ndisasm -au tiny-crackme_work2 -o 0x2002c9 -e 0x2c9 |head -13
002002C9  E903000000        jmp 0x2002d1
002002CE  B0C8              mov al,0xc8
002002D0  43                inc ebx
002002D1  31C0              xor eax,eax
002002D3  89C3              mov ebx,eax
002002D5  B9DF020000        mov ecx,0x2df
002002DA  C1E902            shr ecx,0x2
002002DD  BE08002000        mov esi,0x200008
002002E2  AD                lodsd
002002E3  01C3              add ebx,eax
002002E5  E2FB              loop 0x2002e2
002002E7  81F36B040855      xor ebx,0x5508046b
002002ED  C3                ret

Ainsi, dans le cas où le processus ne serait pas ptracé, on met ebx sur la pile et nous voyons successivement les branchements suivants : 2 calls, 1 jump conditionnel (jz), un call puis un jump.

  • call 0x2002aa :

int_80h (3, 0, 0x200296, 4) = read(O, 0x200296, 4) lit le mot de passe (4 caractères) et le stocke en 0x200296.

  • call 0x2002c9 :

fonction de type calcul CRC sur le binaire lui-même, à partir de l'adresse 0x200008.

  • jz 0x2000cc :

saut si ebx xor [0x200296] == 0, donc si ebx xor password == 0 (voir ci-après).

  • call 0x20029a :

write(0, 0x2000e5, 0x01d) : on retrouve le message d'erreur à l'adresse 0x2000e5 :

kaya$ hexdump -C tiny-crackme_work2 -s 0x000e5 -n 30
  000000e5  0a 20 57 72 6f 6e 67 20  70 61 73 73 77 6f 72 64 |. Wrong password|
  000000f5  2c 20 73 6f 72 72 79 2e  2e 2e 0a 0a 00 53       |, sorry......S|
  • jmp 0x20030c : exit(0) : comme on connaît déjà.

Nous en déduisons qu'il convient d'effectuer le saut conditionnel. Intéressons-nous donc à cette partie :

kaya$ ndisasm -au tiny-crackme_work2 -o 0x002000CC -e 0xCC |head -7
002000CC  5B                pop ebx
002000CD  85DB              test ebx,ebx
002000CF  75E8              jnz 0x2000b9
002000D1  B936012000        mov ecx,0x200136  ;adresse du texte
002000D6  BA68000000        mov edx,0x68      ;longueur du texte
002000DB  E8BA010000        call 0x20029a     ;write(0, txt, len)
002000E0  E927020000        jmp 0x20030c
 
kaya$ ndisasm -au tiny-crackme_work2 -o 0x002000b9 -e 0xb9 |head -4
002000B9  B9E5002000        mov ecx,0x2000e5
002000BE  B21D              mov dl,0x1d
002000C0  E8D5010000        call 0x20029a
002000C5  E942020000        jmp 0x20030c

ebx avait été poussé sur la pile et était censé être nul (non ptracé). En le tirant de la pile, il ne change donc pas et reste nul, à moins que le binaire ait été patché plus tôt maladroitement.
Voyons le texte qui est alors affiché :

kaya$ hexdump -C tiny-crackme_work2 -s 0x00136 -n 110
00000136  2d 3e 20 53 75 63 63 65  73 73 20 21 21 20 43 6f  |-> Success !! Co|
00000146  6e 67 72 61 74 75 6c 61  74 69 6f 6e 73 2e 2e 2e  |ngratulations...|
00000156  0a 20 20 2d 3e 20 59 6f  75 20 63 61 6e 20 73 65  |.  -> You can se|
00000166  6e 64 20 6d 65 20 79 6f  75 72 20 73 6f 6c 75 74  |nd me your solut|
00000176  69 6f 6e 2f 63 6f 6d 6d  65 6e 74 73 20 61 74 20  |ion/comments at |
00000186  74 68 65 20 61 62 6f 76  65 20 6d 61 69 6c 20 61  |the above mail a|
00000196  64 64 72 2e 2e 2e 0a 00  20 20 20 20 20 20        |ddr.....      |
000001a4

Il s'agit donc de refaire un calcul CRC et de nous assurer que le saut conditionnel soit validé, i. e. (ebx xor password == 0).
Le but de ce crackme n'étant pas de fournir un keygen rapide, nous n'optimiserons pas le générateur de clé, mais collerons plutôt à l'algorithme afin de bien comprendre.

keygen.c
char * alphabet =	"0123456789"
			"abcdefghijklmnopqrstuvwxyz"
			"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
 
/* Reproduit le calcul de la fonction  002002C9 */
void crc_dw(unsigned int *start, int length, unsigned int *key)
{
    for (int i = 0; i < length; i++)
	*key += *start++;
    *key ^= 0x5508046b;
}
 
/* Génère un mot de passe de 4 caractères */
unsigned int *put_pass (void *img, unsigned int index);
 
int main(int argc, char **argv)
{
    void *img;
    struct stat filestat;
    int length, i, fd;
    unsigned int crc = 0, *pass;
    char * passwd;
    fd = open(argv[1], O_RDONLY);
 
    length = strlen(alphabet);
    fstat(fd, &filestat);
    img = mmap(0L, filestat.st_size, PROT_READ | PROT_WRITE,
	     MAP_PRIVATE, fd, 0);
 
    passwd = img + 0x0296;
 
    for (i = 0; i < length^4; i++) {
	    crc = 0;
	    pass = put_pass(img, i);
	    crc_dw(img + 0x08, 0x2df >> 2, &crc);
	    if ((crc ^ *pass) == 0)
	        fprintf(stdout, "Password : 0x%08X <-> %c%c%c%c\n", *pass,
		   *passwd, *(passwd+1), *(passwd+2), *(passwd+3));
    }
 
    if (munmap(0L, filestat.st_size) != 0) {
	perror("munmap");
	exit - 1;
    }
 
    close(fd);
}
kaya$ gcc -Wall keygen.c -o keygen
 
kaya$ ./keygen tiny-crackme_work2
Password : 0x6D303062 <-> b00m
Password : 0x6F303262 <-> b20o
Password : 0x69303462 <-> b40i
...

Et hop… Nous avons une liste de mots de passe valides. Bien entendu, nous aurions pu étendre cela à tous les caractères imprimables mais ce n'était pas le but. Essayons en un :

kaya$ ./tiny-crackme
Tiny_crackme - nisto's crackme #2
---------------------------------
...
Enter the Password :   b00m
-> Success !! Congratulations...
  -> You can send me your solution/comments at the above mail addr...

Et voilà, le crackme est cracké :) .

II. Genèse d'un crackme...

Certes, cracker le crackme est amusant, mais nous continuons maintenant en montrant comment on le construit, c'est-à-dire comment on met en place les différentes protections, par exemple, en modifiant le point d'entrée du programme ou en désalignant certaines instructions.

Le programme source initial

Ici, nous considérons le programme dans son ensemble. Son rôle principal est de vérifier que le mot de passe saisi est valide, rien de plus. La première chose à remarquer concerne le header Elf, construit à la main (cf. section ELF Headers). On retrouve bien les 8 octets qui seront placés à l'adresse 0x00200000, ce qui nous donne bien un entry point à l'adresse virtuelle 0x00200008 (champ e_entry de l'en-tête, égal à _start). De plus, les instructions mov et jmp présentes dans cette mini procédure fixent des valeurs hexadécimales théoriquement non attribuables dans un en-tête ELF, ce qui induit des erreurs d'interprétation avec certains outils construits sur BFD.
Enfin, on prend soin de mettre la valeur des flags du Program Header (p_flags) à LECTURE + ECRITURE + EXECUTION (RWX), soit la valeur 4+2+1=7. En effet, on va avoir besoin d'écrire le mot de passe et les instructions déchiffrées, de lire toute la mémoire pour calculer un CRC et quand même d'exécuter le code.
On retrouve également dans les sources la protection anti-debug : l'appel système 0x1a (ptrace()) est effectué et selon le résultat, on en déduit ce qu'il en est.

...
; Ce qui suit est issu de tiny.asm (site muppetlabs)
; A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux
;	A little bit adapted...   http://nuxed.org/code/asm/teensy.html
 
BITS 32
                org   0x00200000	         ; Adresse Virtuelle ELF = 0x0200000
; ELF Headers
                db    0x7F, "ELF"             ; e_ident
                db    1, 1, 1, 0
  _start:			         ; Point d'entrée initial
	          mov     bl, 42
	          jmp         _secstart  ; Saut vers le vrai point d'entrée
 
                db    0
                dw    2                       ; e_type
                dd    _start                  ; e_entry
                dd    phdr - $$               ; e_phoff
  phdr:         dd    1                       ; e_shoff
                ...
                dd    filesize                ; p_memsz
                dd    7                       ; p_flags = RWX
                dd    0x1000                  ; p_align
 
; Fin des headers ELF, début du programme...
 
_secstart:
	jmp       .unalign
	db        0xB0                    ; octet pour désaligner
.unalign
	call unstrap
 
_begin:
	; Déchriffre puis affiche le message d'invite
	mov       eax, invite_msg
	mov       ebx, invite_msg_len
	shr       ebx, 2
	mov       edx, [dword key]
	call      uncipher
        ...
 
	; Teste si le programme est ptracé
	mov       eax, 0x1a
	xor       ecx, ecx
	mov       esi, ecx
	mov       edx, 1
	int       80h  ; appel système ptrace()
	sub       ebx, eax
	test      eax, eax
	jz        get_answer          ; Saute si le programme n'est pas tracé
 
traced:
	mov       ecx, traced_txt     ; Procédure invoquée lorsque ptrace est détecté
	mov       dl, traced_txt_len
	call      print
	jmp       exit
 
get_answer:
	jmp       short .unalign
	db	0xB0
.unalign
	push          ebx
	; Lit la réponse à la question (ie le mot de passe)
        ...
	; Vérifie la validité du mot de passe
	call          crc
	xor           ebx, [dword pass]
	jz	         won                     ; Si ebx == 0, c'est gagné
 
wrong_pass
	; Sinon, affiche un message de mot de passe erroné...
        ...
	jmp           exit
	db	         0x72, 0x36
 
won:
	; Dernière chose, on vérifie que la valeur sur la pile (ebx) est bien à 0...		pop	         ebx
	test          ebx, ebx
	jnz           wrong_pass
        mov	         ecx, won_txt
	mov	         edx, won_txt_len
	call	         print
	jmp	         exit
 
; Data
...
;invite_msg 	         db    "     Tiny_crackme - nisto's crackme #2", 10
;		         db    "      ---------------------------------",10,10
;                    ...
invite_msg           dd  0x9ecfe0fa,0x93c2e0fa,0x93c2edf7,0x93c2edf7,0x93c2edf7
     	   	         dd  0x93c2edf7,0x93c2edf7,0x93c2edf7,0x93c2edf7,0xb4c2edf7
                     ...
    	   	         dd  0x9ecfe0e0
 
key	  dd	         0xbeefc0da
pass	  dd	         0
 
; -------------------------------------------------
; --------------   Standard functions -------------
; -------------------------------------------------
print:
	...
read:
	...
 
; Déchiffrement du message d'invite
uncipher:
        ...
 
; Calcul d'un CRC 32 bits
crc:
        ...
 
; déchiffre les instructions du programme
unstrap:
	 ret			         ; Ne pas exécuter maintenant
	 mov	          eax, _begin
         ...
	 ret
 
; quitte le programme
exit:
	  ...
end:
	  ret

On remarque bien les techniques supplémentaires utilisées :

  • jmp [short] .unalign / db 0xXX : les instructions ne sont plus alignées durant un désassemblage sauvage ;
  • calcul de CRC, mais nous y reviendrons plus loin ;
  • layer de cryptage.

Les deux dernières ont un même mode de fonctionnement. Elles utilisent des lectures successives du code (ou de données), y appliquent une fonction (ADD, XOR…) puis éventuellement remplacent la valeur lue par celle calculée (dans le cas du layer).

Le chiffrement

Pour chiffrer, on utilise les instructions STOSx et LODSx, respectivement commandes d'écriture et de lecture d'adresse. Un bon exemple valant mieux qu'un long discours, voici un prototype général de layer de cryptage :

_layer:
	mov	esi,	INPUT_ADDRESS
	mov	edi,	OUTPUT_ADDRESS
	mov	ecx,	LENGTH ; (/4 if lodsD, /2 if lodsW)
.loop
	lodsd				;
Lit la valeur en [INPUT_ADDRESS] et la stocke en EAX.
	xor	eax, 	0xdeadbeef		;
pplique une fonction XOR en 32 bits sur EAX (car lodsD)
	stosd				;
Ecrit la valeur de EAX en [OUTPUT_ADDRESS]
	loop	.loop			;
Boucle LENGTH fois.

En supposant que le flag DF soit nul (il précise le sens incr/décrémentation des adresses pour lods et stos), dans cette boucle, esi est incrémenté de 4 (car on est en lodsD = 4 octets) à chaque lecture. De la même manière, chaque écriture incrémente edi de 4.
On aura donc lu le code/les données s'étendant de INPUT_ADDRESS à INPUT_ADDRESS+LENGTH en appliquant un 'XOR 0xdeadbeef' à chaque bloc de 32 bits et le résultat sera stocké de OUTPUT_ADDRESS à OUTPUT_ADDRESS+LENGTH. Pour un layer de cryptage banal, sans décalage de données, INPUT_ADDRESS == OUTPUT_ADDRESS.
Dans notre programme, cela est appliqué une fois pour le message d'invite (procédure uncipher avec la clé 0xbeefc0da), mais aussi pour l'ensemble des instructions (procédure unstrap).

Ce qui donne, une fois le layer de cryptage appliqué

La structure du programme étant maintenant en place, il reste à chiffrer les instructions et les données. Pour cela, on crée un outil qui lit l'exécutable précédent 32 bits par 32 bits à partir de l'adresse souhaitée, en appliquant à chaque reprise notre opération de chiffrement, soit XOR 0x3f5479f1. Le résultat obtenu est placé dans le corps de la source et l'instruction ret est remplacée par un nop (no operation) dans la procédure unstrap : elle sera donc désormais exécutée intégralement, juste après le point d'entrée et les instructions seront déchiffrées en mémoire.

BITS 32
                org     0x00200000
                db      0x7F, "ELF"             ; e_ident
                ...                             ; suite normale des sections
 
; Début du code
_secstart:
	jmp	.unalign
	db	0xB0
.unalign
	; Déchiffrons le code
	call unstrap
_begin:
	; Ajout du bytecode chiffré calculé précédemment
	dd      0x1F55E749, 0x3FA0C2F1, 0xD49579F1, 0xAD41F2F3,
        dd      0xD75459F3, 0x3F547BA8, 0x1F55E748, 0x3FA0C3F1,
        	[...]
	dd      0x2D972CF9,
 
	; A cause de l'alignement, nous ajoutons cette partie à la main
	db 0x23
 
        ...
 
	; Routine de déchiffrement
        ; XOR 32b de clé aléatoire : 0x3f5479f1
unstrap:
	nop 	 	  ; remplace le ret de la version précédente
	mov	eax, _begin
	mov	esi, eax
	mov	edi, esi
	mov	ecx, unstrap-_begin
	shr	ecx, 2
.lewp
	lodsd
	xor	eax, 0x3f5479f1
	stosd
	loop	.lewp
	ret
 
exit:
	...

Oui mais, comment stocke-t-on le(s) mot(s) de passe ?

Où et comment a-t-on stocké la (les) clé(s) valide(s) ? Pour comprendre cela, nous allons détailler la procédure de CRC :

 crc:
        jmp     .unalign
        db      0xB0, 0xC8, 0x43
.unalign
        xor     eax, eax
        mov     ebx, eax
        mov     ecx, .end-_start
        shr     ecx, 2
        mov     esi, _start
.lewp
        lodsd
        add     ebx, eax
        loop    .lewp
.end				  ; Fin du pseudo CRC (ebx = X).
        xor     ebx, 0x5508046B
        ret

En fait, c'est la valeur 0x5508046B qui fixe le mot de passe. Voyons de plus près comment obtenir cette valeur en fonction d'un mot de passe que l'on souhaite voir fonctionner.
Une boucle est tout d'abord effectuée (nous l'appellerons pseudo CRC) puis le XOR est appliqué avec une valeur très particulière.
En fait, le CRC s'étend de _start à .end. Ce calcul est effectué sur cette partie, à un instant t postérieur à l'entrée du mot de passe (le read a lieu avant le call crc) et une fois le message d'invite affiché (donc déchiffré). Pour le calcul de la valeur 0x5508046B, il faut donc charger le programme en mémoire, déchiffrer l'invite, stocker à sa place le mot de passe voulu (ex : “b00m” à l'adresse 0x200296), puis calculer ce pseudo CRC (noté X par la suite). Une fois cette valeur obtenue, la comparaison sera effectuée avec le mot de passe saisi. On ne peut donc effectuer directement la comparaison (car X contient déjà le mot de passe) : il faut donc ” retirer ” le mot de passe de X pour obtenir la valeur à mettre dans le code, soit 0x5508046B = X xor b00m. On peut se permettre de faire cela UNIQUEMENT parce que le pseudo CRC ne parcourt pas la zone où est stockée l'instruction XOR ebx, 0x5508046B (le code est en effet situé après crc.end).
En pratique, j'avais remplacé en dur (dans le code ASM) les valeurs précédentes (invite en clair, mot de passe,…) dans une copie destinée au calcul du pseudo CRC pour obtenir la valeur désirée.

Conclusion

J'espère que cette incursion dans le monde des crackmes vous aura intéressés. Nous sommes venus à bout de cet exemple à l'aide d'outils simples (éditeur hexa + disassembleur + compilateur C).
L'étude nous a toutefois été facilitée car le programme a été rédigé en assembleur directement et c'est donc un style assez bref. Cela n'aurait pas du tout été pareil avec un langage plus haut niveau comme du C : il y aurait eu davantage de ” pollution ” et d'appel de procédures de la glib (printf…).
Si d'aventure vous souhaitiez m'envoyer des remarques ou corrections, n'hésitez pas à me contacter.

Remerciements

Merci à Tof pour son soutien, Micky pour son sofa, à Belek, May et à Nicolas Brulez.

Références

misc/crackme_linux.txt · Dernière modification: 2011/10/31 15:55 (modification externe)