本文翻译自:https://github.com/Gallopsled/pwntools-tutorial,主要是考虑到目前中文互联网中关于系统介绍pwntools使用方法的文章都比较老和杂乱,且转换为Python3后又有许多零零散散的问题,看到这个仓库中包含了很多使用技巧和调试问题的解决方案,感到可以翻译一下
这个资源库包含了一些开始使用pwntools(和pwntools)的基本教程。
这些教程并不致力于解释逆向工程或利用,而是假定读者有这方面的知识。
Pwntools
是一个工具包,使选手们在CTF期间的尽可能容易的编写EXP,并使EXP尽可能的容易阅读。
有些代码每个人都写过无数次,而且每个人都有自己的方法。Pwntools的目标是以半标准的方式提供所有这些,这样你就可以停止复制粘贴相同的struct.unpack('>I', x)
代码,而是使用更多稍微清晰的包装器,如pac
k或p32
甚至p64(..., endian='big', sign=True)
。
除了对日常的功能进行方便的包装外,它还提供了一套非常丰富的IO管道,将所有你曾经执行过的IO封装在一个统一的界面中。从本地攻击切换到远程攻击,或者通过SSH进行本地攻击,都只是修改一行代码的工作。
最后但并非最不重要的是,它还包括一系列用于中级到高级使用情况的开发协助工具。这些工具包括给定内存泄露基元的远程符号解析(MemLeak
和DynELF
),ELF解析和修补(ELF
),以及ROP小工具发现和调用链构建(ROP
)。
Installing Pwntools
Tubes
Utility
Bytes vs. Strings
Python2
Python3
Context
ELFs
Assembly
Debugging
ROP
Logging
Leaking Remote Memory
这个过程可以说是简单明了,Ubuntu 18.04和20.04是唯一 "官方支持 "的平台,因为它们是官方对软件进行自动化测试的唯二平台。
$ apt-get update
$ apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential
$ python3 -m pip install --upgrade pip
$ python3 -m pip install --upgrade pwntools
如果以下命令成功,一切都应该是OK的
$ python -c 'from pwn import *'
如果你想为其它的架构组装或反汇编代码,你需要安装一个合适的binutils
。对于Ubuntu和Mac OS X用户,安装说明可在docs.pwntools.com上找到。
$ apt-get install binutils-*
管道是方便高校的I/O包装器,里面包含了你需要执行的大多数类型的I/O。
本介绍提供了一些所提供功能的例子,但更复杂的组合是可能的。关于如何进行正则表达式匹配,以及将管道连接在一起的更多信息,请参阅完整的文档。
下面介绍一些IO中的基本功能:
接收数据
recv(n)
- 接收任何数量的可用字节recvline()
- 接收数据,直到遇到换行recvuntil(delim)
- 接收数据,直到找到一个分隔符recvregex(pattern)
- 接收数据,直到满足一个与pattern重合的内容为止recvrepeat(timeout)
- 继续接收数据,直到发生超时clean()
- 丢弃所有缓冲的数据发送数据
send(data)
- 发送数据sendline(line)
- 发送数据加一个换行操作整数
pack(int)
- 打包发送一个字(word)大小的整数unpack()
- 接收并解包一个字(word)大小的整数为了创建一个与进程对话的管道,你只需创建一个进程对象并给它一个目标二进制的名字。
from pwn import *
io = process('sh')
io.sendline('echo Hello, world')
io.recvline()
# 'Hello, world\n'
如果你需要提供命令行参数,或设置环境,可以使用额外的选项。更多信息请参见完整的文档。
from pwn import *
io = process(['sh', '-c', 'echo $MYENV'], env={'MYENV': 'MYVAL'})
io.recvline()
# 'MYVAL\n'
读取二进制数据也不是一个问题。你可以用recv
接收多达若干字节的数据,或者用recvn
接受精确的字节数。
from pwn import *
io = process(['sh', '-c', 'echo A; sleep 1; echo B; sleep 1; echo C; sleep 1; echo DDD'])
io.recv()
# 'A\n'
io.recvn(4)
# 'B\nC\n'
hex(io.unpack())
# 0xa444444
你在游戏服务器中获取了一个shell吗?赶快!互动地使用它是很容易的。
from pwn import *
# Let's pretend we're uber 1337 and landed a shell.
io = process('sh')
# <exploit goes here>
io.interactive()
创建一个网络连接也很容易,而且有完全相同的接口。一个remote
对象连接到其他地方,而一个listen
对象则在等待连接。
from pwn import *
io = remote('google.com', 80)
io.send('GET /\r\n\r\n')
io.recvline()
# 'HTTP/1.0 200 OK\r\n'
如果你需要指定协议信息,也是很直接方便的。
from pwn import *
dns = remote('8.8.8.8', 53, typ='udp')
tcp6 = remote('google.com', 80, fam='ipv6')
侦听连接并没有多复杂。请注意,这正好是在监听一个连接,然后停止监听。
from pwn import *
client = listen(8080).wait_for_connection()
SSH连接也同样简单。可以将下面的代码与上面 "Hello Process "中的代码进行比较。
你还可以用SSH做更复杂的事情,如端口转发和文件上传/下载。更多信息请参见SSH教程。
from pwn import *
session = ssh('bandit0', 'bandit.labs.overthewire.org', password='bandit0')
io = session.process('sh', env={"PS1":""})
io.sendline('echo Hello, world!')
io.recvline()
# 'Hello, world!\n'
如果你需要在本地进行一些黑客攻击,也有一个串行管道。一如既往,在完整的在线文档中有更多信息。
from pwn import *
io = serialtube('/dev/ttyUSB0', baudrate=115200)
Pwntools大约有一半的内容是实用功能,这样你就不再需要到处复制粘贴这样的东西。
import struct
def p(x):
return struct.pack('I', x)
def u(x):
return struct.unpack('I', x)[0]
1234 == u(p(1234))
此外,你不仅得到了漂亮的小包装,作为额外的奖励,在阅读别人的漏洞代码时,一切都更清晰,更容易理解。
from pwn import *
1234 == unpack(pack(1234))
这可能是你最常做的事情,所以它在最前面。主要的pack
和unpack
函数都知道context
中的全局设置,如endian
、bits
和sign
。
你也可以在函数调用中明确指定它们。
pack(1)
# '\x01\x00\x00\x00'
pack(-1)
# '\xff\xff\xff\xff'
pack(2**32 - 1)
# '\xff\xff\xff\xff'
pack(1, endian='big')
# '\x00\x00\x00\x01'
p16(1)
# '\x01\x00'
hex(unpack('AAAA'))
# '0x41414141'
hex(u16('AA'))
# '0x4141'
只需调用一个函数,它就能做你想做的事。
from pwn import *
write('filename', 'data')
read('filename')
# 'data'
read('filename', 1)
# 'd'
能够快速的将你的数据转换成你需要的任何格式。
Base64
'hello' == b64d(b64e('hello'))
Hashes
md5sumhex('hello') == '5d41402abc4b2a76b9719d911017c592'
write('file', 'hello')
md5filehex('file') == '5d41402abc4b2a76b9719d911017c592'
sha1sumhex('hello') == 'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d'
URL Encoding
urlencode("Hello, World!") == '%48%65%6c%6c%6f%2c%20%57%6f%72%6c%64%21'
Hex Encoding
enhex('hello')
# '68656c6c6f'
unhex('776f726c64')
# 'world'
Bit Manipulation and Hex Dumping
bits(0b1000001) == bits('A')
# [0, 0, 0, 1, 0, 1, 0, 1]
unbits([0,1,0,1,0,1,0,1])
# 'U'
Hex Dumping
print hexdump(read('/dev/urandom', 32))
# 00000000 65 4c b6 62 da 4f 1d 1b d8 44 a6 59 a3 e8 69 2c │eL·b│·O··│·D·Y│··i,│
# 00000010 09 d8 1c f2 9b 4a 9e 94 14 2b 55 7c 4e a8 52 a5 │····│·J··│·+U|│N·R·│
# 00000020
样例生成是一种非常方便的方法,可以在不需要进行数学计算的情况下找到偏移量。
假设我们有一个直接的缓冲区溢出,我们生成一个样例并提供给目标应用程序。
io = process(...)
io.send(cyclic(512))
在核心转储中,我们可能看到崩溃发生在0x61616178。我们可以不用对崩溃帧做任何分析,只需把这个数字打回去,得到一个偏移量。
cyclic_find(0x61616178)
# 92
当Pwntools最初(重新)编写时,大约在十年前,Python2是最受欢迎的。
commit e692277db8533eaf62dd3d2072144ccf0f673b2e
Author: Morten Brøns-Pedersen <mortenbp@gmail.com>
Date: Thu Jun 7 17:34:48 2012 +0200
ALL THE THINGS
多年来在Python中编写的许多EXP都假定str
对象与bytes
对象有1:1的映射,因为这是Python2上的工作原理。 在这一节中,我们讨论在Python3上编写EXP所需的一些变化,并阐述与Python2的对应关系。
在Python2中,str
类和bytes
类是一样的,而且有一个1:1的映射。从来不需要对任何东西调用encode
或decode
– 文本就是字节,字节就是文本。
这对编写EXP来说是非常方便的,因为你只需写"\x90\x90\x90\x90 "就可以得到一个NOP滑块。Python2上所有的Pwntools管道和数据操作都支持字符串或字节。
从来没有人使用unicode
对象来编写漏洞,所以unicode
到字节的转换极其罕见。
在 Python3 中,unicode
类实际上就是str
类。这有一些直接和明显的影响。
乍一看,Python3似乎让事情变得更难了,因为bytes
声明的是单个的八位数(正如名字bytes
所暗示的),而str
用于任何基于文本的数据表示。
Pwntools花了很大力气来遵循 “最小惊喜原则”——也就是说,事情会按照你预期的方式进行。
>>> r.send('❤️')
[DEBUG] Sent 0x6 bytes:
00000000 e2 9d a4 ef b8 8f │····│··│
00000006
>>> r.send('\x00\xff\x7f\x41\x41\x41\x41')
[DEBUG] Sent 0x7 bytes:
00000000 00 ff 7f 41 41 41 41 │···A│AAA│
00000007
然而,有时事情会出现一些故障。注意这里99f7e2如何被转换为c299c3b7c3a2。
>>> shellcode = "\x99\xf7\xe2"
>>> print(hexdump(flat("padding\x00", shellcode)))
00000000 70 61 64 64 69 6e 67 00 c2 99 c3 b7 c3 a2 │padd│ing·│····│··│
0000000e
这是因为文本字符串"\x99\xf7\xe2 "被自动转换为UTF-8代码。这不可能是用户想要的。
作为解决方案,我们只需要以b为前缀:
>>> shellcode = b"\x99\xf7\xe2"
>>> print(hexdump(flat(b"padding\x00", shellcode)))
00000000 70 61 64 64 69 6e 67 00 99 f7 e2 │padd│ing·│···│
0000000b
好极了!
一般来说,Python3上的Pwntools的修复方法是确保你所有的字符串都有一个b
前缀。这就解决了歧义,并使一切变得简单明了。
关于Python3的bytes
对象,有一个值得一提的 “麻烦”。当对它们进行迭代时,你会得到整数,而不是bytes
对象。这是与Python2的巨大差异,也是一个主要的烦恼。
>>> x=b'123'
>>> for i in x:
... print(i)
...
49
50
51
为了解决这个问题,我们建议使用切片,它产生长度为1bytes
的对象。
>>> for i in range(len(x)):
... print(x[i:i+1])
...
b'1'
b'2'
b'3'
context
对象是一个全局的、线程感知的对象,包含了pwntools
使用的各种设置。
一般来说,在一个EXP的首部,你会发现类似的东西:
from pwn import *
context.arch = 'amd64'
这通知pwntools生成的shellcode将用于amd64,并且默认字大小为64位。
arch
目标架构。有效值是"arch64"
、"arm"
、"i386"
、"amd64"
,等等。默认是 "i386"
。
第一次设置时,它会自动将默认的context.bits和context.endian设置为最可能的值。
bits
在目标二进制中,有多少位组成一个字,如32
或64
。
binary
从ELF文件中获取配置。例如:context.binary='/bin/sh'
log_file
将所有的日志输出送入的文件。
log_level
日志的详细程度。有效值是整数(越小越详细),以及"debug"
、"info "
和"error "
等字符串值。
sign
设置整数打包/解包的是否有符号。默认为 "unsigned"
。
terminal
用来打开新窗口的首选终端程序。默认情况下,使用x-terminal-emulator
或tmux
。
timeout
管道操作的默认超时范围。
update
一次设置多个值,例如context.update(arch='mips', bits=64, endian='big')
Pwntools通过ELF类使与ELF文件的交互变得相对简单。你可以在RTD上找到完整的文档。
ELF文件是按路径加载的。在被加载后,一些与安全有关的文件属性被打印出来。
from pwn import *
e = ELF('/bin/bash')
# [*] '/bin/bash'
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: Canary found
# NX: NX enabled
# PIE: No PIE
# FORTIFY: Enabled
ELF文件有几组不同的符号表可用,每组都包含在{name: data}
的字典中。
ELF.symbols
列出所有已知的符号,包括下面的符号。优先考虑PLT条目,而不是GOT条目。ELF.got
只包含GOT表ELF.plt
只包含PLT表ELF.functions
只包含函数符号表(需要DWARF符号表)这对于保持漏洞的稳健性非常有用,因为它消除了对硬编码地址的需要。
from pwn import *
e = ELF('/bin/bash')
print "%#x -> license" % e.symbols['bash_license']
print "%#x -> execve" % e.symbols['execve']
print "%#x -> got.execve" % e.got['execve']
print "%#x -> plt.execve" % e.plt['execve']
print "%#x -> list_all_jobs" % e.functions['list_all_jobs'].address
这将打印出类似下面的内容:
0x4ba738 -> license
0x41db60 -> execve
0x6f0318 -> got.execve
0x41db60 -> plt.execve
0x446420 -> list_all_jobs
使用pwntools改变ELF文件的基址(比如为ASLR做调整)是非常直接和简单的。让我们改变bash的基址,看看所有的符号都有什么变化。
from pwn import *
e = ELF('/bin/bash')
print "%#x -> base address" % e.address
print "%#x -> entry point" % e.entry
print "%#x -> execve" % e.symbols['execve']
print "---"
e.address = 0x12340000
print "%#x -> base address" % e.address
print "%#x -> entry point" % e.entry
print "%#x -> execve" % e.symbols['execve']
这应该打印出类似的内容:
0x400000 -> base address
0x42020b -> entry point
0x41db60 -> execve
---
0x12340000 -> base address
0x1236020b -> entry point
0x1235db60 -> execve
我们可以通过pwntools直接与ELF互动,就像它被加载到内存中一样,使用read
、write
和与packing
模块中的函数命名相同。此外,你可以通过disasm
方法看到反汇编。
from pwn import *
e = ELF('/bin/bash')
print repr(e.read(e.address, 4))
p_license = e.symbols['bash_license']
license = e.unpack(p_license)
print "%#x -> %#x" % (p_license, license)
print e.read(license, 14)
print e.disasm(e.symbols['main'], 12)
打印出来的东西应该如下:
'\x7fELF'
0x4ba738 -> 0x4ba640
License GPLv3+
41eab0: 41 57 push r15
41eab2: 41 56 push r14
41eab4: 41 55 push r13
对ELF文件的修补也同样简单。
from pwn import *
e = ELF('/bin/bash')
# Cause a debug break on the 'exit' command
e.asm(e.symbols['exit_builtin'], 'int3')
# Disable chdir and just print it out instead
e.pack(e.got['chdir'], e.plt['puts'])
# Change the license
p_license = e.symbols['bash_license']
license = e.unpack(p_license)
e.write(license, 'Hello, world!\n\x00')
e.save('./bash-modified')
然后我们可以运行我们修改过的bash版本。
$ chmod +x ./bash-modified
$ ./bash-modified -c 'exit'
Trace/breakpoint trap (core dumped)
$ ./bash-modified --version | grep "Hello"
Hello, world!
$ ./bash-modified -c 'cd "No chdir for you!"'
/home/user/No chdir for you!
No chdir for you!
./bash-modified: line 0: cd: No chdir for you!: No such file or directory
在编写EXP的时候,你经常需要找到一些字节序列。最常见的例子是搜索例如"/bin/sh\x00 "
的execve
调用。search
方法返回一个迭代器,允许你选择第一个结果,或者如果你需要一些特殊的东西(比如地址中没有坏字符),可以继续搜索。你可以选择传递一个writable
参数给search
,表示它应该只返回可写段的地址。
from pwn import *
e = ELF('/bin/bash')
for address in e.search('/bin/sh\x00'):
print hex(address)
上面的例子打印的内容应该如下:
0x420b82
0x420c5e
通过pwntools我们可以很方便地从头开始创建一个ELF文件。所有这些功能都是上下文感知的。相关的函数是from_bytes
和from_assembly
。每一个都返回一个ELF
对象,它可以很容易地被保存到文件中。
from pwn import *
ELF.from_bytes('\xcc').save('int3-1')
ELF.from_assembly('int3').save('int3-2')
ELF.from_assembly('nop', arch='powerpc').save('powerpc-nop')
如果你有一个ELF对象,你可以直接运行或调试它。以下两个代码是等同的:
>>> io = elf.process()
# vs
>>> io = process(elf.path)
同样地,你可以启动一个调试器,并将其连接到ELF上。这在测试shellcode时是非常有用的,不需要用C语言包装器来加载和调试它。
>>> io = elf.debug()
# vs
>>> io = gdb.debug(elf.path)
Pwntools使得用户在几乎所有的架构中进行汇编变得非常容易,并带有各种可以开箱即用已经生成好且依然可定制的shellcode。
在walkthrough目录中,有几个较长的shellcode教程。本页为您提供了基础知识。
最基本的例子,是将汇编代码转换成shellcode。
from pwn import *
print repr(asm('xor edi, edi'))
# '1\xff'
print enhex(asm('xor edi, edi'))
# 31ff
shellcraft
)shellcraft
模块会提供给你一些现成的汇编代码。它通常是可定制的。找出存在哪些shellcraft
模板的最简单方法是查看RTD上的文档。
from pwn import *
help(shellcraft.sh)
print '---'
print shellcraft.sh()
print '---'
print enhex(asm(shellcraft.sh()))
Help on function sh in module pwnlib.shellcraft.internal:
sh()
Execute /bin/sh
---
/* push '/bin///sh\x00' */
push 0x68
push 0x732f2f2f
push 0x6e69622f
/* call execve('esp', 0, 0) */
push (SYS_execve) /* 0xb */
pop eax
mov ebx, esp
xor ecx, ecx
cdq /* edx=0 */
int 0x80
---
6a68682f2f2f73682f62696e6a0b5889e331c999cd80
有三个命令行工具用于与汇编进行交互。
asm
disasm
shellcraft
asm
asm
工具的功能正如其名,它将汇编码转换为机器码,它为汇编指令输出的格式化提供了几个选项,当输出是一个终端时,它默认为十六进制编码。
$ asm nop
90
当输出是其他东西时,它显示的是原始数据。
$ asm nop | xxd
0000000: 90 .
如果在命令行上没有提供指令,它将在stdin上获取数据。
$ echo 'push ebx; pop edi' | asm
535f
最后,它支持一些不同的选项,通过--format
选项来指定输出格式。支持的参数有raw
、hex
、string
和elf
。
$ asm --format=elf 'int3' > ./int3
$ ./halt
Trace/breakpoint trap (core dumped)
disasm
Disasm是asm
的反义词,也就是将16进制的机器码反汇编成汇编指令。
$ disasm cd80
0: cd 80 int 0x80
$ asm nop | disasm
0: 90 nop
shellcraft
shellcraft
命令是内部shellcraft
模块的命令行接口。在命令行中,必须按arch.os.template
的顺序指定完整的环境信息。
$ shellcraft i386.linux.sh
6a68682f2f2f73682f62696e6a0b5889e331c999cd80
为其它非X86架构进行汇编交互,你需要自行安装适当版本的binutils
。你应该看看installing.md以了解更多这方面的信息。我们唯一需要改变的是在全局环境变量中设置架构。你可以在 context.md 中看到更多关于context
的信息。
from pwn import *
context.arch = 'arm'
print repr(asm('mov r0, r1'))
# '\x01\x00\xa0\xe1'
print enhex(asm('mov r0, r1'))
# 0100a0e1
shellcraft
模块会自动切换到相应的架构。
from pwn import *
context.arch = 'arm'
print shellcraft.sh()
print enhex(asm(shellcraft.sh()))
adr r0, bin_sh
mov r2, #0
mov r1, r2
svc SYS_execve
bin_sh: .asciz "/bin/sh"
08008fe20020a0e30210a0e10b0000ef2f62696e2f736800
你也可以通过使用--context
命令行选项,使用命令行来汇编生成其它架构的shellcode
。
$ asm --context=arm 'mov r0, r1'
0100a0e1
$ shellcraft arm.linux.sh
08008fe20020a0e30210a0e10b0000ef2f62696e2f736800
Pwntools对在你的漏洞工作流程中使用调试器有丰富的支持,在开发EXP的问题出现时,调试器非常有用。
除了这里的调试资源外,你可能想通过以下项目来增强你的GDB经验:
你的机器上应该同时安装了gdb
和gdbserver
。你可以用which gdb
或which gdbserver
来轻松检查。
如果你发现你没有安装它们,它们可以很容易地从大多数软件包管理器中安装。
$ sudo apt-get install gdb gdbserver
在GDB下启动一个进程,同时还能从pwntools与该进程进行交互,这在之前是一个棘手的过程,但幸运的是,这一切都已经被解决了,而且这个过程是相当无感和便捷的。
要在GDB下从第一条指令开始启动一个进程,只需使用gdb.debug。
>>> io = gdb.debug("/bin/bash", gdbscript='continue')
>>> io.sendline('echo hello')
>>> io.recvline()
# b'hello\n'
>>> io.interactive()
这应该会自动在一个新的窗口中启动调试器,以便你进行交互。如果不是这样,或者你看到关于context.terminal
的错误,请查看指定终端窗口的章节。
在这个例子中,我们传入了gdbscript='continue'
,以使调试器恢复执行,但是你可以传入任何有效的GDB脚本命令,它们将在调试进程启动时被执行。
有时你不想在调试器下启动你的目标,但想在开发过程的某个阶段附加到它。这也已经被Pwntools便捷无缝的实现了。
一般来说,你会创建一个process()
管道,以便与目标可执行文件交互。你可以简单地把它传递给gdb.attach()
,它将神奇地打开一个新的终端窗口,在调试器中运行目标二进制文件。
>>> io = process('/bin/sh')
>>> gdb.attach(io, gdbscript='continue')
一个新的窗口应该出现,你可以继续与进程进行互动,就像你通常在Pwntools中做的一样。
有时你想调试的二进制文件运行在一个远程服务器上,你想调试你所连接的进程(而不是服务器本身)。只要服务器在当前机器上运行,这也可以无缝地完成。
让我们用socat伪造一个服务器!
>>> socat = process(['socat', 'TCP-LISTEN:4141,reuseaddr,fork', 'EXEC:/bin/bash -i'])
然后我们像往常一样用远程管道连接到远程进程。
>>> io = remote('localhost', 4141)
[x] Opening connection to localhost on port 4141
[x] Opening connection to localhost on port 4141: Trying 127.0.0.1
[+] Opening connection to localhost on port 4141: Done
>>> io.sendline('echo hello')
>>> io.recvline()
b'hello\n'
>>> io.lport, io.rport
它是有效的!为了调试特定的bash
进程,只要把它我们的远程对象传给gdb.attach()
。Pwntools将查找连接的远程端的PID,并尝试自动连接到它。
>>> gdb.attach(io)
调试器应该自动出现,你可以与进程进行交互。
从基于英特尔的系统中在pwntools下调试异构架构(如ARM或PowerPC)是十分容易的。
>>> context.arch = 'arm'
>>> elf = ELF.from_assembly(shellcraft.echo("Hello, world!\n") + shellcraft.exit())
>>> process(elf.path).recvall()
b'Hello, world!\n'
用gdb.debug(...)
来代替调用process(...)
>>> gdb.debug(elf.path).recvall()
b'Hello, world!\n'
运行异构架构的进程必须用gdb.debug
启动,以便对其进行调试,由于QEMU的工作方式,不可能附加到一个正在运行的进程上。
需要注意的是,QEMU有一个非常有限的用来通知GDB各种库的位置存根,所以调试可能会更加困难,一些命令也无法工作。
Pwntools推荐使用Pwndbg来处理这种情况,因为它拥有专门处理QEMU存根下调试程序的能力。
有时程序就是不正常工作,你需要看看Pwntools内部在调试器的设置下发生了什么。
你可以在全局范围内设置日志上下文(例如通过context.log_level='debug'
),也可以通过传递相同的参数,只为GDB会话设置。
你应该看到在幕后为你处理的一切操作。比如说:
>>> io = gdb.debug('/bin/sh', log_level='debug')
[x] Starting local process '/home/user/bin/gdbserver' argv=[b'/home/user/bin/gdbserver', b'--multi', b'--no-disable-randomization', b'localhost:0', b'/bin/sh']
[+] Starting local process '/home/user/bin/gdbserver' argv=[b'/home/user/bin/gdbserver', b'--multi', b'--no-disable-randomization', b'localhost:0', b'/bin/sh'] : pid 34282
[DEBUG] Received 0x25 bytes:
b'Process /bin/sh created; pid = 34286\n'
[DEBUG] Received 0x18 bytes:
b'Listening on port 45145\n'
[DEBUG] Wrote gdb script to '/tmp/user/pwnxcd1zbyx.gdb'
target remote 127.0.0.1:45145
[*] running in new terminal: /usr/bin/gdb -q "/bin/sh" -x /tmp/user/pwnxcd1zbyx.gdb
[DEBUG] Launching a new terminal: ['/usr/local/bin/tmux', 'splitw', '/usr/bin/gdb -q "/bin/sh" -x /tmp/user/pwnxcd1zbyx.gdb']
[DEBUG] Received 0x25 bytes:
b'Remote debugging from host 127.0.0.1\n'
Pwntools[attempts to launch a new window][run_in_new_terminal],根据你当前使用的任何窗口系统来展示你的调试器。
默认情况下,它是自动检测的:
如果你没有使用支持的终端环境,或者它没有以你想要的方式工作(例如,水平与垂直分割),你可以通过设置context.terminal
环境变量来增加支持。
例如,下面将使用TMUX进行水平分割,而不是默认设置。
>>> context.terminal = ['tmux', 'splitw', '-h']
也许你是一个GNOME终端的用户,而默认的设置并不工作?
>>> context.terminal = ['gnome-terminal', '-x', 'sh', '-c']
你可以指定任何你喜欢的终端,甚至可以把设置放在~/.pwn.conf
里面,这样它就会被用于你的所有脚本了
[context]
terminal=['x-terminal-emulator', '-e']
Pwntools允许你通过process()
指定任何你喜欢的环境变量,对于gdb.debug()
也是如此。
>>> io = gdb.debug(['bash', '-c', 'echo $HELLO'], env={'HELLO': 'WORLD'})
>>> io.recvline()
b'WORLD\n'
CWD
不幸的是,当使用gdb.debug()
时,该进程是在gdbserver
下启动的,它增加了自己的环境变量。当环境必须被非常仔细地控制时,这可能会带来复杂的情况。
>>> io = gdb.debug(['env'], env={'FOO':'BAR'}, gdbscript='continue')
>>> print(io.recvallS())
=/home/user/bin/gdbserver
FOO=BAR
Child exited with status 0
GDBserver exiting
这只在你用gdb.debug()
在调试器下启动进程时发生。如果你能够启动你的进程,然后用gdb.attach()
附加,你就可以避免这个问题。
环境变量排序
一些漏洞可能需要某些环境变量以特定的顺序出现。但是Python2的字典是没有顺序的,这可能会加剧这个问题。
为了让你的环境变量有一个特定的顺序,我们建议使用Python3(它基于插入顺序对字典进行排序),或者使用collection.OrderedDict
。
现代的Linux系统有一个叫做trace_scope
的设置,它可以阻止非子进程的进程被调试。Pwntools对于它自己启动的任何进程都能解决这个问题,但是如果你必须在Pwntools之外启动一个进程,并试图通过pid附加到它(例如gdb.attach(1234)
),你可能被阻止附加。
你可以通过禁用安全设置和重启机器来解决这个问题:
sudo tee /etc/sysctl.d/10-ptrace.conf <<EOF
kernel.yama.ptrace_scope = 0
EOF
有些题目要求在启动时将argv[0]
设置为一个特定的值,甚至要求它是NULL(即argc==0
)。
通过gdb.debug()
不可能用这种配置启动一个processs,但你可以使用gdb.attach()
。这是因为在gdbserver下启动二进制文件的限制。
面向返回的编程(ROP)是一种绕过NX(no-execute,也称为预防数据执行(DEP))的技术。
Pwntools有几个特点,使ROP的利用更简单,但只适用于i386和amd64架构。
要创建一个ROP
对象,只需向它传递一个ELF
文件。
elf = ELF('/bin/sh')
rop = ROP(elf)
这将自动加载二进制文件,并从其中提取大多数简单的gadgets。例如,如果你想加载rbx
寄存器。
rop.rbx
# Gadget(0x5fd5, ['pop rbx', 'ret'], ['rbx'], 0x8)
在这里,我们可以看到gadgets的地址,它的反汇编内容,它加载了什么寄存器,以及gadgets执行时堆栈被调整了多少。
由于在我们的例子中,/bin/sh
是地址无关的(即使用ASLR),我们可以先调整ELF对象上的加载地址。
elf.address = 0xff000000
rop = ROP(elf)
rop.rbx
# Gadget(0xff005fd5, ['pop rbx', 'ret'], ['rbx'], 0x8)
你可以通过魔法访问器询问ROP对象如何加载你想要的任何寄存器。我们在上面使用了rbx
,但是我们也可以寻找其他的寄存器。
rop.rbx
# Gadget(0xff005fd5, ['pop rbx', 'ret'], ['rbx'], 0x8)
如果寄存器不能被加载,返回值为None
。在我们的例子中,假如没有pop rcx; ret
的gadgets:
rop.rcx
# None
Pwntools有意排除了大多数非实质性的gadgets,但你可以通过查看ROP.gadgets
属性看到它已经加载的列表,该属性将一个gadgets的地址映射到gadgets本身。
rop.gadgets
# {4278225723: Gadget(0xff008b3b, ['add esp, 0x10', 'pop rbx', 'pop rbp', 'pop r12', 'ret'], ['rbx', 'rbp', 'r12'], 0x20),
# 4278278088: Gadget(0xff0157c8, ['add esp, 0x130', 'pop rbp', 'ret'], ['rbp'], 0x138),
# 4278284789: Gadget(0xff0171f5, ['add esp, 0x138', 'pop rbx', 'pop rbp', 'ret'], ['rbx', 'rbp'], 0x144),
# 4278272966: Gadget(0xff0143c6, ['add esp, 0x18', 'ret'], [], 0x1c),
# 4278239612: Gadget(0xff00c17c, ['add esp, 0x20', 'pop rbx', 'pop rbp', 'pop r12', 'ret'], ['rbx', 'rbp', 'r12'], 0x30),
# 4278259611: Gadget(0xff010f9b, ['add esp, 0x28', 'pop rbp', 'pop r12', 'ret'], ['rbp', 'r12'], 0x34),
# ...
# 4278216828: Gadget(0xff00687c, ['pop rsp', 'pop r13', 'ret'], ['rsp', 'r13'], 0xc),
# 4278214225: Gadget(0xff005e51, ['pop rsp', 'ret'], ['rsp'], 0x8),
# 4278210586: Gadget(0xff00501a, ['ret'], [], 0x4)}
Pwntools的ROP过滤掉了非实质性的小工具,所以如果它没有你想要的东西,我们建议使用ROPGadget来检查二进制文件。
为了将原始数据添加到ROP栈中,只需调用ROP.raw()
。
rop.raw(0xdeadbeef)
rop.raw(0xcafebabe)
rop.raw('asdf')
现在我们有了一些gadgets,让我们看看ROP栈上有什么:
print(rop.dump())
# 0x0000: 0xdeadbeef
# 0x0004: 0xcafebabe
# 0x0008: b'asdf' 'asdf'
现在我们有了一个ROP栈,我们想从它那里得到原始字节。我们可以使用byte()
方法来实现这个功能。
print(hexdump(bytes(rop)))
# 00000000 ef be ad de be ba fe ca 61 73 64 66 │····│····│asdf│
# 0000000c
Pwntools的ROP工具的真正威力在于能够调用任意的函数,无论是通过神奇的访问器还是通过ROP.call()
例程。
elf = ELF('/bin/sh')
rop = ROP(elf)
rop.call(0xdeadbeef, [0, 1])
print(rop.dump())
# 0x0000: 0xdeadbeef 0xdeadbeef(0, 1, 2, 3)
# 0x0004: b'baaa' <return address>
# 0x0008: 0x0 arg0
# 0x000c: 0x1 arg1
注意这里它使用的是32位ABI,这是不正确的。我们也可以对64位二进制文件进行ROP,但我们需要相应地设置context.arch
。我们可以使用context.binary
来自动完成这个工作。
context.binary = elf = ELF('/bin/sh')
rop = ROP(elf)
rop.call(0xdeadbeef, [0, 1])
print(rop.dump())
# 0x0000: 0x61aa pop rdi; ret
# 0x0008: 0x0 [arg0] rdi = 0
# 0x0010: 0x5f73 pop rsi; ret
# 0x0018: 0x1 [arg1] rsi = 1
# 0x0020: 0xdeadbeef
如果你的库在其GOT/PLT中有你想调用的函数,或者有二进制的符号,你可以直接调用函数名。
context.binary = elf = ELF('/bin/sh')
rop = ROP(elf)
rop.execve(0xdeadbeef)
print(rop.dump())
# 0x0000: 0x61aa pop rdi; ret
# 0x0008: 0xdeadbeef [arg0] rdi = 3735928559
# 0x0010: 0x5824 execve
一般来说,在你的进程的地址空间中,一次有一个以上的ELF可用。让我们看一个使用/bin/sh
以及其libc
的例子。最初,我们看了rop.rcx
,这个gadgets是不存在的,因为bash中没有pop rcx; ret
这个gadgets。然后,现在我们也有来自libc的所有gadgets了。
context.binary = elf = ELF('/bin/sh')
libc = elf.libc
elf.address = 0xAA000000
libc.address = 0xBB000000
rop.rax
# Gadget(0xaa00eb87, ['pop rax', 'ret'], ['rax'], 0x10)
rop.rbx
# Gadget(0xaa005fd5, ['pop rbx', 'ret'], ['rbx'], 0x10)
rop.rcx
# Gadget(0xbb09f822, ['pop rcx', 'ret'], ['rcx'], 0x10)
rop.rdx
# Gadget(0xbb117960, ['pop rdx', 'add rsp, 0x38', 'ret'], ['rdx'], 0x48)
注意rax
和rbx
的gadgets是在主二进制文件中(0xAA…),而后两个是在libc(0xBB…)。
现在,让我们做一个更复杂的函数调用吧!
rop.memcpy(0xaaaaaaaa, 0xbbbbbbbb, 0xcccccccc)
print(rop.dump())
# 0x0000: 0xbb11c1e1 pop rdx; pop r12; ret
# 0x0008: 0xcccccccc [arg2] rdx = 3435973836
# 0x0010: b'eaaafaaa' <pad r12>
# 0x0018: 0xaa0061aa pop rdi; ret
# 0x0020: 0xaaaaaaaa [arg0] rdi = 2863311530
# 0x0028: 0xaa005f73 pop rsi; ret
# 0x0030: 0xbbbbbbbb [arg1] rsi = 3149642683
# 0x0038: 0xaa0058a4 memcpy
请注意,Pwntools能够使用pop rdx; pop r12; ret
gadgets,并说明堆栈上需要的额外值。还要注意的是,每个项目的符号值都在rop.dump()
中获取。例如,它显示我们正在设置rdx=3435973836
。
当我们了解了pwntools的ROP功能时,获得一个shell是很容易的!我们直接调用execve
,并从内存中的某个地方找到一个"/bin/sh/x00 "
的实例作为第一个参数传递进去。
context.binary = elf = ELF('/bin/sh')
libc = elf.libc
elf.address = 0xAA000000
libc.address = 0xBB000000
rop = ROP([elf, libc])
binsh = next(libc.search(b"/bin/sh\x00"))
rop.execve(binsh, 0, 0)
显示我们的ROP栈
print(rop.dump())
# 0x0000: 0xbb11c1e1 pop rdx; pop r12; ret
# 0x0008: 0x0 [arg2] rdx = 0
# 0x0010: b'eaaafaaa' <pad r12>
# 0x0018: 0xaa0061aa pop rdi; ret
# 0x0020: 0xbb1b75aa [arg0] rdi = 3139138986
# 0x0028: 0xaa005f73 pop rsi; ret
# 0x0030: 0x0 [arg1] rsi = 0
# 0x0038: 0xaa005824 execve
提取ROP的原始字节
print(hexdump(bytes(rop)))
# 00000000 e1 c1 11 bb 00 00 00 00 00 00 00 00 00 00 00 00 │····│····│····│····│
# 00000010 65 61 61 61 66 61 61 61 aa 61 00 aa 00 00 00 00 │eaaa│faaa│·a··│····│
# 00000020 aa 75 1b bb 00 00 00 00 73 5f 00 aa 00 00 00 00 │·u··│····│s_··│····│
# 00000030 00 00 00 00 00 00 00 00 24 58 00 aa 00 00 00 00 │····│····│$X··│····│
# 00000040
Pwntools有一个丰富的内部调试系统,可用于你自己的调试,以及弄清Pwntools幕后发生的事情。
当你从pwn导入*时,日志功能就导入了。这些功能如下:
error
warn
info
debug
例如:
>>> warn('Warning!')
[!] Warning!
>>> info('Info!')
[*] Info!
>>> debug('Debug!')
注意,最后一行默认不显示,因为默认的日志级别是 “info”。
你可以在你的开发脚本中使用这些,而不是打印,这可以让你准确地调控你看到的调试信息量
你可以通过各种方式控制哪些日志信息是可见的,所有这些都将在下面解释。
最简单的方法是在运行你的脚本时加入神奇的参数DEBUG,例如:打开最大限度的日志记录功能:
$ python exploit.py DEBUG
这对于查看正在发送/接收的确切字节,以及在pwntools内部发生的事情,以使你的EXP发挥作用是很有用的。
你也可以通过context.log_level来设置日志的粗略程度,就像你设置目标架构等的方式一样。这与在命令行中控制所有的日志语句的方式相同。
>>> context.log_level = 'debug'
log_console
默认情况下,所有的日志都转到STDOUT。如果你想把它改成一个不同的文件,例如STDERR,你可以通过log_console设置来实现。
>>> context.log_console = sys.stderr
log_file
有时你想让你的日志转到一个特定的文件,例如log.txt,以便以后查看。你可以通过设置context.log_file来添加一个日志文件。
>>> context.log_file = './log.txt'
每个管子在创建时都可以单独控制其日志的粗略程度。只需将level='...'
传递给对象的构造。
>>> io = process('sh', level='debug')
[x] Starting local process '/usr/bin/sh' argv=[b'sh']
[+] Starting local process '/usr/bin/sh' argv=[b'sh'] : pid 34475
>>> io.sendline('echo hello')
[DEBUG] Sent 0xb bytes:
b'echo hello\n'
>>> io.recvline()
[DEBUG] Received 0x6 bytes:
b'hello\n'
b'hello\n'
这适用于所有的管子(process
、remote
等),也适用于类似管子的东西(如gdb.attach
和gdb.debug
)以及其他许多例程。
例如,如果你想确切地看到一些shellcode是如何组装的。
>>> asm('nop', log_level='debug')
[DEBUG] cpp -C -nostdinc -undef -P -I/home/user/pwntools/pwnlib/data/includes /dev/stdin
[DEBUG] Assembling
.section .shellcode,"awx"
.global _start
.global __start
_start:
__start:
.intel_syntax noprefix
nop
[DEBUG] /usr/bin/x86_64-linux-gnu-as -32 -o /tmp/user/pwn-asm-0yy12n6i/step2 /tmp/user/pwn-asm-0yy12n6i/step1
[DEBUG] /usr/bin/x86_64-linux-gnu-objcopy -j .shellcode -Obinary /tmp/user/pwn-asm-0yy12n6i/step3 /tmp/user/pwn-asm-0yy12n6i/step4
b'\x90'
有时你希望所有的日志都被启用,但只针对部分漏洞脚本。你可以手动切换context.log_level
,或者你可以使用一个范围内的助手。
io = process(...)
with context.local(log_level='debug'):
# Things inside the 'with' block are logged verbosely
io.recvall()