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

7.1 KiB

CryptOracle v3

CryptOracle v3 is the "Gold Master" release of the HSM simulator. The vulnerabilities from v1 (direct read) and v2 (confused deputy) have been patched. The challenge is to find the new vulnerability in the cryptographic functions to dump the secret flag.

Information Gathering

After connecting to the server, we see a familiar interface:

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

The binary is not stripped, which helps our analysis in Ghidra.

Reverse Engineering

1. The Patches

First, we verify the fixes.

  • do_read: Still has the check if (offset < 0x20000), blocking direct reads of secure memory.
  • get_ptr: Now has a fourth is_privileged argument. We find that do_cipher (for enc and dec) calls get_ptr with is_privileged=0, meaning it can no longer access secure memory at all. The confused deputy attack is fixed.

2. The New Attack Surface (do_sign)

The challenge description hints that the sig command is our new focus. The do_sign function is called, and unlike do_cipher, it is privileged.

/* Decompiled do_sign function */
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]; // A 16-byte buffer on the stack
  
  // All pointers are requested with privileged access (last argument is 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 (/* pointers are valid */) {
    AES_set_encrypt_key(key_ptr, 0x80, &k);
    
    // The stack buffer is zeroed out
    memset(block, 0, 0x10);
    
    // The input length is capped at 16 bytes
    if (0x10 < len) {
      len = 0x10;
    }
    
    // The (up to) 16 bytes from the source are copied into the block
    memcpy(block, src_ptr, (ulong)len);
    
    // The block is encrypted and written to the destination
    AES_encrypt(block, dst_ptr, &k);
    puts("OK");
  }
}

Key findings from do_sign:

  1. Privileged: It can read from anywhere, including the secret flag at 0x10000.
  2. Deterministic: The "signing" is just AES ECB encryption. For a given key and a given input block, the output is always the same.
  3. Padding: The function takes up to 16 bytes of input, copies it into a zero-padded 16-byte block, and then encrypts the block. This is crucial: sig on a single byte X is effectively Enc(Key, [X, 0, 0, ...]).

3. The Vulnerability: Deterministic Oracle

Because the signing is deterministic, we can use it as an encryption oracle. We can ask it to "sign" (encrypt) any data we want using one of the master keys (e.g., slot 0). If we encrypt every possible byte from 0-255, we can build a lookup table mapping the original byte to its signature.

This allows for a classic Rainbow Table attack.

Solution

The attack has two phases:

  1. Build a Rainbow Table:

    • Iterate through all 256 possible byte values.
    • For each byte, write it to user memory (e.g., 0x20000).
    • Use the sig command with a master key (slot 0) to encrypt that single byte.
    • Read the 16-byte signature from the output buffer.
    • Store the signature -> byte mapping.
  2. Dump Secure Memory:

    • Iterate through the addresses of the secret flag (0x10000, 0x10001, etc.).
    • For each address, use the sig command to get the signature of the single secret byte at that address.
    • Look up the resulting signature in our rainbow table to find the original secret byte.

Step-by-Step Execution

Here's how we would manually perform the lookup for the first byte of the flag.

1. Build table for a known byte, e.g., 'A' (0x41)

# Write 'A' to user memory
> wm 0x20000 1 41
OK

# Sign that byte with the master key in slot 0
> sig 0x20000 1 0 0x20100
OK

# Read the signature
> rm 0x20100 16
00020100: d85de6195410...

Now we know that the signature d85de6... corresponds to the plaintext byte A. We repeat this for all 256 bytes.

2. Get signature of the first secret byte

# Sign the byte at the start of the secure region
> sig 0x10000 1 0 0x20100
OK

# Read its signature
> rm 0x20100 16
00020100: 555c441c2... 

3. Lookup and repeat We find which plaintext byte corresponds to the signature 555c44... in our pre-built table. This reveals the first byte of the flag. We repeat this for all 32 bytes to dump the entire flag.

Final Solver Script

The solve.py script automates this entire process.

from pwn import *
import sys

# --- Configuration ---
HOST = '192.168.178.46'
PORT = 1339

# Memory Map (Matches v6.0)
SECRET_BASE = 0x10000
USER_BASE   = 0x20000
USER_SCRATCH = USER_BASE + 0x200 

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

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

    for b in range(256):
        byte_hex = f"{b:02x}"
        
        # 1. Write byte to User Memory
        io.sendline(f"wm 0x{USER_BASE:x} 1 {byte_hex}".encode())
        io.recvuntil(b"OK\n") 

        # 2. Sign it using Slot 0 (Output to scratch)
        io.sendline(f"sig 0x{USER_BASE:x} 1 0 0x{USER_SCRATCH:x}".encode())
        io.recvuntil(b"OK\n") 

        # 3. Read the signature
        io.sendline(f"rm 0x{USER_SCRATCH:x} 16".encode())
        signature = io.recvline().strip().decode()
        
        rainbow_table[signature] = b  # Store integer value
        
        if b % 32 == 0: prog.status(f"{b}/255")

    prog.success(f"Done. ({len(rainbow_table)} entries)")

    # --- Phase 2: Dump Secure Memory ---
    log.info("Phase 2: Dumping first 64 bytes of Secure Memory...")
    
    dumped_bytes = []
    
    for i in range(64):
        target_addr = SECRET_BASE + i
        
        # 1. Exploit: Sign byte from Secret Memory -> User Memory
        io.sendline(f"sig 0x{target_addr:x} 1 0 0x{USER_SCRATCH:x}".encode())
        
        # Check if the server is happy
        resp = io.recvline().strip().decode()
        if resp != "OK":
            log.error(f"Server Error at offset {i}: {resp}")
            break

        # 2. Read the signature
        io.sendline(f"rm 0x{USER_SCRATCH:x} 16".encode())
        secret_sig = io.recvline().strip().decode()

        # 3. Lookup
        if secret_sig in rainbow_table:
            val = rainbow_table[secret_sig]
            dumped_bytes.append(val)
            # Live print as hex
            print(f"{val:02x} ", end='', flush=True)
        else:
            print("?? ", end='', flush=True)
            dumped_bytes.append(0) # Placeholder

    print("\n")
    
    # Print ASCII representation
    ascii_repr = "".join([chr(b) if 32 <= b <= 126 else '.' for b in dumped_bytes])
    log.success(f"Dump (ASCII): {ascii_repr}")

    io.close()

if __name__ == "__main__":
    solve()

Running the script gives the flag: {flag: mapped_memory_is_unsafe!}