Aller au contenu principal
own2pwn
pwn/pwn-iva.tsx

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

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.

attack-surface-overview.txt
  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é -> exploitation
Vue d'ensemble des vecteurs initiaux couverts dans cet article.

Large 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 :

sh
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 :

text
undefined
undef
null
NULL
(null)
nil
NIL
true
false
True
False
TRUE
FALSE
None
hasOwnProperty
then
constructor

Integer 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é
En C, dépasser 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 :

text
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-308

Format 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}".

text
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
format-string-read.txt
  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 write
Une format string non controlée lit la stack frame : chaque %p dump une adresse.

Special 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 :

text
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 !

race-condition-toctou.txt
  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 use
Pattern TOCTOU : Time Of Check vs Time Of Use. La fenetre entre check et use est exploitable.

Logic 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 :

sh
./prog --file A.txt --file BBBBBBBBBBBBBBBBBBBBB.txt

Quand 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
Contrairement aux buffer overflows ou aux format strings, il n'existe pas d'outil automatique pour détecter un logic bug. Il faut lire le code (ou l'assembleur), comprendre l'intention du dev, et trouver le cas non prévu. C'est là que l'humain garde un avantage sur les scanners.

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 !

symbolic-execution.txt
  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 automatiquement
Exploration symbolique : l'outil explore tous les chemins possibles du programme.
Concolic testing
Le concolic testing (conCRETE + symbOLIC) alterne exécution concrète et résolution de contraintes symboliques. Angr fait du concolic par défaut. Très utile pour passer des checks que le fuzzing classique ne passerait jamais.

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.

fuzzing-coverage.txt
  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 !
AFL en mode greybox : il instrumente le binaire pour mesurer la couverture de code et privilegie les inputs qui decouvrent de nouveaux chemins.
OutilTypeCas d'usage typique
afl / afl++Greybox coverageBinaires compilables, librairies C/C++
libFuzzerIn-process greyboxFonctions isolées, harness C
radamsaBlackbox mutationProtocoles réseau, parsers de fichiers
angrSymbolic + concolicBinaires fermés, CTF, analyse de checks