← Back to Writeups
CUSTOMEasyReversing

Angry Opaq

XESXOR9/10/20264 min read
#reversing#custom#easy#opaque_predicate_resolution#dead_code_elimination#xor_decryption#rotate_left_keystream#gate_value_identification

Angry Opaq

Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: opaque_predicate_resolution, dead_code_elimination, xor_decryption, rotate_left_keystream, gate_value_identification

Summary

Task: x86-64 ELF crackme whose main is bloated with opaque predicates that conditionally XOR single bits into a scanf'd integer before a final compare. Solution: recognize the maze is dead code, read the gate value 0x7f89409, and seed it into the rol32/XOR keystream decrypt loop to recover the flag directly.

Angry Opaq — spbctf

Description

Make Angry Opaq less angry and more transparent. He'll give you the flag.

Provided file: angryopaq.elf (from https://rev-kids20.forkbomb.ru/files/rev/new8/angryopaq.elf).

English summary: a single x86-64 ELF crackme reads an integer, runs it through a long chain of confusing branches, and — if a hidden condition is met — prints the flag. The goal is to recover that flag.

Semantic Clue

The title and description are the whole hint:

  • "Opaq" → opaque predicates, a classic code-obfuscation technique where a branch condition always evaluates to a constant (always-true or always-false) but is written so that a static analyzer cannot easily see it.
  • "make transparent" → resolve the opaque predicates: once you evaluate them, the control flow collapses and the real logic becomes visible. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

So before touching a disassembler seriously, the plan is: "find the branches that only look data-dependent, prove they are constant, and delete them from the picture."

Recon

$ file angryopaq.elf
ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped,
BuildID[sha1]=dbf36c23c5a1926813acd0d54be611b08e6b3290

$ nm angryopaq.elf | grep main     # main @ 0x401150
$ strings angryopaq.elf
...
Your flag is: spbctf{%s}
exit 1
exit 0
YES
no

Not stripped, main is at 0x401150, and the format string Your flag is: spbctf{%s} tells us the flag body is computed at runtime and printed between literal spbctf{ }. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis — the opaque-predicate maze

main does:

  1. scanf("%d", &n) — reads a signed int into [rbp-0x24].
  2. A long chain of opaque predicates, each of which conditionally XORs one distinct bit into n (0x1, 0x2, 0x4, 0x8, 0x10, ... up to 0x40000000). The branch conditions are deliberately obfuscated, e.g.:
    • ((n + n) & 1) == 0 — 2n is always even, so this predicate is constant-true.
    • (~n) == n — impossible for any integer, so this predicate is constant-false.
    • Several conditions gate on the return value of system("exit 1") (child exits 1 → system returns 256, so eax != 0) and system("exit 0") (returns 0, so eax == 0). The "exit 1" / "exit 0" strings live at 0x402036 / 0x40203d. These real runtime side-effects are woven in purely to make static reasoning look scary.
  3. The real gate at 0x401543:
    asm
    cmp dword ptr [rbp-0x24], 0x7f89409
    jne <print "no">
    i.e. the final, post-XOR value of n must equal 0x7f89409; otherwise the program prints no and exits.
  4. On success it prints YES, runs a decryption loop over 16 encrypted bytes at 0x402010, and prints Your flag is: spbctf{%s}. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Encrypted data (16 bytes @ 0x402010)

cd 4b e2 b0 3c 23 57 30 04 99 4c 8a 5f df dc c8

Decryption loop (@ 0x40156b..0x4015b4)

c
for (int i = 0; i < 16; i++) {
    n = (n * 0x876d3acd) & 0xffffffff;           // imul
    n = ((n << 3) | (n >> 29)) & 0xffffffff;     // rol32(n, 3): shl 3 | shr 29
    data[i] ^= n & 0xff;                         // low byte is the keystream byte
}

Key insight — the maze is irrelevant

The decrypt loop consumes n as it is at the gate. The gate forces n == 0x7f89409. Therefore the flag output depends only on that single seed value — not on the input you type, not on which opaque predicates fired, not on the system() side effects. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

There is no need to:

  • emulate the predicate chain,
  • recover the correct scanf input that XORs its way to 0x7f89409, or
  • run the binary at all.

You just seed the keystream generator with 0x7f89409 and decrypt.

This is the whole point of "make Opaq transparent": once the opaque predicates are recognized as dead/constant control flow, the reachable-state at the gate is a single known constant, and everything before it collapses to nothing.

Solution

python
#!/usr/bin/env python3
# angryopaq.elf solver — no emulation needed
data = bytes.fromhex('cd4be2b03c2357300 4994c8a5fdfdcc8'.replace(' ', ''))
n = 0x7f89409  # value forced by the gate at 0x401543
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

out = bytearray(data)
for i in range(16):
    n = (n * 0x876d3acd) & 0xffffffff        # imul
    n = ((n << 3) | (n >> 29)) & 0xffffffff  # rol32(n, 3)
    out[i] ^= n & 0xff

print('spbctf{' + out.decode('latin1') + '}')
# -> spbctf{REDACTED}

The decrypted inner string is REDACTED — leetspeak for "first time angry" — confirming the routine and the seed are correct. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍