资讯动态

基于Pwntools的命令行工具自动化Fuzz框架实战

发布时间:2026/9/20 20:52:41 来源:尧图企业网站定制
1. 为什么我放弃了纯手工Fuzz转而用Pwntools搭自动化框架命令行工具的安全测试一直是个容易被忽略的角落。大家聊漏洞挖掘第一反应往往是Web应用、内核驱动、网络协议栈但那些每天在服务器上跑的tar、grep、awk、file、strings同样是用C写出来的同样会读文件、解析格式、处理用户输入同样可能因为一个边界检查没做好就崩掉。更关键的是这类工具通常以较高权限运行一旦被利用影响面比一个普通Web漏洞大得多。我最初做命令行工具Fuzz的时候用的是最原始的办法写个Shell脚本循环生成随机输入喂给目标程序看它会不会Segfault。这个方法能跑但问题很快暴露出来——效率极低无法感知崩溃的具体原因更没法在崩溃时自动保存现场。后来试过AFL确实强大但AFL的插桩编译对很多场景不友好尤其是当你拿不到源码、只能对二进制做黑盒测试的时候AFL的门槛就上来了。转折点是有一次我需要批量测试一批闭源的命令行工具数量大概三十多个每个都要单独配AFL环境、单独编译插桩版本光环境搭建就耗掉两天。那时候我就在想有没有一种更轻量的方式能快速对任意命令行工具做自动化Fuzz不需要插桩不需要源码崩溃了还能自动记录输入和上下文。Pwntools进入了视野。大多数人提到Pwntools第一反应是写PWN题的Exploit构造ROP链、打远程、调gdb。但Pwntools本质上是一个进程交互与二进制操作的工具库它的process模块可以启动任意程序、发送任意数据、捕获输出和退出状态cyclic模块可以生成模式化字符串用于偏移定位ELF模块可以解析二进制信息。这些能力组合起来恰好就是一个轻量级Fuzz框架需要的基础设施。所以这篇文章要聊的就是怎么用Pwntools写一个自动化Fuzz脚本专门针对Linux命令行工具做漏洞挖掘。不依赖AFL插桩不需要源码纯黑盒能自动生成测试用例、自动检测崩溃、自动保存现场、自动做初步的崩溃分类。适合有一定Python基础、了解基本漏洞挖掘概念、想扩展自己工具箱的安全从业者。如果你之前只用Pwntools做过PWN题这篇文章会给你打开另一个使用场景。2. 命令行工具Fuzz的核心难点与Pwntools的切入点2.1 命令行工具和网络服务的本质差异做Web Fuzz或者网络协议Fuzz的时候目标是一个持续运行的服务你发请求、收响应连接断了可以重连状态相对可控。但命令行工具不一样它的生命周期是“启动—执行—退出”每次测试都要重新拉起进程。这意味着两件事第一进程启动的开销会直接影响Fuzz效率第二每次执行都是独立的状态你没法像网络服务那样维持一个长连接反复发数据。另一个差异是输入形式。网络服务的输入是字节流命令行工具的输入可能是命令行参数、标准输入、环境变量、文件内容甚至是这些的组合。一个tar命令你可以通过参数指定解压文件也可以通过管道喂数据还可以让它读取一个恶意构造的压缩包。输入面更分散需要针对不同工具设计不同的输入策略。还有一个容易被忽略的点命令行工具通常会调用其他系统命令或库函数。比如file命令会调用libmagicstrings会调用libbfd。崩溃可能发生在工具本身也可能发生在它依赖的库里。这对崩溃定位提出了更高要求。2.2 为什么Pwntools适合做这件事Pwntools的process模块提供了非常干净的进程交互接口。你可以这样启动一个进程from pwn import * p process([/usr/bin/target, arg1, arg2]) p.sendline(payload) p.recvall(timeout2) exit_code p.poll()这几行代码覆盖了Fuzz需要的核心能力启动进程、发送数据、读取输出、获取退出状态。而且process模块底层用的是subprocess但封装了更多便利功能比如recvall、sendlineafter、clean等处理交互逻辑时比裸写subprocess舒服得多。更重要的是Pwntools的cyclic模块可以生成De Bruijn序列用于快速定位缓冲区溢出偏移。当你发现一个崩溃时用cyclic_find可以立刻算出覆盖返回地址需要的字节数省去了手工构造模式串的麻烦。ELF模块则可以在Fuzz之前先解析目标二进制获取架构、保护机制、依赖库等信息帮助判断哪些类型的漏洞更可能出现。比如一个没有开NX的程序栈溢出直接就能执行shellcode开了PIE和Full RELRO的利用难度就高很多。2.3 黑盒Fuzz的边界与合理预期需要明确一点纯黑盒Fuzz有它的天花板。没有源码插桩你拿不到代码覆盖率反馈只能靠随机变异和启发式策略来生成输入。这意味着对于深层逻辑漏洞黑盒Fuzz的命中率远不如AFL这类覆盖率引导的工具。但这不代表黑盒Fuzz没有价值。大量命令行工具的漏洞是浅层的参数解析时的缓冲区溢出、格式化字符串、整数溢出导致的堆破坏、文件解析时的越界读取。这些漏洞往往只需要一个构造巧妙的输入就能触发不需要复杂的路径探索。对于这类目标黑盒Fuzz的投入产出比反而更高因为省去了插桩编译和环境适配的成本。我的策略是先用Pwntools做一轮快速黑盒Fuzz把浅层漏洞捞出来如果目标足够重要且黑盒Fuzz没有收获再考虑上AFL做深度覆盖。两者不是替代关系而是不同阶段的工具选择。3. 搭建Fuzz脚本的骨架从进程启动到崩溃捕获3.1 目标程序的信息采集在写Fuzz逻辑之前先要对目标做一轮信息采集。这一步很多人会跳过直接开始随机测试结果就是效率低下且容易漏掉关键线索。用Pwntools的ELF模块可以快速获取二进制的基本信息from pwn import * elf ELF(/usr/bin/target, checksecFalse) print(fArch: {elf.arch}) print(fBits: {elf.bits}) print(fPIE: {elf.pie}) print(fNX: {elf.nx}) print(fCanary: {elf.canary})这些信息直接影响后续的Fuzz策略。比如32位程序比64位更容易出现栈溢出因为地址空间小、寄存器少没有Canary的程序栈溢出后更容易直接控制执行流开了PIE的即使控制了返回地址还需要信息泄露才能完成利用。除了二进制本身还要采集它的命令行接口信息。运行target --help或者target -h看看它接受哪些参数、哪些参数需要文件输入、哪些参数会触发特殊逻辑。这一步可以用Pwntools自动完成p process([/usr/bin/target, --help]) help_output p.recvall(timeout3).decode(errorsignore) print(help_output)把帮助信息保存下来后续设计Fuzz用例时可以针对性地覆盖不同参数组合。3.2 输入生成策略从随机到结构化最朴素的Fuzz输入生成就是随机字节。用os.urandom或者random.randbytes生成任意长度的随机数据直接喂给目标。这种方法实现简单但命中率低因为大多数随机输入在解析的早期阶段就被拒绝了。稍微好一点的做法是基于已知格式做变异。比如目标是一个处理PNG图片的工具那就拿一个合法的PNG文件作为种子随机翻转其中的字节、修改长度字段、插入额外数据。这种“种子变异”的策略比纯随机高效得多。Pwntools本身不提供变异引擎但可以很方便地和其他库配合。我通常用random模块做简单的字节级变异import random def mutate(seed_data, num_mutations10): data bytearray(seed_data) for _ in range(num_mutations): pos random.randint(0, len(data) - 1) data[pos] random.randint(0, 255) return bytes(data)对于命令行参数变异策略又不一样。参数通常是字符串需要针对字符串做变异超长字符串、格式化字符串%s%n、路径穿越../../etc/passwd、特殊字符\x00、\n、\xff等。我一般会准备一个“危险字符串”列表DANGEROUS_STRINGS [ bA * 1000, b%s * 100, b%n * 100, b../../../../etc/passwd, b\x00 * 100, b\xff * 100, b${IFS}, bid, b$(id), b|id, b;id, bid, ]这些字符串覆盖了缓冲区溢出、格式化字符串、路径穿越、命令注入等常见漏洞类型。把它们作为参数值或者标准输入喂给目标观察是否触发异常。3.3 崩溃检测与现场保存崩溃检测的核心是判断进程是否异常退出。在Linux下进程因为信号而终止时退出状态是负数绝对值等于信号编号。比如Segfault对应SIGSEGV11退出状态是-11Abort对应SIGABRT6退出状态是-6。Pwntools的process对象提供了poll()方法返回进程的退出状态。如果进程还在运行返回None如果已经退出返回退出码或负的信号编号。p process([/usr/bin/target, payload]) p.wait(timeout5) exit_status p.poll() if exit_status is not None and exit_status 0: signal_num -exit_status print(fCrash detected! Signal: {signal_num})检测到崩溃后必须立刻保存现场。现场包括触发崩溃的输入、崩溃信号、进程的输出stdout和stderr、以及可能的core dump信息。def save_crash(payload, exit_status, output, crash_dircrashes): os.makedirs(crash_dir, exist_okTrue) timestamp int(time.time() * 1000) crash_id fcrash_{timestamp}_{abs(exit_status)} with open(os.path.join(crash_dir, f{crash_id}.input), wb) as f: f.write(payload) with open(os.path.join(crash_dir, f{crash_id}.log), w) as f: f.write(fExit status: {exit_status}\n) f.write(fOutput:\n{output}\n) return crash_id这里有个细节p.recvall()会读取进程的所有输出但如果进程崩溃时输出缓冲区没有刷新可能会丢失部分输出。更稳妥的做法是用p.recv(timeout1)循环读取直到超时或者进程退出。注意保存输入时一定要用二进制模式wb因为触发崩溃的输入往往包含不可打印字符用文本模式写入会损坏数据。3.4 超时处理与资源控制Fuzz过程中最怕的就是目标程序挂死。有些输入会让程序进入死循环或者等待永远不会到来的输入。如果不设超时整个Fuzz脚本就会卡住。Pwntools的process对象支持超时参数但更可靠的做法是在外部用signal模块设置闹钟import signal class TimeoutError(Exception): pass def timeout_handler(signum, frame): raise TimeoutError(Process timed out) signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(5) # 5秒超时 try: p process([/usr/bin/target, payload]) p.wait() finally: signal.alarm(0)另一种方式是使用subprocess的timeout参数但Pwntools的process没有直接暴露这个参数。我通常用p.wait(timeout5)如果超时会抛出TimeoutError然后手动p.kill()。资源控制还包括限制进程的内存使用。有些输入会导致程序申请大量内存如果不加限制可能把测试机搞挂。可以用resource模块设置内存上限import resource def limit_memory(): resource.setrlimit(resource.RLIMIT_AS, (512 * 1024 * 1024, 512 * 1024 * 1024)) p process([/usr/bin/target, payload], preexec_fnlimit_memory)preexec_fn会在子进程执行前调用适合做这类资源限制。4. 让Fuzz更聪明变异策略与参数组合优化4.1 基于种子文件的变异引擎纯随机生成的输入大部分在解析的早期阶段就被拒绝了。比如一个处理ZIP文件的工具随机字节流连ZIP头都过不了根本走不到解压逻辑。所以需要“种子变异”的策略。种子文件从哪来最直接的方式是让目标程序自己生成。比如tar命令你可以先用它打包一个正常文件得到一个合法的tar包然后对这个tar包做变异。或者从系统里找一些现成的样本文件比如/usr/share/下的各种格式文件。变异操作我通常组合使用以下几种位翻转随机选择一个字节翻转其中一位或几位。字节替换随机选择一个字节替换为另一个随机字节。块删除随机删除一段连续字节。块插入在随机位置插入一段随机字节。块复制把一段字节复制到另一个位置。长度字段篡改找到文件中表示长度的字段修改为极大或极小值。def mutate_seed(seed_data, num_mutations20): data bytearray(seed_data) for _ in range(num_mutations): op random.choice([flip, replace, delete, insert, copy]) if op flip and len(data) 0: pos random.randint(0, len(data) - 1) bit random.randint(0, 7) data[pos] ^ (1 bit) elif op replace and len(data) 0: pos random.randint(0, len(data) - 1) data[pos] random.randint(0, 255) elif op delete and len(data) 1: pos random.randint(0, len(data) - 2) length random.randint(1, min(64, len(data) - pos)) del data[pos:poslength] elif op insert: pos random.randint(0, len(data)) length random.randint(1, 64) data[pos:pos] os.urandom(length) elif op copy and len(data) 1: src random.randint(0, len(data) - 1) dst random.randint(0, len(data) - 1) length random.randint(1, min(64, len(data) - max(src, dst))) data[dst:dstlength] data[src:srclength] return bytes(data)这个变异引擎虽然简单但覆盖了常见的文件格式破坏方式。实测下来对于解析类工具这种策略能在几百次迭代内触发浅层崩溃。4.2 命令行参数的组合爆炸问题命令行工具的参数组合是一个组合爆炸问题。一个工具有10个参数每个参数有3种取值那就是3的10次方将近6万种组合。全量测试不现实必须做剪枝。我的做法是分两轮第一轮做单参数测试每次只变异一个参数其他参数用默认值或空值第二轮做多参数组合测试但只组合那些在第一轮中表现出“有趣行为”的参数。“有趣行为”包括程序崩溃、程序输出异常、程序执行时间显著变长、程序内存占用显著增加。这些信号说明该参数触发了特殊逻辑值得深入测试。def test_single_param(target, param_name, param_values): interesting [] for value in param_values: p process([target, param_name, value]) p.wait(timeout5) status p.poll() if status is not None and status 0: interesting.append((param_name, value, status)) return interesting第一轮结束后把“有趣”的参数和值收集起来第二轮做两两组合def test_param_combinations(target, interesting_params): for i in range(len(interesting_params)): for j in range(i 1, len(interesting_params)): p1, v1, _ interesting_params[i] p2, v2, _ interesting_params[j] p process([target, p1, v1, p2, v2]) p.wait(timeout5) status p.poll() if status is not None and status 0: print(fCrash with combo: {p1}{v1}, {p2}{v2})这种策略不能保证覆盖所有组合但能在有限时间内找到大部分浅层漏洞。4.3 标准输入与文件输入的差异化处理很多命令行工具既支持从参数读取输入也支持从标准输入读取。比如grep可以从文件读也可以从管道读。Fuzz的时候要分别覆盖这两种模式。标准输入的Fuzz相对简单直接把变异后的数据通过p.send()发过去就行p process([/usr/bin/target]) p.send(mutated_data) p.shutdown(send) # 关闭stdin让程序知道输入结束 p.wait(timeout5)文件输入的Fuzz需要先把变异数据写入临时文件再把文件路径作为参数传给目标import tempfile def fuzz_with_file(target, mutated_data, extra_argsNone): with tempfile.NamedTemporaryFile(deleteFalse) as f: f.write(mutated_data) temp_path f.name try: args [target] (extra_args or []) [temp_path] p process(args) p.wait(timeout5) return p.poll() finally: os.unlink(temp_path)这里有个坑有些工具会根据文件扩展名选择解析器。比如file命令会根据文件内容判断类型但有些工具会根据.png、.jpg这样的扩展名走不同分支。所以临时文件的命名也要考虑最好用目标工具期望的扩展名。提示临时文件最好放在/tmp或者内存文件系统/dev/shm下避免频繁磁盘IO拖慢Fuzz速度。如果测试机内存充足用tmpfs挂载一个专用目录效果更好。4.4 环境变量与工作目录的Fuzz命令行工具的输入面不止参数和标准输入环境变量和工作目录也可能影响行为。比如PATH变量会影响程序查找依赖命令的方式HOME变量会影响配置文件加载路径TMPDIR会影响临时文件创建位置。有些漏洞就藏在这些地方。比如程序用getenv(HOME)获取家目录然后拼接路径加载配置文件如果HOME被设置为一个超长字符串可能导致缓冲区溢出。或者程序用相对路径创建临时文件如果工作目录不可写可能触发异常处理逻辑中的漏洞。Fuzz环境变量时可以用process的env参数custom_env os.environ.copy() custom_env[HOME] A * 5000 custom_env[PATH] B * 5000 p process([/usr/bin/target], envcustom_env)工作目录则用cwd参数p process([/usr/bin/target], cwd/tmp/fuzz_workdir)这些维度的测试往往被忽略但在实际漏洞挖掘中确实遇到过因为环境变量处理不当导致的崩溃。5. 崩溃分类与初步可利用性判断5.1 从信号类型看漏洞性质不是所有崩溃都值得深入分析。有些崩溃只是空指针解引用利用难度极高有些是栈溢出直接就能控制执行流。根据崩溃信号做初步分类可以快速筛选出高价值目标。信号编号常见原因可利用性SIGSEGV11段错误访问非法内存高可能是栈溢出或堆溢出SIGABRT6断言失败或堆破坏中高堆破坏可能可利用SIGBUS7总线错误对齐问题中取决于具体场景SIGFPE8算术异常除零低通常是逻辑错误SIGILL4非法指令中可能是代码执行的前兆SIGKILL9被强制杀死低通常是OOMSIGSEGV是最值得关注的。但同样是SIGSEGV栈溢出和空指针解引用的利用价值天差地别。怎么区分看崩溃地址。如果崩溃地址是0x0或者接近0x0的小地址大概率是空指针解引用利用难度高。如果崩溃地址是0x41414141AAAA的十六进制或者类似的模式化数据说明攻击者控制的数据被当作了地址利用价值极高。Pwntools的cyclic模块在这里派上用场。用cyclic(1000)生成模式串作为输入崩溃后查看崩溃地址用cyclic_find反查偏移from pwn import * pattern cyclic(1000) # 假设崩溃地址是 0x6161616c offset cyclic_find(0x6161616c) print(fOffset to return address: {offset})如果崩溃地址不在模式串范围内说明不是直接的栈溢出可能是堆溢出或者其他类型的破坏。5.2 用Core Dump做崩溃现场还原Linux系统在进程崩溃时可以生成core dump文件里面包含了崩溃时的内存状态、寄存器值、调用栈等信息。这是分析崩溃原因的金矿。首先要确保系统开启了core dumpulimit -c unlimited echo /tmp/cores/core.%e.%p /proc/sys/kernel/core_pattern然后在Fuzz脚本中崩溃发生后用gdb加载core dumpgdb /usr/bin/target /tmp/cores/core.target.12345在gdb里可以查看崩溃时的寄存器、栈回溯、内存映射(gdb) info registers (gdb) bt (gdb) info proc mappings (gdb) x/20x $rsp这些信息能帮你判断崩溃是栈溢出、堆溢出还是其他类型。如果$rip被模式串数据覆盖那就是典型的栈溢出直接可以写Exploit了。自动化这一步可以用Pwntools的gdb模块但更轻量的做法是用gdb的批处理模式import subprocess def analyze_core(binary, core_path): cmd [gdb, -batch, -ex, bt, -ex, info registers, binary, core_path] result subprocess.run(cmd, capture_outputTrue, textTrue, timeout30) return result.stdout把输出保存下来后续人工分析或者用脚本做模式匹配。5.3 去重避免同一个漏洞反复报警Fuzz跑久了同一个漏洞会被反复触发。比如一个栈溢出只要输入长度超过某个阈值就会崩溃那随机变异会生成无数个触发同样崩溃的输入。如果不去重崩溃目录里会堆满重复文件后续分析效率极低。去重的核心是判断两个崩溃是否“相同”。最简单的办法是比较崩溃信号和崩溃地址。如果信号相同、崩溃地址相同或者落在同一个内存页大概率是同一个漏洞。def crash_signature(exit_status, crash_address): return (exit_status, crash_address 12) # 按页对齐更精确的做法是比较调用栈的哈希。但调用栈获取需要core dump或者gdb开销较大。折中方案是先用信号地址做粗筛对粗筛后仍不确定的再做调用栈比对。我通常维护一个seen_crashes集合每次检测到崩溃时计算签名如果签名已存在就跳过保存seen_crashes set() def is_duplicate(exit_status, crash_address): sig crash_signature(exit_status, crash_address) if sig in seen_crashes: return True seen_crashes.add(sig) return False这个策略能过滤掉90%以上的重复崩溃大幅减少后续分析工作量。5.4 从崩溃到PoC最小化触发输入找到一个崩溃输入后下一步是把它最小化。原始输入可能很长里面大部分字节和崩溃无关。最小化后的PoC更短、更清晰也更容易理解漏洞本质。最小化的基本思路是逐步删除输入中的字节每次删除后重新测试如果仍然崩溃就保留删除后的版本否则恢复。这个过程可以迭代多轮直到输入长度不再减少。def minimize_crash(target, crash_input, extra_argsNone): data bytearray(crash_input) changed True while changed: changed False i 0 while i len(data): # 尝试删除从i开始的chunk chunk_size min(64, len(data) - i) candidate data[:i] data[ichunk_size:] if test_crash(target, bytes(candidate), extra_args): data candidate changed True else: i chunk_size return bytes(data)这个算法的时间复杂度是O(n^2)对于大输入会比较慢。优化方法是先用二分法快速缩小范围再做细粒度删除。或者用ddmin算法它是专门为测试用例最小化设计的效率更高。最小化之后你得到的可能就是一个几十字节的输入里面能清楚看到触发漏洞的关键字段。这对写漏洞报告和Exploit都很有帮助。6. 实战中的坑与效率优化技巧6.1 进程启动开销Fuzz速度的隐形杀手命令行工具Fuzz最大的性能瓶颈是进程启动开销。每次测试都要forkexec一个新进程在Linux下这个开销大概是几毫秒到几十毫秒。如果每秒只能跑几十次测试那Fuzz效率会非常低。优化方向有几个第一用fork代替exec。如果目标程序在启动后、读取输入前有一个稳定的状态可以先启动一个“模板进程”然后每次测试时fork这个进程在子进程中注入输入。这样省去了exec的开销。但Pwntools的process不直接支持这种模式需要自己用os.fork实现。第二并行化。用多进程或者多线程同时跑多个Fuzz任务。Python的multiprocessing模块很适合这个场景from multiprocessing import Pool def fuzz_worker(args): target, seed args # Fuzz逻辑 return crashes with Pool(processes8) as pool: results pool.map(fuzz_worker, task_list)并行度设置为CPU核心数即可太高反而会因为上下文切换降低效率。第三减少不必要的IO。每次测试都写临时文件、读输出日志这些IO操作累积起来很可观。可以把输出重定向到/dev/null只在崩溃时才保存现场。6.2 输出缓冲与死锁问题有些命令行工具在输出大量数据时会阻塞因为管道缓冲区满了而Fuzz脚本没有及时读取。这会导致进程挂起看起来像是死循环。解决办法是及时读取输出。Pwntools的recvall会一直读到进程结束但如果进程不结束recvall就会一直等。更好的做法是用非阻塞读取import select def read_output_nonblocking(p, timeout1): output b while True: ready, _, _ select.select([p.proc.stdout], [], [], timeout) if not ready: break chunk p.proc.stdout.read(4096) if not chunk: break output chunk return output或者用p.recv(timeout0.1)循环读取直到超时。注意如果目标程序同时写stdout和stderr要分别处理否则可能因为其中一个缓冲区满而阻塞。6.3 种子语料库的积累与复用Fuzz的命中率和种子质量强相关。好的种子能覆盖更多代码路径变异后更容易触发深层漏洞。种子从哪来一是从目标程序的测试用例里找。很多开源工具自带测试套件里面有各种格式的样本文件。二是从系统里收集。/usr/share/、/usr/lib/、/opt/下面有大量各种格式的文件可以按扩展名筛选。三是从网上下载公开的样本集比如各种格式的测试文件。收集到的种子要分类管理按文件格式或者按目标工具分组。每次Fuzz时从对应类别里随机选取种子做变异。def load_seeds(seed_dir): seeds [] for root, _, files in os.walk(seed_dir): for f in files: path os.path.join(root, f) with open(path, rb) as fp: seeds.append(fp.read()) return seeds种子不是越多越好。太多种子会导致每个种子被变异的次数减少反而降低效率。我通常每个类别保留几十到几百个种子优先选择体积小、结构清晰的。6.4 日志与监控让Fuzz过程可观测Fuzz脚本跑起来之后你需要知道它当前在做什么、进度如何、有没有异常。没有日志的Fuzz就像盲人摸象出了问题都不知道从哪查。我通常记录以下几类信息当前迭代次数和已用时当前使用的种子和变异操作检测到的崩溃及其签名进程异常情况超时、OOM等日志用logging模块输出到文件同时用tqdm在终端显示进度条from tqdm import tqdm import logging logging.basicConfig(filenamefuzz.log, levellogging.INFO) for i in tqdm(range(total_iterations)): seed random.choice(seeds) mutated mutate_seed(seed) status run_target(mutated) if status is not None and status 0: logging.info(fCrash at iteration {i}, status {status})tqdm的进度条能直观看到Fuzz速度如果速度突然下降说明可能遇到了挂起的进程或者资源瓶颈。6.5 安全边界Fuzz脚本自身的稳定性Fuzz脚本本身也要足够健壮不能因为目标程序的异常行为而崩溃。常见的问题包括目标程序输出非UTF-8数据解码时抛异常。解决方法是统一用errorsignore。目标程序创建大量子进程导致系统资源耗尽。解决方法是用resource限制进程数。目标程序修改了工作目录或环境变量影响后续测试。解决方法是在每次测试前重置环境。临时文件没有清理磁盘被占满。解决方法是用try/finally确保清理。def safe_run(target, payload, timeout5): temp_file None try: temp_file create_temp_file(payload) p process([target, temp_file]) p.wait(timeouttimeout) return p.poll() except TimeoutError: p.kill() return None except Exception as e: logging.error(fUnexpected error: {e}) return None finally: if temp_file and os.path.exists(temp_file): os.unlink(temp_file)这种防御性编程能保证Fuzz脚本长时间稳定运行不会因为个别异常输入而中断。7. 从Fuzz结果到漏洞报告的完整链路7.1 崩溃复现与根因定位Fuzz脚本报出崩溃只是第一步接下来要确认这个崩溃是否可复现、根因是什么。复现很简单把保存的输入重新喂给目标程序看是否稳定崩溃。如果只在特定条件下崩溃比如特定环境变量、特定工作目录那就要把这些条件也记录下来。根因定位需要结合core dump和反汇编。用gdb加载core dump查看崩溃时的调用栈(gdb) bt #0 0x00005555555551a2 in parse_header (data0x7fffffffe000 AAAA...) at parser.c:42 #1 0x00005555555552b3 in main (argc2, argv0x7fffffffe1a8) at main.c:15如果调用栈显示崩溃发生在parse_header函数那就去看这个函数的反汇编(gdb) disassemble parse_header重点看有没有strcpy、sprintf、memcpy这类危险函数以及缓冲区的大小和输入长度之间有没有检查。如果拿不到源码就只能看反汇编。Pwntools的ELF模块可以辅助分析elf ELF(/usr/bin/target) func_addr elf.symbols.get(parse_header) if func_addr: print(fparse_header at {hex(func_addr)})有符号的二进制可以直接定位函数没有符号的就只能靠崩溃地址反推。7.2 可利用性评估从崩溃到Exploit的距离不是所有崩溃都能变成Exploit。评估可利用性要考虑几个因素第一崩溃类型。栈溢出最容易利用堆溢出次之空指针解引用和整数溢出通常需要配合其他漏洞。第二保护机制。开了Canary的栈溢出需要先泄露Canary开了PIE的需要泄露基址开了Full RELRO的GOT表不可写。保护越少利用越容易。第三输入控制程度。如果攻击者能精确控制覆盖返回地址的字节利用就简单如果只能控制部分字节或者有坏字符限制难度就大。第四目标运行环境。如果目标以root运行利用价值高如果只是普通用户权限价值相对低。我通常用一个简单的评分表来快速评估因素高价值中价值低价值崩溃类型栈溢出堆溢出空指针Canary无有但可泄露有且不可泄露PIE无有但可泄露有且不可泄露权限root系统用户普通用户综合评分高的优先深入分析评分低的记录在案但不投入太多时间。7.3 编写可复现的PoCPoC的目标是让其他人能稳定复现漏洞。一个好的PoC应该包含目标程序的版本和运行环境触发漏洞的完整命令或输入文件预期的崩溃现象必要的环境配置说明对于命令行工具PoC通常是一个输入文件加上一条命令# 目标: /usr/bin/target version 1.2.3 # 环境: Ubuntu 20.04, x86_64 # 命令: /usr/bin/target crash_input.bin # 预期: Segmentation fault (core dumped)如果漏洞需要特定参数组合也要写清楚/usr/bin/target -x -y --inputcrash_input.binPoC文件要尽量最小化去掉所有无关字节。这样既方便别人理解也方便开发者定位问题。7.4 漏洞报告的结构与要点漏洞报告不是越详细越好而是要抓住重点。我通常按以下结构组织概述一句话说明漏洞类型和影响。影响版本明确哪些版本受影响。复现步骤编号列出每一步操作包括环境准备、命令执行、预期结果。根因分析结合代码或反汇编说明漏洞产生的具体原因。修复建议给出具体的修复方向比如增加长度检查、使用安全函数替代危险函数。附件PoC文件、core dump、崩溃日志。报告的语言要客观、准确避免夸大影响。比如一个需要本地访问的漏洞不要写成“远程代码执行”。同时要给出足够的细节让开发者能独立复现和验证。提示提交漏洞报告前先自己在干净环境里完整走一遍复现步骤确保没有遗漏依赖或者环境差异。我遇到过好几次因为测试机上装了某个库而目标环境没有导致PoC无法复现的情况。8. 一些个人体会与后续扩展方向用Pwntools做命令行工具Fuzz这件事我从最初写几十行脚本到现在有一套相对完整的框架踩过的坑比想象中多。最大的体会是Fuzz的效率不取决于变异算法有多复杂而取决于你对目标程序的理解有多深。一个对tar格式了如指掌的人写出来的Fuzz脚本命中率远高于一个只会随机翻转字节的人。另一个体会是不要追求“全自动”。Fuzz脚本能帮你发现崩溃但判断崩溃是否可利用、如何构造Exploit仍然需要人工分析。把Fuzz当作一个“线索生成器”而不是“漏洞挖掘机”心态会好很多。后续如果想继续扩展这套框架有几个方向可以考虑。一是接入覆盖率反馈用ptrace或者perf获取代码覆盖率引导变异向未覆盖的路径倾斜。二是支持分布式Fuzz把任务分发到多台机器上并行执行。三是集成符号执行对崩溃输入做约束求解自动生成更精确的PoC。不过这些都是锦上添花。核心的Fuzz循环——生成输入、执行目标、检测崩溃、保存现场——用Pwntools几十行代码就能搭起来。先把这套跑通再考虑优化比一上来就追求大而全的框架要务实得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价