BitsCTF VM Challenge (El Diablo) Writeup
- Solver
- Author
- react
- Category
-
Rev - Flag
-
BITSCTF{l4y3r_by_l4y3r_y0u_unr4v3l_my_53cr375}
Description: I bought this program but I lost the license file…
Initial Analysis
First Run
When we first executed the binary, we were greeted with:
$ ./challengeWelcome my DRM-protected application!To sucessfully get in, you must present a valid license file.Reverse engineer this binary to figure out the license format, and get the flag. :)Good luck!
Usage: ./challenge <license file path>This confirmed the binary expects a license file as input. We also noticed the binary was packed with UPX, which is a common packer that can be easily unpacked.
Static Analysis Setup
I loaded the binary into IDA Pro and discovered:
- Binary Type: Linux x64 ELF (originally UPX packed, I unpacked it using
upx -d challenge) - File Size: ~60KB after unpacking
- Entry Point: Standard
_start→__libc_start_main→main
Key Observations
- License File Required: The binary expects a license file as command-line argument
- License Format:
LICENSE-<hex_encoded_bytes>(e.gLICENSE-deadbeef) - Expected Behavior: Should print a flag when given the correct license
Understanding the Protection Mechanisms
Anti-Debug Protection (sub_2E82)
I analyzed function sub_2E82 at address 0x2E82:
int sub_2E82() { int var_1 = 0; // Initialized to 0
// Check 1: ptrace detection if (ptrace(PTRACE_TRACEME, 0, 1, 0) == -1) { var_1 = 1; // Set flag on detection }
// Check 2: TracerPid check in /proc/self/status if (check_tracer_pid()) { var_1 = 1; }
// Check 3: Timing check with clock_gettime if (timing_check()) { var_1 = 1; }
// Check 4: Parent PID check if (getppid() == 1) { // Not debugged if parent is init var_1 = 1; }
return var_1; // Returns 0 if clean, 1 if debugger detected}Critical Discovery: The main function expects sub_2E82 to return 0 to continue execution:
int main(int argc, char **argv) { if (sub_2E82() != 0) { // Inverted logic! puts("DEBUGGER DETECTED!"); return -1; } // Continue to VM initialization...}This is inverted logic - the function returns 0 when clean, but sets var_1 = 1 when debuggers are detected.
VM-Based Obfuscation
After the anti-debug check, execution reaches an unusual instruction:
0x31D1: ud2 ; Undefined instruction - triggers SIGILLThis is intentional! The binary has a custom SIGILL signal handler that:
- Catches the undefined instruction exception
- Decrypts VM bytecode
- Initializes the virtual machine
- Resumes execution
Bytecode Encryption
The VM bytecode is stored encrypted at address 0xBB00:
- Size: 928 bytes
- Encryption: Custom AES-CTR implementation
- Decryption happens in:
sub_3248(called by SIGILL handler) - Destination: Bytecode is decrypted to
0xC2E0in memory
Bypassing Anti-Debug
Initial Attempts (Failed)
Attempt 1: Patch return value
# Tried patching the ret instruction to always return 0patch_bytes(0x2F7C, b'\x31\xC0\xC3') # xor eax, eax; retFailed: This broke the stack frame and caused segfault
Attempt 2: Patch jump conditions
# Tried inverting jz to jnzpatch_bytes(0x2EA9, b'\x75') # Change jz to jnzFailed: Only fixed one check, others still detected debugger
Attempt 3: Force return 1
# Tried to make it return 1 thinking that's "success"patch_bytes(0x2F7A, b'\xB8\x01\x00\x00\x00') # mov eax, 1Failed: This made it always detect debugger
Successful Approach
We realized the key was to prevent var_1 from ever being set to 1. Each detection sets it with:
mov [rbp+var_1], 1 ; C6 45 FF 01Solution: NOP out these assignments!
def patch_anti_debug(binary_path, output_path): with open(binary_path, 'rb') as f: data = bytearray(f.read())
# Patch locations where var_1 is set to 1 patches = [ 0x2EC6, # After ptrace check 0x2EF3, # After TracerPid check 0x2F19, # After timing check 0x2F3F # After parent PID check ]
for addr in patches: # Replace "mov [rbp+var_1], 1" with NOPs data[addr:addr+4] = b'\x90\x90\x90\x90'
with open(output_path, 'wb') as f: f.write(data)This forces sub_2E82 to always return 0, allowing execution to continue!
Testing the Patch
$ chmod +x challenge_correct$ echo "LICENSE-0000000000000000" > /tmp/test.txt$ ./challenge_correct /tmp/test.txt[i] loaded license fileprocessing... please wait...[i] running program...The flag lies here somewhere...BITSCTF{<garbled output>}Success! The binary now executes past anti-debug and reaches the VM.
VM Initialization and Bytecode Extraction
Understanding SIGILL Handler Chain
After bypassing anti-debug, execution hits ud2 at 0x31D1. The handler chain:
- Constructor
sub_33C9(.init_array[1]): Registers SIGILL handler - Handler
sub_3379: Catches SIGILL signal - Decryption
sub_3248: Decrypts bytecode from0xBB00to0xC2E0
The decryption key comes from qword_C290, which must be 0 for correct decryption.
Extracting Real Bytecode
Since bytecode is only decrypted at runtime, we needed to extract it:
$ gdb ./challenge_correct(gdb) handle SIGILL pass noprint nostop # Let handler run(gdb) break *0x3AB4 # Break after memcpy in VM creation(gdb) run /tmp/test.txt(gdb) x/928bx $rsi # Examine bytecode location(gdb) dump binary memory /tmp/vm_real.bin $rsi $rsi+928We extracted 928 bytes of decrypted VM bytecode.
Analyzing VM Bytecode
with open('/tmp/vm_real.bin', 'rb') as f: bytecode = f.read()
# Find key opcodesfor i, byte in enumerate(bytecode): if byte == 0x9A: print(f"GET_LICENSE_BYTE at offset {i}") elif byte == 0x9C: print(f"PRINT_FLAG_CHAR at offset {i}")Key Opcodes:
- 0x9A (GET_LICENSE_BYTE): Reads a byte from the license file into a register
- 0x9C (PRINT_FLAG_CHAR): Prints a character from a register
VM Execution Model
The VM operates on 10 registers (each 16 bytes):
- Structure:
value(8 bytes) +flags(8 bytes) - Operations: Load license, XOR, arithmetic, conditional jumps
- Output: Characters printed via opcode 0x9C
Flag Extraction Strategy
Initial Testing
I then wrote a test license of all zeros:
$ echo "LICENSE-0000000000000000000000000000000000000000000000000000000000000000" > /tmp/test.txt$ PRINT_FLAG_CHAR=1 ./challenge_patched /tmp/test.txtBITSCTF{y3r_by_l�E3r_y0u_u�N4v3l_my_�cr375}Observations:
- Prefix
BITSCTF{appears correctly - Many characters are garbled (non printable)
- Some readable patterns:
y3r_by_l,3r_y0u_u,4v3l_my_,cr375}
Byte-by-Byte Solving
I then created a solver that brute-forces each license byte:
def solve_position(lic_bytes, position, target_char): """Find license byte that produces target_char at position""" for byte_val in range(256): test_lic = list(lic_bytes) test_lic[position] = byte_val
output = test_license(test_lic)
if len(output) > position and output[position] == ord(target_char): return byte_val
return NoneSolving the Prefix
Target: BITSCTF{
Position 0 → 'B' (66): Found license[0] = 0x99Position 1 → 'I' (73): Found license[1] = 0xf5Position 2 → 'T' (84): Found license[2] = 0x67Position 3 → 'S' (83): Found license[3] = 0x11Position 4 → 'C' (67): Found license[4] = 0x24Position 5 → 'T' (84): Found license[5] = 0xd5Position 6 → 'F' (70): Found license[6] = 0x20Position 7 → '{' (123): Found license[7] = 0xd5License so far: 99f5671124d520d5
Discovering Position Variability
We made a critical discovery that not all positions are variable
# Test if position changes with different license bytesfor lic_byte in range(256): test_lic[10] = lic_byte output = test_license(test_lic) print(f"lic[10]={lic_byte:02x} → output[10]='{chr(output[10])}'")
# Result: ALL values produce 'y' at position 10!Analysis:
- Positions 10-17: CONSTANT (always same character regardless of license)
- Positions 20-27: CONSTANT
- Positions 30-31: CONSTANT
- Only positions 8, 9, 18, 19, 28, 29 vary with license!
This told us the VM has conditional logic that only uses certain license bytes.
Identifying the Pattern
BITSCTF{lay3r_by_l??3r_y0u_u???4v3l_my_??cr375} ^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^ "layer by l" "er you u" "el my secrets"Solving Variable Positions
Position 8: First character after {
# Target: 'l' (layer)Found: license[8] = 0xf6 produces 'l'Position 9: Second character after {
# Target: 'a' (layer)Found: license[9] = 0x69 produces 'a'Output: BITSCTF{lay3r_by_l4,3r_y0u_un'4v3l_my_5fcr375}Wait, position 19 shows ’,’ but we want ‘y’ for “layer”!
Position 9 (retry): Testing more values
# We need 'y' at position 19, not ','# Test all bytes for position 9 and check position 19!for byte_val in range(256): test_lic[9] = byte_val output = test_license(test_lic) if output[19] == ord('y'): print(f"Found: {byte_val:02x}") break
# Found: license[9] = 0x3c produces 'y' at position 19!Understanding License-to-Output Mapping
The VM doesn’t have a 1-to-1 mapping. Some license bytes affect multiple output positions:
| License Byte | Affects Output Positions |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
| 6 | 6 |
| 7 | 7 |
| 8 | 8 |
| 9 | 9, 19 |
| 10-31 | No visible effect |
This explains why changing license[9] affected both position 9 and 19!
Final Solution
The Flag
$ echo "LICENSE-99f5671124d520d5f63c00000000000000000000000000000000000000000000" > /tmp/final.txt$ PRINT_FLAG_CHAR=1 ./challenge_patched /tmp/final.txtBITSCTF{l4y3r_by_l4y3r_y0u_unr4v3l_my_53cr375}Decodes to: “layer by layer you unravel my secrets” well it wasn’t exactly the same but close enough ig xD
The License
LICENSE-99f5671124d520d5f63c00000000000000000000000000000000000000000000Thank you for reading this long aah writeup! I hope it provided insight into reversing VM-based obfuscation and bypassing anti-debugging techniques.
