PWN入门
CTF(Capture The Flag,夺旗赛)中的 “pwn”(发音类似 “pone”)是 “own” 这个词的一种黑客俚语写法,通常表示**“完全控制”、“取得支配权”或“击败”**
起源于早期的网络游尤其是《魔兽争霸》,当时人们打字太快或拼写错误,将 “own” 打成了 “pwn”。这个错误很快被黑客和游戏社区接受,并演变成一个表示“彻底击败”或“占有”的流行词
当你在CTF赛事中“pwn”了一个程序或服务,意思就是你成功利用了它的漏洞(例如缓冲区溢出、格式化字符串漏洞等),取得了对该程序的完全控制权,通常是获得了远程Shell或执行了任意代码,最终目的是读取服务器上的“flag”(旗帜)
终端(Terminal):一种用于与计算机交互的输入输出界面,负责接收用户输入并显示程序输出。
Shell:一种命令行解释器,用于解析用户输入的命令并与操作系统内核交互执行。“拿到 shell”通常就是拿到与远端主机的命令行交互权限,相当于在那台机器上打开了一个终端(可以输入命令、看输出、读写文件)
考察方向
1)逆向程序的能力。
2)挖掘漏洞的能力。(深度)
3)编写利用链的能力
前置知识
gcc main.c
1 | #include <stdio.h> |
:ghost:源码到可执行文件
预处理 → 编译 → 汇编 → 链接 → 运行时加载/重定位


开发链路分为:source.c →(预处理)→ .i →(编译)→ .s →(汇编)→ .o(ET_REL)→(链接)→ app(ET_EXEC 或 ET_DYN)→(运行时)→ 动态链接器把 .so 映射并做重定位。
1) 源码 — main.c
是什么:人类可读的 C 代码;包含函数、变量、控制流、头文件等。
包含:#include、#define、函数/结构体/注释、字符串常量。
2) 预处理输出 — main.i (预处理器结果)
是什么:预处理器将 #include 展开并替换宏,生成纯 C 文本(含 # line 指示)。
包含:展开的头文件内容、宏展开结果、行号指令
生成:
1 | gcc -E main.c -o main.i |

3)汇编源 — main.s (编译→生成汇编文本)
是什么:编译器把 C 转成可读汇编指令的文本输出。
如何生成:
1 | gcc -S -O0 -g main.c -o main.s |
(-O0 -g 保持结构清晰且带调试信息;-S 生成 .s)

包含 / 注意点:
- 函数的汇编实现(
push rbp; mov rbp, rsp等) - 参数/局部变量如何分配(栈 vs 寄存器)
- 编译器生成的 helper calls(
memcpy、__stack_chk_fail等)
4) 目标文件 — source.o (汇编 ->机器码,ELF 可重定位,ET_REL)
是什么:汇编后的机器码和节信息
机器码是 CPU 能直接执行的二进制指令序列
- 把每条汇编指令编码成机器码字节
- 划分节(Sections):
.text代码.rodata只读常量.data已初始化全局/静态变量.bss未初始化全局/静态变量(只记大小)
- 生成符号表(Symbol Table)
- 生成重定位表(Relocation Entries)
- 记录“哪些地址暂时未知,将来链接时修补”
符号表:
负责“名字级别的信息”——谁被定义、谁被引用、定义在哪个节的什么偏移。重定位表:
负责“字节级别的补丁”——机器码/数据里哪几个具体字节要用某个符号的最终地址去替换。
1 | extern void foo(); |
编译到 .o 后,符号表大概会长这样(概念化):
| 符号名 | 状态 | 节 | 偏移 |
|---|---|---|---|
| main | 已定义 | .text | 0x0 |
| g | 已定义 | .data | 0x0 |
| foo | 未定义(UND) | 无 | 无 |
解释:
main、g在本文件“有定义”,所以知道它们在本 .o 的哪个节、节内偏移多少。foo在本文件只是extern声明,没有定义,所以标记为 UND。
意思是:“我需要 foo,但我这里没有,你链接时给我找一个。”
| 需要修的地方 | 在哪个节 | 偏移 | 关联符号 | 重定位类型 |
|---|---|---|---|---|
call puts 的地址字段 |
.text |
0x15 | puts |
R_X86_64_PC32 |
mov eax, g 的地址字段 |
.text |
0x30 | g |
R_X86_64_32 |
关键点:
- “需要修的地方”是一个具体字节位置(比如
.text+0x15) - “关联符号”告诉链接器该用谁的地址来修
只包含了的自定义的内容
1 | gcc -c main.c -o main.o |

包含 / 注意点:
.text,.data,.rodata,.bss节- 符号表:定义了该文件出口的函数/变量(GLOBAL)以及未定义的引用(UNDEF)
- 重定位表:表示此文件有哪些地址需要链接器在链接时修正(例如对
printf的引用)
pwn / 调试的“钥匙”:
看哪些符号是未定义(意味着链接时会从库或其它 .o 获取)
如果你想做更底层的二次打包或修改(比如注入符号、改变节),.o 是合适的阶段
重定位条目说明链接器需要做哪些修正(理解 PLT/GOT 的由来)
5) 可执行文件 — app (ET_EXEC 或 ET_DYN)
是什么:链接器把 .o 和库合并,分配最终虚拟地址,产出可执行 ELF。若启用 PIE,则类型为 ET_DYN。
如何生成:
1 | gcc main.o foo.o -o app |
查看命令(必查):
1 | file app |
程序调用
puts()这样的函数时,函数实现其实在 libc 里,而不是你自己的 ELF 文件中。
编译阶段你只知道“我需要 puts 这个符号”,运行阶段才能确定“puts 真正在哪个地址”。got表是libc库中该函数的真实位置
plt表是放置的一段代码,通过调用got表知道 跳转到哪里
| 角色 | 存放位置 | 作用 | 是否可写 |
|---|---|---|---|
| PLT | .text 段 |
函数跳板(代码),调用 GOT | 不可写 |
| GOT | .got/.got.plt 段 |
保存外部函数实际地址 | 可写(部分只读) |

最左侧是高层语言的表示,语义是“把两个整数相加返回”。编译器需要把这种高层语义翻译成一系列 CPU 指令来实现同样的功能(编译)
中间就是进行的实际操作,通过对一系列寄存器的操作来实现代码
最右侧是机器码,也就是机器实际可以识别的内容,每条汇编指令都有对应的机器码字节。
ida查看
Options>General>调节之后就可以看到机器码
1 | # 普通编译(带调试符号,关闭优化) |
-g
包含调试符号(符号表、源代码行号等)到输出文件。
→ 作用:可以在 gdb 里看到函数名、变量名和源代码行,便于调试
-o <file>
指定输出文件名(默认 a.out)。
→ 作用:把编译/链接结果写到你想要的路径。
-O0, -O2(-O* 系列)
控制优化等级:-O0 无优化(编译速度快、符号保留好),-O2 开启较多优化(更快但会重排/内联/消除栈帧)。
→ 作用:它是编译器的优化级别,目的是让程序跑得更快、更小、用更少资源
1 | 控制保护 |
cat 1
cat 1把一个 可执行文件(ELF 二进制) 的原始字节直接吐到终端里 —— 终端不是文本编辑器,而是会把那些“原始字节”当作控制字符去解释(换行、响铃、光标移动、甚至改变显示属性),所以你看到一堆乱码、控制符拼凑的怪样子。简单来说:
- 可执行文件不是纯文本,里面有二进制机器码、ELF 头、节/段表、符号表、字符串常量、调试信息等 —— 都不是人类可读的纯文字。
cat只是把字节原封不动输出到终端,终端把不可见字节当控制序列执行,结果就是“乱码 + 显示混乱”。
elf可执行文件
该板块参考了楚权的世界师傅的文章
可执行文件就是一段被组织成特定格式的二进制数据,操作系统加载它到内存后由 CPU 执行。
不同 OS/架构用不同格式:Windows 用 PE,macOS 用 Mach-O,Linux/Unix 主流用 ELF。

ELF Header
位置:文件开头(固定大小)。
作用:基本元信息 —— ELF 魔数(0x7f’ELF)、类(32/64)、字节序、ELF 类型(ET_REL/ET_EXEC/ET_DYN)、机器架构、入口点地址 e_entry、程序头偏移 e_phoff、节头偏移 e_shoff。
Type 决定这是 .o(ET_REL)、可执行(ET_EXEC)还是共享对象/PIE(ET_DYN);e_entry 是程序入口(运行时从这里开始)。
readelf -h 2
./extract debs/libc6_2.23-0ubuntu11_amd64.deb libs/2.23-0ubuntu11_amd64
./extract debs/libc6-dbg_2.23-0ubuntu11_amd64.deb libs/2.23-0ubuntu11_amd64/.debug
Program Header Table(程序头表,Segments)
ELF 节头表是一个节头数组。每一个节头都描述了其所对应的节的信息,如节名、节大小、在文件中的偏移、读写权限等。编译器、链接器、装载器都是通过节头表来定位和访问各个节的属性的。
readelf -l 2
PT_LOAD:可加载段(会映射到内存,带权限 PF_R/W/X)
PT_INTERP:指定动态链接器路径(有的话就是动态可执行)
PT_DYNAMIC:动态段信息(DT_NEEDED、DT_RPATH、DT_RELA 等)
PT_NOTE、PT_PHDR、PT_GNU_STACK(表示栈是否可执行)等
Sections(节 / Sections)
常见节名:
.text:代码(只读/可执行).rodata:只读数据(字符串常量).data:已初始化的可写数据.bss:未初始化数据(不占文件空间,运行时占内存).symtab/.strtab:符号表与字符串表(链接时用).dynsym/.dynstr:动态符号表(动态链接时用).got/.got.plt:全局偏移表(数据,运行时可写).plt:过程链接表(代码段,跳板).rela/.rel:重定位条目(relocations).init_array/.fini_array:构造/析构函数列表
.text
- 放什么:程序的机器码(函数指令)。
- 特点:只读(r–)+ 可执行(–x)。
- 例子:
main,foo这些函数编译出的指令都在这。
.rodata
- 放什么:只读常量数据。
- 特点:只读(r–),不可写。
- 例子
- 字符串常量:
"hello world" const修饰且不需要可写的全局常量- 只读查找表、跳转表(有时也会在这里)
- 字符串常量:
.data
放什么:已初始化的全局变量 / 静态变量。
特点:可读可写(rw-),文件里保存初值。
例子
1
2int g = 5;
static int s = 2;初值
5、2都会在.data里。
.bss
放什么:未初始化(或初始化为 0)的全局变量 / 静态变量。
特点
- 文件里不存具体内容,只记大小
- 运行时会分配内存并清 0
例子
1
2
3int g;
static int s;
int z = 0; // 很多编译器也会放 bss
.symtab / .strtab
- 放什么
.symtab:静态符号表
记录本目标文件/可执行文件里所有符号(函数、全局变量、段符号等)的信息。.strtab:符号名字的字符串池(符号表里只存“指向这里的偏移”)。
- 什么时候用:主要给链接器、调试器用。
- 发布版程序常见情况:
strip后.symtab/.strtab可能被删掉(因为运行时不需要)。
.dynsym / .dynstr
- 放什么
.dynsym:动态符号表
只保存动态链接需要的那部分符号(比.symtab少很多)。.dynstr:动态符号名字符串池。
- 什么时候用:运行时动态链接器解析共享库符号时用。
- 例子:你程序调用
puts,puts会出现在.dynsym。
.got / .got.plt
- 放什么:全局偏移表(GOT),本质是一张“指针数组”。
.got:给全局变量地址、函数地址等做间接引用。.got.plt:更偏向配合.plt做延迟绑定(lazy binding)的函数地址槽。
- 特点
- 在文件里有初始值(通常指向解析代码或 0)
- 运行时可写,动态链接器会把真实地址写入这里
- 例子:
puts@GOT这个槽,运行时会被填成 libc 中 puts 的真实地址。
.plt
- 放什么:过程链接表(PLT),是一段“跳板代码”。
- 作用:当你
call puts时:- 先跳到
puts@plt puts@plt去.got.plt查真实地址- 若没解析过就触发动态解析,解析后再跳
- 先跳到
- 特点:位于代码区,可执行,但通常不含真实函数逻辑。
- 直观理解:
.plt是“中转站”;.got.plt是“电话簿”。
.rela.* / .rel.*
- 放什么:重定位条目(relocation entries)。
- 作用:告诉链接器/动态链接器:
- 要修补的位置在哪(某段某偏移)
- 用哪个符号修补
- 修补类型(填绝对地址?相对偏移?加上附加常量?)
.relvs.rela.rel:不显式保存 addend(附加常量),addend 藏在原位置里.rela:显式保存 addend(x86-64 常用.rela)
- 什么时候用
- 静态链接:链接器消耗大部分
.rela.text/.rela.data - 动态链接:
.rela.plt、.rela.dyn留给运行时修补 GOT/PLT
- 静态链接:链接器消耗大部分
.init_array / .fini_array
- 放什么
.init_array:一串函数指针,程序启动时调用(构造)。.fini_array:一串函数指针,程序退出时调用(析构)。
- 来源
- C++ 全局对象构造/析构
__attribute__((constructor))/__attribute__((destructor))
- 执行顺序
- 入口
_start→ libc 初始化 → 依次调用.init_array→main exit→ 依次调用.fini_array
- 入口
小结(把它们按用途分组)
- 程序内容
.text代码.rodata只读常量.data/.bss可写全局数据
- 链接/调试
.symtab/.strtab
- 动态链接运行时
.dynsym/.dynstr.plt/.got/.got.plt.rela/.rel
- 程序生命周期钩子
.init_array/.fini_array
:ghost:汇编
寄存器和栈是 CPU 执行许多操作时最直接、最关键的工具
寄存器是CPU内部的小型高速存储单元,用于临时存储指令执行时的数据、地址、标志等。
要求不高

64
RIP— 指令指针(程序当前执行到哪儿)。RSP— 栈指针(栈顶地址)。RBP— 帧指针(传统上指向栈帧基底)。RAX— 返回值 & syscall 编号(syscall 用)。RDI, RSI, RDX, RCX, R8, R9— 函数/系统调用参数(顺序很重要:- 函数调用时参数顺序为
RDI, RSI, RDX, RCX, R8, R9;(前6个参数放在寄存器中,之后放在栈上) - syscall 参数顺序为
RDI, RSI, RDX, R10, R8, R9)。 RBX, R12-R15— callee-saved(被调用者需保存)。RAX, R10, R11, RDI, RSI, RDX, RCX, R8, R9— caller-saved(调用者必须自己处理其值如果需要保留)。
记住两条最重要的习语:
- “函数参数在
RDI, RSI, RDX, RCX, R8, R9。” - “syscall 号在
RAX,返回值也在RAX。
| 场景 | 参数顺序 | 被覆盖寄存器 |
|---|---|---|
| 函数调用 | RDI → RSI → RDX → RCX → R8 → R9 | 无自动覆盖 |
| 系统调用 | RDI → RSI → RDX → R10 → R8 → R9 | RCX 和 R11 被 syscall 自动保存 |
32
参数放在栈上
EIP — 指令指针(下一条要执行的地址,等同于 x86_64 的 RIP)。
ESP — 栈指针(当前栈顶,等同于 RSP)。
EBP — 帧指针(传统上指向栈帧基底,相当于 RBP)。
EAX — 返回值 & syscall 编号(32-bit 下 syscall 号放 EAX,返回值也放 EAX)。
EDI, ESI, EDX, ECX, EBX — 通用寄存器(在不同约定下分配参数或保存临时值)。
EBX, ESI, EDI, EBP — callee-saved(被调用者应保持这些寄存器的值)。
EAX, ECX, EDX — caller-saved(调用者在需要保留时须先保存)。
| 场景 | 参数传递方式 | 参数顺序(第1→第6) | syscall/syscall号 & 返回 |
|---|---|---|---|
| 函数调用(常见 C ABI:cdecl/stdcall) | 压栈(从右到左 push) | 在栈上,调用时布局为 argN ... arg2 arg1(传入时最后一个先 push,函数通过 [esp + offset] 读取) |
返回值在 EAX |
| 系统调用(int 0x80) | 寄存器(直接放寄存器) | EBX → ECX → EDX → ESI → EDI → EBP(对应第1→第6) |
syscall 号放 EAX,返回值在 EAX |
一、数据操作类(最基础的)

| 指令 | 含义 | pwn 中常见用途 |
|---|---|---|
mov dst, src |
拷贝数据(寄存器/内存/立即数) | 改变量、保存返回地址、传参 |
lea dst, [expr] |
计算地址放入寄存器 | 拿变量地址、计算偏移 |
push src |
把值压栈 (rsp -= 8) |
函数调用时保存现场 / 手动构造栈 |
pop dst |
从栈顶弹出值 (rsp += 8) |
函数返回 / ROP gadget 常用 |
xchg rax, rbx |
交换寄存器 | 常见 gadget:xchg eax, esp (pivot stack) |
movzx / movsx |
零/符号扩展 | 处理 byte → int 之类的类型扩展 |
[...] 表示 “把方括号里算出的值当作内存地址去访问(读或写)
lea 用来 计算地址表达式的值,并把这个“地址值”放进寄存器。它不会访问内存
1 | 内存[0x1010] = 0xDEADBEEF |
⚙️ 二、算术运算类
| 指令 | 含义 | pwn 实战中的常见点 |
|---|---|---|
add dst, src |
相加 | 指针运算、偏移调整 |
sub dst, src |
相减 | 栈空间分配 (sub rsp, 0x20) |
inc reg / dec reg |
加1/减1 | 常见循环 / 指针偏移 |
imul / mul |
乘法(有符号/无符号) | 不太常见,出现在运算逻辑 |
div / idiv |
除法(注意用 rdx:rax) | 算数逻辑题、算法题中 |
neg reg |
取负数 | 算术 gadget 时有用 |
💡 三、逻辑 / 位操作类(pwn 中非常常见)
| 指令 | 含义 | pwn 用法 |
|---|---|---|
xor reg, reg |
异或(常用于清零) | xor rax, rax → rax = 0(经典 gadget) |
and reg, imm |
位与(掩码) | 清除特定位 |
or reg, imm |
位或 | 设置特定位 |
not reg |
取反 | 混淆或 gadget 构造 |
shl, shr, sar |
移位(逻辑/算术) | 位运算、乘除 2 的幂次 |
| 指令 | 操作 | 结果 | 常见用途 |
|---|---|---|---|
xor |
按位异或 | 不同为 1 | 清零、加密、gadget |
and |
按位与 | 都为 1 才为 1 | 清除位(掩码) |
or |
按位或 | 只要有 1 就为 1 | 设置位 |
not |
按位取反 | 0→1, 1→0 | 取补、混淆 |
shl |
左移 | ×2ⁿ | 乘法、移位 |
shr |
逻辑右移 | ÷2ⁿ (无符号) | 除法、位处理 |
sar |
算术右移 | ÷2ⁿ (保符号) | 有符号除法 |
🔍 四、比较 / 分支控制类
| 指令 | 含义 | pwn 场景 |
|---|---|---|
cmp a, b |
计算 a - b,只影响标志位,不保存结果 | 条件跳转判断 |
test a, b |
按位与,只设标志位 | 检查为零或特定位 |
je / jz |
如果相等(ZF=1)跳转 | if (a == b) |
jne / jnz |
不相等跳转 | if (a != b) |
ja / jb / jg / jl |
无符号 / 有符号大于小于跳转 | if (a < b) |
jmp label |
无条件跳转 | 跳转、跳表、shellcode |
call func |
调用函数(压返回地址) | 函数调用、溢出时覆盖返回地址 |
ret |
从函数返回(弹出返回地址跳转) | 栈溢出、ROP gadget 的关键 |
🧱 五、栈与函数调用(pwn 最常分析的区域)
| 指令 | 功能 | 用处 |
|---|---|---|
push rbp / mov rbp, rsp |
建立栈帧 | 函数入口(prologue) |
leave |
等价于 mov rsp, rbp; pop rbp |
函数退出(epilogue) |
ret |
从栈顶取返回地址跳转 | 溢出劫持控制流的关键点 |
pop rdi / pop rsi / pop rdx / pop rax |
从栈加载参数 | ROP 链构造必备 |
syscall |
执行系统调用 | 直接调用内核(shellcode) |
int 0x80 |
旧版 32-bit syscall 方式 | 仅用于 32 位靶机 |
🧨 六、系统调用类(shellcode / ROP 核心)
| 指令 | 功能 | 备注 |
|---|---|---|
syscall |
触发系统调用(64 位) | 根据 rax 的 syscall 号执行系统调用 |
int 0x80 |
触发系统调用(32 位) | 较老架构使用 |
hlt |
挂起 CPU(少见) | 调试或陷阱 |
nop |
空操作 | 对齐、NOP-sled(shellcode) |
1 | // 编译选项会关掉优化和内联,这里再显式禁止内联 |
gcc -m32 -g -O0 -fno-omit-frame-pointer -fno-inline -no-pie -fno-pie add.c -o add
:ghost:栈结构
先说“栈”和“堆”是啥
- 栈(stack):放函数调用相关的临时东西
例如:局部变量、返回地址、保存的寄存器等。
特点:自动管理,函数进来分配,函数退出释放。- 堆(heap):放你运行时“手动申请”的内存
例如:malloc/new出来的对象。
特点:手动/库管理,你申请多少就给多少,用完要释放(free/delete)。

入栈
add(2,3)
(1)压入参数
根据调用约定,main函数作为调用者,首先需要将add函数的两个参数a,b压入栈中。
注意压入参数的顺序,首先压入b,接着压入a,也就是说,压入参数的顺序是从右到左。
(2)返回地址压栈
call target 的行为等价于两步:
push rip_next(把下一条指令的地址压到栈上)jmp target(把 RIP 跳到被调用函数)
返回地址压栈。main函数为了让add函数调用后正常返回,需要将当前指令(call指令)的下一条指令的地址压入栈中
call指令包含两个步骤:main函数的下一条指令压栈;控制eip为func函数的第一条语句,eip存储着CPU将要执行的指令的地址
(3)add函数运行
1 | add`函数需要自己的内部运作空间。可以使用以下指令序列开辟空间:`push ebp` `mov ebp,esp` `sub esp,28h |
这个28h就是开辟的存储func函数的相关内存的空间
mov ebp,esp看似没有变化,但EBP 从“保存调用者基址”变为“当前函数的栈帧基址”。把当前栈顶指针(esp)的值复制到 ebp
(4)func函数的局部变量和参数
func函数有自己内部的运作空间,即func_ebp到func_esp
局部变量在编译之后会被表示为与ebp 不同的距离

v2被表示为ebp -18h,v3被表示为ebp -10h。
注意,这里的v2, v3和v4都是局部变量。参数的读取方式例子:第一个参数是ebp+8,第二个参数是ebp+12
因为调用约定,肯定有main_next和main_ebp在ebp的上方,故偏移是固定的,在返回地址main_next上面
然后,func函数返回。两个步骤:清除栈空间,返回到之前执行的指令(main_next)处。
出栈
这里add函数进行了简化
(5)add函数返回步骤1:清除栈空间
每个函数清除栈空间时可以使用一条公用指令leave。
leave包含两个步骤:
①mov esp,ebp将栈顶设置为栈基,即将func_ebp到func_esp的内容全部出栈;
②pop ebp 将main_ebp的内容重新弹回ebp,使main函数的栈基址得到恢复
(6)func函数返回步骤2:返回之前执行的指令(main_next)处
每个函数返回时可以使用一条公用指令ret。ret包含一个步骤:将栈顶弹出到eip寄存器(即下一条指令的位置),达到恢复main函数执行顺序的目的。
到这里,main函数的栈基址和下一条指令都得到了恢复,也就完成了整个add函数的调用。
实操
题目概述
在常规的CTF比赛中,通常会给一个预置漏洞elf文件,该二进制文件会同时在远程目标主机上运行,并绑定一个端口。
连接某个端口不会“魔法般地跑起文件”,而是:有一个程序事先在那台机器上运行并把那个端口“占着”(绑定/监听)——当你连上去,操作系统把这条连接交给那个程序,程序就开始跟你交互。
- 服务器 = 餐厅
- 端口 = 餐厅的门号(比如门 1337)
- 监听程序 = 开门的服务员(一直站在门口)
- 你连上端口 = 你走到门口按门铃(发起连接)
- 结果:服务员听到有人来,就开始接待(把连接交给内部进程处理)
你看到的是“按门铃 → 有人来接待”,而不是“按门铃 → 门口才突然出现服务员”。
环境配置
需要工具
IDA Pro
虚拟机:
pwntools
pwndbg
checksec
ROPgadget
one gadget
LibcSearcher
main arena offset
虚拟机
改变量主机名

1 | 改主机名 |
1 | 改用户名(推荐创建一个新用户) |
保护机制

Arch:amd64-64-little:
amd64即x86_64(CPU架构)
64即字长为64,返回地址在栈中占 8字节,32位是4字节
little表示小端序,如果是big则表示大端序。以数字0x12345678为例,小端序存储的是\x78\x56\x34\x12,大端序存储的是\x12\x34\x56\x78
RELRO:Partial RELRO:
它是一种 ELF 链接时生成的安全机制,目的是防止 GOT(全局偏移表,Global Offset Table) 被修改。
简单说:
RELRO 把一部分关键的内存区域(特别是 GOT 表)在程序加载完重定位后改成只读,防止攻击者利用写入 GOT 来控制程序流。
| 段/表 | 正常作用 | 为什么能写 | RELRO保护后的变化 |
|---|---|---|---|
| .got.plt | 存储动态链接函数的真实地址(lazy binding后) | lazy binding 需要写入真实地址 | NO RELRO 时可写,PARTIAL RELRO 也可写,FULL RELRO 时不可写 |
| .init_array | 程序启动时调用的函数数组 | 存函数指针 | NO RELRO 时可写,PARTIAL/FULL 时不可写 |
| .fini_array | 程序退出时调用的函数数组 | 存函数指针 | NO RELRO 时可写,PARTIAL/FULL 时不可写 |
Stack: Canary found
栈保护,一般是在rbp地址之前放置一个数字,在返回时进行校验,以防止栈溢出
NX:NX enabled:
如果开启了NX保护机制,那么w权限和x权限就是互斥的,即不存在既拥有写权限又拥有执行权限的段。这个保护机制是用来防止shellcode植入的。在gdb调试中,可以通过vmmap命令来查看每个段的权限。
PIE: PIE enabled
使程序可以被加载到任意内存地址运行
有 PIE → 程序的 .text、.data 等段在每次运行时会被加载到不同的基址。
没有 PIE → 程序的代码段基址固定,比如永远是 0x400000。


题型
ret2text(覆盖返回地址)
题目system(“/bin/sh”)
1 | from pwn import * |
CPU / 函数调用 /远端进程都读取字节,不识别 Python 的整数类型。p64(0x400611) 产生 b'\x11\x06\x40\x00\x00\x00\x00\x00',这是 64 位小端表示,直接放到返回地址位置进栈后,CPU 按字节解释为地址。
题目system(“cat flag.txt”)
1 | from pwn import * |
原题(111)在系统上运行时用的是系统的 glibc(/lib/x86_64-linux-gnu/libc.so.6),而你把 bjdctf_2020_babystack 换成了 带有特定 glibc 2.23 的副本(放在 /usr/lib/freelibs/.../libc.so.6),这会改变 libc 中函数/符号/内存布局(比如 system、__libc_start_main、one_gadget、ROP gadget 的偏移等)。很多 pwntools/exp 脚本依赖 精确的 libc 偏移,所以当本地 libc 与目标 libc 版本不一致时(偏移不同),exploit 会失败或 crash。把本地运行环境改成目标用的 libc(2.23)后,地址/偏移匹配了,脚本就能正常运行/成功利用了。
libc-2.23.so
1 | from pwn import* |
ret2shellcode
mmap((void *)0x30303000, 0x1000u, 7, 50, -1, 0);- 在地址
0x30303000处申请一页(0x1000= 4096 字节)的内存映射。 - 第三个参数
7是prot:PROT_READ | PROT_WRITE | PROT_EXEC(1|2|4 = 7),说明该页可读、可写、可执行(RWX)。 - 第四个参数
50(十进制)即0x32(十六进制),通常分解为 mmap flags 的位组合:0x02 (MAP_PRIVATE)+0x10 (MAP_FIXED)+0x20 (MAP_ANONYMOUS)=0x32 (50)。MAP_ANONYMOUS:不从文件映射,内存初始化为 0。MAP_FIXED:强制在给定地址映射(如果地址不可用会失败或替换原有映射)。MAP_PRIVATE:写时复制(对我们的目的影响不大)。
- 总结:在确切地址
0x30303000建立 匿名、私有、RWX、固定地址 的内存页。
- 在地址
read(0, (void *)0x30303000, 0x64u);- 从文件描述符
0(stdin)读0x64= 100 字节,写入刚映射的内存0x30303000。 - 也就是说程序允许通过标准输入把最多 100 字节的数据放到该可执行页里。
- 从文件描述符
MEMORY[0x30303000]();- 把
0x30303000视为函数指针并调用它 —— 等同于((void(*)())0x30303000)();。 - 也就是:把刚读进来的 100 字节当作机器码来执行。
- 这是直接代码执行(code injection/execution):攻击者可以把 shellcode、或第一阶段 shellcode 写入并直接执行。
- 把
1 | from pwn import * |
ret2libc1
1 | from pwn import * |
1 | from pwn import * |
ret2libc2
1 | from pwn import * |
pop_ebx 在这类 exploit 里主要作用不是把值放到 ebx 里去给后面的 system 用(system 不看 ebx),而是通过 pop ebx; ret 这个 gadget 把栈向前推进一个 dword,从而把栈上的下一项变成 ret 会跳转到的地址(也就是 system)
ret2libc3
64位的是要16字节对齐的
gets(s):从标准输入读字符,直到遇到换行或 EOF,然后把换行替换成'\0'并写入缓冲区。不会检查缓冲区长度(危险!)。strlen(s):从s开始逐字节向后找,直到遇到第一个'\0',返回其前面的长度(size_t)。puts(s):把从s开始到第一个'\0'的字符串写到 stdout,然后自动输出一个换行(\n)。
因此,三者都以 '\0' 为结束条件(C 风格字符串),它们会把或依赖 '\0' 来确定字符串结尾。
用libc文件
1 | from pwn import* |
用Libcsearcher
1 | from pwn import * |
1 | p.recvuntil('\n') |
- 从进程/远端读取直到遇到换行(包含换行本身)。注意:在 Python3 / pwntools 中应传
bytes:p.recvuntil(b'\n'),否则会出错或行为不一致。
1 | [:-1] |
- 去掉最后一个字节(也就是把换行
\n去掉),得到“行内容”。
1 | .ljust(8, b'\0') |
- 把得到的字节串右侧填充到 8 字节(小端 u64 需要 8 字节)。例如如果你得到
b'\x10\x20\x30'(3 字节),ljust(8,b’\0’) 变成b'\x10\x20\x30\x00\x00\x00\x00\x00'。
1 | u64(...) |
- 把上面的 8 字节按 小端 解为一个 64 位整数(pwntools 的
u64)。得到puts_addr。
ret2syscall
寄存器
在32位系统中,通过int 0x80指令触发系统调用。其中,EAX寄存器用于传递系统调用号,参数按顺序赋值给EBX、ECX、EDX、ESI、
EDI、EBP这6个寄存器。
在64位系统中,使用syscall指令来触发系统调用,同样使用EAX(RAX)寄存器传递系统调用号,RDI、RSI、RDX、RCX、R8、R9
这6个寄存器用来传递参数。

eg:read(0,buf,0x100)
32位时,eax=3(#define__NR_read 3);ebx=0;ecx=buf;edx=0x100;,设置完每个寄存器的值之后,再执行int 0x80指令,就会执行对应的read(0,buf,0x100)。实际上,最后还会进入内核态执行。
64位时,rax=0(#define__NR_read 0);rdi=0;rsi=buf;rdx=0x100,设置完每个寄存器的值之后,再执行syscall指令,就会执行对应的read(0,buf,0x100)。
也是进入内核态执行。
如果想要完成Get shell
system(“/bin/sh”)
32位时,:eax=11(#define__NR_execve 11);ebx=”/bin/sh”;ecx=0;edx=0;(NULL实际上就是0),设置完每个寄存器的值之后,再执行int 0x80指令,就会执行对应的execve(”/bin/sh”,NULL,NULL)。
64位时,rax=59(#define__NR_execve 59);rdi=”/bin/sh”;rsi=0;rdx=0;
1 | from pwn import * |
1 | ROPgadget --binary rop --only 'pop|ret' | grep 'eax' |
- 系统调用号,即 eax 应该为 0xb
- 第一个参数,即 ebx 应该指向 /bin/sh 的地址,其实执行 sh 的地址也可以。
- 第二个参数,即 ecx 应该为 0
- 第三个参数,即 edx 应该为 0
gdb
挺多的,慢慢学
gdb ./vuln
在本地打开可执行文件。
run 或 r
运行程序(可加参数:run arg1 arg2)
break main 或 b main
在函数入口下断点。
break *0x40060d 或 b *0x40060d
在具体地址下断点
continue 或 c —— 继续运行到下一个断点。
step 或 s —— 单步,进入函数(按源码/指令)。
next 或 n —— 单步,不进入函数(跳过 call)。
si / ni —— 指令级单步(single instruction / next instruction)。
finish —— 运行到当前函数返回并打印返回值。
p/x $rsp 或 p/x $rax —— 打印寄存器值(十六进制)。
disassemble main 或 disassemble 0x40060d,0x400630 —— 反汇编函数/地址区间。
x/i $rip(x86_64) / x/i $eip(x86) —— 查看当前指令。