$ Rev: El Diablo
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-99f5671124d520d5f63c1. Initial Triage
Binary fingerprint
file challenge
# ELF 64-bit LSB shared object, x86-64, statically linked, no section headerstrings shows UPX signatures and challenge hints:
LICENSE-invalid license formatDEBUGGER DETECTED! LICENSING TERMS VIOLATED! >:(MYVERYREALLDRMGET_LICENSE_BYTEPRINT_FLAG_CHAR
Runtime behavior
./challenge
# Usage: ./challenge <license file path>
printf 'AAAA' > lic.txt
./challenge lic.txt
# [i] loaded license file
# [!] invalid license formatSo 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
/procdebugger/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.txtThis 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 string0x31: print string register0x01: set register as integer (16-bit immediate little endian)0x82: callbackGET_LICENSE_BYTE(dstReg, idxReg)0x20: XORdst = reg[a] ^ reg[b]0x84: callbackPRINT_FLAG_CHAR(reg)0x00: halt
The bytecode repeatedly does:
- load constant into reg0
- load index into reg3
GET_LICENSE_BYTE(reg1, reg3)reg0 ^= reg1PRINT_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 ^ '{' = 0xd5Continue from subsequent equations to recover remaining bytes:
license[8] = 0xf6
license[9] = 0x3cFinal 10-byte license (hex):
99 f5 67 11 24 d5 20 d5 f6 3cSo license file content is:
LICENSE-99f5671124d520d5f63c6. 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_finalOutput includes:
BITSCTF{l4y3r_by_l4y3r_y0u_unr4v3l_my_53cr375}