Files
HIP7CTF_Writeups/de/cryptoracle_v3.md
m0rph3us1987 a79656b647 Added writeups
2026-03-08 12:22:39 +01:00

7.0 KiB

CryptOracle v3

CryptOracle v3 ist das "Gold Master" Release des HSM-Simulators. Die Schwachstellen aus v1 (direktes Lesen) und v2 (Confused Deputy) wurden gepatcht. Die Herausforderung besteht darin, die neue Schwachstelle in den kryptographischen Funktionen zu finden, um die geheime Flagge zu dumpen.

Informationsbeschaffung

Nachdem wir uns mit dem Server verbunden haben, sehen wir eine vertraute Oberfläche:

CryptOracle v3.0 (Bullet proof!)
...
Type 'help' for commands.

Die Binärdatei ist nicht gestrippt, was unsere Analyse in Ghidra erleichtert.

Reverse Engineering

1. Die Patches

Zuerst überprüfen wir die Fixes.

  • do_read: Hat immer noch die Prüfung if (offset < 0x20000), die direkte Lesevorgänge im sicheren Speicher blockiert.
  • get_ptr: Hat jetzt ein viertes Argument is_privileged. Wir stellen fest, dass do_cipher (für enc und dec) get_ptr mit is_privileged=0 aufruft, was bedeutet, dass es überhaupt nicht mehr auf sicheren Speicher zugreifen kann. Der Confused Deputy Angriff ist behoben.

2. Die neue Angriffsfläche (do_sign)

Die Challenge-Beschreibung deutet an, dass der sig-Befehl unser neuer Fokus ist. Die do_sign-Funktion wird aufgerufen, und im Gegensatz zu do_cipher ist sie privilegiert.

/* Dekompilierte do_sign Funktion */
void do_sign(uint32_t src_off, uint32_t len, int slot, uint32_t dst_off)
{
  long lVar1;
  uint8_t *src_ptr;
  uint8_t *dst_ptr;
  uint8_t *key_ptr;
  AES_KEY k;
  uint8_t block [16]; // Ein 16-Byte-Puffer auf dem Stack
  
  // Alle Zeiger werden mit privilegiertem Zugriff angefordert (letztes Argument ist 1)
  src_ptr = get_ptr(src_off, len, 0, 1);
  dst_ptr = get_ptr(dst_off, 0x10, 1, 1);
  key_ptr = get_ptr((slot + 0x800) * 0x20, 0x20, 0, 1);
  
  if (/* Zeiger sind gültig */) {
    AES_set_encrypt_key(key_ptr, 0x80, &k);
    
    // Der Stack-Puffer wird ausgenullt
    memset(block, 0, 0x10);
    
    // Die Eingabelänge wird auf 16 Byte begrenzt
    if (0x10 < len) {
      len = 0x10;
    }
    
    // Die (bis zu) 16 Bytes von der Quelle werden in den Block kopiert
    memcpy(block, src_ptr, (ulong)len);
    
    // Der Block wird verschlüsselt und an das Ziel geschrieben
    AES_encrypt(block, dst_ptr, &k);
    puts("OK");
  }
}

Wichtige Erkenntnisse aus do_sign:

  1. Privilegiert: Sie kann von überall lesen, einschließlich der geheimen Flagge bei 0x10000.
  2. Deterministisch: Das "Signieren" ist einfach eine AES-ECB-Verschlüsselung. Für einen gegebenen Schlüssel und einen gegebenen Eingabeblock ist die Ausgabe immer gleich.
  3. Padding: Die Funktion nimmt bis zu 16 Bytes Eingabe, kopiert sie in einen mit Nullen aufgefüllten 16-Byte-Block und verschlüsselt dann den Block. Das ist entscheidend: sig auf einem einzelnen Byte X ist effektiv Enc(Key, [X, 0, 0, ...]).

3. Die Schwachstelle: Deterministisches Orakel

Da das Signieren deterministisch ist, können wir es als Verschlüsselungsorakel verwenden. Wir können es bitten, beliebige Daten, die wir wollen, mit einem der Hauptschlüssel (z.B. Slot 0) zu "signieren" (verschlüsseln). Wenn wir jedes mögliche Byte von 0-255 verschlüsseln, können wir eine Lookup-Tabelle erstellen, die das ursprüngliche Byte auf seine Signatur abbildet.

Dies ermöglicht einen klassischen Rainbow Table Angriff.

Lösung

Der Angriff besteht aus zwei Phasen:

  1. Erstellen einer Rainbow Table:

    • Iteriere durch alle 256 möglichen Bytewerte.
    • Schreibe jedes Byte in den Benutzerspeicher (z.B. 0x20000).
    • Verwende den sig-Befehl mit einem Hauptschlüssel (Slot 0), um dieses einzelne Byte zu verschlüsseln.
    • Lies die 16-Byte-Signatur aus dem Ausgabepuffer.
    • Speichere die Zuordnung Signatur -> Byte.
  2. Dumpen des sicheren Speichers:

    • Iteriere durch die Adressen der geheimen Flagge (0x10000, 0x10001, usw.).
    • Verwende für jede Adresse den sig-Befehl, um die Signatur des einzelnen geheimen Bytes an dieser Adresse zu erhalten.
    • Suche die resultierende Signatur in unserer Rainbow Table, um das ursprüngliche geheime Byte zu finden.

Schritt-für-Schritt-Ausführung

Hier ist, wie wir das Lookup für das erste Byte der Flagge manuell durchführen würden.

1. Tabelle für ein bekanntes Byte erstellen, z.B. 'A' (0x41)

# Schreibe 'A' in den Benutzerspeicher
> wm 0x20000 1 41
OK

# Signiere dieses Byte mit dem Hauptschlüssel in Slot 0
> sig 0x20000 1 0 0x20100
OK

# Lies die Signatur
> rm 0x20100 16
00020100: d85de6195410...

Jetzt wissen wir, dass die Signatur d85de6... dem Klartext-Byte A entspricht. Wir wiederholen dies für alle 256 Bytes.

2. Signatur des ersten geheimen Bytes abrufen

# Signiere das Byte am Anfang des sicheren Bereichs
> sig 0x10000 1 0 0x20100
OK

# Lies seine Signatur
> rm 0x20100 16
00020100: 555c441c2... 

3. Nachschlagen und wiederholen Wir finden heraus, welches Klartext-Byte der Signatur 555c44... in unserer vorgefertigten Tabelle entspricht. Dies enthüllt das erste Byte der Flagge. Wir wiederholen dies für alle 32 Bytes, um die gesamte Flagge zu dumpen.

Finales Solver-Skript

Das solve.py Skript automatisiert diesen gesamten Prozess.

from pwn import *
import sys

# --- Konfiguration ---
HOST = '127.0.0.1'
PORT = 1339

# Speicherkarte
SECRET_BASE = 0x10000
USER_BASE   = 0x20000
USER_SCRATCH = USER_BASE + 0x200 

def solve():
    io = remote(HOST, PORT)
    io.recvuntil(b"Type 'help' for commands.
")
    log.info(f"Connected. dumping memory from 0x{SECRET_BASE:x}...")

    # --- Phase 1: Rainbow Table erstellen (0x00 - 0xFF) ---
    log.info("Phase 1: Building Rainbow Table...")
    rainbow_table = {}
    prog = log.progress("Mapping")

    for b in range(256):
        byte_hex = f"{b:02x}"
        io.sendline(f"wm 0x{USER_BASE:x} 1 {byte_hex}".encode())
        io.recvuntil(b"OK\n") 
        io.sendline(f"sig 0x{USER_BASE:x} 1 0 0x{USER_SCRATCH:x}".encode())
        io.recvuntil(b"OK\n") 
        io.sendline(f"rm 0x{USER_SCRATCH:x} 16".encode())
        signature = io.recvline().strip().decode()
        rainbow_table[signature] = b
        if b % 16 == 0: prog.status(f"{b}/255")
    prog.success(f"Done. ({len(rainbow_table)} entries)")

    # --- Phase 2: Sicheren Speicher dumpen ---
    log.info("Phase 2: Dumping first 32 bytes of Secure Memory...")
    dumped_bytes = []
    for i in range(32):
        target_addr = SECRET_BASE + i
        io.sendline(f"sig 0x{target_addr:x} 1 0 0x{USER_SCRATCH:x}".encode())
        io.recvuntil(b"OK\n")
        io.sendline(f"rm 0x{USER_SCRATCH:x} 16".encode())
        secret_sig = io.recvline().strip().decode()

        if secret_sig in rainbow_table:
            val = rainbow_table[secret_sig]
            dumped_bytes.append(val)
        else:
            dumped_bytes.append(0)

    # ASCII-Darstellung drucken
    ascii_repr = "".join([chr(b) if 32 <= b <= 126 else '.' for b in dumped_bytes])
    log.success(f"Flag: {ascii_repr}")

    io.close()

if __name__ == "__main__":
    solve()

Das Ausführen des Skripts liefert die Flagge: {flag: self_encryption_is_nice!}