Post

Dungeon Game Memory Exploitation CTF

Dungeon Game Memory Exploitation CTF

Dungeon Game Memory Structure

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:

  1. Wizard Authentication: Player must solve a random number challenge
  2. Key Acquisition: Successful wizard interaction grants a key flag
  3. Chest Interaction: Key is required to open chest and reveal boss coordinates
  4. Boss Combat: Player must reach and defeat the boss
  5. 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

  1. Overview
  2. Initial Analysis
  3. Reverse Engineering Process
  4. Memory Structure Discovery
  5. Exploitation Strategy
  6. Final Exploit Code
  7. Flag Extraction
  8. Conclusion

Overview

This CTF challenge involves a SDL2-based dungeon game where the objective is to:

  1. Bypass wizard interaction to get a key
  2. Open a chest to reveal boss coordinates
  3. Teleport to the boss location
  4. 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:

  1. Player interacts with wizard (E key)
  2. Must provide secret number to get key
  3. Use key to open chest (F key)
  4. 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:

  1. Player structure is heap-allocated using operator new(0xa0)
  2. Memory scanning is unreliable due to memory layout changes
  3. 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:

  1. FUN_00106784 (Constructor): Player creation (args[2])
  2. FUN_001099de (Wizard): Wizard interaction (args[1])
  3. FUN_00109d34 (Chest): Chest interaction (args[1])
  4. 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:

  1. Module.findExportByName() reliability issues - Sometimes returns null in Frida environment
  2. Too many false positives - Many 160+ byte allocations are not the player structure
  3. Performance overhead - Hooking every malloc call impacts game performance
  4. 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_00116e30 or 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:

  1. FUN_001099de (Wizard): Receives player structure as param_2 (args[1])
  2. FUN_00109d34 (Chest): Receives player structure as param_2 (args[1])
  3. 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

  1. Launch Frida heap-detection exploit:
    1
    
    frida -l ctf_heap_detection_fixed.js -f ./Dungeon
    
  2. Interact with wizard in-game:
    • Move player character to wizard
    • Press ‘E’ to interact with wizard
    • This triggers player structure capture
  3. Run auto-exploit once player is detected:
    1
    
    auto()
    
  4. 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
    
  5. 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

  1. No External Dependencies - No need for Frida installation
  2. Stealth - Library injection is less detectable
  3. Performance - No interpretation overhead
  4. Portability - Works on any Linux system with standard libraries
  5. 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

  1. Boss Defeat Detection: Game checks offset 0x39 for boss defeat flag
  2. Kernel Driver Access: Opens /dev/ctf_driver device (not /dev/ctf)
  3. Ioctl Command: Uses command 0x6301 to extract flag from kernel
  4. Flag Storage: Stores 264-byte flag at player structure offset 0x18
  5. 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:

  1. Direct ioctl access to /dev/ctf_driver
  2. Memory manipulation to set boss defeat flag
  3. 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

  1. Fight the boss in the game (you’re invincible with 9999 HP)
  2. Boss defeat triggers FUN_0010ab12 function call
  3. Kernel driver /dev/ctf_driver provides flag via ioctl
  4. Flag stored at player memory offset 0x18
  5. 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

  1. Static Analysis: Used Ghidra to decompile and understand game logic
  2. Dynamic Analysis: Used Frida for real-time memory manipulation
  3. Shared Library Injection: Created .so library for LD_PRELOAD exploitation
  4. Memory Structure Discovery: Mapped complete player structure layout
  5. Function Hooking: Intercepted key game functions for bypass
  6. Memory Scanning: Located structures using signature patterns
  7. Binary Exploitation: Modified game state through memory writes
  8. 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

MethodAdvantagesDisadvantagesUse Case
Frida ScriptInteractive REPL, real-time debugging, flexibleRequires Frida installation, easily detectedDevelopment, research, CTF competitions
Shared LibraryStealth, no dependencies, external controlLess interactive, requires compilationProduction environments, persistence

Attack Vector Summary

  1. Information Gathering: Analyzed binary and identified SDL2 game framework
  2. Reverse Engineering: Mapped game flow and data structures using Ghidra
  3. Memory Discovery: Located player structure using health signature scanning
  4. Control Bypass: Manipulated key flag to bypass wizard authentication
  5. Privilege Escalation: Enhanced player capabilities (god mode)
  6. 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

This post is licensed under CC BY 4.0 by the author.