Του Μάριου Καραγιαννόπουλου, Principal Software Engineer, Citrix Bytemobile
Η συγγραφή κώδικα με την βοήθεια της γλώσσας προγραμματισμού C μπορεί να φαίνεται απλή υπόθεση. Χρειάζεται όμως μεγάλη προσοχή ιδιαιτέρως όταν αυτός ο κώδικας θα εκτελεστεί από διεργασίες "δαίμονες", σε συστήματα πραγματικού χρόνου. Αν δεν έχουμε επενδύσει τον χρόνο που χρειάζεται για να κάνουμε review ή να δοκιμάσουμε τον κώδικά μας κάτω από συνθήκες υψηλού φορτίου, τότε αργά η γρήγορα θα εμφανιστούν τα προβλήματα αυτά (bugs) στα συστήματα του πελάτη.
Οι συνέπειες τέτοιων προβλημάτων μπορεί να είναι από ένα λάθος υπολογισμό μέχρι ένα memory corruption το οποίο θα οδηγήσει σε crash της εφαρμογής μας. Είναι λοιπόν επιβεβλημένη η ανάγκη εξαντλητικών δοκιμών καθώς και προσεκτικού review του κώδικα που έχουμε γράψει, στα πλαίσια μιας διόρθωσης (bug fix) η ακόμα και της υλοποίησης ενός νέου feature. Αυτό που έχει σημασία επίσης να παρατηρήσουμε, είναι ότι στα πλαίσια μιας διόρθωσης πολλές φορές κάνουμε το λάθος να κάνουμε copy paste κώδικα που έχει γράψει κάποιος άλλος στο ίδιο module ή component, χωρις να δούμε κατά πόσον αυτός ο κώδικας «υποφέρει» από κάποιο bug ή κάποιο λογικό λάθος.
Μέσα από αυτό το άρθρο θα προσπαθήσουμε να καλύψουμε ένα ικανοποιητικό αριθμό προβλημάτων που κάνουν συχνά οι προγραμματιστές, δίνοντας παράλληλα την ευκαιρία να θυμηθούμε διάφορα πράγματα από τα πρώτα μας βήματα στην C. Πριν όμως μπούμε σε λεπτομέρειες θα ήταν χρήσιμο να παραθέσουμε ένα ενδιαφέρον trick το οποίο μπορεί να μας εμφανίσει προβλήματα κατά την εκτέλεση ακόμα και ενός unit test.
Σε κάποιο παλαιότερο άρθρο του συναδέλφου Δημήτρη Καλαμαρά, παρουσίαζε πως μπορούμε να χρησιμοποιήσουμε optimization flags για να χτίσουμε το project μας.
Υπάρχουν περιπτώσεις προβλημάτων λοιπόν όπως αυτή του memcpy() με λάθος size που θα δούμε παρακάτω, οι οποίες μπορεί να "κρυφτούν" αν ο κώδικας χτιστεί με optimization flag -O3 ή -Ο2. Αν όμως ξαναχτιστεί χωρίς optimization flags τότε κάποιο buffer overflow μπορεί εύκολα να κάνει crash την εφαρμογή μας.
Λάθος 1 - memcpy() και sizeof()
Βάσει του manual του memcpy() στο Linux:
void *memcpy(void *dest, const void *src, size_t n);
Η συνάρτηση memcpy() αντιγράφει n bytes από την περιοχή μνήμης src στην περιοχή μνήμης dest. Οι προαναφερθείσες περιοχές μνήμης δεν πρέπει να επικαλύπτονται.
Ένα συνηθισμένο λάθος που γίνεται εδώ είναι ότι στην θέση του n χρησιμοποιείται το sizeof(src) χωρίς να λαμβάνεται υπόψη ότι το src μπορεί να έχει δηλωθεί πιο πάνω ως pointer σε κάποιου τύπου data type. Για παράδειγμα:
unsigned char *src = calloc(1, sizeof(char));
unsigned char *dest = calloc(1, sizeof(char));
src[0] = 'd';
printf("src = %s\n", src);
memcpy(dest, src, sizeof(src));
printf("dest = %s\n", dest);
To sizeof(src) είναι 8 bytes σε Linux 64 bit αρχιτεκτονικής. Αυτό σημαίνει ότι προσπαθεί να αντιγράψει 8 bytes από τη περιοχή μνήμης του src στο dest. Όπως βλέπουμε όμως και το src και το dest είναι pointers σε θέσεις 1 byte μνήμης. Το πρόγραμμα αυτό θα οδηγήσει σε memory corruption και δεν είναι σίγουρο πως θα αντιδράσει κάθε φορά στην εκτέλεσή του.
Η σωστή χρήση της memcpy εδώ θα ήταν:
memcpy(dest, src, sizeof(*src)); ή memcpy(dest, src, sizeof(unsigned char));
Λάθος 2 – structure padding και sizeof()
Ένα άλλο συχνό λάθος γίνεται όταν χρησιμοποιούμε την sizeof() για να μετρήσουμε το μέγεθος μιας structure που έχουμε ορίσει στο σημείο ορισμών του κώδικά μας (π.χ. στο header file). Ένα παράδειγμα είναι το παρακάτω:
struct my_structure {
int *a;
int b;
unsigned char c;
};
Το sizeof(my_structure) είναι ίσο με 16 bytes (8 bytes(pointer σε int) + 4 bytes(int) + 4 bytes(padding)) = 16 bytes). Το πραγματικό size όμως του structure είναι 13 bytes:
int *a = 8 bytes
int b = 4 bytes
unsigned char c = 1 byte
Τι είναι όμως το structure padding;
Πολλοί επεξεργαστές απαιτούν συγκεκριμένο memory alignment σε μεταβλητές συγκεκριμένων τύπων. Για παράδειγμα:
- Οι μεταβλητές τύπου char (1 byte) μπορεί να γίνουν 1 byte aligned και να εμφανίζονται σε οποιοδήποτε μονό byte boundary.
- Οι μεταβλητές τύπου short (2 bytes) πρέπει να είναι 2 bytes aligned, και μπορούν να εμφανίζονται σε οποιοδήποτε ζυγό byte boundary. Αυτό σημαίνει ότι η διεύθυνση μνήμης 0x10004567 δεν είναι σωστό location για μια μεταβλητή short αλλά η θέση 0x10004566 είναι.
- Οι μεταβλητές τύπου long (4 bytes) πρέπει να είναι 4 byte aligned, και μπορούν να εμφανίζονται σε οποιοδήποτε byte boundary πολλαπλάσιο των 4 bytes. Αυτό σημαίνει ότι 0x10004566 δεν είναι σωστό location για μια long μεταβλητή αλλά η θέση 0x10004568 είναι.
Το structure padding γίνεται από τον compiler διότι τα μέλη μιας structure πρέπει να εμφανίζονται στο σωστό byte boundary, και για να το πετύχει αυτό ο compiler τοποθετεί padding bytes (η bits αν χρησιμοποιούμε bit μέλη) ώστε τα μέλη της structure να εμφανίζονται στο σωστό location. Επιπρόσθετα το μέγεθος της structure πρέπει να είναι τέτοιο ώστε σε ένα πίνακα από structures, όλα τα structures είναι aligned σωστά στην μνήμη, οπότε μπορεί να έχουμε και padding και στο τέλος της structure, όπως πιο πάνω.
Στο πιο πάνω παράδειγμα η πρώτη μεταβλητή a είναι 8 bytes και καταλαμβάνει τα πρώτα 8 bytes. Η επόμενη b είναι 4 bytes και ξεκινάει από ζυγό μιας και η προηγούμενη ήταν 8 bytes. Η τελευταία c είναι 1 byte, επειδή όμως υπάρχει ένα μέλος στην structure που είναι 8 bytes (η μεγαλύτερη), θα πρέπει να γίνει το μέγεθος της structure πολλαπλάσιο του 8 δηλαδή σε αυτή τη περίπτωση 16. Για να το πετύχει αυτό ο compiler προσθέτει άλλα 7 padding bytes.
Γενικά θα πρέπει να προσέχουμε ποιο μέγεθος χρησιμοποιούμε στον κώδικά μας. Δεν θα πρέπει να χρησιμοποιούμε σε άλλα σημεία το sizeof(my_structure) και σε άλλα σημεία το sizeof() των επιμέρους.
Λάθος 3 – έλεγχος των return values
Πολλές φορές χρησιμοποιούμε συναρτήσεις από διάφορα APIs οι οποίες επιστρέφουν διάφορες τιμές που ορίζονται στο man page των συναρτήσεων. Το λάθος που κάνουμε εδώ είναι ότι δεν ελέγχουμε πάντα τα αποτελέσματα των μεταβλητών. Οι compilers δεν παραπονιούνται για κάτι τέτοιο, οπότε κάποια στιγμή θα επιστραφεί τιμή που δεν την έχουμε ελέγξει οδηγώντας το πρόγραμμά μας σε λάθος flow. Οπότε πρέπει να δίνουμε μεγάλη προσοχή στα man pages και στα διάφορα returned values τα οποία πρέπει να ελέγχουμε και να διαφοροποιούμαστε στον κώδικά μας ανάλογα με το error code.
Λάθος 4 – Memory leaks
Η χρήση της malloc() και calloc() σύμφωνα με τις οποίες το πρόγραμμά μας ζητάει από το λειτουργικό σύστημα να δεσμεύσει ένα κομμάτι μνήμης, απαιτούν και την αντίστοιχη χρήση της free() συνάρτησης. Αν δεν χρησιμοποιηθεί η free(), τότε δεν θα απελευθερωθεί το αντίστοιχο κομμάτι μνήμης οδηγώντας σε memory leak. Για προγράμματα δαίμονες που «τρέχουν» σε real time συστήματα αυτό είναι ένα είδος αυτοκτονίας του ίδιου του συστήματος μιας και η μνήμη κάποια στιγμή θα τελειώσει με ανεξέλεγκτα αποτελέσματα για την λειτουργία του. Σε ένα απλό πρόγραμμα είναι σίγουρο ότι δε θα ξεχάσουμε να απελευθερώσουμε την μνήμη που κάναμε allocate. Τι γίνεται όμως σε προγράμματα που έχουν χιλιάδες γραμμές κώδικα και πόσο μάλλον αν δεν έχουν γραφτεί από εμάς τους ίδιους; Υπάρχουν διάφορα εργαλεία που μας βοηθούν να βρούμε που ακριβώς χάνουμε μνήμη, όπως είναι το valgrind το οποίο θα δούμε σε επόμενο άρθρο.
Λάθος 5 – Buffer overflow
Το buffer overflow μπορεί να συμβεί και σε περιπτώσεις που έχουμε κάνει εξονυχιστικό code review και δεν είναι ορατό τόσο εύκολα, παρά μόνον αν ειδικές συνθήκες αναγκάσουν το πρόγραμμά μας να το προκαλέσει. Αυτή η μέθοδος (το να αναγκάσουμε κάποιον buffer να κάνει overflow) είναι μια από τις πιο γνωστές μεθόδους των hackers όταν θέλουν να αποκτήσουν πρόσβαση σε διάφορα συστήματα ή web servers που κάνουν φιλοξενούν γνωστές ιστοσελίδες. Ένα παράδειγμα είναι το παρακάτω:
void foo(const char* input)
{
char buf[10];
strcpy(buf, input);
printf("%s\n", buf);
}
Αν καλέσουμε την συνάρτηση foo() με input ένα string που έχει πάνω από 10 χαρακτήρες τότε η strcpy θα προσπαθήσει να αντιγράψει στον buf που είναι 10 θέσεων (μαζί με το NUL termination ‘\0’) κάτι που είναι πάνω από 10 θέσεις, οδηγώντας το πρόγραμμα μας σε crash. Μερικά παραδείγματα συναρτήσεων που δεν είναι safe από buffer overflows αλλά έχουν άλλες αντίστοιχες που δέχονται ως παράμετρο και το max μέγεθος του buffer που προσπαθούν να αντιγράψουν είναι:
a. gets() -> fgets()
b. strcpy() -> strncpy()
c. strcat() -> strncat()
d. sprintf() -> snprintf()
Υπάρχει μια εταιρία επ’ ονόματι Coverity της οποίας το βασικό προϊόν είναι ένας στατικός αναλυτής που βρίσκει λάθη όπως τα παραπάνω. Για όσους ασχολούνται με opensource projects, η Coverity δίνει τη δυνατότητα να περάσετε δωρεάν το project σας από στατική ανάλυση εδώ: https://scan.coverity.com/
- Συνδεθείτε ή εγγραφείτε για να σχολιάσετε
Σχόλια
Ωραία ανάλυση, λεπτά σημεία που χρίζουν μέγιστης προσοχής. Να 'σαι καλά φίλε ξαναθυμήθηκα τις παρουσιάσεις στο 1ο έτος.
Προσοχή στο που γράφουν οι pointer!
Ωραίο κείμενο. Νομίζω πως σπάνια πια βλέπουμε υπερχείλιση του buffer.
Τα εύσημα και από μένα ....
Φοβερή ανάλυση του θέματος !
εξαιρετικό άρθρο !!
Ωραίο άρθρο! Αναλυτικό και χρήσιμο για όσους μαθαίνουν C.
μπραβο ωραιο κι εναλιτικο αρθρο
Ωραίο άρθρο,ευχαριστούμε!