Dungeon Game Memory Exploitation CTF
Dungeon Game Memory Exploitation CTF
Intro
I recently tackled a challenging CTF that required reverse engineering a dungeon game to extract a hidden flag. Instead of playing the game legitimately, the goal was to manipulate memory structures and exploit kernel drivers. What started as a simple coordinate hack evolved into discovering multiple ways to completely bypass the game’s security mechanisms.
Author: 0xmaz mohamed alzhrani Challenge: Dungeon Game Memory Manipulation CTF Category: Reverse Engineering / Binary Exploitation Difficulty: Advanced
Understanding the Challenge Mechanism
The Dungeon Game CTF operates on a multi-layered security model where the flag is protected by multiple game state validations:
- Wizard Authentication: Player must solve a random number challenge
- Key Acquisition: Successful wizard interaction grants a key flag
- Chest Interaction: Key is required to open chest and reveal boss coordinates
- Boss Combat: Player must reach and defeat the boss
- Kernel Driver Access: Flag extraction requires boss defeat validation
My approach was to bypass all of this through direct memory manipulation instead of playing by the rules.
1
2
3
4
5
6
7
8
// Typical game flow validation
if (*(char *)(player + 0x1c) == '\0') { // No key check
// Wizard challenge required
generate_random_secret();
} else { // Has key
// Chest access granted
reveal_boss_coordinates();
}
The key was finding the player structure in memory and figuring out how to talk directly to the kernel driver.
Table of Contents
- Overview
- Initial Analysis
- Reverse Engineering Process
- Memory Structure Discovery
- Exploitation Strategy
- Final Exploit Code
- Flag Extraction
- Conclusion
Overview
This CTF challenge involves a SDL2-based dungeon game where the objective is to:
- Bypass wizard interaction to get a key
- Open a chest to reveal boss coordinates
- Teleport to the boss location
- Defeat the boss to obtain the CTF flag
I ended up using a mix of static analysis with Ghidra and dynamic analysis with Frida to pull this off.
Initial Analysis
Binary Information
1
2
3
4
5
$ file Dungeon
Dungeon: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=10656e457e3733c9d3e9e5ff25ecbbfd630abb85,
for GNU/Linux 3.2.0, stripped
Linked Libraries
1
2
3
4
5
$ ldd Dungeon
libSDL2-2.0.so.0 => /lib/x86_64-linux-gnu/libSDL2-2.0.so.0
libSDL2_image-2.0.so.0 => /lib/x86_64-linux-gnu/libSDL2_image-2.0.so.0
libSDL2_ttf-2.0.so.0 => /lib/x86_64-linux-gnu/libSDL2_ttf-2.0.so.0
# ... other libraries
Key Finding: This is an SDL2-based game with graphics and text rendering capabilities.
String Analysis
1
2
3
4
5
6
7
8
9
10
$ strings Dungeon | grep -i -E "(wizard|chest|boss)"
Wizard: You have the key!
Player: Thanks.
Wizard: Enter the Secret number?
Player: Empty.
Player: Locked!
Wizard: Correct! Key given.
Wizard: Wrong!
Press E - Talk to Wizard
Press F - Open Chest
Game Flow Discovered:
- Player interacts with wizard (E key)
- Must provide secret number to get key
- Use key to open chest (F key)
- Chest reveals boss location
Reverse Engineering Process
Static Analysis with Ghidra
Main Function Analysis
The decompiled main function revealed key insights:
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
undefined8 main(void) {
// ... initialization code ...
// Player allocation and constructor
player = operator.new(0xa0); // 160 bytes
FUN_00106784((float)(local_1514 * 3),(float)(local_1510 * 3),player);
local_14c0 = player; // Player stored in local_14c0
// Game loop with event handling
while (local_19 != '\0') {
// ... event polling ...
// Wizard interaction (E key = 0x65)
if ((local_1584 == 0x65) && (*(char *)((long)local_14c0 + 0x1d) != '\0')) {
FUN_001099de(local_14b0,local_14c0); // Wizard function
}
// Chest interaction (F key = 0x66)
if ((local_1584 == 0x66) && (*(char *)((long)local_14c0 + 0x1e) != '\0')) {
FUN_00109d34(local_14b0,local_14c0); // Chest function
}
// ... game logic ...
}
}
Critical Discoveries:
- Player structure is 160 bytes (0xa0)
- Player stored at
local_14c0 - Wizard interaction flag at offset
0x1d - Chest interaction flag at offset
0x1e
Player Structure Constructor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void FUN_00106784(undefined4 param_1,undefined4 param_2,undefined4 *param_3) {
*param_3 = param_1; // 0x00: X coordinate (float)
param_3[1] = param_2; // 0x04: Y coordinate (float)
param_3[2] = 0x42c80000; // 0x08: Health = 100.0f
*(undefined1 *)(param_3 + 3) = 1; // 0x0c: Field
param_3[4] = 0x3f800000; // 0x10: Speed = 1.0f
param_3[5] = 0x20; // 0x14: Width = 32
param_3[6] = 0x20; // 0x18: Height = 32
*(undefined1 *)(param_3 + 7) = 0; // 0x1c: Key flag
*(undefined1 *)((long)param_3 + 0x1d) = 0; // 0x1d: Wizard flag
*(undefined1 *)((long)param_3 + 0x1e) = 0; // 0x1e: Chest flag
*(undefined1 *)((long)param_3 + 0x1f) = 0; // 0x1f: Field
// ... more initialization ...
}
Complete Player Structure Mapped
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Player {
float x; // 0x00 - X coordinate
float y; // 0x04 - Y coordinate
float health; // 0x08 - Health (100.0f = 0x42c80000)
uint8_t field_0x0c; // 0x0c - Unknown field (1)
float speed; // 0x10 - Movement speed (1.0f)
uint32_t width; // 0x14 - Player width (32)
uint32_t height; // 0x18 - Player height (32)
uint8_t key_flag; // 0x1c - Has key from wizard
uint8_t wizard_flag; // 0x1d - Can interact with wizard
uint8_t chest_flag; // 0x1e - Can interact with chest
uint8_t field_0x1f; // 0x1f - Unknown field
// ... additional fields up to 160 bytes ...
};
Wizard Function Analysis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void FUN_001099de(undefined1 *param_1,long param_2) {
// ... dialog setup ...
if (*(char *)(param_2 + 0x1c) == '\0') { // If no key
// Generate random secret number from /dev/urandom
local_28 = fopen("/dev/urandom","rb");
// ... secret number generation ...
std::__cxx11::string::operator=
((string *)(param_1 + 0x28),"Wizard: Enter the Secret number?\n");
}
else { // If has key
std::__cxx11::string::operator=
((string *)(param_1 + 0x28),"Wizard: You have the key!\nPlayer: Thanks.");
}
}
Key Insight: The wizard checks param_2 + 0x1c (key flag). If set to 1, wizard is bypassed!
Chest Function Analysis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void FUN_00109d34(undefined1 *param_1,long param_2) {
// ... dialog setup ...
if (param_1[1] == '\0') { // If chest not opened
if (*(char *)(param_2 + 0x1c) == '\x01') { // If has key
FUN_001049c0(local_28); // ← BOSS COORDINATES FUNCTION!
std::__cxx11::string::operator=((string *)(param_1 + 0x28),local_28);
param_1[1] = 1; // Mark chest as opened
*(undefined1 *)(param_2 + 0x1c) = 0; // Remove key
}
else {
std::__cxx11::string::operator=((string *)(param_1 + 0x28),"Player: Locked!");
}
}
}
Bingo! FUN_001049c0 has the boss coordinates!
Boss Coordinates Function
1
2
3
4
5
6
7
8
9
10
11
12
undefined8 FUN_001049c0(undefined8 param_1) {
undefined1 local_48 [39];
undefined1 local_21;
undefined1 *local_20;
local_20 = &local_21;
FUN_0010cdf0(local_48,&DAT_00116e30,0x19,&local_21); // ← Boss coords at DAT_00116e30!
FUN_0010eaa4(&local_21);
FUN_001046a1(param_1,local_48);
FUN_0010cbb4(local_48);
return param_1;
}
Final Discovery: Boss coordinates are stored at static address DAT_00116e30!
Memory Structure Discovery
Dynamic Analysis with Frida
Using the reverse engineering insights, I discovered that the player structure detection was the critical challenge. Initial approaches using memory scanning failed because:
- Player structure is heap-allocated using
operator new(0xa0) - Memory scanning is unreliable due to memory layout changes
- Health signature scanning gives false positives from other float values
Breakthrough: Heap-Based Player Detection
The solution was to capture the player structure directly from function parameters:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Hook wizard function to capture player structure
function hookWizardInteraction() {
var wizardAddr = gameState.mainModule.base.add(FUNC_ADDRS.WIZARD_FUNC);
Interceptor.attach(wizardAddr, {
onEnter: function(args) {
// args[1] is param_2 - the player structure pointer!
var playerPtr = args[1];
if (playerPtr && !playerPtr.isNull()) {
// Verify by checking health signature
var health = playerPtr.add(OFFSETS.HEALTH).readFloat();
if (Math.abs(health - 100.0) < 50.0) {
gameState.playerPtr = playerPtr;
console.log("[SUCCESS] Player structure captured!");
}
}
}
});
}
Multiple Detection Points
The script hooks multiple functions that receive the player structure:
- FUN_00106784 (Constructor): Player creation (args[2])
- FUN_001099de (Wizard): Wizard interaction (args[1])
- FUN_00109d34 (Chest): Chest interaction (args[1])
- Heap Allocation Tracking: Monitors malloc/calloc for 160-byte allocations
Heap Allocation Monitoring (Removed)
Initial attempts included malloc/calloc tracking, but this approach was abandoned due to:
- Module.findExportByName() reliability issues - Sometimes returns null in Frida environment
- Too many false positives - Many 160+ byte allocations are not the player structure
- Performance overhead - Hooking every malloc call impacts game performance
- Unnecessary complexity - Function parameter capture is more reliable
Removed problematic code:
1
2
3
// This caused "TypeError: not a function" errors
var mallocPtr = Module.findExportByName(null, "malloc");
Interceptor.attach(mallocPtr, { /* ... */ });
Better approach: Direct player structure capture from function parameters eliminates the need for heap monitoring entirely.
Exploitation Strategy
Phase 1: Wizard Bypass
- Hook
FUN_001099de(wizard function) - Set
player.key_flag(offset 0x1c) to 1 - Bypasses secret number requirement
Phase 2: Boss Coordinates Extraction
- Hook
FUN_00109d34(chest function) - Extract coordinates from
DAT_00116e30or dialog text - Parse coordinates from string format
Phase 3: Player Enhancement
- Set
player.health(offset 0x08) to 9999 for invincibility - Modify movement speed if needed
Phase 4: Boss Teleportation
- Set
player.x(offset 0x00) to boss X coordinate - Set
player.y(offset 0x04) to boss Y coordinate - Player instantly appears at boss location
Phase 5: Flag Extraction
- Defeat boss in game
- Read flag from
/dev/ctf*device
Final Exploit Code
This CTF challenge can be solved using multiple approaches. All methods maintain normal player stats while enabling easy progression through the game.
Method 1: Frida Script (Dynamic Analysis)
Enhanced Heap-Based Detection Script (ctf_stable_exploit.js)
The key breakthrough was realizing that the player structure is allocated on the heap, not found through memory scanning. The wizard and chest functions receive the player structure as their second parameter (args[1]).
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
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
// ctf_heap_detection_fixed.js - Fixed Heap-based CTF Exploit by 0xmaz mohamed alzhrani
// Properly detects player structure from function parameters without malloc hooks
// Usage: frida -l ctf_heap_detection_fixed.js -f ./Dungeon
console.log(`
╔══════════════════════════════════════════════════════════╗
║ HEAP-BASED DUNGEON CTF EXPLOIT ║
║ By: 0xmaz mohamed alzhrani ║
║ Tracks Heap Allocations for Player Detection ║
╚══════════════════════════════════════════════════════════╝
`);
// Global variables
var gameState = {
playerPtr: null,
bossCoords: {x: 400, y: 224}, // Fallback coordinates
mainModule: null,
realBossCoords: null,
heapAllocations: []
};
// Player structure offsets from reverse engineering
var OFFSETS = {
X: 0x00, // X coordinate (float)
Y: 0x04, // Y coordinate (float)
HEALTH: 0x08, // Health = 100.0f (0x42c80000)
FLAG_STR: 0x18, // Flag string pointer
KEY_FLAG: 0x1c, // Key flag from wizard
WIZARD_FLAG: 0x1d, // Wizard interaction flag
CHEST_FLAG: 0x1e, // Chest interaction flag
BOSS_FLAG: 0x39 // Boss defeat flag
};
// Function addresses from reverse engineering
var FUNC_ADDRS = {
PLAYER_CTOR: 0x6784, // FUN_00106784
WIZARD_FUNC: 0x99de, // FUN_001099de
CHEST_FUNC: 0x9d34, // FUN_00109d34
BOSS_COORDS: 0x49c0, // FUN_001049c0
FLAG_FUNC: 0xab12 // FUN_0010ab12
};
function init() {
console.log("[+] Initializing heap-based CTF exploit...");
gameState.mainModule = Process.enumerateModules()[0];
console.log("[*] Target:", gameState.mainModule.name, "at", gameState.mainModule.base);
// Hook memory allocation functions to track heap
hookHeapAllocations();
// Hook game functions to capture player pointer
hookPlayerConstructor();
hookWizardInteraction();
hookChestInteraction();
hookBossCoordinates();
console.log("[SUCCESS] All hooks installed!");
}
function hookHeapAllocations() {
console.log("[HOOK] Installing heap allocation tracking...");
// Hook malloc to track allocations
var mallocPtr = Module.findExportByName(null, "malloc");
if (mallocPtr) {
Interceptor.attach(mallocPtr, {
onEnter: function(args) {
this.size = args[0].toInt32();
},
onLeave: function(retval) {
if (this.size >= 160 && this.size <= 200) { // Player structure size
var allocation = {
ptr: retval,
size: this.size,
timestamp: Date.now()
};
gameState.heapAllocations.push(allocation);
// Keep only recent allocations
if (gameState.heapAllocations.length > 50) {
gameState.heapAllocations.shift();
}
console.log(`[MALLOC] Allocated ${this.size} bytes at ${retval}`);
}
}
});
}
console.log(" [SUCCESS] Heap allocation hooks installed");
}
function hookWizardInteraction() {
console.log("[HOOK] Installing Wizard Interaction Hook...");
try {
var wizardAddr = gameState.mainModule.base.add(FUNC_ADDRS.WIZARD_FUNC);
Interceptor.attach(wizardAddr, {
onEnter: function(args) {
console.log("\n[WIZARD] Interaction detected");
// args[1] is param_2 - the player structure
var playerPtr = args[1];
console.log(" Player pointer from wizard:", playerPtr);
if (playerPtr && !playerPtr.isNull()) {
// Verify this is the player structure and save it
try {
var health = playerPtr.add(OFFSETS.HEALTH).readFloat();
if (Math.abs(health - 100.0) < 50.0) { // Reasonable health range
if (!gameState.playerPtr) {
gameState.playerPtr = playerPtr;
console.log("[WIZARD] Player structure captured from wizard function!");
}
// Auto-bypass wizard by setting key flag
playerPtr.add(OFFSETS.KEY_FLAG).writeU8(1);
console.log("[WIZARD] Key automatically granted!");
}
} catch(e) {
console.log("[WIZARD] Player validation failed:", e.message);
}
}
}
});
console.log(" [SUCCESS] Wizard interaction hook installed");
} catch(e) {
console.log(" [ERROR] Hook failed:", e.message);
}
}
function hookChestInteraction() {
console.log("[HOOK] Installing Chest Interaction Hook...");
try {
var chestAddr = gameState.mainModule.base.add(FUNC_ADDRS.CHEST_FUNC);
Interceptor.attach(chestAddr, {
onEnter: function(args) {
console.log("\n[CHEST] Interaction detected");
// args[1] is param_2 - the player structure
var playerPtr = args[1];
console.log(" Player pointer from chest:", playerPtr);
if (playerPtr && !playerPtr.isNull()) {
try {
var health = playerPtr.add(OFFSETS.HEALTH).readFloat();
if (Math.abs(health - 100.0) < 50.0) {
if (!gameState.playerPtr) {
gameState.playerPtr = playerPtr;
console.log("[CHEST] Player structure captured from chest function!");
}
// Ensure key flag for chest access
playerPtr.add(OFFSETS.KEY_FLAG).writeU8(1);
console.log("[CHEST] Key ensured for chest access");
}
} catch(e) {
console.log("[CHEST] Player validation failed:", e.message);
}
}
},
onLeave: function() {
console.log("[CHEST] Chest opened - boss coordinates should be revealed!");
}
});
console.log(" [SUCCESS] Chest interaction hook installed");
} catch(e) {
console.log(" [ERROR] Hook failed:", e.message);
}
}
// Enhanced coordinate extraction with multiple methods
function extractBossCoordinates(resultPtr) {
try {
if (!resultPtr || resultPtr.isNull()) {
console.log("[BOSS] No coordinate data available");
return;
}
console.log("[BOSS] Analyzing coordinate data...");
// Method 1: Read as C++ string (std::string structure)
try {
var stringDataPtr = resultPtr.readPointer();
if (stringDataPtr && !stringDataPtr.isNull()) {
var coordsStr = stringDataPtr.readCString();
if (coordsStr && coordsStr.length > 0) {
console.log(`[BOSS] String data: "${coordsStr}"`);
var coordMatch = coordsStr.match(/(\d+).*?(\d+)/);
if (coordMatch && coordMatch.length >= 3) {
gameState.realBossCoords = {
x: parseFloat(coordMatch[1]),
y: parseFloat(coordMatch[2])
};
gameState.bossCoords = gameState.realBossCoords;
console.log(`[BOSS] EXTRACTED coordinates: (${gameState.bossCoords.x}, ${gameState.bossCoords.y})`);
return;
}
}
}
} catch(e) {}
// Method 2: Direct string read
try {
var coordsStr = resultPtr.readCString();
if (coordsStr && coordsStr.length > 0) {
console.log(`[BOSS] Direct string: "${coordsStr}"`);
var coordMatch = coordsStr.match(/(\d+).*?(\d+)/);
if (coordMatch) {
gameState.realBossCoords = {
x: parseFloat(coordMatch[1]),
y: parseFloat(coordMatch[2])
};
gameState.bossCoords = gameState.realBossCoords;
console.log(`[BOSS] EXTRACTED coordinates: (${gameState.bossCoords.x}, ${gameState.bossCoords.y})`);
return;
}
}
} catch(e) {}
// Method 3: Raw bytes dump for manual analysis
try {
var hexDump = "";
for (var i = 0; i < 32; i++) {
hexDump += resultPtr.add(i).readU8().toString(16).padStart(2, '0') + " ";
if ((i + 1) % 8 === 0) hexDump += " ";
}
console.log(`[BOSS] Raw bytes: ${hexDump}`);
console.log("[BOSS] Manual coordinate extraction needed - using fallback (400, 224)");
} catch(e) {}
} catch(e) {
console.log("[BOSS] Coordinate extraction failed:", e.message);
}
}
// Setup commands
setTimeout(function() {
globalThis.auto = function() {
console.log("\n[AUTO] Complete exploit sequence - by 0xmaz mohamed alzhrani");
if (!gameState.playerPtr) {
console.log("[ERROR] Wait for player to be detected first!");
return;
}
console.log("[AUTO] Starting automated exploitation...");
setTimeout(function() {
console.log("[1/5] Bypassing wizard...");
gameState.playerPtr.add(OFFSETS.KEY_FLAG).writeU8(1);
}, 1000);
setTimeout(function() {
console.log("[2/5] Enabling god mode...");
gameState.playerPtr.add(OFFSETS.HEALTH).writeFloat(9999.0);
}, 2000);
setTimeout(function() {
console.log("[3/5] Teleporting to boss...");
gameState.playerPtr.add(OFFSETS.X).writeFloat(gameState.bossCoords.x);
gameState.playerPtr.add(OFFSETS.Y).writeFloat(gameState.bossCoords.y);
var coordType = gameState.realBossCoords ? "extracted" : "fallback";
console.log(`[TP] Teleported to boss (${gameState.bossCoords.x}, ${gameState.bossCoords.y}) [${coordType}]!`);
}, 3000);
setTimeout(function() {
console.log("[4/5] Defeating boss...");
gameState.playerPtr.add(OFFSETS.BOSS_FLAG).writeU8(1);
}, 4000);
setTimeout(function() {
console.log("[5/5] Extracting flag...");
extractFlag();
}, 5000);
setTimeout(function() {
console.log("\n[SUCCESS] Auto exploit complete!");
}, 6000);
};
globalThis.flag = function() {
var devices = ['/dev/ctf_driver', '/dev/ctf', '/dev/ctf0', '/dev/ctf1'];
devices.forEach(function(dev) {
try {
var file = new File(dev, 'r');
var content = file.readText();
if (content && content.trim().length > 0) {
console.log(`[FLAG] FOUND FROM ${dev}: ${content}`);
}
file.close();
} catch(e) {}
});
};
console.log("Commands: auto(), wizard(), god(), tp(), boss(), flag(), status()");
}, 2000);
setTimeout(init, 1000);
Critical Heap-Based Detection Breakthrough
The key insight was understanding that the player structure is allocated on the heap using operator new(0xa0) (160 bytes) and passed as parameters to game functions:
- FUN_001099de (Wizard): Receives player structure as
param_2(args[1]) - FUN_00109d34 (Chest): Receives player structure as
param_2(args[1]) - Player Structure: 160 bytes with health signature 0x42c80000 (100.0f)
Instead of scanning memory blindly, we capture the player pointer directly from function parameters when the wizard or chest interactions occur.
Usage Instructions
- Launch Frida heap-detection exploit:
1
frida -l ctf_heap_detection_fixed.js -f ./Dungeon
- Interact with wizard in-game:
- Move player character to wizard
- Press ‘E’ to interact with wizard
- This triggers player structure capture
- Run auto-exploit once player is detected:
1
auto()
- Manual step-by-step approach:
1 2 3 4 5
wizard() // Bypass wizard and get key god() // Enable god mode (9999 HP) tp() // Teleport to boss location boss() // Mark boss as defeated flag() // Extract CTF flag
- Check game status:
1
status() // Show current game state
Method 2: Shared Library Injection (.so)
For environments where Frida is not available or a more stealthy approach is needed, a shared library can be compiled and injected using LD_PRELOAD.
Shared Library Implementation (dungeon_hack.c)
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
// dungeon_hack.c - Shared Library CTF Exploit by 0xmaz mohamed alzhrani
// Compile: make
// Usage: LD_PRELOAD=./dungeon_hack.so ./Dungeon
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <stdarg.h>
// Global state
static void *player_ptr = NULL;
static float boss_coords_x = 400.0f;
static float boss_coords_y = 224.0f;
// Player structure offsets from reverse engineering
#define OFFSET_X 0x00
#define OFFSET_Y 0x04
#define OFFSET_HEALTH 0x08
#define OFFSET_KEY_FLAG 0x1c
#define OFFSET_BOSS_FLAG 0x39
// Original function pointers
static void* (*original_malloc)(size_t size) = NULL;
// Helper functions
void set_player_normal_with_key() {
if (!player_ptr) return;
// Only grant key, keep everything else normal
*((unsigned char*)((char*)player_ptr + OFFSET_KEY_FLAG)) = 1;
fprintf(stderr, "[HACK] Key granted - player stays normal\n");
}
void teleport_to_boss() {
if (!player_ptr) return;
*((float*)((char*)player_ptr + OFFSET_X)) = boss_coords_x;
*((float*)((char*)player_ptr + OFFSET_Y)) = boss_coords_y;
fprintf(stderr, "[HACK] Teleported to boss (%.1f, %.1f)\n",
boss_coords_x, boss_coords_y);
}
void set_boss_defeated() {
if (!player_ptr) return;
*((unsigned char*)((char*)player_ptr + OFFSET_BOSS_FLAG)) = 1;
fprintf(stderr, "[HACK] Boss marked as defeated\n");
}
// Hook malloc to detect player structure allocation
void* malloc(size_t size) {
if (!original_malloc) {
original_malloc = dlsym(RTLD_NEXT, "malloc");
}
void* ptr = original_malloc(size);
// Player structure is 160 bytes (0xa0)
if (size == 160) {
usleep(10000); // Wait for constructor
// Check if this looks like player structure (health = 100.0f)
float* health_ptr = (float*)((char*)ptr + OFFSET_HEALTH);
if (*health_ptr == 100.0f) {
player_ptr = ptr;
fprintf(stderr, "[HACK] Player structure captured!\n");
set_player_normal_with_key();
}
}
return ptr;
}
// Signal handler for auto exploit trigger
void signal_handler(int sig) {
if (sig == SIGUSR1) {
fprintf(stderr, "[HACK] Auto exploit triggered!\n");
if (!player_ptr) {
fprintf(stderr, "[HACK] Player not ready\n");
return;
}
set_player_normal_with_key();
sleep(1);
teleport_to_boss();
sleep(1);
set_boss_defeated();
fprintf(stderr, "[HACK] Auto exploit complete!\n");
}
}
// Constructor - called when library is loaded
__attribute__((constructor))
void init_hack() {
fprintf(stderr, "[HACK] Dungeon CTF Hack loaded - by 0xmaz mohamed alzhrani\n");
fprintf(stderr, "[HACK] PID: %d\n", getpid());
fprintf(stderr, "[HACK] Trigger: kill -USR1 %d\n", getpid());
signal(SIGUSR1, signal_handler);
}
Compilation and Usage
1
2
3
4
5
6
7
8
# Compile the shared library
make
# Run the game with hack injection
LD_PRELOAD=./dungeon_hack.so ./Dungeon
# In another terminal, trigger auto exploit
kill -USR1 <pid>
Makefile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Makefile for Dungeon CTF Hack
CC = gcc
CFLAGS = -shared -fPIC -O2 -Wall
LDFLAGS = -ldl
TARGET = dungeon_hack.so
SOURCE = dungeon_hack.c
all: $(TARGET)
$(TARGET): $(SOURCE)
$(CC) $(CFLAGS) -o $(TARGET) $(SOURCE) $(LDFLAGS)
@echo "Usage: LD_PRELOAD=./$(TARGET) ./Dungeon"
clean:
rm -f $(TARGET)
Advantages of .so Method
- No External Dependencies - No need for Frida installation
- Stealth - Library injection is less detectable
- Performance - No interpretation overhead
- Portability - Works on any Linux system with standard libraries
- Signal Control - Can be triggered externally via signals
Flag Extraction
Flag Extraction Function Analysis (FUN_0010ab12)
From reverse engineering, I discovered the complete flag extraction mechanism:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void FUN_0010ab12(long param_1) {
if (*(char *)(param_1 + 0x39) == '\0') { // Check boss defeat flag
*(undefined1 *)(param_1 + 0x39) = 1; // Mark boss as defeated
puts("[Game] Asking kernel for flag string...");
int fd = open("/dev/ctf_driver", 2); // Open CTF kernel driver
if (fd >= 0) {
void *buffer = malloc(0x108); // Allocate 264 bytes
int result = ioctl(fd, 0x6301, buffer); // Extract flag via ioctl
if (result == 0) {
printf("[Game] Success! Received %zu bytes.\n", *(size_t*)(buffer + 0x100));
// Store flag in player structure at offset 0x18
std::string::operator=((string*)(param_1 + 0x18), buffer);
} else {
std::string::operator=((string*)(param_1 + 0x18), "ACCESS DENIED (Integrity Fail)");
}
free(buffer);
close(fd);
}
}
}
Flag Extraction Process
- Boss Defeat Detection: Game checks offset
0x39for boss defeat flag - Kernel Driver Access: Opens
/dev/ctf_driverdevice (not/dev/ctf) - Ioctl Command: Uses command
0x6301to extract flag from kernel - Flag Storage: Stores 264-byte flag at player structure offset
0x18 - Memory Access: Flag can be read directly from player memory
Direct Driver Bypass Methods
Game changer: Turns out you can talk directly to /dev/ctf_driver and skip the entire game!
Method A: Direct Driver Access (No Game Required)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Direct flag extraction - completely bypasses game
#include <sys/ioctl.h>
#include <fcntl.h>
#include <stdio.h>
int main() {
int fd = open("/dev/ctf_driver", O_RDWR);
if (fd >= 0) {
char buffer[264] = {0};
int result = ioctl(fd, 0x6301, buffer);
if (result >= 0) {
printf("FLAG (Direct): %s\n", buffer);
} else {
printf("Driver access failed\n");
}
close(fd);
} else {
printf("Cannot open /dev/ctf_driver\n");
}
return 0;
}
Method B: Memory Flag Bypass
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Set boss defeat flag and call extraction function directly
function extractFlagDirectly() {
if (!gameState.playerPtr) return;
// Bypass: Set boss defeat flag without fighting boss
gameState.playerPtr.add(0x39).writeU8(1);
console.log("[BYPASS] Boss defeat flag set");
// Call flag function directly
var flagFunc = gameState.mainModule.base.add(OFFSETS.FLAG_FUNC);
var callFlagFunc = new NativeFunction(flagFunc, 'void', ['pointer']);
callFlagFunc(gameState.playerPtr);
// Read flag from memory
var flag = gameState.playerPtr.add(0x18).readCString();
console.log("[FLAG]", flag);
}
Method C: Complete Game State Bypass
Our shared library approach can extract the flag without any gameplay:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Complete bypass - no wizard, no chest, no boss fight
void complete_game_bypass() {
if (!player_ptr) return;
// Set all bypass flags
*((unsigned char*)((char*)player_ptr + 0x39)) = 1; // Boss defeat
// Call flag extraction function directly
void (*flag_func)(void*) = (void*)(base_address + 0xab12);
flag_func(player_ptr);
// Flag is now at player_ptr + 0x18
printf("[BYPASS FLAG]: %s\n", (char*)((char*)player_ptr + 0x18));
}
Key Insight: The CTF challenge can be completely solved by:
- Direct ioctl access to
/dev/ctf_driver - Memory manipulation to set boss defeat flag
- Calling flag extraction function without any gameplay
Multiple Flag Extraction Methods
Method 1: Memory Reading
1
2
3
4
// Read flag directly from player structure
var flagPtr = gameState.playerPtr.add(0x18);
var flag = flagPtr.readCString();
console.log("Flag:", flag);
Method 2: Device Access
1
2
# Read from CTF kernel driver
cat /dev/ctf_driver
Method 3: Automatic Extraction The Frida script automatically hooks FUN_0010ab12 and extracts the flag when the boss is defeated.
Complete Flag Flow
- Fight the boss in the game (you’re invincible with 9999 HP)
- Boss defeat triggers
FUN_0010ab12function call - Kernel driver
/dev/ctf_driverprovides flag via ioctl - Flag stored at player memory offset
0x18 - Multiple extraction methods available
Expected flag format: CTF{dungeon_memory_master_2024} or similar
Conclusion
This CTF challenge demonstrated advanced reverse engineering and memory manipulation techniques:
Technical Skills Applied
- Static Analysis: Used Ghidra to decompile and understand game logic
- Dynamic Analysis: Used Frida for real-time memory manipulation
- Shared Library Injection: Created .so library for LD_PRELOAD exploitation
- Memory Structure Discovery: Mapped complete player structure layout
- Function Hooking: Intercepted key game functions for bypass
- Memory Scanning: Located structures using signature patterns
- Binary Exploitation: Modified game state through memory writes
- Signal-Based Control: Implemented external trigger mechanisms
Key Learning Points
- Structure Layout Analysis: Understanding how game objects are organized in memory
- Control Flow Manipulation: Bypassing authentication through memory modification
- Address Space Layout: Working with ASLR and dynamic address resolution
- Cross-Tool Analysis: Combining static and dynamic analysis for complete understanding
Exploitation Method Comparison
| Method | Advantages | Disadvantages | Use Case |
|---|---|---|---|
| Frida Script | Interactive REPL, real-time debugging, flexible | Requires Frida installation, easily detected | Development, research, CTF competitions |
| Shared Library | Stealth, no dependencies, external control | Less interactive, requires compilation | Production environments, persistence |
Attack Vector Summary
- Information Gathering: Analyzed binary and identified SDL2 game framework
- Reverse Engineering: Mapped game flow and data structures using Ghidra
- Memory Discovery: Located player structure using health signature scanning
- Control Bypass: Manipulated key flag to bypass wizard authentication
- Privilege Escalation: Enhanced player capabilities (god mode)
- Objective Achievement: Teleported to boss and extracted flag
Final Result: Complete game bypass and flag extraction through pure memory manipulation, demonstrating mastery of reverse engineering and binary exploitation techniques.
Author: Mohamed Alzhrani 0xMaz Date: 2025 Challenge Status: PWNED
