Comment on tombe sur un
bug exploitable
Panorama des techniques qui déclenchent un bug exploitable en amont d'un détournement de flow : large input, type confusion, integer overflow, format strings, race conditions, logic bugs, exécution symbolique et fuzzing.
Maxime Jérôme··8 min de lecture
Prérequis
- Compréhension des fichiers ELF (voir Segmentation Mémoire)
- Maniement de GDB
- Introduction PWN
Hello ! o/
On va voir les différentes techniques qui permettent d'engendrer un bug dans une application, et plus particulièrement pour essayer ensuite de détourner le flow d'exécution de cette application.
Input utilisateur
┌────────────────────────────────────────────────┐
│ large input ──► buffer overflow │
│ weird input │
│ ├── type confusion ──► leak / crash │
│ ├── integer overflow ──► wraparound │
│ ├── format string ──► read/write arb. │
│ └── special chars ──► parser confusion │
│ race condition ──► TOCTOU / memory corruption │
│ logic bug ──► usecase non prévu │
│ symbolic exec ──► path exploration auto │
│ fuzzing ──► crash hunting en masse │
└────────────────────────────────────────────────┘
▼
bug trouvé -> exploitationLarge input
Un principe très connu... mais connu de l'époque ! À l'époque on utilisait des fonctions non sécurisées pour la lecture d'une entrée. En C, par exemple, gets(3) prend en entrée une adresse et... c'est tout. Aucun check sur la longueur du texte entré.
Voici un petit one-liner pour faire vos plus grosses strings :
python -c "print 'A'*5000"Weird input
On commence à rentrer doucement dans le vif du sujet. Il peut arriver que votre programme crash car vous n'avez pas mis le format attendu.
Type confusion
Le programme peut s'attendre à un entier, mais vous allez mettre "AAAAAA" comme un con, et puis en plus ça marche. Ca peut vous permettre de récupérer un leak d'une adresse, par exemple. Il arrive que des programmes crash... mais pas entièrement : on vous donne le code d'erreur, peut-être une adresse, puis hop tout fonctionne - vous avez juste fait planter une petite fonctionnalité de merde qui servait à rien.
Voici un exemple concret (CVE-2015-0336) dans la vraie vie.
On peut imaginer ce genre de payloads :
undefined
undef
null
NULL
(null)
nil
NIL
true
false
True
False
TRUE
FALSE
None
hasOwnProperty
then
constructorInteger overflow
À une époque on s'en foutait qu'un entier soit signé ou non. Du coup petite technique : essayez parfois d'aller plus loin que ce qui est prévu. Si ce n'est pas géré par le programme... c'est cool pour nous !
Un exemple récent (Sequoia, CVE-2021-33909) ne peut pas vous faire de mal.
Wraparound signé/non-signé
INT_MAX sur un entier signé est un comportement indéfini (undefined behavior). Sur un non-signé c'est un wraparound modulo garanti. Les deux sont exploitables différemment.Payloads à tester :
0
1
1.00
$1.00
1/2
1E2
1E02
1E+02
-1
-1.00
-$1.00
-1/2
-1E2
-1E02
-1E+02
1/0
0/0
-2147483648/-1
-9223372036854775808/-1
-0
-0.0
+0
+0.0
0.00
0..0
.
0.0.0
0,00
0,,0
,
0,0,0
0.0/0
1.0/0.0
0.0/0.0
1,0/0,0
0,0/0,0
--1
-
-.
-,
999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
NaN
Infinity
-Infinity
INF
1#INF
-1#IND
1#QNAN
1#SNAN
1#IND
0x0
0xffffffff
0xffffffffffffffff
0xabad1dea
123456789012345678901234567890123456789
1,000.00
1 000.00
1'000.00
1,000,000.00
1 000 000.00
1'000'000.00
1.000,00
1 000,00
1'000,00
1.000.000,00
1 000 000,00
1'000'000,00
01000
08
09
2.2250738585072011e-308Format string
À l'époque (oui, c'est toujours à l'époque...) on utilisait printf(3), et en plus on l'utilisait mal ! On a même un exemple hyper récent avec le SSID Stripping sur iOS.
N'hésitez plus à mettre ces payloads dans votre liste. Attention : ne les collez pas en brut, pensez à echo -e "$${payload}".
AAAAAAAAA%p%s%s%s%s%n
AAAAAAAAA %p%s%s%s%s%n
AAAAAAAAA
%p%s%s%s%s%n
AAAAAAAAA%p%s%s%s%s%n
AAAAAAAAA_office Stack au moment de printf(user_input)
┌────────────────────────────────────────┐
│ ret addr │ saved rbp │ local vars... │
└────────────────────────────────────────┘
▲ ▲ ▲
%p dump #1 %p dump #2 %p dump #3
Avec %n : écriture à l'adresse contenue dans le param -> arbitrary writeSpecial chars
Vous pouvez essayer de mettre de l'unicode, des caractères accentués, etc. dans vos payloads. Attention, votre payload list ressemblera à rien - genre vraiment, à que dalle ! Je vous la donne pas, je suis sûr que vous pouvez la générer vous-même.
Race condition
La race condition (ou "condition de course" selon bitoduc), c'est juste le fait d'être rapide. Imaginons que votre programme procède de la sorte :
read
wait(5s)
...Bah ça se trouve vous pouvez interagir avec la mémoire pendant ces 5 secondes, et ça se trouve, détruire tout le programme !
Thread A (victime) Thread B (attaquant)
─────────────────────────────────────────────
check(file) -> OK
swap(file -> symlink)
use(file) -> oops, c'est le symlink
─────────────────────────────────────────────
Fenetre d'attaque : entre check et useLogic bugs
Les bugs logiques (ou business logic bugs) sont les plus durs à déceler : il faut comprendre à 100% comment le programme fonctionne et ce que font exactement chaque fonction. Pour donner un exemple, on pourrait essayer de mettre deux fois le même paramètre :
./prog --file A.txt --file BBBBBBBBBBBBBBBBBBBBB.txtQuand on parle de bug logique, on parle d'un usecase qui n'a absolument pas été prévu dans le développement et qui permet de détourner le programme.
Pourquoi c'est le plus dur
Symbolic execution / concolic testing
L'exécution symbolique (SE) permet d'analyser un programme pour déterminer quels inputs ont quelles conséquences sur son flow. Les outils les plus connus sont angr et KLEE. C'est très efficace : on peut aller de la détection de vulnérabilité jusqu'au build du payload !
Programme : if (x > 0) { A() } else { B() }
─────────────────────────────────────────────
SE cree deux etats symboliques :
x = symbol_S1
├── contrainte x > 0 -> chemin A
│ model SAT -> x = 1 (input concret)
└── contrainte x <= 0 -> chemin B
model SAT -> x = 0 (input concret)
-> couvre les deux branches avec des inputs generes automatiquementConcolic testing
Fuzzing
Le fuzzing, dernière technique à la mode - et à la mode depuis un petit moment déjà. Si on peut résumer le fuzzing en quelques mots : foutre de la merde partout et voir si ça crash. Et parfois on fout la merde de manière un peu plus propre.
Pour ça on a des outils - le mieux étant de créer le sien - comme AFL (American Fuzzy Lop). afl est un fuzzer de coverage qui fonctionne en greybox : il a accès au code car vous allez compiler votre cible avec son instrumentation.
Corpus initial
[seed1, seed2, ...]
│
▼
┌─────────────────────────────────────────┐
│ AFL mutate input │
│ (bitflip, arithmetique, splice, ...) │
└─────────────────────────────────────────┘
│
▼
execute binaire instrumente
│
├── nouveau chemin couvert ? ──► ajoute au corpus
│
└── CRASH ? ──► sauvegarde dans out/crashes/
▲
on veut ca !| Outil | Type | Cas d'usage typique |
|---|---|---|
| afl / afl++ | Greybox coverage | Binaires compilables, librairies C/C++ |
| libFuzzer | In-process greybox | Fonctions isolées, harness C |
| radamsa | Blackbox mutation | Protocoles réseau, parsers de fichiers |
| angr | Symbolic + concolic | Binaires fermés, CTF, analyse de checks |
Article suivant
Stack overflow : tout ce que la stack peut t'offrir