$ Rev: El Diablo

BITSCTF 2026 By TaklaMan
Flag: BITSCTF{l4y3r_by_l4y3r_y0u_unr4v3l_my_53cr375}

TL;DR

  • The binary is UPX-packed and stripped.
  • It validates license format as LICENSE-<hex...>.
  • It has anti-debug checks (ptrace/timing/env checks).
  • After bypassing anti-debug, it runs a custom VM.
  • VM bytecode is embedded and decrypted path is executed via opcodes.
  • True output chars are hidden behind env var PRINT_FLAG_CHAR.
  • Recovered working license payload:
LICENSE-99f5671124d520d5f63c

1. Initial Triage

Binary fingerprint

file challenge
# ELF 64-bit LSB shared object, x86-64, statically linked, no section header

strings shows UPX signatures and challenge hints:

  • LICENSE-
  • invalid license format
  • DEBUGGER DETECTED! LICENSING TERMS VIOLATED! >:(
  • MYVERYREALLDRM
  • GET_LICENSE_BYTE
  • PRINT_FLAG_CHAR

Runtime behavior

./challenge
# Usage: ./challenge <license file path>

printf 'AAAA' > lic.txt
./challenge lic.txt
# [i] loaded license file
# [!] invalid license format

So the first format gate is prefix LICENSE-.


2. Bypassing Anti-Debug

The binary’s anti-debug logic combines multiple checks:

  • ptrace behavior
  • timing-based detection
  • /proc debugger/process checks

Direct debugging/strace in sandbox was blocked by ptrace restrictions anyway. A practical bypass was done using LD_PRELOAD.

Anti-debug bypass preload (hook_bypass_adbg.c)

#define _GNU_SOURCE
#include <time.h>
#include <dlfcn.h>
#include <sys/ptrace.h>
#include <stdarg.h>

static int (*real_clock_gettime)(clockid_t, struct timespec*) = 0;
static long fake_ns = 1000000000L;

long ptrace(enum __ptrace_request request, ...) {
    (void)request;
    return 0;
}

int clock_gettime(clockid_t clk_id, struct timespec *tp){
    if(!real_clock_gettime) real_clock_gettime = dlsym(RTLD_NEXT, "clock_gettime");
    if(!tp) return real_clock_gettime(clk_id,tp);
    if (clk_id == CLOCK_MONOTONIC || clk_id == CLOCK_MONOTONIC_RAW) {
        fake_ns += 1000;
        tp->tv_sec = fake_ns / 1000000000L;
        tp->tv_nsec = fake_ns % 1000000000L;
        return 0;
    }
    return real_clock_gettime(clk_id,tp);
}

Compile and run:

gcc -shared -fPIC -o hook_bypass_adbg.so hook_bypass_adbg.c -ldl
LD_PRELOAD=./hook_bypass_adbg.so ./challenge lic.txt

This moves execution past debugger-detected exit and into VM execution.


3. VM Discovery and Extraction

After bypass:

[i] loaded license file
processing... please wait...
[i] running program...
The flag lies here somewhere...

The real logic is in a VM program. We extracted the VM bytecode from live memory.

Key runtime findings

  • VM object alloc size: 0x18c8
  • VM code length field observed: 0x3a0 (= 928 bytes)
  • Extracted VM program blob: vm_prog.bin (928 bytes)

Program extraction helper (core idea)

  • Hook malloc(0x18c8) to capture VM pointer.
  • On [i] running program..., dump:
    • vm+0xa8 (code pointer)
    • vm+0xb0 (code length)
    • dump code bytes to file.

4. VM Opcode Reconstruction

From live VM dispatch table and handler reversing:

  • 0x30: set register as string
  • 0x31: print string register
  • 0x01: set register as integer (16-bit immediate little endian)
  • 0x82: callback GET_LICENSE_BYTE(dstReg, idxReg)
  • 0x20: XOR dst = reg[a] ^ reg[b]
  • 0x84: callback PRINT_FLAG_CHAR(reg)
  • 0x00: halt

The bytecode repeatedly does:

  1. load constant into reg0
  2. load index into reg3
  3. GET_LICENSE_BYTE(reg1, reg3)
  4. reg0 ^= reg1
  5. PRINT_FLAG_CHAR(reg0)

This is repeated for 46 output chars.


5. Parsing VM Program and Solving License

Parser used

from pathlib import Path

p = Path('vm_prog.bin').read_bytes()
i = 0
ops = []

while i < len(p):
    op = p[i]
    if op == 0x30:
        r = p[i+1]
        ln = p[i+2] | (p[i+3] << 8)
        s = p[i+4:i+4+ln]
        ops.append((i, 'SET_STR', r, ln, s))
        i += 4 + ln
    elif op == 0x31:
        r = p[i+1]
        ops.append((i, 'PRINT_STR', r))
        i += 2
    elif op == 0x01:
        r = p[i+1]
        v = p[i+2] | (p[i+3] << 8)
        ops.append((i, 'SET_INT', r, v))
        i += 4
    elif op == 0x82:
        d, s = p[i+1], p[i+2]
        ops.append((i, 'GET_LICENSE', d, s))
        i += 3
    elif op == 0x20:
        d, a, b = p[i+1], p[i+2], p[i+3]
        ops.append((i, 'XOR', d, a, b))
        i += 4
    elif op == 0x84:
        r = p[i+1]
        ops.append((i, 'PRINT_CHAR', r))
        i += 2
    elif op == 0x00:
        ops.append((i, 'HALT'))
        i += 1
        break
    else:
        raise RuntimeError(f'Unknown opcode {op:02x} at {i:#x}')

# collect (license_index, xor_constant) for each printed char
seq = []
cur_const = None
cur_idx = None
for o in ops:
    if o[1] == 'SET_INT' and o[2] == 0:
        cur_const = o[3]
    elif o[1] == 'SET_INT' and o[2] == 3:
        cur_idx = o[3]
    elif o[1] == 'PRINT_CHAR':
        seq.append((cur_idx, cur_const))

print('char equations:', len(seq))
for x in seq:
    print(x)

Derivation

Given output equation per char:

out_char = const ^ license_byte[index]

Using known prefix BITSCTF{ for first 8 chars, solve bytes:

license[0] = 0xdb ^ 'B' = 0x99
license[1] = 0xbc ^ 'I' = 0xf5
license[2] = 0x33 ^ 'T' = 0x67
license[3] = 0x42 ^ 'S' = 0x11
license[4] = 0x67 ^ 'C' = 0x24
license[5] = 0x81 ^ 'T' = 0xd5
license[6] = 0x66 ^ 'F' = 0x20
license[7] = 0xae ^ '{' = 0xd5

Continue from subsequent equations to recover remaining bytes:

license[8] = 0xf6
license[9] = 0x3c

Final 10-byte license (hex):

99 f5 67 11 24 d5 20 d5 f6 3c

So license file content is:

LICENSE-99f5671124d520d5f63c

6. Why Flag Was Hidden Even With Correct License

Even with correct license + anti-debug bypass, only teaser text appeared.

Reason: PRINT_FLAG_CHAR callback checks an environment variable before printing. It prints only when env var PRINT_FLAG_CHAR exists.

So final run requires both:

  • anti-debug bypass preload
  • PRINT_FLAG_CHAR=1

7. Final Reproduction

printf 'LICENSE-99f5671124d520d5f63c' > lic_final
PRINT_FLAG_CHAR=1 LD_PRELOAD=./hook_bypass_adbg.so ./challenge lic_final

Output includes:

BITSCTF{l4y3r_by_l4y3r_y0u_unr4v3l_my_53cr375}