PDF Split-Merge reverse engineering Analisi di algoritmi, keygenning e cracking
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.
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).
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:
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.
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):
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];
Ed è proprio questa routine all'indirizzo 0x10002656, la SubCheckSerial, il vero cuore del check del seriale, vediamola disassemblata:
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]):
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.
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:
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è:
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:
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:
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:
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:
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:
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:
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!![]()
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!
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* ![]() 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:
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.
Per questo algoritmo non vale neanche la pena scrivere un programma di keygen, basterebbero qualche random al posto dei caratteri a scelta arbitraria.
Script Execution Time: 0.638351 seconds - Visite: 112550 Copyright © 2007-2009 Suondmao v0.1.5-1 |