Ένα πρόγραμμα, ως γνωστόν, είναι μια ροή εντολών. Οι σύγχρονες (sic) γλώσσες προγραμματισμού, όπως είναι η C, η Java, ή η Pascal, επιτρέπουν στον προγραμματιστή να δημιουργεί ρουτίνες, οι οποίες συνήθως αποκαλούνται συναρτήσεις (η κάθε γλώσσα έχει τη δική της ορολογία).
Μια συνάρτηση περιέχει ένα αυτόνομο κομμάτι κώδικα. Μπορεί να πάρει κάποια δεδομένα, να τα επεξεργαστεί και να επιστρέψει κάποια άλλα. Το ενδιαφέρον είναι ότι τη στιγμή που καλείται από το πρόγραμμα μια συνάρτηση, η αρχική ροή διακόπτεται. Επομένως, με κάποιον τρόπο, μετά την εκτέλεση της συνάρτησης πρέπει να επιστρέψουμε στην κατάσταση που υπήρχε πριν ακριβώς γίνει η κλήση.
Αυτό γίνεται με την αποθήκευση των απαραίτητων πληροφοριών σε μια περιοχή μνήμης, γνωστή με την ονομασία stack. Απαραίτητες πληροφορίες είναι η κατάσταση των καταχωρητών (registers) πριν την κλήση της συνάρτησης καθώς και η διεύθυνση μνήμης από την οποία πρέπει να συνεχιστεί η ροή του προγράμματος (θα ονομάσουμε αυτήν τη διεύθυνση return address) μετά το πέρας της συνάρτησης. Τέλος, στο stack αποθηκεύονται τα εσωτερικά δεδομένα (μεταβλητές, δομές κ.λπ.) που χρησιμοποιεί η συνάρτηση.
Η δομή του stack είναι, όπως συνηθίζουμε να λέμε, μια δομή LIFO (Last In First Out). Σκεφτείτε μια τεράστια στοίβα άπλυτων πιάτων: το πρώτο πιάτο που θα αφήσετε στον πάγκο, θα πλυθεί τελευταίο! Με παρόμοιο τρόπο αποθηκεύονται οι απαραίτητες πληροφορίες που αναλύσαμε πιο πάνω, στο stack. Στην περίπτωση που η συνάρτηση μεταχειρίζεται ένα buffer (πρόκειται για ένα κομμάτι μνήμης, συνήθως είναι ένας πίνακας χαρακτήρων) μπορεί, από ”προγραμματιστική απροσεξία”, το τελευταίο να υπερχειλίσει, κοινώς να έχουμε ένα buffer overflow.
Η απλούστατη περίπτωση είναι η αντιγραφή δεδομένων σε ένα buffer, χωρίς να γίνει έλεγχος αν αυτό είναι αρκετά μεγάλο, ώστε να μπορεί να τα χωρέσει. Αμεση συνέπεια είναι η εξής: τα επιπλέον δεδομένα γράφονται, επίσης, στο stack (θυμηθείτε ότι εκεί είναι αποθηκευμένο το buffer που διαχειρίζεται η συνάρτηση), διαγράφοντας τα υπάρχοντα δεδομένα του. Στα τελευταία περιέχεται και η return address.
Υπάρχει μεγάλη πιθανότητα (αν τα επιπλέον δεδομένα που δεν χωράνε στο buffer, είναι αρκετά) να αλλάξει η εν λόγω διεύθυνση. Αποτέλεσμα: η ροή του προγράμματος συνεχίζει από μια ”τυχαία” θέση της μνήμης. Αν η ”υπερχείλιση” δεν έχει γίνει σκόπιμα, τότε το πρόγραμμα θα προσπαθήσει να εκτελέσει εντολές από μια ”ακατάλληλη” περιοχή, με φυσικό επόμενο να έχουμε ένα ομορφότατο crash!
Ας έλθουμε τώρα στο πικάντικο σημείο της ιστορίας: πώς μπορεί να είναι τόσο μοιραίο ένα τέτοιο πρόβλημα; Αν με κάποιον τρόπο μπορούσαμε να διαγράψουμε, έντεχνα, τη return address, έτσι ώστε να δείχνει σε μια διεύθυνση που περιέχει τα δικά μας δεδομένα, τότε αναγκάζουμε το πρόγραμμα να εκτελέσει το δικό μας κώδικα. Το τελικό ερώτημα είναι και το πιο… καυτό: πού και πώς θα αποθηκεύσουμε το δικό μας κώδικα, ώστε να ξέρουμε, μάλιστα, και τη διεύθυνση από την οποία αυτός αρχίζει; Η απάντηση είναι απλή: Μπορούμε να τοποθετήσουμε τον επίμαχο κώδικα στο buffer, με το οποίο θα επιτύχουμε το overflow, τροποποιώντας έτσι την return address, ώστε να δείχνει ”πίσω” στο buffer!
Ας δούμε ένα μικρό υποθετικό παράδειγμα. Σκεφτείτε ένα πρόγραμμα, το οποίο αλλάζει το όνομα ενός αρχείου. Ως χρήστες πρέπει να δώσετε στο πρόγραμμα το αρχείο προς μετονομασία και το νέο όνομα. Αν σε κάποιο σημείο του προγράμματος η πληροφορία που δίνετε, αντιγράφεται σε ένα buffer, χωρίς να γίνει έλεγχος αν αυτό την χωράει, τότε μπορείτε να επιτύχετε overflow. Αν, τώρα, αντί για όνομα αρχείου δώσετε τον απαραίτητο κώδικα, ώστε να ”ανοίξετε” ένα root shell, μπορείτε να εκμεταλλευθείτε κατά προφανή τρόπο το buffer overflow.
Για να μπορέσετε, βέβαια, να οδηγηθείτε σε shell με δικαιώματα root, τα τελευταία θα πρέπει να προϋπάρχουν. Αυτός είναι ο λόγος που τα buffer overflows είναι καταστροφικά όταν συμβαίνουν σε προγράμματα, τα οποία έχουν ενεργοποιημένο το user id του root (suid root). Το πρόγραμμα που αλλάζει το όνομα ενός αρχείου, δεν έχει κανένα λόγο να είναι suid root και επομένως δεν υπάρχουν άμεσοι κίνδυνοι από μια αδυναμία του. Αν όμως στη θέση του είχαμε τον pppd, το passwd ή το sendmail (όλα τρέχουν ως suid root), τότε η ενεργοποίηση ενός root shell μέσω ενός overflow θα ήταν εφικτή.
Στην πράξη, τα πράγματα είναι λιγάκι πιο δύσκολα, αν και βασικά η σύνταξη ενός exploit που εκμεταλλεύεται ένα buffer overflow, είναι… συνταγή. Ο επίδοξος cracker, αφού έχει εντοπίσει την αδυναμία ενός προγράμματος, πρέπει να κάνει αρκετούς υπολογισμούς, ώστε να μάθει την ”απόσταση” που έχει η return value από το buffer που θα υπερχειλίσει. Στο παιχνίδι, επίσης, εμπλέκονται και αρκετά θέματα γύρω από την αρχιτεκτονική του υπολογιστή, όπου έχουμε να κάνουμε με διαφορετική διαχείριση του stack.
Δυστυχώς, η τεχνική είναι τόσο διαδεδομένη, που η μοναδική λύση είναι προσεκτικός προγραμματισμός. Από τη μεριά μας, προσπαθήσαμε να αναλύσουμε την τεχνική, όσο πιο διακριτικά γίνεται, έτσι ώστε αυτοί που θέλουν να μάθουν και να προστατευτούν από crackers, να καταλάβουν από τι πρέπει να φυλάσσονται, οι δε επίδοξοι crackers να μη βρουν ένα άρθρο της μορφής: ”Γράψτε ένα exploit βήμα – βήμα”.