Let's crack a binary (again)!

In den folgenden Abschnitten werden zwei dynamische „Angriffe” gegen das Beispielprogramm beschrieben. Zuerst wird ein „time-based side-channel” Angriff gezeigt, danach wird das Programmverhalten für einen weiteren Angriff ausgenutzt.

Um zu zeigen wie einfach Programme geknackt werden können, wird im Folgenden ein konkretes Programm auf mehreren Wegen geknackt. Zum Ausprobierenladen Sie das Programm auf den eigenen Rechner herunter.

In den ersten Teilen der Reversing 101 Serie wurden die Grundlagen von Reverse Engineering und statische Analysetechniken vorgestellt. In diesem Teil wird die dynamischer Analyse unter die Lupe genommen.

Side-Channel Angriff

Ein side-channel Angriff nutzt nicht direkt Schwachstellen einer Applikation aus, sondern leitet von beobachtbaren Faktoren (wie vergangene Zeit oder erhöhte CPU-Leistung) Informationen über den internen Zustand des Programms ab.
Zum Beispiel: Prüft ein Programm ein Passwort Zeichen um Zeichen und terminiert sobald ein inkorrektes Zeichen gefunden wird, so sind – abhängig vom Passwort – unterschiedliche Ausführungszeiten messbar. Ein Angreifer kann somit das Passwort zeichenweise erraten. Er konzentriert sich in einem ersten Schritt auf das erste Zeichen und misst die Ausführungszeit jedes eingegebenen Zeichens hervorruft. Das korrekte Zeichen wird die längste Ausführungszeit haben, da im Anschluss weitere Programmlogik ausgeführt wird. Dann versucht der Angreifer das nächste Zeichen zu „erraten”.
Somit sinkt die maximale Anzahl der Versuche von „Passwortlänge hoch Anzahl der möglichen Zeichen” auf „Passwortlänge mal Anzahl der möglichen Zeichen”.

Die Ausführungszeit zu messen, kann sehr ungenau sein: Programme können vom Kernel unterbrochen werden oder im Fall von Netzwerkkommunikation kann der Verbingungsaufbau unterschiedlich lang dauern und verändert damit die absolute Ausführungszeit.
Deswegen ist es besser, stattdessen einen anderen Faktor zu beobachten: zum Beispiel die Anzahl von ausgeführten CPU-Instruktionen. Sofern keine Schutzmechanismen implementiert sind, bleibt diese – bei gleichen Eingangsparametern – nahezu konstant.

Dieser Angriff kann unter Linux mit „perf“ durchgeführt werden. „Perf” ist ein Kerneltool mit dem Leistungsanalysen durchgeführt werden. Auf den nächsten Bildern wurde unser Beispielprogramm einmal mit dem Input „t” als Passwort und einmal mit dem Input „y” aufgerufen. Der Programmaufruf wurde dabei von „perf” analysiert.

perf mit dem Input "t"
perf mit dem Input "y"

Wird das Beispielprogramm mit dem Eingabewert „y” gestartet, so werden von der CPU 8 Instruktionen mehr ausgeführt als mit dem Eingabewert „t”.
Anstatt alle weiteren Zeichen händisch durchzuprobieren, automatisieren wir den weiteren Angriff mit einem Python Script:

# Side-channel attack exploiting counting instructions
import string
import sys
from subprocess import Popen, PIPE, STDOUT

# Grab the path of the target from the command line
executable = sys.argv[1]
# Educated guess, that the length of the password is shorter than 100 chars
key = [' '] * 100
# Template for the perf command
cmd = "perf stat -x, -e instructions:u " + executable + " 1>/dev/null"

# Iterate over the password array
for i in range(len(key)):
    maximum = 0
    character = 'x'

    # Iterate over every printable ASCII character
    for c in string.printable:
        key[i] = c
        key_str = ''.join(key)

        # Start a new process where perf is called with a new
        # character at the position we try to bruteforce
        p = Popen(cmd, stdout=PIPE, stdin=PIPE, stderr=STDOUT, shell=True)
        stdout, _ = p.communicate(input=b'%s\n' % key_str.encode('utf-8'))

        # Check if the number of executed instructions is higher
        # than the current maximum, if so set the current character
        # as a possible finding
        nb_instructions = int(stdout.split(',')[0])
        if nb_instructions > maximum:
            maximum = nb_instructions
            character = c

    # Set the character with the highest number of instructions at the current position 
    key[i] = character
    # Check the process again to verify if the closing string which is printed
    # on success is in the output
    p = Popen(executable, stdout=PIPE, stdin=PIPE, stderr=STDOUT, shell=True)
    stdout, _ = p.communicate(input=b'%s \n' % ''.join(key))
    if "sum is" in stdout:
        print("flag is: '" + ''.join(key) + "'")
        break
    print(''.join(key))

Das Python Script führt folgende Schritte aus:

  1. Iteriert über alle druckbaren ASCII-Zeichen und führt „perf” mit dieser Eingabe aus
  2. Überprüft bei welchem Zeichen die meisten CPU Instruktionen ausgeführt werden
  3. Speichert das Zeichen mit den meisten Instruktionen
  4. Wiederholt die vorherigen Schritten, und verwendet bei jedem Zwischenschritt die zuvor gefundenen Zeichen. Damit wird „perf” in der zweiten Runde mit „ya”, „yb”, „yc” usw. aufgerufen.

Auf dem folgenden Bild sieht man die Ausgabe dieses Scripts:

Ausnutzen des Verhaltens

Am Beispielprogramm kann man einen zweiten Angriff durchprobieren, der das Verhalten des Programms ausnutzt. Das Programm gibt abhängig vom fehlgeschlagenen Vergleich unterschiedliche Statuscodes zurück.
Mit einem weiteren Python Script kann die richtige Eingabe zeichenweise erraten, indem auf den Statuscodes reagiert wird. Erhöht sich der aktuelle Statuscode im Vergleich zum vorangegangenen Versuch, so kann davon ausgegangen werden, dass ein weiteres korrektes Zeichen gefunden wurde. Das Programm kann nun den nächsten Buchstaben durchprobieren.

# Side-channel attack exploiting the exit value
from subprocess import Popen, PIPE, STDOUT 

import string


context.log_level = "error"
CMD = './4a2181aaf70b04ec984c233fbe50a1fe600f90062a58d6b69ea15b85531b9652'

def try_input(inp): 
    p = Popen(CMD, stdout=PIPE, stdin=PIPE, stderr=STDOUT, shell=True) 
    stdout, _ = p.communicate(input=b'%s\n' % inp.encode('utf-8')) 
    return p.returncode

correct_input = ""
return_code = 1
while return_code != 0:
    for c in string.printable:
        return_code = try_input(correct_input + c)
        if return_code != len(correct_input) + 1:
            correct_input += c
            print(correct_input)
            break

print("this is you valid input: '{0}'".format(correct_input))

Debugger

Um zu zeigen, wie man die Ausführung einer Applikation mit einem Debugger manipuliert werden kann, wird das Beispielprogramm in GDB geladen. GDB ist ein Debugger für Linux-Programme.

Um beim Starten automatisch einen Haltepunkt bei der der „Main” Funktion hinzuzufügen, wird ein GDB Plugin namens „gef” installiert und verwendet. Nachdem GDB gestartet wurde, wird damit automatisch ein Haltepunkt bei der „Main” Funktion (Adresse 0x770) gesetzt und das Beispielprogramm bis zu dieser Funktion ausgeführt.

Im ersten Block des GDB Output sind die CPU Register und deren Inhalt sichtbar. Darunter sieht man den Stack und darunter ist das „Disassembly” der darauffolgenden Instruktionen.

Am folgenden Bild sieht man den Status vor dem ersten Vergleich – Vergleich mit „y”. An dieser Stelle könnte man einfach den Inhalt des Registers „RDI” so modifizieren, dass dieser Vergleich bestanden wird. Das Register kann man mit dem GDB-Kommando „set $rdi = 0x79” ändern. (Der Wert „0x79” entspricht einem „y” in der ASCII Tabelle.)
Mit „p $rdi” kann man verifizieren, dass RDI tatsächlich geändert wurde.

Mit dem GDB-Kommando „next” kann man die Ausführung schrittweise fortsetzen. Im Register "Flags" ist zu beobachten, dass das „ZERO-Flag” gesetzt wird, wenn der Vergleich bestanden wurde. Wenn man die weiteren Vergleichsmethoden genauso anschauen würde, könnte man so den richtigen Input erraten.

Folgt man den beiden Methoden kann man als Passwort der Binärdatei „yes and his hands shook with ex” ausmachen, ohne Zugriff auf den ursprünglichen Quellcode zu bekommen. Auf diese Weise ist es Crackern möglich bei einem kompilierten Programmen die Lizenzierung zu umgehen und damit ein Programm ohne gültige zu Lizenz benutzen.