· ~10 λεπτά ανάγνωσης

Έκανα security audit στον δικό μου password manager και τον ξανάγραψα

Η πρώτη μου έκδοση χρησιμοποιούσε Argon2id και AES-256-GCM και παρ' όλα αυτά είχε εννέα ελαττώματα ασφαλείας. Εδώ είναι τι βρήκα, πώς είναι σχεδιασμένη η έκδοση 2, και πέντε bugs που φάνηκαν μόνο όταν τον έτρεξα στην πράξη.

Τον Μάρτιο έγραψα έναν μικρό password manager σε Python. Έβγαζε ένα κλειδί από τον master password με Argon2id, κρυπτογραφούσε κάθε κωδικό με AES-256-GCM και αποθήκευε τα πάντα σε SQLite. Αυτά είναι τα σωστά primitives, και το README το έλεγε με κάποια περηφάνια.

Τον Οκτώβριο τον ξαναδιάβασα, αυτή τη φορά σαν να τον είχε γράψει κάποιος άλλος και να με πλήρωναν για να τον σπάσω. Αυτό άλλαξε αυτό που έβλεπα.

Τι βρήκε ο έλεγχος

ΕύρημαΓιατί έχει σημασία
Μια σταθερά «pepper» γραμμένη μέσα στον κώδικαΤο repository ήταν δημόσιο. Ένα μυστικό που κυκλοφορεί σε δημόσιο κώδικα είναι απλώς μια σταθερά.
Οι παράμετροι του KDF δεν αποθηκεύονταν μαζί με το vaultΌταν αύξησα το κόστος μνήμης του Argon2 στον κώδικα, το δικό μου υπάρχον vault δεν άνοιγε πια. Σε πραγματικό vault, αυτό είναι ολική απώλεια δεδομένων.
Ονόματα sites και usernames σε απλό κείμενοΌποιος έχει το αρχείο μαθαίνει πού έχεις λογαριασμούς και με ποια ονόματα. Το README το έλεγε zero-knowledge. Δεν ήταν.
Κάθε γραμμή κρυπτογραφούνταν χωριστά, χωρίς associated dataΤα ciphertexts μπορούσαν να αλλάξουν θέση ανάμεσα σε γραμμές, ή γραμμές να διαγραφούν, χωρίς να γίνει αντιληπτό. Κάθε blob ήταν έγκυρο από μόνο του.
Ο master password διαβαζόταν με input()Εμφανιζόταν στην οθόνη, στο ιστορικό του τερματικού και σε όποιον έβλεπε ένα screen share.
Μια «δυνατή» γεννήτρια master passphrase με λίστα 30 λέξεωνΈξι λέξεις από 30 είναι περίπου 229· με την κατάληξη, γύρω στα 39 bits. Πολύ αδύναμο για το κλειδί που ανοίγει τα πάντα.
Ο χειρισμός του clipboardΤο μήνυμα έλεγε 15 δευτερόλεπτα, ο timer χρησιμοποιούσε 10· έσβηνε ό,τι υπήρχε στο clipboard, ακόμα και κείμενο που αντιγράφηκε αργότερα· και το ιστορικό clipboard των Windows κρατούσε τον κωδικό έτσι κι αλλιώς.
Το VACUUM παρουσιαζόταν ως forensic διαγραφήΣε SSD, με journals και snapshots, δεν εγγυάται τίποτα τέτοιο.
Ένα compiled .exe μέσα στο repositoryΈνα binary που κανείς δεν μπορεί να επαληθεύσει, φτιαγμένο από κανένα συγκεκριμένο commit.

Μάθημα πρώτο: τα καλά primitives είναι το εύκολο κομμάτι. Κάθε ένα από αυτά τα ελαττώματα βρίσκεται στον τρόπο που τα primitives συνδυάστηκαν, αποθηκεύτηκαν, αναλύθηκαν και εμφανίστηκαν στην οθόνη.

Ο σχεδιασμός της έκδοσης 2

Ένα κρυπτογραφημένο αρχείο αντί για βάση δεδομένων

Η κρυπτογράφηση ανά γραμμή σε SQLite διαρρέει τη δομή: πόσες εγγραφές υπάρχουν, τα μεγέθη τους, ποιες άλλαξαν. Ένα προσωπικό vault έχει μερικές εκατοντάδες εγγραφές, οπότε η κρυπτογράφηση όλου του περιεχομένου ως ενός blob κοστίζει microseconds. Όλα βρίσκονται μέσα στο ciphertext, μαζί με τίτλους, usernames, URLs και το πλήθος των εγγραφών, και το plaintext συμπληρώνεται ως τα όρια του 1 KiB ώστε το μέγεθος του αρχείου να αποκαλύπτει ελάχιστα.

Μια ιεραρχία κλειδιών

master password ─┐
                 ├─► Argon2id ─► KEK ─unwraps─► vault key ─decrypts─► entries
keyfile (opt.) ──┘   (params in header)        (random 256-bit)    (AES-256-GCM)

Το κλειδί του vault είναι τυχαίο αντί να προκύπτει από τον κωδικό, κάτι που αφήνει χώρο για περισσότερους τρόπους ξεκλειδώματος: ένα keyfile σήμερα, ένα recovery key ή ένα hardware token αργότερα. Το keyfile μπαίνει στην ειδική είσοδο secret του Argon2id, που είναι αυτό που έπρεπε να είναι το παλιό «pepper».

Το header αυθεντικοποιείται

Το αρχείο ξεκινά με ένα μικρό header σε απλό κείμενο: την έκδοση του format, τις παραμέτρους του Argon2id και το τυλιγμένο κλειδί του vault. Ολόκληρο το header είναι τα associated data της κρυπτογράφησης AES-GCM του payload, οπότε η αλλαγή οποιουδήποτε byte του, ακόμα και μιας παραμέτρου, εντοπίζεται. Έγραψα μια προδιαγραφή του format αρκετά ακριβή ώστε να μπορεί να την υλοποιήσει κάποιος άλλος.

Οι παράμετροι ταξιδεύουν με το αρχείο, μέσα σε όρια

Το Argon2id ρυθμίζεται σε κάθε μηχάνημα (μνήμη ως 1 GiB, περίπου ένα δευτερόλεπτο για ξεκλείδωμα), και οι παράμετροι αποθηκεύονται στο header. Αυτό διορθώνει το bug του κλειδώματος, αλλά ανοίγει μια νέα ερώτηση: τι γίνεται αν κάποιος αντικαταστήσει το vault σου με ένα που έχει σκόπιμα φθηνές παραμέτρους, ώστε μόλις πληκτρολογήσεις τον κωδικό σου να μετατραπεί σε κάτι εύκολο να σπάσει; Γι' αυτό υπάρχει ένα κατώτατο όριο πολιτικής, τα 64 MiB × 3 περάσματα του RFC 9106, που ελέγχεται πριν το KDF δει καν τον κωδικό. Ένα ανώτατο όριο εμποδίζει ένα εχθρικό αρχείο να εξαντλήσει τη μνήμη.

Η αλλαγή κωδικού αλλάζει και το κλειδί

Με ιεραρχία κλειδιών, είναι δελεαστικό να κάνεις την αλλαγή κωδικού φθηνή, τυλίγοντας ξανά το ίδιο κλειδί vault. Αλλά ας πούμε ότι ο παλιός κωδικός διέρρευσε: όποιος έχει ένα παλιό αντίγραφο του αρχείου θα μπορούσε να ξετυλίξει το αμετάβλητο κλειδί και να διαβάζει κάθε μελλοντική έκδοση. Γι' αυτό η αλλαγή κωδικού δημιουργεί νέο κλειδί vault, ξανακρυπτογραφεί τα πάντα και σβήνει τα backups που ήταν κρυπτογραφημένα με το παλιό. Υπάρχει ένα test που δοκιμάζει ακριβώς αυτή την επίθεση.

Αυστηρό parsing πριν από την αυθεντικοποίηση

Το header αναλύεται πριν αυθεντικοποιηθεί οτιδήποτε, οπότε ο parser είναι αυστηρός: διπλά κλειδιά JSON, άγνωστα πεδία, NaN, booleans που παριστάνουν ακέραιους και υπερβολικά μήκη απορρίπτονται όλα. Δύο parsers που διαφωνούν για το τι σημαίνει ένα αρχείο είναι εκεί όπου ζουν τα parser-differential bugs.

Πέντε bugs που φάνηκαν μόνο όταν τον έτρεξα

Ο σχεδιασμός πέρασε τον έλεγχο στα χαρτιά. Αυτά τα πέντε δεν φάνηκαν μέχρι να τρέξω τον κώδικα σε πραγματικό μηχάνημα Windows, με πραγματική είσοδο.

1. Ένας ελληνικός κωδικός έβγαζε λάθος κλειδί

Στα Windows, τα pipes της Python χρησιμοποιούν από προεπιλογή το ANSI code page (cp1252), όχι UTF-8. Ένας master password με ελληνικούς χαρακτήρες που περνούσε στο εργαλείο μέσω pipe αποκωδικοποιούνταν σε διαφορετικούς χαρακτήρες από τον ίδιο κωδικό πληκτρολογημένο στην κονσόλα, οπότε έβγαζε διαφορετικό κλειδί. Αυτό είναι bug κλειδώματος, όχι bug εμφάνισης. Επιπλέον, το ίδιο ορατό τονισμένο γράμμα μπορεί να είναι ένα code point ή δύο (NFC έναντι NFD), ανάλογα με το λειτουργικό και το πληκτρολόγιο. Η διόρθωση: υποχρεωτικό UTF-8 στα redirected streams και κανονικοποίηση NFC των κωδικών πριν από το KDF.

2. Αόρατοι χαρακτήρες στον δικό μου κώδικα

Στη συνάρτηση που αφαιρεί από την έξοδο τα terminal escape sequences και τα Unicode bidi overrides, τα escapes τύπου \u202e είχαν μετατραπεί σε κυριολεκτικούς αόρατους χαρακτήρες από ένα εργαλείο στη ροή επεξεργασίας μου. Ο κώδικας δούλευε ακόμα, αλλά κάποιος που θα διάβαζε το diff σε μια ιστοσελίδα δεν θα τους έβλεπε. Αυτό είναι το μοτίβο Trojan Source (CVE-2021-42574). Ο linter το έπιασε· το CI πλέον το μπλοκάρει.

3. Race ανάμεσα στο auto-lock και την αποθήκευση

Το interactive mode κλειδώνει το vault μετά από λίγα λεπτά αδράνειας, από ένα thread με timer. Αν ο timer χτυπούσε τη στιγμή που ξεκινούσε μια αποθήκευση, το κλειδί μπορούσε να μηδενιστεί ενώ χρησιμοποιούνταν, και το vault να γραφτεί με κλειδί από μηδενικά. Απίθανο, αλλά το αποτέλεσμα είναι απώλεια δεδομένων. Το κλείδωμα και η αποθήκευση μοιράζονται πλέον ένα mutex, και ο timer αδράνειας δεν μπορεί να χτυπήσει μόλις ξεκινήσει μια εντολή.

4. Η πακεταρισμένη εφαρμογή καθάριζε το clipboard πριν προλάβω να κάνω paste

Μετά την αντιγραφή ενός κωδικού, μια μικρή ανεξάρτητη βοηθητική διεργασία τον σβήνει 20 δευτερόλεπτα αργότερα. Τον σβήνει μόνο αν το clipboard κρατά ακόμα αυτόν τον κωδικό, με σύγκριση hash. Από τον πηγαίο κώδικα δούλευε. Στο single-file build των Windows, η εντολή αντιγραφής δεν επέστρεφε μέχρι η βοηθητική διεργασία να έχει ήδη σβήσει το clipboard. Μια one-file εφαρμογή PyInstaller που εκκινεί το δικό της εκτελέσιμο κάνει το παιδί να μοιράζεται τον φάκελο εξαγωγής της, και ο γονιός το περιμένει. Μία μεταβλητή περιβάλλοντος κάνει τη βοηθητική διεργασία ανεξάρτητη. Το βρήκα μόνο επειδή έφτιαξα το release binary τοπικά και το δοκίμασα όπως θα το δοκίμαζε ένας χρήστης.

5. Ένα fuzzy match μαζί με --yes μπορούσε να σβήσει λάθος εγγραφή

Οι εντολές δέχονται έναν μοναδικό όρο αναζήτησης αντί για τον ακριβή τίτλο, κάτι βολικό για το get. Για το rm --yes, σήμαινε ότι το rm bank --yes μπορούσε να σβήσει σιωπηλά την εγγραφή «Bank of Example». Η διαγραφή απαιτεί πλέον ακριβή τίτλο ή ID.

Πώς ελέγχεται

Από τι δεν προστατεύει

Πιστεύω ότι αυτή η ενότητα μετράει όσο όλες οι υπόλοιπες. Malware που τρέχει με τον δικό σου λογαριασμό ενώ το vault είναι ξεκλείδωτο μπορεί να το διαβάσει· κανένας password manager που τρέχει ως απλός χρήστης δεν μπορεί να το σταματήσει αυτό. Η Python δεν μπορεί να εγγυηθεί ότι τα μυστικά σβήνονται από τη μνήμη. Κάποιος με δικαίωμα εγγραφής μπορεί να γυρίσει το αρχείο σε μια παλαιότερη, γνήσια έκδοση, και κανένα offline format δεν μπορεί να το εντοπίσει. Το threat model του project τα απαριθμεί όλα αυτά ρητά. Ένα εργαλείο ασφαλείας που υπερβάλλει για τις εγγυήσεις του είναι χειρότερο από ένα που λέει τα όριά του.

Συμπεράσματα

  1. Η επιλογή AES-GCM και Argon2id είναι η αρχή, όχι το τέλος. Ο συνδυασμός, η αποθήκευση, το parsing και το UI είναι εκεί όπου ήταν τα bugs.
  2. Αποθήκευε τις παραμέτρους μαζί με τα δεδομένα, και έλεγξέ τες πριν τις χρησιμοποιήσεις.
  3. Τρέξε το στην πραγματική πλατφόρμα, με πραγματική είσοδο. Τα bugs στο encoding, στο packaging και στα threads δεν φαίνονται σε μια αναθεώρηση σχεδιασμού.
  4. Γράψε από τι δεν προστατεύεις.

Θέλεις τα configs ή τον κώδικα από αυτό το lab, ή έχεις κάποια ερώτηση; Στείλε μου email στο kostaspapas1023@gmail.com

← Πίσω στην αρχική