zenpwnpy只是一个我自用的python库,没啥特别的,面向pwn
项目地址:
https://github.com/Zenquiem/zenpwnpy
1. 安装
1.1 从 GitHub 安装
如果只是想使用,可以直接从 GitHub 安装:
python3 -m pip install git+https://github.com/Zenquiem/zenpwnpy.git
如果你希望在本地修改源码,建议克隆后editable安装:
git clone https://github.com/Zenquiem/zenpwnpy.git cd zenpwnpy python3 -m pip install -e .
-e 表示editable install。
这样安装后,你修改本地源码,不需要反复重新安装,Python会直接使用当前目录下的源码。
1.2 验证安装
安装完成后可以执行:
python3 - <<'PY' import zenpwnpy print("zenpwnpy import ok") print(len(zenpwnpy.__all__)) PY
如果没有报错,说明库已经可以正常导入。
1.3 运行测试
如果你是从源码目录使用,可以跑单元测试:
python3 -m unittest discover -s tests -v
当前测试覆盖了 PoW、payload layout、RISC-V 编码、shell upload 等主要功能。
2. 推荐导入方式
最推荐的方式是按需导入:
from zenpwnpy import command_pow, ShellUploader, PayloadLayout
也可以使用:
from zenpwnpy import *
不过需要注意:zenpwnpy 的 import * 只导出推荐的核心 API。
一些兼容旧脚本的别名仍然能直接导入,但不会出现在默认 __all__ 中。
例如下面这些是推荐名字:
from zenpwnpy import nonzero_mask
from zenpwnpy import load_imm32
from zenpwnpy import pack_instruction_words
from zenpwnpy import command_pow
下面这些是兼容旧脚本的别名,能用,但新脚本不建议优先使用:
from zenpwnpy import care_nonzero
from zenpwnpy import li32
from zenpwnpy import pack_words
from zenpwnpy import solve_command_pow
3. PoW 模块
PoW 模块主要解决题目开头常见的 proof-of-work 场景。
对应能力:
- command_pow
- hashcash_pow
- is_hashcash_challenge
- PowError
- PowTimeoutError
- PowCommandError
3.1 命令型 PoW:command_pow
很多pwn题开头会给你一条命令,例如:
run this command:
python3 -c 'print("answer")'
然后服务端要求你把命令的 stdout 发回去。
这类场景可以用 command_pow():
io.recvuntil(b"run: ")
cmd = io.recvline().decode().strip()
answer = command_pow(cmd, timeout=20)
io.sendline(answer.encode())
command_pow() 的核心含义很简单:
本地执行一条命令 -> 取 stdout -> 返回字符串
默认行为:
- 命令通过 shell 执行
- 超时时间为 30 秒
- 返回 stdout 去掉首尾空白后的结果
- 命令返回非 0 会抛异常
- 命令没有输出会抛异常
3.2 常用参数
answer = command_pow(
cmd,
timeout=30,
shell=True,
strip=True,
check=True,
require_output=True,
)
参数说明:
- timeout:命令执行超时时间,单位秒
- shell:是否通过 shell 执行
- cwd:指定命令运行目录
- env:指定环境变量
- encoding:stdout/stderr 解码编码,默认 utf-8
- errors:解码错误处理方式,默认 replace
- strip:是否对 stdout 做 .strip()
- check:返回码非 0 是否报错
- require_output:stdout 为空是否报错
3.3 保留原始输出
如果题目需要保留输出末尾换行,可以设置:
answer = command_pow(cmd, strip=False)
默认 strip=True,适合大多数 PoW 回答,因为服务端通常只要那一段 token。
3.4 不要求输出
如果你只是想跑一条命令,不关心输出,可以设置:
command_pow("true", require_output=False)
不过这种用法不是 PoW 的主场景。
3.5 捕获失败信息
当命令执行失败时,会抛 PowCommandError:
from zenpwnpy import command_pow, PowCommandError
try:
answer = command_pow("false")
except PowCommandError as exc:
print("returncode:", exc.returncode)
print("stdout:", exc.stdout)
print("stderr:", exc.stderr)
这比简单地 subprocess.check_output() 更适合做题调试,因为失败时 stdout/stderr 都能拿到。
3.6 超时处理
命令超时时会抛 PowTimeoutError:
from zenpwnpy import command_pow, PowTimeoutError
try:
answer = command_pow("sleep 999", timeout=1)
except PowTimeoutError:
print("PoW timeout")
3.7 Hashcash PoW:hashcash_pow
有些题直接输出 hashcash 命令,例如:
hashcash -mb29 abcdef
这类可以用:
from zenpwnpy import hashcash_pow
challenge = io.recvline().decode().strip()
token = hashcash_pow(challenge, timeout=30)
io.sendline(token.encode())
hashcash_pow() 会先检查输入是不是看起来像 hashcash 命令。
如果不是,会抛 PowCommandError。
3.8 判断是否为 hashcash challenge
from zenpwnpy import is_hashcash_challenge
if is_hashcash_challenge(challenge):
token = hashcash_pow(challenge)
它只做轻量判断:命令的第一个部分名字里包含 hashcash。
3.9 指定本地 hashcash 路径
如果远端给的是:
hashcash -mb29 abcdef
但你本机 hashcash 不在 PATH,或者你想使用自己编译的版本,可以:
token = hashcash_pow(challenge, binary="/usr/bin/hashcash")
这会替换命令中的第一个参数。
4. PayloadLayout:稀疏约束的 payload 重叠布局
它解决的问题是:
做 fake structure 时,我们经常会构造多个逻辑结构,但它们实际可以在内存中重叠,因为很多 padding 字节并没有语义约束。
传统写法可能是:
payload = flat({ 0x00: b"...", 0x88: p64(lock), 0xa0: p64(wide_data), 0xd8: p64(vtable), })
但是如果结构越来越多,并且想让它们自动找一个更短的重叠布局,手写 offset 会变得很难维护。
PayloadLayout 的核心思想是:
只有显式写入的字节才算约束。 没有写入的字节,不自动视为必须是 0。
这和普通 dense payload 很不一样。
4.1 最小例子
from zenpwnpy import PayloadLayout
layout = PayloadLayout(base=0x404000)
a = layout.fragment("a", size=0x20, offset=0)
a.write(0x00, b"AAAA")
b = layout.fragment("b", size=0x20, align=8)
b.write(0x10, b"BBBB")
payload = layout.build(search_end=0x40)
print(payload)
这里:
- a 是固定片段,放在 offset 0
- b 是可重定位片段,按 align=8 搜索位置
- search_end=0x40 表示搜索范围最多到 0x40
如果 b 的约束字节和 a 不冲突,它可能会和 a 重叠。
4.2 创建 layout
layout = PayloadLayout(base=heap_base)
base 表示最终 payload 对应的基址。
它主要影响 write_ptr() 和 absolute_address_of()。
例如某个片段最终 offset 是 0x80,base 是 0x404000,那么它的绝对地址就是:
0x404000 + 0x80 = 0x404080
4.3 创建 fragment
frag = layout.fragment( "fake_file", size=0xe0, offset=0, )
参数说明:
- name:片段名字,必须唯一
- size:片段逻辑大小
- offset:固定偏移;如果不传,表示可重定位
- align:可重定位时的对齐
- candidates:可选候选偏移列表
固定片段:
layout.fragment("base", size=0x100, offset=0)
可重定位片段:
layout.fragment("wide_data", size=0xe8, align=8)
只允许某些位置:
layout.fragment("node", size=0x40, candidates=[0x20, 0x80, 0x100])
4.4 写入普通字节
frag.write(0x00, b"/bin/sh\x00")
含义是:
在这个 fragment 的相对 offset 0x00 写入这些字节。
如果同一个 fragment 内部对同一位置写入不同字节,会抛 LayoutConflictError。
4.5 写入整数
写入 64 位整数:
frag.write_u64(0x88, lock_addr)
写入任意大小整数:
frag.write_int(0x10, 0x1234, size=2)
frag.write_int(0x20, 0x12345678, size=4)
frag.write_int(0x30, 0x1234567890abcdef, size=8)
默认小端:
frag.write_int(0x10, 0x1234, size=2, endian="little")
如果需要大端:
frag.write_int(0x10, 0x1234, size=2, endian="big")
4.6 写入指向其他 fragment 的指针
这是 PayloadLayout 很实用的地方。
fake_file.write_ptr(0xa0, "wide_data")
含义是:
等 layout 求解完成后,把 wide_data 的最终地址写到 fake_file + 0xa0。
最终写入的值是:
layout.base + offset_of("wide_data") + addend
可以加偏移:
fake_file.write_ptr(0xa0, "wide_data", addend=0x20)
也可以指定指针大小:
fake_file.write_ptr(0x20, "target", size=4)
4.7 求解布局
placements = layout.solve(search_end=0x300)
返回一个字典:
{ "fake_file": 0, "wide_data": 0, "wide_vtable": 0, }
如果有可重定位 fragment,并且没有提供 candidates,就需要传 search_end。
否则库不知道搜索范围。
4.8 构造最终 payload
payload = layout.build(search_end=0x300)
默认未约束的字节填 0。
如果你希望填充为其他字节:
payload = layout.build(fill=0x41, search_end=0x300)
4.9 查询片段位置
查询相对偏移:
off = layout.offset_of("wide_data", search_end=0x300)
查询绝对地址:
addr = layout.absolute_address_of("wide_data", search_end=0x300)
查询所有 placement:
for placement in layout.placements(search_end=0x300):
print(placement.name, hex(placement.offset), hex(placement.end), placement.fixed)
查询最终 payload 长度:
length = layout.end_offset(search_end=0x300)
4.10 使用 nonzero_mask
有时候我们已经有一段旧的 dense payload:
old_payload = b"\x00" * 0x18 + p64(0xdeadbeef)
其中很多 \x00 只是 padding,不想让它们参与冲突判断。
可以用:
from zenpwnpy import PayloadLayout, nonzero_mask
layout = PayloadLayout(base=0x404000)
overlay = layout.fragment("overlay", size=len(old_payload), align=8)
overlay.write(0, old_payload, mask=nonzero_mask(old_payload))
nonzero_mask() 会返回一个同长度 mask:
字节非 0 -> 1,表示参与约束 字节为 0 -> 0,表示忽略这个位置
例子:
nonzero_mask(b"\x00A\x00B")
结果:
b"\x00\x01\x00\x01"
注意:
如果某个字段语义上必须为 0,不要依赖 nonzero_mask(),应该显式写入:
frag.write_u64(0x30, 0)
4.11 一个 fake structure 风格例子
下面是一个接近真实做题风格的例子:
from zenpwnpy import PayloadLayout
heap_base = 0x555555800000
fake_io = heap_base + 0x2e0
system_addr = 0x7ffff7e12345
lock_addr = heap_base + 0x100
wfile_jumps = 0x7ffff7fb0000
layout = PayloadLayout(base=fake_io)
fake_file = layout.fragment("fake_file", size=0xe0, offset=0)
fake_file.write(0x00, b" sh;".ljust(8, b"\x00"))
fake_file.write(0x88, p64(lock_addr))
fake_file.write_ptr(0xa0, "wide_data")
fake_file.write(0xd8, p64(wfile_jumps))
wide_data = layout.fragment("wide_data", size=0xe8, align=8)
wide_data.write_ptr(0xe0, "wide_vtable")
wide_vtable = layout.fragment("wide_vtable", size=0x70, align=8)
wide_vtable.write(0x68, p64(system_addr))
payload = layout.build(search_end=0x300)
print("wide_data offset:", hex(layout.offset_of("wide_data")))
print("wide_vtable addr:", hex(layout.absolute_address_of("wide_vtable")))
print("payload length:", hex(len(payload)))
这个例子的重点不是具体 glibc 结构,而是写法:
- 每个逻辑结构用一个 fragment 表示
- 指针关系用 write_ptr() 表示
- 最终由 layout 自动决定可重定位片段放在哪里
- 只对真正写入的字节做冲突检查
4.12 常见错误
如果可重定位 fragment 没有 candidates,且调用时没有传 search_end:
layout.build()
可能会抛:
ValueError: fragment 'xxx' is relocatable; solve() needs search_end or candidates
解决:
layout.build(search_end=0x300)
如果两个约束字节冲突,会抛 LayoutConflictError:
from zenpwnpy import LayoutConflictError
try:
payload = layout.build(search_end=0x300)
except LayoutConflictError as exc:
print("relative offset:", exc.relative_offset)
print("absolute offset:", exc.absolute_offset)
print("fragments:", exc.fragments)
如果找不到兼容布局,会抛 LayoutSolveError。
5. ShellUploader:只有 shell 时上传文件
这是另一个很实用的模块。
它适合这种场景:
- 已经拿到远程交互式 shell
- 远程没有 scp
- 远程没有 wget
- 远程没有 curl
- 远程没有 Python
- 但远程有基础 shell 命令,例如 cat、base64、chmod、gzip
这时可以通过 shell 把本地文件传上去。
5.1 基本用法
from zenpwnpy import ShellUploader
io = remote("example.com", 31337)
io.recvuntil(b"$ ")
uploader = ShellUploader(io)
result = uploader.upload("./exp", "/home/pwn/exp", chmod_x=True)
io.sendline(b"/home/pwn/exp")
io.interactive()
5.2 推荐参数
远程 TTY shell 比较怪时,可以显式设置:
uploader = ShellUploader(
io,
chunk_size=0x100,
commands_per_sync=16,
max_batch_bytes=0xc00,
command_timeout=30,
decode_timeout=120,
upload_method="cat_eof",
suppress_echo=True,
cat_eof_settle_time=0.2,
)
这些参数的含义:
- chunk_size:base64 文本分块大小
- commands_per_sync:append 模式下每轮同步的命令数量
- max_batch_bytes:单行命令最大估算长度
- command_timeout:普通远程命令等待超时
- decode_timeout:base64/gzip 解码等待超时
- upload_method:上传方法
- suppress_echo:是否尝试 stty -echo 抑制回显
- cat_eof_settle_time:Ctrl-D 后等待 shell 返回 prompt 的时间
5.3 上传模式
当前支持三种上传模式:
cat_eof
heredoc
append
默认是:
upload_method="cat_eof"
cat_eof
通过:
cat > remote_file
然后发送 base64 文本,最后发送 Ctrl-D 结束。
优点:
- 对很多交互式 shell 更稳
- 不依赖复杂 shell 语法
- 适合回显很烦的 TTY 场景
缺点:
- 需要目标 shell 能正确处理 Ctrl-D
heredoc
通过 here-doc 一次写入:
cat > remote_file <<'EOF'
...
EOF
优点:
- 思路简单
- 非交互环境里通常不错
缺点:
- 某些题目 shell 会回显、插 prompt、处理 here-doc 不稳定
append
通过多条命令追加:
printf %s 'chunk' >> remote_file
优点:
- 不依赖 Ctrl-D
缺点:
- 往返次数更多
- 命令行长度和特殊字符处理更敏感
5.4 压缩上传
如果本地文件较大,可以:
uploader.upload("./exp", "/home/pwn/exp", chmod_x=True, compress=True)
这会在本地先 gzip 压缩,再 base64 传输,远程执行:
gzip -dc temp.gz > final_file
要求远程有 gzip。
5.5 自动 chmod
uploader.upload("./exp", "/home/pwn/exp", chmod_x=True)
上传后会执行:
chmod +x /home/pwn/exp
要求远程有 chmod。
5.6 大小校验
默认 verify=True。
上传完成后会用:
wc -c < remote_file
检查远程文件大小是否和本地一致。
如果目标环境没有 wc,可以关掉:
uploader.upload("./exp", "/home/pwn/exp", verify=False)
5.7 清理临时文件
默认 cleanup=True。
成功后会删除传输过程中产生的 .b64、.gz 临时文件。
如果你想调试远端中间文件:
uploader.upload("./exp", "/home/pwn/exp", cleanup=False)
5.8 等待初始 prompt
有些服务连接后会先输出启动日志,shell prompt 不是立刻出现。
可以让 ShellUploader 等 prompt:
uploader = ShellUploader(
io,
initial_prompt=b"~ $ ",
initial_prompt_timeout=20,
initial_prompt_settle_time=0.2,
auto_probe=True,
)
参数说明:
- initial_prompt:要等待的 prompt
- initial_prompt_timeout:等待 prompt 的超时时间
- initial_prompt_settle_time:看到 prompt 后再稍等一会,吃掉残留输出
- auto_probe:初始化时自动执行一次轻量探活
5.9 手动执行远程命令
ShellUploader 内部有远程命令同步机制,也可以拿来执行简单命令:
output = uploader.run("pwd; id; ls -la", check=False)
print(output.decode(errors="ignore"))
如果 check=True,远程命令返回非 0 会抛 ShellCommandError。
from zenpwnpy import ShellCommandError
try:
uploader.run("false")
except ShellCommandError as exc:
print("status:", exc.status)
print("output:", exc.output.decode(errors="ignore"))
5.10 批量执行远程命令
uploader.run_batch([
"cd /home/pwn",
"chmod +x exp",
"./exp",
])
run_batch() 会在远端按顺序执行多条命令,并用一个完成标记同步。
如果中间某条失败,异常里会包含失败的序号和命令。
5.11 常见坑:/tmp 不一定可写
很多人习惯上传到:
/tmp/exp
但 CTF 题目环境里 /tmp 不一定是 1777 权限。
有些内核题、qemu 题、busybox rootfs 里,/tmp 可能是 root 拥有且普通用户不可写。
如果看到类似:
Permission denied
应该先检查:
print(uploader.run("pwd; id; ls -ld . /tmp /dev/shm /home/pwn 2>&1", check=False).decode(errors="ignore"))
优先选择当前用户可写目录,例如:
uploader.upload("./exp", "./exp", chmod_x=True)
或者:
uploader.upload("./exp", "/home/pwn/exp", chmod_x=True)
5.12 一个完整上传片段
from zenpwnpy import ShellUploader, ShellCommandError
io.recvuntil(b"$ ")
uploader = ShellUploader(
io,
chunk_size=0x100,
commands_per_sync=16,
max_batch_bytes=0xc00,
command_timeout=30,
decode_timeout=120,
upload_method="cat_eof",
suppress_echo=True,
cat_eof_settle_time=0.2,
)
try:
result = uploader.upload(
"./exp",
"/home/pwn/exp",
chmod_x=True,
compress=True,
)
log.success(f"uploaded {result.remote_path}, sha256={result.sha256}")
except ShellCommandError as exc:
log.failure(str(exc))
log.failure(exc.output.decode(errors="ignore"))
raise
io.sendline(b"/home/pwn/exp")
io.interactive()
6. RISC-V 指令编码
zenpwnpy.riscv 提供一组轻量 RISC-V 指令编码 helper。
它不是完整汇编器。
它适合你在题目里需要直接构造 32-bit 指令字,但又不想引入完整 assembler 的场景。
6.1 基础导入
from zenpwnpy import (
r_type,
i_type,
s_type,
b_type,
u_type,
j_type,
addi,
lw,
sw,
jal,
jalr,
load_imm32,
pack_instruction_words,
)
6.2 底层格式编码器
RISC-V 基础 32-bit 指令格式包括:
- R-type
- I-type
- S-type
- B-type
- U-type
- J-type
对应函数:
r_type(opcode, rd, funct3, rs1, rs2, funct7=0)
i_type(opcode, rd, funct3, rs1, imm)
s_type(opcode, funct3, rs1, rs2, imm)
b_type(opcode, funct3, rs1, rs2, imm)
u_type(opcode, rd, imm)
j_type(opcode, rd, imm)
例子:
word = r_type(0x33, 5, 0x0, 6, 7) print(hex(word))
6.3 常用指令 helper
当前提供:
lui(rd, imm)
auipc(rd, imm)
addi(rd, rs1, imm)
lw(rd, offset, rs1)
sw(rs2, offset, rs1)
jal(rd, offset)
jalr(rd, offset, rs1)
例子:
words = [
addi(5, 0, 123),
lw(6, 0, 5),
sw(6, 4, 5),
jalr(0, 0, 1),
]
6.4 分支指令
当前提供:
beq(rs1, rs2, offset)
bne(rs1, rs2, offset)
blt(rs1, rs2, offset)
bge(rs1, rs2, offset)
bltu(rs1, rs2, offset)
bgeu(rs1, rs2, offset)
注意:
这里的 offset 是字节偏移,不是你手动右移后的 immediate 字段。
例如:
word = beq(5, 6, 16)
表示:
beq x5, x6, +16
分支和跳转偏移需要 2 字节对齐。
如果传入奇数偏移,会抛 ValueError。
6.5 32 位常量装载:load_imm32
RISC-V 里装载一个 32 位常量通常可以用:
lui
addi
load_imm32() 会帮你生成这两条指令:
from zenpwnpy import load_imm32
words = load_imm32(5, 0x12345678)
含义是把 0x12345678 装进 x5。
内部会做:
upper = ((value + 0x800) >> 12)
lower = value - (upper << 12)
这和常见 lui + addi 拆分方式一致。
也可以单独拆分:
from zenpwnpy import split_imm32
upper, lower = split_imm32(0x12345678)
边界说明:
- load_imm32() 只承诺处理 32 位常量装载
- 它不是完整的 li 伪指令展开器
- 不覆盖 RV64 下所有更复杂的常量装载策略
6.6 打包指令字
RISC-V 指令通常按 little-endian 字节序放进 payload:
from zenpwnpy import pack_instruction_words
payload = pack_instruction_words([
0x12345678,
0xaabbccdd,
])
结果是:
b"\x78\x56\x34\x12\xdd\xcc\xbb\xaa"
6.7 一个完整 RISC-V payload 例子
from zenpwnpy import (
load_imm32,
lw,
sw,
jalr,
pack_instruction_words,
)
words = []
# x5 = 0x12345678
words.extend(load_imm32(5, 0x12345678))
# x6 = *(uint32_t *)x5
words.append(lw(6, 0, 5))
# *(uint32_t *)(x5 + 4) = x6
words.append(sw(6, 4, 5))
# ret: jalr x0, 0(x1)
words.append(jalr(0, 0, 1))
payload = pack_instruction_words(words)
print(payload.hex())
7. 错误处理风格
zenpwnpy 的异常大多是模块级异常,适合在 exploit 里做精确捕获。
7.1 PoW 异常
from zenpwnpy import PowError, PowTimeoutError, PowCommandError
层级大致是:
PowError
PowTimeoutError
PowCommandError
用法:
try:
answer = command_pow(cmd)
except PowTimeoutError:
print("timeout")
except PowCommandError as exc:
print(exc.stderr)
7.2 Layout 异常
from zenpwnpy import PayloadLayoutError, LayoutConflictError, LayoutSolveError
层级:
PayloadLayoutError
LayoutConflictError
LayoutSolveError
LayoutConflictError 里可能包含:
- absolute_offset
- relative_offset
- fragments
7.3 Shell 上传异常
from zenpwnpy import (
ShellUploadError,
ShellLocalFileError,
ShellSyncError,
ShellMissingCommandError,
ShellVerificationError,
ShellCommandError,
)
常见含义:
- ShellLocalFileError:本地文件不存在或读不了
- ShellSyncError:远端 shell 同步失败
- ShellMissingCommandError:远端缺少必要命令
- ShellVerificationError:上传后校验失败
- ShellCommandError:远端命令返回非 0
捕获远端命令失败:
try:
uploader.upload("./exp", "/home/pwn/exp")
except ShellCommandError as exc:
print("command:", exc.command)
print("status:", exc.status)
print("context:", exc.context)
print(exc.output.decode(errors="ignore"))
评论