pwngdb与GDB
查看信息
info functions #查看所有function
info variables # 查看所有的可用变量
plt #查看外部调用函数
程序运行参数
set args 指定运行时参数 (set args "xxx")
show args 查看设置好的运行参数
调试
调试过程主要包含四个步骤:
| Step 步骤 | Description 描述 |
|---|---|
Break | 在各个关键位置设置断点 |
Examine | 运行程序,并检查程序在这些阶段的运行状态。 |
Step | 通过程序来观察它如何响应每个指令以及用户输入的情况。 |
Modify | 在特定的断点处修改特定寄存器或地址中的值,以研究这将如何影响程序的执行过程。 |
设置断点
调试的第一步是设置 breakpoints ,使其在达到特定位置或满足特定条件时停止执行。这样我们可以检查程序在那一刻的状态以及寄存器中存储的值。此外, Breakpoints 还允许我们在该时刻暂停程序的执行,从而可以逐条执行每条指令,观察它们如何改变程序及其中的数值。
break 设置断点,简写为b(b *0x401000)
info break 查看设置好的断点,简写为i b
delete 删除断点,简写为d(d 1删除第一个断点)
我们可以在特定的地址或某个特定函数处设置断点。要设置断点,可以使用 break 或 b 命令,并附带想要暂停执行的地址或函数的名称。
运行程序
运行
run 运行程序 简写为r
next 单步跟踪 一行一行的执行 简写为n
step 步入 进入被调用函数 简写为s
finish 退出循环 简写为fin
until 在循环内单步跟踪时,可跳出循环,简写为u
continue 继续运行程序到下一个断点 简写为c
检查
要手动检查任何地址或寄存器,我们可以使用 x 命令,其格式为 x/FMT ADDRESS ,例如 help x 表示我们要检查的地址或寄存器。 ADDRESS 就是我们要检查的地址或寄存器,而 FMT 则表示检查的格式。 FMT 这个检查格式可以包含三个部分:
| Argument 论点/论据 | Description 描述 | Example 示例 |
|---|---|---|
Count | 我们希望重复检查的次数 | 2, 3, 10 |
Format | 我们希望结果以何种格式呈现出来? | x(hex), s(string), i(instruction) |
Size | 我们想要检查的内存大小 | b(byte), h(halfword), w(word), g(giant, 8 bytes) |
查看运行数据
print 打印有符号的变量、字符串、表达式等的值,可简写为p
stack 查看栈数据,后可跟数字输出指定行数
x 以格式化的形式打印内存数据,格式为x/FMT address,格式字符串有o(八进制),x(十六进制),d(十进制),u(无符号十进制),t(二进制),f(浮点数),a(地址),i(指令),c(字符),s(字符串),z(十六进制对齐)。同时在FMT后面还可以加上每个单元
pwntools
shellcode转换
每个 x86 指令和每个寄存器都有其对应的 binary 机器代码(通常表示为 hex ),这些机器代码就是直接传递给处理器以指示其执行何种指令的二进制代码(通过指令周期实现)。
此外,各种常见的指令和寄存器的组合也有各自的机器代码。例如, push rax 指令对应的机器代码是 50 ,而 push rbx 指令对应的机器代码则是 53 ,以此类推。当我们使用 nasm 来组装代码时,就能将我们的汇编指令转化为相应的机器代码,这样处理器就能理解这些指令了。
现在,我们可以使用 pwn asm 将任何汇编代码组装成机器代码,具体步骤如下:
pwn asm 'push rax' -c 'amd64'
提取shellcode
现在我们已经了解了如何将每条汇编指令转换为机器代码(反之亦然),那么接下来我们就来看看如何从任何二进制文件中提取外壳代码吧。
一个二进制文件的 shell 代码仅包含其可执行的 .text 部分,因为 shell 代码本就是用于直接执行的。要提取 .text 部分的 pwntools 数据,我们可以使用 ELF 库来加载 elf 二进制文件,这样就能对其执行各种操作了。
>>> from pwn import *
>>> file = ELF('helloworld')
我们可以对其执行各种 pwntools 功能。我们需要从可执行文件 .text 部分中提取机器代码,可以使用 section() 函数来实现这一操作,具体步骤如下:
>>> file.section(".text").hex()
我们看到,我们非常轻松地提取出了该二进制程序的 shellcode。现在,我们可以将这一过程转化为一个 Python 脚本,这样我们就可以快速地使用它来提取任何二进制程序的 shellcode 了:
import sys
from pwn import *
context(os="linux", arch="amd64", log_level="error")
file = ELF(sys.argv[1])
shellcode = file.section(".text")
print(shellcode.hex())
加载shellcode
现在我们已经有了 shell 代码,接下来尝试运行它,这样就能测试我们之前准备的任何 shell 代码了。不过,上面提取的 shell 代码不符合 Shellcoding Requirements 的要求,我们将在下一节讨论这个问题,所以这段代码无法运行。为了演示如何运行 shell 代码,我们将使用以下符合 Shellcoding Requirements 要求的 shell 代码:
要使用 pwntools 执行我们的外壳代码,我们可以调用 run_shellcode 函数,并将我们的外壳代码传递给该函数,具体步骤如下:
from pwn import *
context(os="linux", arch="amd64", log_level="error")
+ex
sc = bytes.fromhex(file)
run_shellcode(unhex(sc).interactive()
我们在 shell 代码使用了 unhex() 这个指令,将其转换回二进制形式。
也可以保存为文件:
ELF.from_bytes(unhex('4831db66bb79215348bb422041636164656d5348bb48656c6c6f204854534889e64831c0b0014831ff40b7014831d2b2120f054831c0043c4030ff0f05')).save('helloworld')
pwn脚本的编写
全局与环境配置
在脚本开头设置目标架构和调试级别,能省去后续生成 shellcode 或 ROP 链时的很多麻烦。
from pwn import *
# 核心配置:指定目标程序的架构、操作系统及日志级别
context(arch='amd64', os='linux', log_level='debug')
# log_level='debug' 是写 exp 时最关键的配置,它会将所有收发的数据(包括不可见字符)以十六进制形式打印在终端,方便定位程序阻塞在哪一步。
建立交互对象
无论是本地调试还是远程打靶机,都会返回一个统一的 IO 管道对象(通常命名为 io, p 或 sh),后续的所有收发操作都基于此对象。
# 本地进程交互 (常用于调试)
io = process('./pwn_file')
# 远程网络交互 (打靶机)
io = remote('192.168.1.100', 1337)
数据接收
接收目标程序的输出,用于同步执行流或泄漏(Leak)内存地址。注意:在 Python 3 中,强烈建议使用字节串 b'...' 进行匹配。
io.recv(numb=2048) # 基础接收:最多接收 numb 个字节的数据。
io.recvline() # 按行接收:持续接收直到遇到换行符 '\n'。常用于读取程序打印的一整行提示或 Leak 的地址。
# 精确同步 (最常用)
io.recvuntil(b"Input your name: ")
# 阻塞程序,直到接收到指定的特征字符串后才继续执行。这是保证 exp 稳定性的关键,避免因网络延迟导致发送的数据被丢弃。
数据发送
将 Payload 发送给目标程序。发送时同样需要注意是否需要附带换行符。
io.send(payload) # 基础发送:直接发送 payload,不附加任何额外字符。常用于程序使用 read() 等按字节读取的情况。
io.sendline(payload) # 按行发送:在 payload 末尾自动加上换行符 '\n'。常用于 gets(), scanf() 等需要回车触发的输入。
# 组合技:接收并发送 (推荐)
io.sendafter(b"name:", payload) # 等同于 recvuntil(b"name:") + send(payload)
io.sendlineafter(b"choice:", b"1") # 等同于 recvuntil(b"choice:") + sendline(b"1")
# 使用 after 系列函数可以使代码更简洁,且极大降低时序引发的玄学 Bug。
ELF 文件解析
避免在脚本中硬编码地址(Hardcoding),利用 ELF 模块动态获取函数、符号的地址,提高脚本在不同环境下的兼容性。
elf = ELF('./pwn_file')
libc = ELF('./libc.so.6') # 如果题目提供了对应的 libc 版本
# 常用地址获取
puts_plt = elf.plt['puts'] # 获取 plt 表中 puts 函数的跳板地址 (常用于泄露真实地址)
puts_got = elf.got['puts'] # 获取 got 表中 puts 函数的实际加载地址
main_addr = elf.sym['main'] # 获取 main 函数的起始地址
bss_addr = elf.bss() # 获取 .bss 段的首地址 (常用于 ROP 链中存放伪造的输入数据)
交互与辅助
取得 Shell 后,或者在开发过程中需要切入动态调试。
io.interactive()
# 将控制权交还给用户,允许你在终端直接与目标程序进行标准输入输出交互。通常放在 exp 的最后一步(拿到 shell 之后)。
# 附加调试 (GDB)
gdb.attach(io, '''
b *main
c
''')
# 在 process() 启动的本地进程上自动挂载 GDB 并执行 GDB 脚本(如打断点、继续执行)。仅在本地调试阶段使用。
附:标准 Exploit 脚本模板
from pwn import *
# 1. 基础配置
file_name = './pwn'
elf = ELF(file_name)
context(arch=elf.arch, os=elf.os, log_level='debug')
# 2. 运行环境切换
local = True
if local:
io = process(file_name)
libc = elf.libc
else:
io = remote('node.example.com', 12345)
libc = ELF('./libc.so.6') # 替换为题目提供的 libc
def debug():
if local:
gdb.attach(io)
pause() # 暂停脚本,等待 GDB 附加完成
# 3. 核心漏洞利用逻辑 (Payload 构造区)
# io.recvuntil(b"something")
# payload = b'A' * 0x20 + p64(elf.sym['main'])
# io.sendlineafter(b"input:", payload)
# 4. 获取交互
io.interactive()
IDA使用
快捷键
r: 转换成字符/字符串
h: 转换成十六进制
d: 转换成数据 (db/dw/dd)
c: 转换成代码
a: 转换成 ASCII 字符串
u: 取消定义 (恢复原始字节)
n: 重命名变量或函数
y: 修改变量或函数类型
x: 查看交叉引用
F5: 生成伪代码
shift + e:提取数据
objdump
objdump 是 GNU Binutils 工具集中的一个重要命令行工具,用于显示目标文件(object files)和可执行文件的各种信息。它是 Linux 系统下进行二进制分析、逆向工程和调试的利器。
objdump 的主要功能包括:
- 反汇编二进制文件
- 查看文件头部信息
- 显示节区(section)内容
- 查看符号表
- 显示重定位信息
- 分析文件结构
常用选项详解
反汇编相关选项
-d, --disassemble # 反汇编包含代码的节区
-D, --disassemble-all # 反汇编所有节区
-S, --source # 混合显示源代码和汇编代码(需要编译时使用-g选项)
--prefix-addresses # 在反汇编时显示完整地址
--no-addresses # 不显示地址信息
节区信息
-h, --section-headers # 显示节区头部信息
-j, --section=名称 # 仅显示指定节区的内容
符号表选项
-t, --syms # 显示符号表
-T, --dynamic-syms # 显示动态符号表
文件头部信息
-f, --file-headers # 显示文件头部信息
其他
-l, --line-numbers # 显示行号信息(需要调试信息)
-r, --reloc # 显示重定位条目
-R, --dynamic-reloc # 显示动态重定位条目
-s, --full-contents # 显示所有节区的完整内容