Reverse Engineering σε περιβάλλον Linux, Μέρος 1 : Hands-on Παράδειγμα
Description: This is issue 33 of Magaz, the Hellenic LinuxMagazine
Keywords: magaz hellenic greek hellas greece hellug linux magazine

Magaz, The Greek Linux Magazine
Τεύχος #33
Περιληπτικά Περιεχόμενα Αναλυτικά Περιεχόμενα Mirrors Links Πληροφορίες Συντάκτες Αναζήτηση Επικοινωνία
> Επόμενο Προηγούμενο Περιεχόμενα ### 6. Hands-on Παράδειγμα Έστω ότι μια μέρα πέφτει στα χέρια σας το παρακάτω εκτελέσιμο : rce2.bz2 (1.7k) Όλο χαρά το εκτελείτε:
>     bash$ rce2
>     Say the password: sesame
>     What???
>     bash$ rce2
>     Say the password: kuku
>     What???
> Ποιο να είναι άραγε το μυστικό password; Ας ετοιμαστούμε λοιπόν για αντιμετωπίσουμε ατελείωτα listing με εκατομμύρια γραμμές ακατανόητου κώδικα! Η ας δοκιμάσουμε κάτι πιο απλό:
>     bash$ strings rce2
>     ...
> Η strings τυπώνει όλες τις εκτυπώσιμες ASCII ακολουθίες που υπάρχουν στο εκτελέσιμο και έχουν μήκος πάνω από 4 χαρακτήρες (default). Μέσα στη λίστα με τα strings θα παρατηρήσετε κάποιο πιο ενδιαφέρον από τα υπόλοιπα :) Σπάνια βέβαια συμβαίνει να υπάρχουν plain-text κωδικοί μέσα στο εκτελέσιμο αλλά δε χάνουμε τίποτα να δοκιμάσουμε! Για εκπαιδευτικούς σκοπούς, θεωρήστε ότι η προηγούμενη διαδικασία δε απέδωσε καρπούς. Ας δούμε τι μπορούμε να μάθουμε για τη ροή του προγράμματος.
>     bash$ ltrace -i -o rce2.ltr rce2
>     Say the password: sesame
>     What???
>     bash$ cat rce2.ltr
>     [080483b5] __libc_start_main(0x0804851c, 1, 0xbffff784, 0x0804830c, 0x08048594
>     <unfinished ...>
>     [080484d5] printf("%s: ", "Say the password")                               =
>     18
>     [080484e1] fflush(0x40150340)                                               = 0
>     [080484f5] fgets("sesame\n", 20, 0x401501e0)                                =
>     0xbffff700
>     [0804849c] isspace(10, 0x40153234, 0xbffff700, 0xbffff784, 0xbffff6e8)      =
>     8192
>     [0804849c] isspace(101, 0x40153234, 0xbffff700, 0xbffff784, 0xbffff6e8)     = 0
>     [08048557] puts("What???")                                                  = 8
>     [ffffffff] +++ exited (status 0) +++
> Μόλις αποκτήσαμε δύο σπουδαίες πληροφορίες. Κοιτάξτε καλά τη __libc_start_main(). Συνεχίστε να την κοιτάζετε. H __libc_start_main(), εκτός των άλλων, φροντίζει να κληθεί και το δικό μας κυρίως πρόγραμμα. Κοιτάξτε λίγο ακόμα... H __libc_start_main() πρέπει να γνωρίζει που βρίσκεται ο δικός μας κώδικας. Επομένως... Για να σας βγάλω από την αγωνία, σας λέω πως η πρώτη παράμετρος της __libc_start_main() πρόκειται για τη διεύθυνση της main (0x0804851c)! Αυτή η πρώτη πληροφορία οδηγεί στη δεύτερη. Παρατηρήστε ότι όλες οι διευθύνσεις μετά την __libc_start_main() και μέχρι πριν την puts() είναι μικρότερες από την αρχή της main(). Άρα είναι ασφαλές να υποθέσουμε πως δεν ανήκουν σε αυτή αλλά σε κάποια άλλη συνάρτηση. Το τρίτο πράγμα που μάθαμε, είναι ότι στη διεύθυνση 0x08048557 έχει ήδη αποφασιστεί αν το password μας είναι σωστό ή όχι. Ας χρησιμοποιήσουμε το βαρύ πυροβολικό...
>     bash$ gdb rce2
>     (no debugging symbols found)...(gdb) break main
>     Function "main" not defined.
>     (gdb)
> Ουπς. Το εκτελέσιμο δεν περιέχει σύμβολα αλλά εμείς ξέρουμε τη διεύθυνση της main()!
>     (gdb) break *0x804851c
>     Breakpoint 1 at 0x804851c
>     (gdb) r
>     Starting program: /home/alf/projects/magaz/issue1/rce2
>     (no debugging symbols found)...
>     Breakpoint 1, 0x0804851c in printf ()
>     (gdb) x/30i $eip
>     0x804851c <printf+408>: push   ebp
>     0x804851d <printf+409>: mov    ebp,esp
>     0x804851f <printf+411>: push   edi
>     0x8048520 <printf+412>: push   esi
>     0x8048521 <printf+413>: sub    esp,0x20
>     0x8048524 <printf+416>: and    esp,0xfffffff0
>     0x8048527 <printf+419>: push   esi
>     0x8048528 <printf+420>: push   0x13
>     0x804852a <printf+422>: lea    esi,[ebp-40]
>     0x804852d <printf+425>: push   esi
>     0x804852e <printf+426>: push   0x80485bd
>     0x8048533 <printf+431>: call   0x80484bc <printf+312>
>     0x8048538 <printf+436>: mov    edi,0x80485ce
>     0x804853d <printf+441>: mov    ecx,0xb
>     0x8048542 <printf+446>: cld
>     0x8048543 <printf+447>: add    esp,0x10
>     0x8048546 <printf+450>: repz cmps ds:[esi],es:[edi]
>     0x8048548 <printf+452>: jne    0x8048564 <printf+480>
>     0x804854a <printf+454>: sub    esp,0xc
>     0x804854d <printf+457>: push   0x80485d9
>     0x8048552 <printf+462>: call   0x8048354 <puts>
>     0x8048557 <printf+467>: add    esp,0x10
>     0x804855a <printf+470>: lea    esp,[ebp-8]
>     0x804855d <printf+473>: pop    esi
>     0x804855e <printf+474>: xor    eax,eax
>     0x8048560 <printf+476>: pop    edi
>     0x8048561 <printf+477>: leave
>     0x8048562 <printf+478>: ret
>     0x8048563 <printf+479>: nop
>     0x8048564 <printf+480>: sub    esp,0xc
O GDB νομίζει πως βρισκόμαστε 408 bytes από την αρχή της printf για τους γνωστούς λόγους. Πριν αρχίσουμε σαν παλαβοί να κάνουμε single step ας δούμε τι μπορούμε να συνάγουμε από το listing.
    push   ebp
    mov    ebp,esp
    push   edi
    push   esi
Καταρχάς η main είναι frame-based κάτι που φαίνεται από το γνώριμο πρόλογο. Επίσης σώζονται στο σωρό οι καταχωρητές esi και edi ώστε να μπορούν να ανακτηθούν οι τιμές τους πριν το τέλος της συνάρτησης. Το γιατί γίνεται αυτό πρόκειται για μια εσωτερική υπόθεση του compiler (o gcc διατηρεί τους ebx, esi και edi κατά τις κλήσεις διότι τους χρησιμοποιεί για δικά του θέματα (πχ προσωρινή αποθήκευση τιμών).
    sub    esp,0x20
    and    esp,0xfffffff0
Μετά δεσμεύεται χώρος στο σωρό για 0x20 bytes και επίσης ο esp μειώνεται(πιθανότατα) και άλλο ώστε να έρθει σε όριo των 16 bytes. Η όλη διαδικασία είναι αποτέλεσμα του optimization και δεν είναι απαραίτητο πως το πρόγραμμα θα χρησιμοποιήσει όλη τη μνήμη που δεσμεύτηκε τελικά.
    push   esi <--- ??
    push   0x13
    lea    esi,[ebp-40]
    push   esi
    push   0x80485bd
    call   0x80484bc <printf+312>
    ...
    add    esp,0x10
Ακολουθεί ένα μυστηριώδες "push esi" και ύστερα μια κλασική κλήση συνάρτησης. Η συνάρτηση δέχεται τρεις παραμέτρους: func_0x804845c( 0x80485bd, 0x13, ebp-40). H τελευταία παράμετρος είναι η διεύθυνση μιας τοπικής μεταβλητής, αφού έχει αρνητικό offset από τον ebp. Το αναμενόμενο "add esp, X" (διόρθωση του σωρού) βρίσκεται λίγες γραμμές πιο κάτω. Ο λόγος που δε βρίσκεται αμέσως μετά την call, είναι ότι ο gcc αποφάσισε να αλλάξει τη σειρά των εντολών για λόγους optimization (έχει να κάνει με τα pipelines του pentium). Όλα εντάξει; Ελπίζω να μην απαντήσατε "ναι"! Κάτι δεν πάει καλά σε όσα έχω πει ως τώρα: γιατί δέχτηκα με τόση σιγουριά πως η συνάρτηση δέχεται τρεις παραμέτρους, ενώ όχι μόνο γίνονται τέσσερα push πριν από αυτή αλλά κυρίως, ο σωρός διορθώνεται κατά 0x10 = 4*4 bytes (4 bytes για κάθε παράμετρο). Λοιπόν, εκτός από το γεγονός ότι εγώ έγραψα το πρόγραμμα και το ξέρω :), υπάρχει ένας επιπλέον λόγος. Όταν γίνεται το πρώτο "push esi", ο esi δεν έχει αρχικοποιηθεί μέσα στη main() και έτσι έχει μια άγνωστη τιμή. Γιατί να περάσουμε σε μια συνάρτηση μια παράμετρο με άγνωστη τιμή; Η απάντηση για ακόμα μια φορά είναι το optimization (περιττό να σας πω ότι το πρόγραμμα έγινε compile με -Ο2). Ο compiler θέλει να κρατάει τον esp σε διευθύνσεις πολλαπλάσιες των 16 bytes! Το κόστος προσπέλασης σε μη aligned διευθύνσεις είναι τόσο σημαντικό, ώστε ο gcc εισάγει dummy εντολές για να το αποφύγει!
    mov    edi,0x80485ce
    mov    ecx,0xb
    cld
    ...
    repz cmps ds:[esi],es:[edi]
    jne    0x8048564 <printf+480>
> Εδώ στον edi φορτώνεται μια διεύθυνση μνήμης και στον edi η τιμή 11. Μετά "καθαρίζεται" το direction flag. Ακολουθεί η εντολή "repz cmps ds:[esi],es:[edi]", η οποία με λίγα λόγια λέει: όσο τα bytes που βρίσκονται στις διευθύνσεις που δείχνουν esi και edi είναι ίσα και ο ecx δεν είναι 0 αύξησε (αν το direction flag είναι 0/clear) τους esi και edi κατά ένα byte και έλεγξε ξανά (η μαγεία των CISC επεξεργαστων...). Ουσιαστικά κάνει ακριβώς την ίδια δουλειά με μια κλήση strncmp(esi,edi,ecx), ελέγχει αν τα πρώτα #ecx bytes δύο string που αρχίζουν στις διευθύνσεις esi και edi είναι ίσα. Ωραία, ξέρουμε την τιμή του edi αλλά ο esi τη τιμή έχει; Λίγο πιο πάνω υπάρχει η "lea esi,[ebp-40]" και επειδή ξέρουμε ότι ο gcc φροντίζει να μην αλλάζει ο esi από συναρτήσεις, είμαστε σίγουροι ότι έχει ακόμα την ίδια τιμή. Για να συνοψίσουμε, το πρόγραμμα καλεί μια συνάρτηση της οποίας μια παράμετρος είναι η διεύθυνση μιας τοπικής μεταβλητής (ebp-40) και μετά συγκρίνει τα bytes που βρίσκονται εκεί με κάποια άλλα που βρίσκονται σε μια σταθερή θέση. Δε ξέρω τι λέτε εσείς αλλά εμένα μου φαίνεται πως εδώ γίνεται ο έλεγχος του password! Η επόμενη εντολή είναι η jne (jump if not equal/zero). Αν η σύγκριση είναι επιτυχής τότε το zero flag έχει ενεργοποιηθεί από το προηγούμενο βήμα και έτσι δεν ακολουθούμε το άλμα. Αν το password είναι λάθος το ZF=0 και το άλμα γίνεται. Για να δούμε ποίο είναι το password:
>     (gdb) break *0x8048546
>     Breakpoint 2 at 0x8048546
>     (gdb) c
>     Continuing.
>     Say the password: sesame

>     Breakpoint 2, 0x08048546 in printf ()
>     (gdb) x/5i $eip
>     0x8048546 <printf+450>: repz cmps ds:[esi],es:[edi]
>     0x8048548 <printf+452>: jne    0x8048564 <printf+480>
>     0x804854a <printf+454>: sub    esp,0xc
>     0x804854d <printf+457>: push   0x80485d9
>     0x8048552 <printf+462>: call   0x8048354 <puts>
>     (gdb) x/s $esi
>     0xbffff720:      "sesame"
>     (gdb) x/s $edi
>     0x80485ce <_IO_stdin_used+26>:   .......... xe xe!
> Η παραπάνω τεχνική, όπου βρίσκουμε ένα σωστό password/serial εντοπίζοντας το σημείο που γίνεται η σύγκριση με αυτό που έχουμε εισάγει εμείς, λέγεται password/serial fishing Εντάξει, μάθαμε το password, γιατί όμως να σταματήσουμε εδώ; Γιατί να μην πειράξουμε το πρόγραμμα ώστε να δέχεται ως σωστό κάθε password; Για να το πετύχουμε, αρκεί να μην ακολουθούμε ποτέ το άλμα. Ξέρουμε ότι η jne (σε αυτή τη μορφή) καταλαμβάνει στη μνήμη 2 bytes, διότι η επόμενη εντολή αρχίζει δύο bytes πιο μετά.
>     (gdb) x/2b 0x8048548
>     0x8048548 <printf+452>: 0x75    0x1a
> Το 0x75 είναι το opcode της εντολής ενώ το 0x1a η (προσημασμένη) απόσταση του άλματος. Αν γίνει το άλμα ο έλεγχος θα μεταφερθεί 0x1a bytes από το τέλος της εντολής(αρχή της επόμενης), εδώ 0x804854a + 0x1a = 0x8048564. Για να πετύχουμε το σκοπό μας, αρκεί να αντικαταστήσουμε τα δύο αυτά bytes με nop (no operation, opcode 0x90). Φυσικά δεν έχει νόημα να το κάνουμε αυτό μόνο στη μνήμη αλλά στο image του εκτελέσιμου που βρίσκεται στο αρχείο. Το πρόβλημα είναι να εντοπίσουμε σε ποιο σημείο του αρχείου βρίσκεται ο κώδικας που θέλουμε να πειράξουμε. Ο ELF header έχει όλες τις πληροφορίες που χρειαζόμαστε αλλά αφού δεν έχουμε αναφερθεί ακόμα σε αυτόν θα κάνουμε κάτι άλλο: θα ψάξουμε το αρχείο για να βρούμε την αρχική ακολουθία από bytes. Υπάρχει περίπτωση η ίδια ακολουθία να υπάρχει σε πολλά σημεία και πρέπει να είμαστε σίγουροι πως πειράζουμε το σωστό. Για αυτό χρειαζόμαστε αρκετά bytes γύρω από την εντολή (το context).
>     (gdb) x/10b 0x8048548-5
>     0x8048543 <printf+447>: 0x83    0xc4    0x10    0xf3    0xa6    0x75
>     0x1a    0x83
>     0x804854b <printf+455>: 0xec    0x0c
> Αν ψάξετε για αυτά τα bytes στο αρχείο(με έναν hexeditor πχ του mc) θα τα βρείτε μόνο μια φορά και το offset του 0x75 0x1a είναι 0x548. Αντικαταστήστε τα δύο αυτά bytes με 0x90 0x90.
>     bash$ ./rce2
>     Say the password: qrwrwr
>     Hooray!
>     bash$ ./rce2
>     Say the password: sdfeg4453
>     Hooray!
Συγχαρητήρια, μόλις "σπάσατε" το πρόγραμμα! Επόμενο Προηγούμενο Περιεχόμενα