Ouija (rev) – CTF Write-up

Challenge Overview

We’re given a 64-bit Linux executable named ouija. The program decrypts an embedded flag one character at a time, but deliberately places long delays between every operation.

  • An unstripped x86-64 ELF containing the encrypted flag
  • A Caesar-style substitution cipher with a key of 18
  • More than ten minutes of artificial delays before only two characters are printed
  • Static analysis that makes running the complete program unnecessary

Initial Reconnaissance

Running file identifies the target as a dynamically linked 64-bit PIE executable:

$ file ouija
ouija: ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped

Opening the binary in Ghidra reveals the encrypted flag directly inside main. The compiler stored it across several little-endian integer constants:

local_78 = 0x6877644b7b544c5a;
local_70 = 0x665f6b615f796661;
local_68 = 0x6b6d7874675f6c67;
local_60 = 0x616c7375;
local_5c = 0x6667;
local_5a = 0x7d;

Repacking those values in little-endian order reconstructs the complete ciphertext:

from pwn import p8, p16, p32, p64

encrypted = (
    p64(0x6877644B7B544C5A)
    + p64(0x665F6B615F796661)
    + p64(0x6B6D7874675F6C67)
    + p32(0x616C7375)
    + p16(0x6667)
    + p8(0x7D)
)

print(encrypted)
b'ZLT{Kdwhafy_ak_fgl_gtxmkuslagf}'

Dynamic Analysis

Running the binary confirms that it is slowly decrypting and printing the flag:

$ ./ouija
Retrieving key.
     ..... done!
Hmm, I don't like that one. Let's pick a new one.
     ..... done!
Yes, 18 will do nicely.
     ..... done!
Let's get ready to start. This might take a while!
     ..... done!
This one's an uppercase letter!
     ..... done!
Okay, let's write down this letter!
     ..... done!
H

The useful work is surrounded by repeated sleep(10) and one-second progress loops. Letting the program finish would recover the flag eventually, but the delays are not part of the cipher and can be ignored.

Static Analysis

The key is copied from a global variable and increased by five. The program then announces the final value:

key_copy = key;
key_copy += 5;
puts("Yes, 18 will do nicely.");

For each alphabetic character, the binary subtracts 18 and wraps around the alphabet by adding 26 when necessary. Non-alphabetic characters are left unchanged:

if ('A' <= c && c <= 'Z') {
    if (c - key_copy < 'A')
        c += 26;
    c -= key_copy;
} else if ('a' <= c && c <= 'z') {
    if (c - key_copy < 'a')
        c += 26;
    c -= key_copy;
}

This is a Caesar cipher with a shift of 18. The repeated status messages and delays only obscure an otherwise simple transformation.

Recovering the Flag

We can reproduce the relevant logic without any of the delays:

encrypted = "ZLT{Kdwhafy_ak_fgl_gtxmkuslagf}"
key = 18
flag = []

for char in encrypted:
    if "A" <= char <= "Z":
        flag.append(chr((ord(char) - ord("A") - key) % 26 + ord("A")))
    elif "a" <= char <= "z":
        flag.append(chr((ord(char) - ord("a") - key) % 26 + ord("a")))
    else:
        flag.append(char)

print("".join(flag))

Result

$ python3 solve.py
HTB{Sleping_is_not_obfuscation}

Key Takeaways

  • Artificial delays do not make an algorithm harder to reverse when the binary contains every required operation and constant.
  • Reconstruct multi-byte constants using the target’s endianness before analyzing the encoded data.
  • Reduce a large decompiled function to the instructions that modify state relevant to the flag.
  • Once the key and alphabet wrapping are known, the custom-looking routine is only a Caesar cipher.