Trouver l'OEP

Aujourd'hui on se lance tout doucement dans l'unpacking de Just Cause 3 et on commence... par le commencement c'est à dire la détection de l'OEP (Original Entry Point, le point d'entrée du jeu avant qu'il ait été packé).

Plusieurs techniques plus ou moins génériques permettent de retrouver l'OEP d'un programme, il ne s'agit pas de l'étape la plus compliquée. De nombreux papers académiques se vantent d'ailleurs régulièrement d'unpacker automatiquement une foultitude de packer alors qu'ils se contentent de retrouver l'OEP d'un programme packé. Les méthodes génériques se basent souvent sur un historique des pages écrites et exécutées : si une page mémoire a été écrite puis exécutée, il y a de forte chance qu'elle corresponde à une portion de code qui a été déchiffrée / décompressée / décodée et donc potentiellement le code original. Plusieurs techniques peuvent être utilisées pour faire ça, la plus simple est de modifier les droits des pages de manière à ce qu'une écriture ou une exécution génère une exception. D'autres méthodes utilisent des techniques un peu plus avancées avec du data tainting, de l'émulation etc. mais c'est souvent un peu overkill...

Généralement la bonne combinaison pour retrouver un OEP pour un packer donné est de poser des hooks sur des APIs qu'on sait être utilisées en fin d'unpack et d'utiliser ensuite VirtualProtect pour modifier les droits des pages susceptibles de contenir l'OEP (les sections exécutables du PE). On monitore ensuite les exceptions avec un petit hook dans KiUserExceptionDispatcher (ou une API un peu plus basse pour éviter une détection) ou un vectored exception handler et on arrive à retrouver notre OEP.

Parfois des stolen bytes sont utilisées et compliquent la tache (les X premières instructions à l'OEP sont effacées de leur emplacement original, polymorphisées et placées dans de la mémoire allouée) on a donc plus d'OEP à proprement parler, les méthodes génériques ne fonctionnent plus, il faut reconstruire le code original, trouver un moyen spécifique de déterminer où le code du packer s'arrête et où celui de l'exe original commence mais c'est une autre histoire...

Revenons à nos moutons. La première chose que je fais quand je dois unpacker une cible est tout simplement de la lancer et de m'y attacher pour explorer un peu son code. Le plus simple pour éviter les anti-X est de suspendre le processus (via Process Hacker par exemple, bien supérieur à Process Explorer cela dit en passant). Une fois cette manipulation effectuée, la seule manière possible pour notre cible de détecter un debugger est d'utiliser tiers (processus, service, driver) ou de hooker la fonction DbgUiRemoteBreakin. Dans le premier cas il suffit de suspendre ou unloader tout ce qui peut nous détecter (contre les drivers, WIN64AST est particulièrement utile). Dans le second cas, il suffit de hooker DbgUiIssueRemoteBreakin dans le debugger pour l'empêcher de créer un thread dans le processus cible (étrangement, je n'ai vu aucun plugin faire ça). Dans le cas de Just Cause 3, aucune protection de ce type ne semble avoir été mise en place il suffit donc de suspendre tout les processus Steam ainsi que Just Cause et de s'attacher au processus avec x64dbg.

Une fois attaché au processus on peut observer différentes choses :

  • Just Cause semble être compilé avec Visual Studio (utilisation de MSVCR100.dll)
  • Le code semble être dans l'avant dernière section .data (en RWX..)
  • Mis à part 2 adresses invalides dans l'IAT il ne semble pas y avoir de redirection d'APIs (???).
  • En remontant un peu la call stack de la main thread, on retrouve vite l'entry point et aucun stolen byte ne semble être présents.

Une autre méthode très rapide pour retrouver l'OEP d'un programme compilé avec Visual Studio est de rechercher la constante 0x2B992DDFA232, il s'agit en effet de la valeur par défaut du __security_cookie initialisé au tout début de l'exécution du programme. Dans la capture d'écran ci-dessous (magnifiée avec grâce sous Paint), vous pouvez voir l'EP de x64dbg (excellent débugger au fait) avec les symboles associés et en dessous l'OEP de JC3. On voit tout de suite la similitude et la fameuse constante.

Capture d'écran montrant les similitudes entre l'EP de x64dbg et l'OEP de JC3

Maintenant que nous avons notre OEP, nous aimerions bien y poser un breakpoint de manière à avoir notre programme dans un état "dumpable". Ce n'est pas si facile que ça pour les raisons suivantes :

  • il y a peut être des anti-X un peu partout pendant le chargement du jeu ;
  • il est impossible de jouer à JC3 sans compte steam (ce qui est un peu nul je trouve...) ;
  • les jeux steam sont lancés par Steam et le chargement implique plusieurs processus, il faut donc suivre les processus créé ;
  • il n'y a pas de follow fork mode trivial sous Windows.

Nous allons donc utiliser Frida ! Frida est au premier abord un tool sans aucun sens, il s'agit en effet d'un framework permettant (entre autre...) de créer des scripts python (mais aussi JavaScript, QML, Swift, .NET) qui injectent du C/C++ scripté en JavaScript (via v8 ou duktape) pour reverser de l’assembleur (x86, x86_64, ARM, ARM64 mais aussi des langages plus haut niveau comme de l'Objective-C ou du dalvik) sous Windows (ou Mac, Linux, iOS, Android et... QNX). J'étais incroyablement sceptique au départ mais il s'avère que ce choix du JS n'est pas bête du tout (portabilité, dépendances très faible, énorme quantité de libs existantes en pure JS, asynchrone de base etc.) en plus Frida est extrêmement bien codé, agréable à utiliser et oleavr est quelqu'un de très accessible et serviable. Mais trêve de compliments, Frida ne supporte pas le suivi de processus, il va donc nous falloir l'implémenter.

Le principe est relativement simple, on va se contenter de lancer Steam en y attachant Frida et dès qu'un nouveau processus est créé, on injecte Frida dedans. Pour cela on hook CreateProcessInternalW et dès qu'un processus est créé, on remonte un évènement à notre script python depuis notre JS, notre script python va alors utiliser WinAppDbg pour poser un EBFE sur RtlUserThreadStart. Dès que le nouveau processus est initialisé et avant que l'entry point du nouveau processus soit appelé, on injecte Frida. Enfin on envoi un message au processus parent pour lui dire qu'il peut continuer son exécution. Il y a quelques subtilités pour gérer les processus 32 et 64 bits (les processus Steam vs le jeu) mais ça se gère relativement bien... Frida a aussi un petit bug (pour lequel un patch est en cours de test) et n'est pas capable de s'attacher correctement aux processus lancés avec un integrity level à untrusted, ce qui est le cas pour certains processus de Steam (notamment ceux affichant du contenu Web) heureusement ce bug ne nous empêche pas de lancer notre jeu.

Maintenant que nous avons Frida injecté dans tous les processus y compris celui de JC3, il nous suffit de mettre un hook sur une API utilisée au tout début de l'exécution du processus et nous aurons gagné. J'ai choisi arbitrairement d'utiliser RtlQueryPerformanceCounter qui est encore une fois utilisé pour le calcul du __security_cookie.

Le code est ci-dessous (d'abord le script python, puis le JS (oui je sais s'complètement con de foutre du code inline dans un blog post alors que j'ai un compte github, mais j'm'en fous)) :

# coding: utf-8
import frida
import threading
import sys
import os
from winappdbg import System, Process, HexDump
from pprint import pprint
from time import sleep

sessions = []
PIDs = []
threads = []
continue_events = []

system = System()

RtlUserThreadStart = {}

def get_RtlUserThreadStart_addr(path, ntdll_path):
    pid = frida.spawn((path,))
    session = frida.attach(pid)
    for m in session.enumerate_modules() :
        if m.path.lower().endswith(ntdll_path) :
            for x in m.enumerate_exports() :
                if x.name == 'RtlUserThreadStart' :
                    break
            else :
                session.detach()
                frida.kill(pid)
                raise Exception('unable to find RtlUserThreadStart')
            break
    else :
        session.detach()
        frida.kill(pid)
        raise Exception('unable to find ntdll')
    session.detach()
    frida.kill(pid)
    return x.relative_address + m.base_address

RtlUserThreadStart[32] =get_RtlUserThreadStart_addr('%s\\SysWow64\\notepad.exe'%os.environ['WINDIR'], 'syswow64\\ntdll.dll')
RtlUserThreadStart[64] =get_RtlUserThreadStart_addr('%s\\notepad.exe'%os.environ['WINDIR'], 'system32\\ntdll.dll')

def follow_proc_callback(script):
    def follow_proc_callback_priv(message, data) :
        if message['type'] == 'error' :
            pprint(message)
            print message['stack']
            return
        elif message['type'] == 'send' :
            payload = message['payload']
            event = payload.get('event', '')
            if event == 'new process' :
                print 'New process!'
                pid = payload['PID']
                creation_flags = payload['creation_flags']
                p = Process(pid)
                bps = {}
                for bp_address in RtlUserThreadStart.values() :
                    if p.is_address_readable(bp_address) :
                        bps[bp_address] = p.read(bp_address, 2)
                        p.write(bp_address, '\xEB\xFE')
                t = p.get_thread(p.get_thread_ids()[0])
                p.resume()
                while not bps.has_key(t.get_pc()) :
                    sleep(0.1)
                p.suspend()
                for addr, v in bps.items() :
                    p.write(addr, v)
                follow_proc(pid, js, follow_proc_callback)
                if creation_flags & 0x4 == 0 :
                    p.resume()
                script.post({'type': 'continue_%d'%pid})
                return
            elif event == 'possible OEP' :
                continue_events.append((script, payload['continue_event']))
                print "We reached the OEP (%s-%s), you can know attach your debugger (or press r to resume the process)"%(payload['OEP'], payload['OEP_VA'])
                return
        raise Exception('unknown message')
    return follow_proc_callback_priv

def follow_proc(pid, js, callback) :
    PIDs.append(pid)
    session = frida.attach(pid)
    session.disable_jit()
    sessions.append(session)
    script = session.create_script(js)
    script.on('message', callback(script))
    script.load()

with open("find_oep.js") as f :
    js = f.read()

pid = frida.spawn(("C:\\Program Files (x86)\\Steam\\Steam.exe",))
follow_proc(pid, js, follow_proc_callback)
frida.resume(pid)

try :
    cmd = ''
    while cmd != 'q' :
        cmd = sys.stdin.readline().strip().lower()
        if cmd == 'r' :
            for script, code in continue_events :
                script.post({'type': code})
except :
    pass

map(lambda s: s.detach(), sessions)
for pid in PIDs :
    try :
        Process(pid).kill()
    except :
        pass


"use strict";
var QueryPerformanceCounter = Module.findExportByName('kernel32', 'QueryPerformanceCounter')
var CreateProcessInternalW = Module.findExportByName('kernel32', 'CreateProcessInternalW')
var SetTokenInformation = Module.findExportByName('kernel32', 'SetTokenInformation')
if (SetTokenInformation == null) 
    SetTokenInformation = Module.findExportByName('kernelbase', 'SetTokenInformation')
var RtlQueryPerformanceCounter = Module.findExportByName('ntdll', 'RtlQueryPerformanceCounter')

var CREATE_SUSPENDED = 0x00000004

Interceptor.attach(CreateProcessInternalW, {
    onEnter: function (args) {
        console.log('new process created!');
        if (args[1] != NULL)
            console.log('\tlpApplicationName: '+Memory.readUtf16String(args[1]));
        if (args[2] != NULL)
            console.log('\tlpCommandLine: '+Memory.readUtf16String(args[2]));
        this.lpProcessInformation = args[10];
        console.log('\tlpProcessInformation: '+this.lpProcessInformation);
        this.dwCreationFlags = args[6];
        console.log('\tdwCreationFlags: '+this.dwCreationFlags);
        args[6] = args[6].or(CREATE_SUSPENDED);
    },
    onLeave: function (retval) {
            if (retval.toInt32()) {
                var PID = Memory.readU32(this.lpProcessInformation.add(8))
                var hProcess = Memory.readU32(this.lpProcessInformation)
                console.log('CreateProcessInternalW SUCCEED: hProcess='+hProcess.toString(16)+' PID='+PID);
                send({'event': 'new process', 'PID': PID, 'creation_flags': this.dwCreationFlags.toInt32()})
                var sync_op = recv('continue_'+PID, function(value) {});
                sync_op.wait();
                console.log('Resuming process...');
            } else {
                console.log('CreateProcessInternalW FAILED!');
            }
    }
});


var just_cause = Process.findModuleByName('JustCause3.exe');
if (just_cause !== null) {
    console.log('We are in JustCause3!');
    var OEPs = new Set();
    var just_cause_start = just_cause.base;
    var just_cause_end = just_cause.base.add(just_cause.size);
    Interceptor.attach(RtlQueryPerformanceCounter, {
        onEnter: function (args) {
            if ((just_cause_start.compare(this.returnAddress) <= 0) && (just_cause_end.compare(this.returnAddress) > 0) && (! OEPs.has(''+this.returnAddress))) {
                var OEP_VA = this.returnAddress.sub(just_cause.base);
                var continue_event = 'continue_'+this.threadId;
                console.log('Possible OEP: '+this.returnAddress+' (VA: '+OEP_VA+')');
                send({'event': 'possible OEP', 'OEP': this.returnAddress, 'OEP_VA': OEP_VA, 'continue_event': continue_event})
                var sync_op = recv(continue_event, function(value) {});
                sync_op.wait();
                OEPs.add(''+this.returnAddress);
            }
        }
    });
}

Comments !

blogroll

social