Επόμενο Προηγούμενο Περιεχόμενα
Έστω ότι μια μέρα πέφτει στα χέρια σας το παρακάτω
εκτελέσιμο : 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!
Συγχαρητήρια, μόλις "σπάσατε" το πρόγραμμα!
Επόμενο Προηγούμενο Περιεχόμενα