integrato ;)
PDF Split-Merge reverse engineering
Analisi di algoritmi, keygenning e cracking

 
Disclaimer
 
Questo articolo presenta un'analisi teorica della struttura di un programma, in nessun caso il software è stato realmente modificato. Tutto ciò che si trova descritto in queste pagine viene presentato unicamente a scopo didattico ai fini di aiutare l'apprendimento dell'arte del reverse engineering.
Ogni software oggetto di studio (se commerciale) deve essere legalmente acquistato per poter essere usato oltre il periodo concesso per la valutazione e l’autore di questo articolo non si assume nessuna responsabilità in relazione all’uso malevolo che terzi potrebbero fare delle informazioni contenute in queste pagine.

 
1 passo: Analisi target
 
Il programma che verrà analizzato in questo articolo è PDF Split-Merge v2.2, un software locale per merge/split di documenti pdf che consente un periodo di prova limitato a 50 utilizzi. Questo software è disponibile per il download sul o tra le.
Dopo aver installato e aperto il programma la prima cosa che compare all'avvio è la segunte schermata:

Cioè un pop-up con due editbox, una read-only contente un Machine ID per l'identificazione della nostra macchina e una scrivibile per l'introduzione di un codice seriale.
La protezione che andremo a studiare in questo articolo sarà dunque questa: l'algoritmo di validazione della combinazione Machine ID/Serial per la registrazione del programma.

Prima di partire con il debugging controlliamo con un tool come eventuali protezioni applicate sull'eseguibile (C:\Programmi\PDFSplitMerge\pdfpage.exe)


(in questo caso nessuna).

 
2 passo: Inizio debugging e una possibile soluzione
 
Come praticamente in tutti i programmi di questo genere, essendo scritto in C++ e chiedendo l'introduzione del codice tramite una casella di testo, la via migliore per arrivare a trovare il punto del software che ci interessa è quella di cercare di intercettare l'evento di lettura dalla editbox e questo abbiamo visto può essere fatto posizionando dei breakpoint su API quali GetDlgItemText o GetWindowText.
Apriamo quindi il nostro debugger preferito (anche in questa occasione consiglio l'utilizzo dell'ottimo e gratuito), carichiamo il target (C:\Programmi\PDFSplitMerge\pdfpage.exe) ed eseguiamolo.
A questo punto appena caricato il pop-up "Please register PDF Split-Merge v2.2" posizioniamo il primo breakpoint (GetDlgItemTextA), introduciamo un qualsiasi seriale, e clicchiamo su "Ok"; il debugger brekkerà all'indirizzo 0x10001967:
  1. 10001967 CALL DWORD PTR DS:[<&USER32.GetDlgItemTextA>] ; Lettura seriale
  2. 1000196D PUSH OFFSET verypdf.1001A820 ; Passo il codice letto alla funzione
  3. 10001972 CALL CheckSerial ; Routine di controllo del seriale
  4. 10001977 ADD ESP,4
  5. 1000197A TEST EAX,EAX ; if (EAX == 0) goto WrongSerial
  6. 1000197C JE SHORT WrongSerial
  7. 1000197E PUSH 40
  8. 10001980 PUSH OFFSET verypdf.10017338 ; ASCII "Thank you."
  9. 10001985 PUSH OFFSET verypdf.1001730C ; ASCII "Thank you registered
  10. ; PDF Split-Merge v2.2."
  11. 1000198A PUSH ESI
  12. 1000198B CALL DWORD PTR DS:[<&USER32.MessageBoxA>] ; Stampa
  13. 10001991 PUSH OFFSET verypdf.1001A820
  14. 10001996 PUSH ESI
  15. 10001997 CALL 100012F0
  16. 1000199C ADD ESP,8
  17. 1000199F MOV DWORD PTR DS:[1001A8E8],1
  18. 100019A9 PUSH 1
  19. 100019AB PUSH ESI
  20. 100019AC CALL DWORD PTR DS:[<&USER32.EndDialog>]
  21. 100019B2 JMP 10001BA1
  22. WrongSerial PUSH 10 ; ===== WrongSerial =====
  23. 100019B9 PUSH 0
  24. 100019BB PUSH OFFSET verypdf.100172C4 ; ASCII "Your registration key is wrong,
  25. ; please double check it and try again."
  26. 100019C0 PUSH ESI
  27. 100019C1 CALL DWORD PTR DS:[<&USER32.MessageBoxA>] ; Stampa
Il codice che troviamo a questo punto è molto semplice ed in ordine abbiamo:

0x10001967: La chiamata all'API GetDlgItemText
0x10001972: La chiamata alla routine di validazione del codice (CheckSerial)
0x1000197A: Il controllo sul valore di ritorno della routine (EAX = 0 serial errato)

CASO SERIAL CORRETTO:
0x10001980: Stampa del ringraziamento per la registrazione

CASO SERIAL ERRATO:
0x100019BB: Stampa dell'errore

Da questo semplice disassemblato si capisce subito che una una delle tante vie per registrare al volo il software sarebbe quella di intervenire sul controllo del valore di ritorno (indirizzo 0x1000197A). Impendendo infatti il jump verso WrongSerial (ad esempio noppando l'istruzione) quello che si ottiene dall'esecuzione lineare del codice seguente è appunto la registrazione del prodotto con qualsiasi seriale (anche vuoto):


Ma questo NON è quello che vogliamo!
Come si è dimostrato anche nell'articolo relativo al se ne si ha la possibilità l'analisi intera di algoritmi compilati è un metodo migliore e più robusto per superare blocchi software che ci permette anche di studiare a fondo il modo in cui la protezione è stata concepita.
Quindi senza farci tentare dalla via più breve steppiamo dentro la routine CheckSerial e partiamo con l'analisi vera e propria.

 
3 passo: Studio approfondito
 
Trovato l'inizio del codice che si vuole studiare la cosa migliore da fare è introdurre un seriale facile da riconoscere in memoria, come ABCDEFGHILMNOPQRSTUVZ, 1234567890 o XXXXXXXXXXXXXXXX e proseguire debuggando un paio di volte l'intero algoritmo per vedere generalmente come questo si comporta ed i primi controlli che vengono fatti.
Questo metodo è molto comodo in quanto ci permette di proseguire l'analisi adattando man mano la stringa introdotta a quello che il software si aspetta: se venisse ad esempio fatto un controllo sulla lunghezza massima del serial oppure su determinati caratteri costanti si proseguirebbe modificando di volta in volta il codice introdotto in modo da prolungare ogni esecuzione di qualche altra istruzione e vedere come l'algoritmo si comporta (evitando di dover andare a modficare la memoria del programma a runtime).
Consiglio quindi di introdurre un seriale come XXXXXXXXXXXXXXXX e partire a debuggare dall'indirizzo 0x10001170, ovvero l'inizio della funzione CheckSerial.

Le prime istruzioni all'interno di questa routine come vediamo servono solo a prendere alcuni caratteri (in particolare serial[14] e serial[15]) e preparare i parametri per la chiamata a una seconda routine (rinominata in CallSubCheckSerial):
  1. 10001170 SUB ESP,18 ; ==== CheckSerial ====
  2. 10001173 PUSH EBX
  3. 10001174 PUSH ESI
  4. 10001175 MOV ESI,DWORD PTR SS:[ESP+24] ; ESI = serial
  5. 10001179 LEA EDX,[ESP+8] ; EDX = ptr al serial
  6. 1000117D PUSH EDI ; EDI = spazio vuoto
  7. 1000117E XOR BL,BL ; BL = 0
  8. 10001180 MOV AL,BYTE PTR DS:[ESI+0E] ; AL = serial[14]
  9. 10001183 MOV CL,BYTE PTR DS:[ESI+0F] ; CL = serial[15]
  10. 10001186 PUSH EDX
  11. 10001187 MOV BYTE PTR SS:[ESP+1C],AL ; DS:0x0012E308 = serial[14]
  12. 1000118B MOV BYTE PTR SS:[ESP+1D],BL ; DS:0x0012E309 = 0
  13. 1000118F MOV BYTE PTR SS:[ESP+10],CL ; DS:0x0012E2FC = serial[15]
  14. 10001193 MOV BYTE PTR SS:[ESP+11],BL ; DS:0x0012E2FD = 0
  15. 10001197 CALL CallSubCheckSerial
Le ultime 4 MOV prima della chiamata settano delle variabili in memoria, per semplicità d'ora in poi chiamiamole come:

DS:0x0012E308 = VAR1 = serial[14];
DS:0x0012E309 = VAR2 = 0;
DS:0x0012E2FC = VAR3 = serial[15];
DS:0x0012E2FD = VAR4 = 0;

Quello che fa la routine CallSubCheckSerial è invece chiamare con 3 istruzioni un'altra sottoruotine all'indirizzo 0x10002656:
  1. 100026E1 PUSH DWORD PTR SS:[ESP+4] ; ==== CallSubCheckSerial ====
  2. 100026E5 CALL SubCheckSerial
  3. 100026EA POP ECX
  4. 100026EB RETN ; ==== Fine CallSubCheckSerial ====
Ed è proprio questa routine all'indirizzo 0x10002656, la SubCheckSerial, il vero cuore del check del seriale, vediamola disassemblata:
  1. 10002656 PUSH EBX ; ==== SubCheckSerial ====
  2. 10002657 PUSH EBP
  3. 10002658 PUSH ESI
  4. 10002659 PUSH EDI
  5. 1000265A MOV EDI,DWORD PTR SS:[ESP+14] ; EDI = VAR3 (prossima esecuzione VAR1)
  6. 1000265E CMP DWORD PTR DS:[10017DCC],1 ; DS[10017DCC] = 1 sempre (flag?)
  7. 10002665 JLE SHORT JUMP1 ; Jump sempre preso
  8. 10002667 MOVZX EAX,BYTE PTR DS:[EDI] ; ___________________
  9. 1000266A PUSH 8 ; /
  10. 1000266C PUSH EAX ; | Istruzioni
  11. 1000266D CALL 100046CC ; | mai
  12. 10002672 POP ECX ; | eseguite
  13. 10002673 POP ECX ; |
  14. 10002674 JMP SHORT 10002685 ; \ ____________________
  15. JUMP1 MOVZX EAX,BYTE PTR DS:[EDI] ; EAX = serial[15] (1° esecuzione, serial[14] nella 2°)
  16. 10002679 MOV ECX,DWORD PTR DS:[10017BC0] ; ECX = puntatore a area dati addr (10017BCA)
  17. 1000267F MOV AL,BYTE PTR DS:[EAX*2+ECX] ; AL = ptr[10017BCA + serial[15]*2]
  18. 10002682 AND EAX,00000008 ; EAX = AL AND 8;
  19. 10002685 TEST EAX,EAX ; if (AL == 0) goto JUMP2
  20. 10002687 JE SHORT JUMP2
Come scopiremo più avanti questa routine verrà chiamata quattro volte ad agire su due diversi caratteri, la prima volta (questa che stiana analizzando ora) lavorerà su serial[15], la seconda su serial[14], la terza su serial[1] e l'ultima su serial[0].
Quello che viene fatto in questa prima parte della funzione è utilizzare il doppio del valore del carattere (ora serial[15]) come offset per prelevare da un vettore posto in memoria all'indirizzo 0x10017BCA un dato a 1 byte per poi farne l'AND con 8 e controllare il risultato.
Vediamo intanto questo vettore fisso che si trova all'indirizzo 0x10017BCA (cioè alla DWORD PTR DS:[10017BC0]):
  1. 10017BCA 20 00 20 00|20 00 20 00|20 00 20 00|20 00 20 00|
  2. 10017BDA 20 00 28 00|28 00 28 00|28 00 28 00|20 00 20 00|
  3. 10017BEA 20 00 20 00|20 00 20 00|20 00 20 00|20 00 20 00|
  4. 10017BFA 20 00 20 00|20 00 20 00|20 00 20 00|20 00 20 00|
  5. 10017C0A 48 00 10 00|10 00 10 00|10 00 10 00|10 00 10 00|
  6. 10017C1A 10 00 10 00|10 00 10 00|10 00 10 00|10 00 10 00|
  7. 10017C2A 84 00 84 00|84 00 84 00|84 00 84 00|84 00 84 00|
  8. 10017C3A 84 00 84 00|10 00 10 00|10 00 10 00|10 00 10 00|
  9. 10017C4A 10 00 81 00|81 00 81 00|81 00 81 00|81 00 01 00|
  10. 10017C5A 01 00 01 00|01 00 01 00|01 00 01 00|01 00 01 00|
  11. 10017C6A 01 00 01 00|01 00 01 00|01 00 01 00|01 00 01 00|
  12. 10017C7A 01 00 01 00|01 00 10 00|10 00 10 00|10 00 10 00|
  13. 10017C8A 10 00 82 00|82 00 82 00|82 00 82 00|82 00 02 00|
  14. 10017C9A 02 00 02 00|02 00 02 00|02 00 02 00|02 00 02 00|
  15. 10017CAA 02 00 02 00|02 00 02 00|02 00 02 00|02 00 02 00|
  16. 10017CBA 02 00 02 00|02 00 10 00|10 00 10 00|10 00 20 00|
Ovviamente la fine di un "vettore" (che è solo un concetto di programmazione) non può essere determinata in modo certo (a meno che da qualche parte non si agisca su un parametro quale la lunghezza massima, cosa che in questo disassemblato non avviene) in quanto in memoria tutti i dati si susseguono senza nessuna distinzione, ma dalla tipologia dei dati contenuti in questo array (ovvero sequenze ripetute come 84 00 84 00, 10 00 20 00) mi è sembrato ragionevole farlo terminare a 0x10017CC9.
Facendo un rapido conto, ovvero provando ad utilizzare il carattere ASCII (non esteso) più "alto" introducibile da tastiera (cioè la tilde "~", hex 0x7E) come offset si ottiene un valore pari a 0x7E*2 = 0xFC. Come si vede questo valore è minore della lunghezza massima del vettore (che è uguale a 0xFF elementi); dunque può essere corretto accettare l'indirizzo 0x10017CC9 come ultimo.
  1. BYTE vector[0xFF] = { 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20,
  2. 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x28, 0x00, 0x28, 0x00, 0x28, 0x00, 0x28, 0x00,
  3. 0x28, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20,
  4. 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00,
  5. 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x48, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10,
  6. 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00,
  7. 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x84, 0x00, 0x84, 0x00, 0x84,
  8. 0x00, 0x84, 0x00, 0x84, 0x00, 0x84, 0x00, 0x84, 0x00, 0x84, 0x00, 0x84, 0x00, 0x84, 0x00,
  9. 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x81,
  10. 0x00, 0x81, 0x00, 0x81, 0x00, 0x81, 0x00, 0x81, 0x00, 0x81, 0x00, 0x01, 0x00, 0x01, 0x00,
  11. 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01,
  12. 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00,
  13. 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10,
  14. 0x00, 0x10, 0x00, 0x82, 0x00, 0x82, 0x00, 0x82, 0x00, 0x82, 0x00, 0x82, 0x00, 0x82, 0x00,
  15. 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02,
  16. 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00,
  17. 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10,
  18. 0x00, 0x10, 0x00, 0x20, 0x00 };
Proseguiamo ora con l'analisi:
Come visto il valore prelevato da questo vettore viene posto in AND con 8 e poi controllata l'eguaglianza di questa operazione con 0. Se si prova più volte a debuggare l'intero algoritmo si può verificare che la via più veloce per riuscire ad ottenere man mano un serial sempre "più corretto" è quella di verificare questa condizione, e cioè fare in modo che AL AND 8 sia uguale a 0. Questo è abbastanza facile da realizzare in quanto basta prendere gli elementi del vettore con il 4 bit uguale a 0 (xxxx0xxxxb AND 1000b = 0) come 0x84, 0x20, 0x00, 0x10 ...
Prendiamo ad esempio 0x84. Nel nostro vettore 0x84 si trova in varie posizioni, la prima alla numero 96 (cioè all'indirizzo 0x10017BCA + 96 = 0x10017C2A), poi 98 (0x10017C2C), 100 (0x10017C2E) e così fino alla 114 (0x10017C3C). Scegliamo la posizione 112, che ci porta ad avere un serial[15] = 112/2 = 56 = '8' (la posizione 112 come il numero 0x84 non sono stati scelti a caso, capiremo più avanti il perchè di questa decisione).

Per verificare se quello che abbiamo calcolato fino ad ora è corretto proviamo a riavviare il programma utilizzando come seriale:

XXXXXXXXXXXXXXX8

E vedere se il JE JUMP2 all'indirizzo 0x10002687 viene eseguito.
Se tutto ha funzionato correttamente ci troveremo ad eseguire questo codice:
  1. JUMP2 MOVZX ESI,BYTE PTR DS:[EDI] ; ESI = serial[15] (poi serial[14])
  2. 1000268F INC EDI ; EDI = VAR4
  3. 10002690 CMP ESI,2D ; if (serial[x] == '-') goto serial[x]_is_-
  4. 10002693 MOV EBP,ESI ; EBP = serial[15] (poi serial[14])
  5. 10002695 JE SHORT serial_15_is_-
  6. 10002697 CMP ESI,2B ; if (serial[x] != '+') goto serial[x]_is_not_+
  7. 1000269A JNE SHORT serial_15_is_not_+
  8. serial[x]_is_- MOVZX ESI,BYTE PTR DS:[EDI]
  9. 1000269F INC EDI
  10. serial[x]_is_not_+ XOR EBX,EBX ; EBX = 0 (IMPORTANTE!)
  11. CICLO CMP DWORD PTR DS:[10017DCC],1 ; DS[10017DCC] = 1 sempre (flag?)
  12. 100026A9 JLE SHORT JUMP3 ; Jump sempre preso
Come si vede dal disassemblato questa parte di codice si occupa di settare EBP = serial[15] e di controllare che il nostro serial[15] (e prossimamente serial[14]) non sia uguale al carattere '-' o '+', controlli che non ci interessano. Quello che interessa a noi è il codice da JUMP3 in poi, e cioè:
  1. JUMP3 MOV EAX,DWORD PTR DS:[10017BC0] ; EAX = puntatore a area dati addr (10017BCA)
  2. 100026BC MOV AL,BYTE PTR DS:[ESI*2+EAX] ; AL = ptr[10017BCA + serial[15]*2]
  3. 100026BF AND EAX,00000004 ; EAX = AL AND 4;
  4. 100026C2 TEST EAX,EAX ; if (AL == 0) goto JUMP4
  5. 100026C4 JE SHORT JUMP4
Questa porzione di codice esegue la stessa identica operazione vista poco fa, ovvero utilizzare il doppio del carattere serial[15] come offset del nostro vettore 0x10017BCA per prelevare un valore da porre in AND questa volta con 4.
Adesso a differenza dell'esecuzione precedente è importante non far saltare verso JUMP4 (dopo capiremo il perchè) e questo, grazie al valore scelto da noi come serial[15] (cioè '8') non avviene (infatti 0x84 AND 4 = 4).
Vediamo ora le istruzioni fondamentali che seguono la non esecuzione del JUMP4:
  1. 100026C6 LEA EAX,[EBX*4+EBX] ; EAX contiene un offset (inizialmente 0)
  2. 100026C9 LEA EBX,[EAX*2+ESI-30] ; EBX = EAX * 2 + ESI - 0x30
  3. 100026CD MOVZX ESI,BYTE PTR DS:[EDI] ; ESI = 0
  4. 100026D0 INC EDI
  5. 100026D1 JMP SHORT CICLO
Inizialmente viene caricato in EAX un offset calcolato usando EBX, nel nostro caso questa istruzione azzera EAX in quanto l'istruzione di xor su EBX all'indirizzo serial[x]_is_not_+ aveva portato a 0 EBX (ed 0*4 + 0 = 0 = EAX). Successivamente viene caricato in EBX un valore dato dalla conversione in decimale del carattere in ESI (char - 0x30 come si sa si usa per convertire da ASCII a intero) sommato al doppio del valore in EAX. Nel nostro caso questa istruzione porterà EBX proprio al valore 8 (ESI era il nostro serial[15] = '8', convertito in decimale e sommato con 0 ovviamente da esattamente dec(serial[15])).

Fatto questo viene messo in ESI un valore puntato da EDI in memoria (EDI portato a VAR4 dall'instruzione a 0x1000268F), in particolare lo 0 che era stato inserito dalla MOV eseguita all'indirizzo 0x10001193. Dopo la modifica di ESI si salta con un jump forzato verso CICLO e si riparte ad eseguire il blocco di istruzioni appena visto.
Nella seconda esecuzione di questo codice il valore preso dal vettore all'istruzione 0x100026BC sarà diverso e precisamente uguale a 0x20 in quanto ESI era stato posto = 0 (quindi verrà scelto il primo valore del vettore, appunto 0x20).
Usando 0x20 però avremo che 0x20 AND 4 = 0 e quindi il JE JUMP4 sarà eseguito interrompendo il ciclo.
Quello che segue:
  1. JUMP4 CMP EBP,2D ; if (serial[15] != '-') goto NOT_NEG
  2. 100026D6 MOV EAX,EBX ; EAX = serial[15] (poi serial[14]) in decimale!
  3. 100026D8 JNE SHORT NOT_NEG
  4. 100026DA NEG EAX ; EAX = !EAX (mai eseguito)
  5. NOT_NEG POP EDI
  6. 100026DD POP ESI
  7. 100026DE POP EBP
  8. 100026DF POP EBX
  9. 100026E0 RETN ; ==== Fine SubCheckSerial ====
In queste ultime istruzioni viene controllato se serial[15] (che era stato copiato in EBP dall'istruzione a 0x10002693) è uguale a '-' (cosa mai vera) e viene quindi salvato in EAX il valore in decimale di serial[15].

Per adesso scegliendo serial[15] = '8' abbiamo fatto eseguire la SubCheckSerial in modo che ritorni EAX = 8, ora però prima di fare il punto della situazione analizziamo qualche altra istruzione della routine CheckSerial per capire come utilizza questo valore:
  1. 1000119C MOV EDI,EAX ; EDI = serial[15] in decimale
  2. 1000119E LEA EAX,[ESP+1C] ; EAX = VAR1
  3. 100011A2 PUSH EAX
  4. 100011A3 CALL SubCheckSerial
Quello che avviene in queste 3 istruzioni prima della seconda chiamata alla SubCheckSerial è molto semplice:
Si salva il valore di ritorno della prima chiamata in EDI e si pusha sullo stack il valore di VAR1. Grazie a quest'ultima operazione nella nuova chiamata alla SubCheckSerial all'indirizzo 0x1000265A quell'istruzione vista in precedenza preleverà dallo stack il valore di VAR1 e non di VAR3 come l'esecuzione precedente, per questo ora tutto il codice sarà differenziato e userà serial[14] al posto di serial[15].
Il comportamento della SubCheckSerial lo conosciamo già, quindi evitiamo di rianalizzarla e proseguiamo a vedere quello che farà la CheckSerial dopo questa seconda chiamata:
  1. 100011A8 ADD EDI,EAX ; EDI = dec(serial[15]) + dec(serial[14])
  2. 100011AA ADD ESP,8
  3. 100011AD CMP EDI,0B ; if (EDI != 0x0B) EAX = 0
  4. 100011B0 JE SHORT JUMP5 ; Devo saltare!
  5. 100011B2 POP EDI
  6. 100011B3 POP ESI
  7. 100011B4 XOR EAX,EAX ; EAX = 0, Serial errato
  8. 100011B6 POP EBX
  9. 100011B7 ADD ESP,18
  10. 100011BA RETN ; Ritorna serial errato
  11. JUMP5 ........
Quello che si può vedere da queste "ultime" istruzioni è molto molto importante e ci permetterà di capire il perchè della scelta serial[15] = '8' e come scegliere serial[14].
Se rivediamo le prime istruzioni studiate al passo 2 di questo articolo, in particolare il check fatto all'indirizzo 0x1000197A, capiremo subito quanto sia fondamentale non far tornare 0 alla funzione CheckSerial (infatti if (EAX == 0) goto WrongSerial) e quindi l'importanza di fare in modo che dec(serial[15]) + dec(serial[14]) venga uguale a 0x0B.

Questo è il cuore dell'algoritmo di questo software
Sono infatti i controlli fatti sulla somma dei valori di ritorno di 2 chiamate alla SubCheckSerial che costituiscono il 95% dell'algoritmo di validazione del seriale!

Trovato scritto qui è facile, infatti per fare in modo che dec(serial[15]) + dec(serial[14]) faccia 0x0B ormai è immediato (avendo scelto il primo uguale a 8 per forza il secondo dovrà essere uguale a 3), ma uno dei problemi più duri di questo studio è stato proprio questo!
Per far in modo che il tutto funzioni veramente però è necessario che con serial[14] = '3' venga tornato correttamente EAX = 3 dalla SubCheckSerial e verificare che questo è vero è facile:
scegliendo infatti serial[14] = '3' il valore preso dal vettore sarà l'elemento in posizione 0x66 ('3' = 0x33) che è di nuovo 0x84, quindi la SubCheckSerial si comporterà esattamente nel modo appena visto ritornando 3.
Rieseguendo il programma usando come seriale

XXXXXXXXXXXXXX38

Si può infatti verificare che il JUMP5 all'indirizzo 0x100011B0 verrà eseguito e ci troveremo ancora all'interno della routine CheckSerial:
  1. JUMP5 MOV CL,BYTE PTR DS:[ESI] ; CL = serial[0]
  2. 100011BD MOV DL,BYTE PTR DS:[ESI+1] ; DL = serial[1]
  3. 100011C0 LEA EAX,[ESP+0C]
  4. 100011C4 MOV BYTE PTR SS:[ESP+18],CL ; DS:0x0012E308 = VAR1 = serial[0]
  5. 100011C8 PUSH EAX
  6. 100011C9 MOV BYTE PTR SS:[ESP+1D],BL ; DS:0x0012E309 = VAR2 = 0
  7. 100011CD MOV BYTE PTR SS:[ESP+10],DL ; DS:0x0012E2FC = VAR3 = serial[1]
  8. 100011D1 MOV BYTE PTR SS:[ESP+11],BL ; DS:0x0012E3FD = VAR4 = 0
  9. 100011D5 CALL SubCheckSerial ; SubCheckSerial(serial[1])
  10. 100011DA LEA ECX,[ESP+1C] ; ECX = VAR1 (serial[0])
  11. 100011DE MOV EDI,EAX ; EDI = serial[1] in decimale
  12. 100011E0 PUSH ECX
  13. 100011E1 CALL SubCheckSerial ; SubCheckSerial(serial[0])
  14. 100011E6 ADD EDI,EAX ; EDI = dec(serial[1]) + dec(serial[0])
  15. 100011E8 ADD ESP,8
  16. 100011EB CMP EDI,0A ; if (EDI != 0x0A) EAX = 0
  17. 100011EE JE SHORT JUMP6 ; Devo saltare!
  18. 100011F0 POP EDI
  19. 100011F1 POP ESI
  20. 100011F2 XOR EAX,EAX ; EAX = 0, Serial errato
  21. 100011F4 POP EBX
  22. 100011F5 ADD ESP,18
  23. 100011F8 RETN ; Ritorna serial errato
  24. JUMP6 ........
Capito il funzionamento nel caso precedente questa parte di algoritmo è veramente semplice: quello che fa il programma è chiamare altre due volte la funzione SubCheckSerial passandole prima serial[1] e poi serial[0] e controllare che la somma dei due valori di ritorno questa volta sia uguale a 0x0A.
Anche qui come nel caso precedente un modo per proseguire con l'analisi è necessario trovare una coppia di caratteri da usare in serial[1] e serial[0] che verifichi questa condizione, come ad esempio '55'.
Infatti:'5' = 0x35 => vector[0x6A] = 0x84 (che sappiamo funzionare), inoltre 5 + 5 = 0x0A.
Quindi scegliendo:

55XXXXXXXXXXXX38

Arriveremo fino a JUMP6 senza problemi.
Ormai l'algoritmo è quasi terminato e quello che resta della CheckSerial è veramente molto semplice:
  1. 100011F9 CMP BYTE PTR DS:[ESI+5],50 ; if (serial[5] == 'P') goto JUMP7
  2. 100011FD JE SHORT JUMP7
  3. 100011FF POP EDI
  4. 10001200 POP ESI
  5. 10001201 XOR EAX,EAX ; EAX = 0, Serial errato
  6. 10001203 POP EBX
  7. 10001204 ADD ESP,18
  8. 10001207 RETN ; Ritorna serial errato
  9. JUMP7 MOV CL,BYTE PTR DS:[ESI+6] ; CL = serial[6]
  10. 1000120B XOR EAX,EAX ; EAX = 0
  11. 1000120D CMP CL,47 ; if (serial[6] == 'G') ZF = 1
  12. 10001210 POP EDI
  13. 10001211 POP ESI
  14. 10001212 POP EBX
  15. 10001213 SETE AL ; if (ZF = 1) EAX = 1
  16. 10001216 ADD ESP,18
  17. 10001219 RETN ; ==== Fine CheckSerial ====
Occorre infatti che serial[5] sia uguale a 0x50 ('P') e che serial[6] sia 0x47 ('G'), solo in questo modo infatti sarà ritornato EAX = 1.
In definitiva quindi usando:

55XXXPGXXXXXXX38

Si ottiene la registrazione del prodotto!


 
Cancellare la registrazione
 
E' molto importante alla fine di questo studio per non incorrere in problemi legali rimuovere la registrazione del prodotto così ottenuta. Per far questo basta cancellare il file:

C:\WINDOWS\system32\pdfpg.dat

Ricordo ancora una volta che questo studio è stato svolto solo ai fini dell'apprendimento del reverse engineering e non per rubare software!

 
Conclusioni
 
Ci sono vari punti su cui vale la pena spendere ancora qualche parola:

La dimensione del seriale: Come si può vedere infatti in tutto il codice studiato non viene mai presa in considerazione la lunghezza massima del seriale introdotto. L'unico punto dove questa viene limitata è durante la lettura dalla textbox tramite la GetDlgItemText (tra i parametri di chiamata di quest'API infatti occorre specificare la dimensione massima del buffer di lettura per evitare overflow) a 0x3FB caratteri.
In realtà il buffer in memoria del programma utilizzato per contenere il seriale parte all'indirizzo 0x1001A820 e finisce a 0x1001A8E6, quindi la dimensione massima effettiva è di 0xC7 caratteri. Aldilà di questa precisazione quello che conta realmente è che i seriali accettati possono essere costruiti come:

55???PG???????38*

Dove al posto dei ? può essere sostituito un carattere arbitrario e al posto del * può essere sostituito un numero qualsiasi di caratteri arbitrari.

La via proposta: La scelta che ho illustrato (serial[0] = '5', serial[1] = '5', serial[14] = '3', serial[15] = '8') non è infatti l'unica possibile. Esistono varie altre combinazioni accettate come esistono anche altre strade (ad esempio mediante l'uso di caratteri quali '+', '-' e ' ') che validano il codice. Ad esempio altri pattern validi per superare il check possono essere:

82???PG???????47*
28???PG???????29*
73???PG???????74*

E loro varie combinazioni. Lascio a chi è interessato la verifica.

Il Machine ID: Di questo non compare traccia nella validazione del seriale, probabilmente questo identificatore univoco è usato solo da verypdf per tenere traccia della diverse macchine che hanno comprato il loro software.

In definitiva si può concludere, come potrà concordare chi ha seguito l'articolo fino alla fine, che questo tipo di algoritmo non sia particolarmente elaborato/sicuro. L'idea del vettore e dell'uso dei caratteri come offset è interessante ma non certo originale (chi ha qualche esperienza di reversing sa quante volte viene usato un vettore d'appoggio di questo tipo) ed alla fine è l'unico controllo che questo programma fa.
Inoltre, essendo il controllo di registrazione basato unicamente su sul valore di ritorno di una funzione, il cracking di questo software è veramente banale. Come visto nel passo 2 si tratta praticamente di un costrutto del genere:
  1. if (CheckSerial(serial)) printf("Thank you registered PDF Split-Merge v2.2.");
  2. else printf("Your registration key is wrong, please double check it and try again.");
Dove cambiando un solo bit si riesce a sviare completamente l'esecuzione del programma.
Dunque un caso di studio limitatamente interessante, adatto come esercizio di medio/basso livello.

 
Appendici
 
Per questo algoritmo non vale neanche la pena scrivere un programma di keygen, basterebbero qualche random al posto dei caratteri a scelta arbitraria.
Downloads: 350
Il setup del programma PDF Split-Merge v2.2

Script Execution Time: 0.170015 seconds - Visite: 645992
Copyright © 2007-2017 Suondmao v0.1.5-1