Nexus! _
Crackme writeup for reversing a custom encrypted VM while bypassing anti-debugging and extracting the NexusCore password.
General crackme: L5.0
This is the solution of a crackme that I wanted to tackle this weekend. It is on the harder side, as I wanted something challenging.
The main constraint I set was to avoid dynamic analysis; even with debugging protections, it is fairly simple to retrieve the flag.
Description
NEXUSCORE - VERY HARD CRACKME CHALLENGE
OBJECTIVE: Find the correct password to authenticate into NexusCore and retrieve the FLAG.
PROTECTIONS:
- Custom Virtual Machine - Password validation logic is executed inside an encrypted VM
- Anti-Debug - Multiple debugger detection mechanisms throughout execution
- Anti-Bruteforce - Limited attempts with exponential backoff delays
- Code Obfuscation - Opaque predicates, junk code, runtime transformations
- Timing Checks - Execution must complete within specific time constraints
WHAT YOU NEED TO DO:
- Reverse engineer the custom VM implementation
- Understand the bytecode encryption/decryption mechanism
- Bypass or analyze the anti-debug protections
- Either: a) Generate the correct password using a keygen (requires understanding the algorithm) b) Patch the binary to skip validation c) Emulate/instrument the VM to find the correct password
- Enter the password and retrieve the flag: NEXUSCORE{VM_MASTERED}
Analysis of main @ [140001910]
Once I found the entry point, the main was pretty easy to locate because the file is compiled with MSVC. I tagged it and this is the first result:
int __fastcall main(int argc, const char **argv, const char **envp)
{
FILE *v3; // rax
FILE *v4; // rax
__int64 v6; // [rsp+20h] [rbp-58h]
char *v7; // [rsp+38h] [rbp-40h]
char Buffer[32]; // [rsp+40h] [rbp-38h] BYREF
print("========================================\n");
print(" N E X U S C O R E >_< \n");
print("========================================\n");
print("Enter password: ");
v3 = _acrt_iob_func(1u);
fflush(v3);
v4 = _acrt_iob_func(0);
if ( fgets(Buffer, 32, v4) )
{
v7 = Buffer;
v6 = -1;
do
++v6;
while ( v7[v6] );
if ( v6 && Buffer[v6 - 1] == 10 )
{
if ( (unsigned __int64)(v6 - 1) >= 0x20 )
_canary_check();
Buffer[v6 - 1] = 0;
}
if ( (unsigned int)check_user_pass(Buffer) )
{
print("\n[++] Access granted! Welcome to NexusCore.\n");
print("[++] Flag: NEXUSCORE{VM_MASTERED}\n");
return 0;
}
print("\n[-] Access denied.\n");
}
return 1;
}
Analysis of check_user_pass @ [140001780]
While analyzing the code I reconstructed a pseudo-struct that, at least for me, worked pretty well in understanding the algorithm:
struct VM_CTX
{
uint32_t magic_1;
uint32_t input_index;
byte _pad1[24];
VM_MEM vm_mem;
uint32_t vsp;
uint32_t vip;
uint32_t do_jump_or_do_cmp;
uint32_t flags;
byte buff[16];
uint64_t last_tick;
};
struct VM_MEM_LAYOUT
{
uint8_t text[256];
char user_input[32];
uint8_t data_stack[3808];
};
union VM_MEM
{
VM_MEM_LAYOUT layout;
uint8_t raw[4096];
};
As we can see, using the struct in IDA gives a decent result:
_BOOL8 __fastcall check_user_pass(char *user_pass)
{
unsigned __int64 i; // [rsp+20h] [rbp-10A8h]
char curr_char; // [rsp+28h] [rbp-10A0h]
unsigned __int64 user_input_len; // [rsp+30h] [rbp-1098h]
BOOL is_flag_ok; // [rsp+38h] [rbp-1090h]
ULONGLONG tick_now; // [rsp+40h] [rbp-1088h]
VM_CTX vm_ctx; // [rsp+60h] [rbp-1068h] BYREF
if ( (unsigned int)trials_linear_backoff() )
{
memset(&vm_ctx, 0, sizeof(vm_ctx));
vm_ctx.magic_1 = 0xDEADBEEF;
vm_ctx.flags = 0x1337BEEF;
user_input_len = -1;
do
++user_input_len;
while ( user_pass[user_input_len] );
for ( i = 0; i < 32; ++i )
{
vm_ctx.input_index = i;
if ( i >= user_input_len )
curr_char = 0;
else
curr_char = user_pass[i];
vm_ctx.vm_mem.layout.user_input[i] = curr_char;// At 0x100 - 0x132 there is our input.
}
is_flag_ok = vm_entry(&vm_ctx, vm_ctx.vm_mem.layout.user_input);
tick_now = GetTickCount64() - vm_ctx.last_tick;
return tick_now <= 0x5DC && tick_now >= 0x320 && is_flag_ok;
}
else
{
print("Too many attempts. Wait...\n");
return 0;
}
}
Analysis of trials_linear_backoff @ [140001160]
This function is for anti-debug. LastGlobalTickCount and GlobalPasswordTries are global variables
inside the .data region. Each time the program starts they are reset because the
PE is loaded with a clean state, but this can prevent using a debugger to brute force
passwords.
It can be trivially bypassed by NOP-ing the instructions in Ghidra/IDA (or any patching tool you prefer). After patching, the password can be cracked assuming the attacker has enough computational power and the password length is reasonable.
__int64 trials_linear_backoff()
{
ULONGLONG TickCount64; // [rsp+20h] [rbp-18h]
TickCount64 = GetTickCount64();
if ( TickCount64 - LastGlobalTickCount < 0x1388 )
return 0;
if ( (unsigned int)++GlobalPasswordTries <= 0x32 )
{
LastGlobalTickCount = TickCount64;
return 1;
}
else
{
Sleep(10000 * GlobalPasswordTries);
return 0;
}
}
Analysis of vm_entry @ [1400015a0] and final functions
_BOOL8 __fastcall vm_entry(VM_CTX *vm_ctx, char *input_flag)
{
unsigned int op; // [rsp+30h] [rbp-18h]
decode_bytecode(vm_ctx);
vm_ctx->vip = 0;
vm_ctx->vsp = 0xFFF;
vm_ctx->last_tick = GetTickCount64();
while ( vm_ctx->vip < bytecodeLen_ - 8 )
{
if ( (vm_ctx->vip & 0xF) == 0 )
{
if ( check_dbg() )
return 0;
if ( (unsigned int)check_dbg2() == 0xDEADBEEF )
return 0;
}
op = decode_op(*(_DWORD *)&vm_ctx->vm_mem.layout.text[vm_ctx->vip]);
vm_ctx->vip += 4;
if ( op > 0xA1B2C3D4 )
{
if ( op == 0xB2C3D4E5 )
{
vm_push_imm8(vm_ctx);
}
else if ( op == 0xD4E5F607 )
{
vm_xor(vm_ctx);
}
else
{
skip_instruction:
vm_ctx->vip += 4;
}
}
else
{
switch ( op )
{
case 0xA1B2C3D4:
return vm_ctx->magic_1 == 0x4E455855;
case 0x718293Au:
vm_cmp(vm_ctx);
break;
case 0x18293A4Bu:
vm_jz(vm_ctx);
break;
case 0x4B5C6D7Eu:
vm_push_input_char(vm_ctx, input_flag);
break;
default:
goto skip_instruction;
}
}
}
return 0;
}
In the code we can identify two functions that are interesting and can make our life harder when doing runtime analysis.
The first is pretty simple and straightforward:
BOOL check_dbg() { return IsDebuggerPresent(); }The second is more subtle, as it checks whether the sleep is actually being run:
__int64 check_dbg2() { DWORD TickCount; // [rsp+20h] [rbp-18h] TickCount = GetTickCount(); Sleep(0xAu); if ( GetTickCount() - TickCount >= 5 ) return TickCount; else return 3735928559LL; }Finally, we can see where the instructions are stored and how they are decrypted:
//.data:0000000140005080 ; uint bytecodeLen_ //.data:0000000140005080 bytecodeLen? dd 48h //.data:0000000140005080 //.data:0000000140005084 align 10h //.data:0000000140005090 ; byte bytecode[80] //.data:0000000140005090 bytecode db 12h, 34h, 56h, 78h, 9Ah, 0BCh, 0DEh, 0F0h, 12h, 34h //.data:000000014000509A db 56h, 78h, 0DEh, 0ADh, 0BEh, 0EFh, 1, 23h, 45h, 67h //.data:00000001400050A4 db 89h, 0ABh, 0CDh, 0EFh, 0AAh, 0BBh, 0CCh, 0DDh, 0EEh //.data:00000001400050AD db 0FFh, 0, 11h, 22h, 33h, 44h, 55h, 66h, 77h, 88h, 99h //.data:00000001400050B8 db 0A0h, 0B1h, 0C2h, 0D3h, 0E4h, 0F5h, 6, 17h, 28h, 39h //.data:00000001400050C2 db 4Ah, 5Bh, 6Ch, 7Dh, 8Eh, 9Fh, 0A8h, 0B9h, 0CAh, 0DBh //.data:00000001400050CC db 0ECh, 0FDh, 0Eh, 1Fh, 20h, 31h, 42h, 53h, 64h, 75h //.data:00000001400050D6 db 86h, 97h, 8 dup(0) uint32_t __fastcall decode_bytecode(VM_CTX *vm_ctx) { uint32_t result; // eax uint i; // [rsp+0h] [rbp-18h] uint32_t key; // [rsp+4h] [rbp-14h] key = vm_ctx->magic_1 ^ 0xDEADBEEF; for ( i = 0; ; ++i ) { result = bytecodeLen_; if ( i >= bytecodeLen_ ) break; vm_ctx->vm_mem.layout.text[i] = (0x37 * i) ^ key ^ bytecode[i]; key = __ROL4__(key, 1) ^ 0xCAFEBABE; } return result; }The instructions are decrypted and placed inside
vm_ctx->vm_mem.layout.text[i].The final interesting function is
decode_op. As shown below, IDA did a good job eliminating unused code. This gives us the one and only interesting operation:__int64 __fastcall decode_op(uint32_t instruction) { __rdtsc(); return instruction ^ 0xA1B2C3D4; }
At the beginning of this challenge I thought of using a tool to De-virtualize the program. This is not really necessary as the program is simple and straightforward. Instead I wrote some Python scripts to first decode the instructions and later on re-implement the VM:
Bytecode decryption:
def rol32(x: int, r: int) -> int: x &= 0xFFFFFFFF return ((x << r) | (x >> (32 - r))) & 0xFFFFFFFF def u32le(buf: List[int], off: int) -> int: return (buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24)) & 0xFFFFFFFF def decode_bytecode(encoded: List[int]) -> List[int]: """ key = (vm_ctx[0] ^ 0xDEADBEEF) and vm_ctx[0] is set to 0xDEADBEEF => key=0 decoded[i] = encoded[i] ^ ((key ^ (i*0x1337)) & 0xFF) key = rol(key,1) ^ 0xCAFEBABE """ key = 0 decoded: List[int] = [] for i, b in enumerate(encoded): mask_byte = (key ^ ((i * 0x1337) & 0xFFFFFFFF)) & 0xFF decoded.append(b ^ mask_byte) key = rol32(key, 1) ^ 0xCAFEBABE return decoded encoded_bytecode: List[int] = [ 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x12, 0x34, 0x56, 0x78, 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xA0, 0xB1, 0xC2, 0xD3, 0xE4, 0xF5, 0x06, 0x17, 0x28, 0x39, 0x4A, 0x5B, 0x6C, 0x7D, 0x8E, 0x9F, 0xA8, 0xB9, 0xCA, 0xDB, 0xEC, 0xFD, 0x0E, 0x1F, 0x20, 0x31, 0x42, 0x53, 0x64, 0x75, 0x86, 0x97, ] decoded = decode_bytecode(encoded_bytecode) print("\n[*] Decoded (first 64 bytes):") hexdump(decoded, 0, 64)From this we obtain a bytecode that is not runnable on the VM.
[*] Decoded (first 64 bytes): 0000: 12 BD FB E5 88 8D 6F 38 86 3D 03 7D 45 C6 43 96 0010: 4E 45 A7 B4 F6 F0 78 BF 7E A2 69 C9 E6 63 82 E7 0020: C2 9A C9 E8 14 A6 59 B1 14 98 B7 B6 9F FE 1B 4E 0030: 47 7F C8 68 73 C6 1B EF 5C C0 8F AF 04 41 AC C9In fact if you check this bytecode with the vm’s op codes they do not match:
0xB2C3D4E5 0xD4E5F607 0xA1B2C3D4 0x718293A 0x18293A4B 0x4B5C6D7E
This sent me into a long debugging session of my decoding script. After some hours I decided to give up and simply try to run the program with a debugger. To my surprise, my decoded bytecode was the same as the one decoded inside the VM.
I also spent a lot of time searching for runtime techniques that were catching my debugger and maybe not executing some important code path, but I found nothing.
The flag can be printed simply patching the instruction at the address 0x0140001A08:
- 74 1E 48 8D
+ 75 1E 48 8D
Running the binary now will print the flag:
========================================
N E X U S C O R E >_<
========================================
Enter password: <password>
[++] Access granted! Welcome to NexusCore.
[++] Flag: NEXUSCORE{VM_MASTERED}