Exploit for the stock.saarland secret challenge
Find a file
2023-12-07 10:53:23 +01:00
.devcontainer Add devcontainer config 2023-12-06 23:02:08 +00:00
.gitignore Add .gitignore 2023-12-06 23:02:14 +00:00
get_server_file.py Push helper functions 2023-12-06 23:01:32 +00:00
LICENSE Initial commit 2023-12-06 17:08:33 +01:00
main.py Push helper functions 2023-12-06 23:01:32 +00:00
README.md Correct Readme 2023-12-07 10:53:23 +01:00
requirements.txt Push helper functions 2023-12-06 23:01:32 +00:00

Stock-Vault-Exploit

nc stock.saarland 31337

To access your secret vault, please enter the passkey! Of course, using commas.

WTF?

Enumeration

Zuerstmal

a,b,d

You are trying to cheat on us? We need a list of length 0x4a. Poor hacker...
You have 9 tries left.

Also:

  1. Die Anwendung erwartet 74 (0x4a) Stellen in einer Liste
  2. Wir haben insgesamt 10 Versuche pro Verbindung
import random
",".join([chr(random.randint(50,100)) for _ in range(74)])

Z,<,a,:,c,I,F,9,Y,9,^,:,4,8,H,L,X,d,d,:,2,c,a,9,W,G,M,[,B,T,M,6,d,=,T,X,U,[,V,Y,6,2,a,,C,4,R,;,U,],V,[,G,B,,`,],<,X,^,6,X,U,=,K,3,6,R,W,4,],M,;,Y

You are trying to cheat on us? We need a list of integers. Poor hacker...You don't know how integers look like? This is an example: 26
Oh, and here is a random value from YOUR list... "9" Maybe this is not an integer?
You have 8 tries left.

Nicht sehr freundlich, aber:

  1. Die Anwendung will einen Secret-Key bestehend aus integers
  2. Aus irgendeinem seltsamen Grund gibt sie uns Beispiele!?
import random
",".join([str(random.randint(50,100)) for _ in range(74)])

72,90,84,80,95,83,70,93,90,62,86,54,66,63,58,71,67,52,99,59,91,60,82,83,82,82,55,72,60,54,75,89,67,97,86,62,60,66,93,88,60,76,76,72,100,79,94,61,83,84,57,69,83,81,65,50,90,86,70,95,65,71,53,58,69,79,69,93,75,91,62,86,62,97

We don't take your stupid hash as an input...
i'r
tc o'mtrp
abopsn'deceetidrs ortmdpo=dccie
uocrrtcsmi't tmboat
tos'mtmr
You have 7 tries left.

Ausgabe sieht fast aus wie durcheinander gewürfelter Text, also werfen wir eine Number sequence drauf:

import random
",".join([str(x) for x in range(74)])

0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73

We don't take your stupid hash as an input...
#!/usr/bin/python3
from Crypto.Hash import SHA256
import random
import bin
You have 6 tries left.

Ahja, also eine Python Datei. Man könnte diese manuell auslesen und manuell Listen erstellen und hinsenden, oder...

import socket


def load_file_from_server():
    pos = 0
    read_all = False
    lines = []

    with open("server.py", "w") as f:
        while not read_all:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.connect(("stock.saarland", 31337))
                # Initial welcome message
                print(s.recv(1024).decode())

                # 10 tries in every connection
                for i in range(10):
                    # 74 elements in every key
                    s.sendall((str([x for x in range(pos, pos+74)]
                                   ).replace("[", "").replace("]", "").replace(" ", "") + "\n").encode())

                    # Our own input
                    print(s.recv(1024).decode())

                    data = s.recv(1024).decode()
                    # I could have checked this better but eh \_()_/
                    if len(data.splitlines()[1:-1]) == 0:
                        read_all = True
                        break
                    f.write("\n".join(data.split("\r\n")[1:-2]))

                    pos += 74


if __name__ == "__main__":
    load_file_from_server()

Uuund wir haben die source file. Starten wir mit der Einstiegsfunktion:

if __name__ == '__main__':
    with open('params.txt', 'rt') as p:
        secret, outer_secret = p.read().strip().split(',')

    initify = 1337 * int(binascii.hexlify(secret.encode()), 8*2)
    random.seed(initify)
    print('To access your secret vault, please enter the passkey! Of course, using commas.')

    for t in range(10):
        flag = process(input().strip())
        if flag is None:
            print('You have {} tries left.'.format(9-t))
        else:
            print('Here is the content: {}'.format(flag))

Zu sehen sind hier das Auslesen von zwei secrets (secret,outer_secret) aus einer Datei. Das secret wird in hexadezimal dargestellt und darauf basierend zur Zahl umgewandelt, die resultierende initify-Variable ist dadurch ziemlich lang und wird zum seeden von random verwendet.

Das Seeden von Zufallsfunktionen mit statischen Werten ist problematisch, weil ab dem Seeden vorhersagbare Werte generiert werden, die nur Zufällig aussehen:

 python
Python 3.12.0 (main, Oct  2 2023, 00:00:00) [GCC 13.2.1 20230918 (Red Hat 13.2.1-3)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import random
>>> random.seed(1)
>>> random.randint(0,10)
2
>>> random.randint(0,10)
9
>>> random.randint(0,10)
1
>>> 
 python
Python 3.12.0 (main, Oct  2 2023, 00:00:00) [GCC 13.2.1 20230918 (Red Hat 13.2.1-3)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import random
>>> random.seed(1)
>>> random.randint(0,10)
2
>>> random.randint(0,10)
9
>>> random.randint(0,10)
1
>>> 

Behalten wir das im Hinterkopf.

Als nächstes folgt im Quellcode unsere 10 Versuche zusammen mit dem Aufruf der eigentlichen Funktion.

arr = user_input.split(',')
# since we are secure, we only allow a length of 0x4a
if len(arr) != 0x4a:
    print('You are trying to cheat on us? We need a list of length 0x4a. Poor hacker...')
    return None

Stellt sicher, dass der Key lang genug und nicht länger ist. Macht soweit Sinn.

with open('main.py', 'rt') as source:
    data = source.read()

try:
    arr = [int(i) for i in arr if 0 <= int(i) < len(data)]
except ValueError:
    # we are just printing real random stuff to confuse those script kiddies, right?
    print('You are trying to cheat on us? We need a list of integers. Poor hacker...' +
          'You don\'t know how integers look like? This is an example: {}'
          .format(random.SystemRandom().randint(0, 0x4a)))
    print('Oh, and here is a random value from YOUR list... "{}" Maybe this is not an integer?'
          .format(arr[random.randint(0, 0x4a)]))
    return None

Mit dem Verwirren von "script kiddies" ging das Skript dann etwas zu weit. Beim Beispiel, was eine Zahl ist, wird random.SystemRandom() zum generieren einer zufälligen int genutzt und der Seed von vorhin verworfen.

Der anschließende Text Oh, and here is a random value from YOUR list... "{}" Maybe this is not an integer?' nutzt allerdings nur random.randint(), behält dadurch den Seed bei und verrät uns damit die nächste int, die generiert wurde.

Leaking "random" values

Natürlich xkcd

Da die random-Funktion nur bei der initialen Verbindung geseeded wird, können wir durch das Nutzen mehrere Versuche die ersten 10 Werte, die jedesmal "zufällig" generiert werden, leaken. Da die Nachricht uns ein zufälliges Element aus unserer Liste gibt, fertigen wir die Liste so an, dass sie ihre Indezes als Werte enthält. Damit ein ValueError geworfen wird, muss diese Liste zumindest an einer Stelle einen ungültigen Wert haben:

0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,

(Man beachte die letzte ',', die von keinem Wert gefolgt wird)

You are trying to cheat on us? We need a list of integers. Poor hacker...You don't know how integers look like? This is an example: 61
Oh, and here is a random value from YOUR list... "9" Maybe this is not an integer?
You have 9 tries left.

Eine 9.

You are trying to cheat on us? We need a list of integers. Poor hacker...You don't know how integers look like? This is an example: 48
Oh, and here is a random value from YOUR list... "18" Maybe this is not an integer?
You have 8 tries left.

Eine 18.

... Fügt man also 10x diesen Payload ein, sind die zurückgegebenen Nummern 9,18,19,58,62,16,47,22,64,22. Und das eben bei jeder neuen Verbindung.

Also weiter im Code.

Reverse Engineering

input_hash = ''.join([data[i] for i in arr])

# generate some random string of random length
target_len = random.randint(0, 0x4a)
random_numbers = [random.randint(0, 0x4a) for _ in range(target_len)]
m = ''.join([chr(rnd) for rnd in random_numbers])

h = SHA256.new(m.encode()).hexdigest()

input_hash ist ein seltsamer Variablenname, dafür dass es eigentlich die Daten aus dem Quellcode enthält, die an den Positionen standen, die im Key angegeben wurden.

Umso mehr können wir mit den nächsten Variablen anfangen. Da wir bei einer korrekten Ausführung (mit ausschließlich ints, statt einem defekten Payload) hier das erste mal randint() aufrufen, wissen wir, dass das Ergebnis der Funktion und damit der Wert der Variable 9 sein muss.

Dieser Wert wird direkt danach verwendet, um eine Liste anzufertigen, die entsprechend viele weitere Zufallszahlen entählt. Es werden also 9 weitere Zufallswerte erstellt, die wie folgt lauten: 18,19,58,62,16,47,22,64,22.

Besagte Zufallszahlen werden anschließend in einem One-Liner jeweils zu einer Zeichen-Repräsentation umgewandelt und als string konkateniert. Vom Resultat wird dann ein sha256-Hash angerfertigt: de406e4275ae26e75a14741ca029dfc674537e6a0226300b7bc60469a641514a

try:
    special_char = input_hash[0x3f]
    char_comp = [special_char == h[0x3f], h == input_hash[:0x40]] + \
                [x == y for (x, y) in zip(outer_secret, input_hash[0x40:])]
except IndexError:
    print('Something terrible happened. I know that you are responsible for this! Stop it!' +
          'But at least, I can tell you that some of your integers are too large...')
    return None

if not all(char_comp):
    print('We don\'t take your stupid hash as an input...')
    print(input_hash)
    return None

Hier wird ein special_char anhand des Inhalts des Quellcodes an Zeichenposition 0x3f (63) angelegt. Anschließend erfolgt die eigentliche Sicherheitsprüfung.

Unter char_comp wird eine Liste an Booleans angelegt, die aus mehreren Überprüfungen hervorgeht. Zuerst wird geprüft, ob der special_char mit dem Zeichen des Hashes an derselben Stelle übereinstimmt. Da wir den statischen Hash kennen, wissen wir also auch, welches Zeichen später in input_hash stehen muss. An Position 63 des Hashes ist ein a. Danach wird überprüft, ob der Hash mit dem Inhalt von input_hash bis zu Position 64 übereinstimmt. Dies erübrigt die Überprüfung des Special chars, da der Hash 64 Stellen lang ist und somit exakt mit input_hash übereinstimmen muss.

Erreichen können wir das, in dem wir den Prozess, Daten aus dem Quellcode an den Positionen, die im Schlüssel zu finden sind, zu lesen, einmal umdrehen. Wir wissen also, dass die Ersten 64 Stellen des Keys ints enthalten müssen, die, wenn die Daten von dem Quellcode eingelesen werden, den Zeichen des hashes entsprechen:

def find_char_in_file(char: chr) -> int:
    with open("server.py", "r") as f:
        data = f.read()
        for index, f_char in enumerate(data):
            if f_char == char:
                return index
        raise ValueError(f"Char {char} not found in file!")

[find_char_in_file(char) for char in "de406e4275ae26e75a14741ca029dfc674537e6a0226300b7bc60469a641514a"]

> 60,82,225,223,48,82,225,46,2161,47,32,82,46,48,82,2161,47,32,2158,225,2161,225,2158,76,32,223,46,2468,60,19,76,48,2161,225,47,17,2161,82,48,32,223,46,46,48,17,223,223,7,2161,7,76,48,223,225,48,2468,32,48,225,2158,47,2158,225,32

Zusätzlich zur Prüfung des Hashes wird an die char_comp-Variable noch ein Array von Booleans angehängt. Unpraktischer Weise werden hier die Zeichen aus dem outer_secret (uns unbekannt) mit den letzten 10 gelesenen Zeichen der Quelldatei verglichen. Erst wenn alle Werte, die sich in char_comp befinden, True sind, gibt die funktion all() ebenfalls True zurück und die Anmeldung gelingt. Bruteforce können wir hier vergessen und wir müssen noch 10 Zeichen auftreiben.

Oder nicht?

Underflow.zip

Mit der Verwendung von zip() können Nebeneffekte auftreten. Denn die Funktion verbindet zwar Iterables miteinander,...

>>> list(zip("Hello","world"))
[('H', 'w'), ('e', 'o'), ('l', 'r'), ('l', 'l'), ('o', 'd')]

...allerdings nur so oft, wie das kleinste Objekt an Elementen besitzt:

>>> list(zip("H","world"))
[('H', 'w')]

outer_secret können wir nicht beeinflussen, input_hash allerdings schon. Obwohl am Start der Funktion die Prüfung nach Länge der Eingabe durch den Nutzer erfolgt, werden die Daten erst danach auf ihre Integrität überprüft. Wir erinnern uns:

try:
    arr = [int(i) for i in arr if 0 <= int(i) < len(data)]
except ValueError:
    # we are just printing real random stuff to confuse those script kiddies, right?
    print('You are trying to cheat on us? We need a list of integers. Poor hacker...' +
          'You don\'t know how integers look like? This is an example: {}'
          .format(random.SystemRandom().randint(0, 0x4a)))
    print('Oh, and here is a random value from YOUR list... "{}" Maybe this is not an integer?'
          .format(arr[random.randint(0, 0x4a)]))
    return None

Wichtig ist hier die condition der comprehension: sie überprüft, ob die vom Nutzer gegebene Zahl größer oder gleich 0 ist, parsed die Eingabe zu einer int und fängt eine nicht gelungene Umwandlung mit dem ValueError auf. Sie überprüft außerdem, ob die angegebene Zahl kleiner ist, als die Länge der Quellcode-Datei (ansonsten könnten zu index i keine Daten mehr aus der Datei gelesen werden). Aber: was passiert, wenn die Zahl größer ist, als die Länge der Daten? Die condition ergibt False und as Element fällt still aus der Liste heraus. Wenn die Eingaben des Nutzers gerade noch 74 ints lang waren, sind sie es jetzt bei sehr hohen Zahlen nicht mehr.

input_hash = ''.join([data[i] for i in arr])

Wenn die Liste plötzlich nicht mehr 74 ints lang ist, dann ist es input_hash auch nicht mehr. Aus der Datei werden keine 74 Zeichen mehr gelesen, weshalb

zip(outer_secret, input_hash[0x40:])

jetzt weniger Werte zurückgibt.

Auf diesem Wege können wir outer_secret vollständig obsolet machen. Mit den ersten 64 Zahlen des Schlüssels 60,82,225,223,48,82,225,46,2161,47,32,82,46,48,82,2161,47,32,2158,225,2161,225,2158,76,32,223,46,2468,60,19,76,48,2161,225,47,17,2161,82,48,32,223,46,46,48,17,223,223,7,2161,7,76,48,223,225,48,2468,32,48,225,2158,47,2158,225,32 können wir nun die letzten 10 Anhängen, in dem wir einfach sehr hohe Zahlen angeben, die über die Zeichenlänge der Quelldatei hinausgehen. Dadurch wird input_hash nur 0x3f stellen lang:

 nc stock.saarland 31337
To access your secret vault, please enter the passkey! Of course, using commas.
60,82,225,223,48,82,225,46,2161,47,32,82,46,48,82,2161,47,32,2158,225,2161,225,2158,76,32,223,46,2468,60,19,76,48,2161,225,47,17,2161,82,48,32,223,46,46,48,17,223,223,7,2161,7,76,48,223,225,48,2468,32,48,225,2158,47,2158,225,32,5000,5000,5000,5000,5000,5000,5000,5000,5000,5000

Here is the content: SWAG{******}