绕过AST解析的python沙箱逃逸方法

这类题是在zjusec.com上 ACTF 2019 分组中chenyuan出的一系列python沙箱逃逸题目中看到的
在网上搜索貌似也只能搜到 TokyoWesterns CTF 4th 2018 这一次比赛中的题目

简介

这类题目不像普通的沙箱逃逸一样通过删除内置函数字典或者删除某些模块的内容来实现
而是在输入命令后即使用python的 ast 模块对其进行语法分析,只要使用了某些禁止的抽象语法,就抛出异常导致程序中断

因为它直接使用 ast.parse 分析了语法,所以很难蒙混过关骗过 ast,这时就需要寻找题目中遍历语法树的漏洞了

题目分析

先来看看cy的pysandbox13,这个最终版的AST检查绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
dbgprint = sys.stderr.write

class Traversal():
def __init__(self, node):
self.tisiv(node)

depth = -1
def tisiv(self, nodes):
if not isinstance(nodes, list):
nodes = [nodes]
self.depth += 1
for node in nodes:
func = getattr(self, 'tisiv_' + node.__class__.__name__, None)
if func:
dbgprint(" "*self.depth + "tisiv"[::-1] +"\t"+ node.__class__.__name__+"\n")
return func(node)
else:
if not isinstance(node, ast.expr):
raise Exception("not allowed "+str(node))
self.depth -= 1

def tisiv_Call(self, node):
raise Exception("not allowed")
self.tisiv(node.func)
self.tisiv(node.args)
self.tisiv(node.keywords)

def tisiv_Attribute(self, node):
raise Exception("not allowed")
self.tisiv(node.value)
self.tisiv(node.attr)
self.tisiv(node.ctx)

def tisiv_Import(self, node):
raise Exception("not allowed")

def tisiv_Module(self, node):
self.tisiv(node.body)

def tisiv_BoolOp(self, node):
self.tisiv(node.values)

def tisiv_BinOp(self, node):
self.tisiv(node.left)
self.tisiv(node.right)

def tisiv_UnaryOp(self, node):
self.tisiv(node.operand)

def tisiv_Lambda(self, node):
self.tisiv(node.body)
self.tisiv(node.args)

def tisiv_IfExp(self, node):
self.tisiv(node.test)
self.tisiv(node.body)
self.tisiv(node.orelse)

def tisiv_Dict(self, node):
self.tisiv(node.keys)
self.tisiv(node.values)

def tisiv_Set(self, node):
self.tisiv(node.elts)

def tisiv_ListComp(self, node):
self.tisiv(node.elt)
self.tisiv(node.generators)

def tisiv_SetComp(self, node):
self.tisiv(node.elt)
self.tisiv(node.generators)

def tisiv_DictComp(self, node):
self.tisiv(node.key)
self.tisiv(node.value)
self.tisiv(node.generators)

def tisiv_GeneratorExp(self, node):
self.tisiv(node.elt)
self.tisiv(node.generators)

def tisiv_Yield(self, node):
self.tisiv(node.value)

def tisiv_Compare(self, node):
self.tisiv(node.left)
self.tisiv(node.comparators)

def tisiv_Repr(self, node):
self.tisiv(node.value)

def tisiv_Subscript(self, node):
self.tisiv(node.value)
self.tisiv(node.slice)

def tisiv_List(self, node):
self.tisiv(node.elts)

def tisiv_Tuple(self, node):
self.tisiv(node.elts)

def tisiv_Expr(self, node):
self.tisiv(node.value)

def tisiv_JoinedStr(self, node):
self.tisiv(node.values)

def tisiv_NameConstant(self, node):
pass

Traversal(ast.parse(c))

可以读出,它定义了一个 Traversal 类,在初始化的时候对传入的节点调用 tisiv 方法,即对其所有子节点继续逐层检查
如果 tisiv_{该节点类名} 已经有了存在的方法,就调用它,在那些方法中又分别对其子节点进行了检查
如果不存在这样的方法,就检测这个节点的语法类型是不是 ast.expr,如果不是就直接禁止

再看 TokyoWesterns CTF 4th 2018 这道题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def check(node):
if isinstance(node, list):
return all([check(n) for n in node])
else:
attributes = {
'BoolOp': ['values'],
'BinOp': ['left', 'right'],
'UnaryOp': ['operand'],
'Lambda': ['body'],
'IfExp': ['test', 'body', 'orelse'],
'Dict': ['keys', 'values'],
'Set': ['elts'],
'ListComp': ['elt', 'generators'],
'SetComp': ['elt', 'generators'],
'DictComp': ['key', 'value', 'generators'],
'GeneratorExp': ['elt', 'generators'],
'Yield': ['value'],
'Compare': ['left', 'comparators'],
'Call': False, # call is not permitted
'Repr': ['value'],
'Num': True,
'Str': True,
'Attribute': False, # attribute is also not permitted
'Subscript': ['value'],
'Name': True,
'List': ['elts'],
'Tuple': ['elts'],
'Expr': ['value'], # root node
'comprehension': ['target', 'iter', 'ifs'],
}

for k, v in attributes.items():
if hasattr(ast, k) and isinstance(node, getattr(ast, k)):
if isinstance(v, bool):
return v
return all([check(getattr(node, attr)) for attr in v])

if __name__ == '__main__':
expr = sys.stdin.readline()
body = ast.parse(expr).body

这道题目的代码就更加明确了,道理是类似的

绕过语法树检查

正如前面说的,我们需要找检查程序中的漏洞

寻找没有遍历到的子节点

我们发现,在题目的程序中,都是手动编写了对某个抽象语法的哪些部分进行检测,所以可能就会出现某个语法的某个部分没被检测到的情况。

这时候就可以去和 AST文档中抽象语法 对比,文档中给出的 ast.expr 包含了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
expr = BoolOp(boolop op, expr* values)
| NamedExpr(expr target, expr value)
| BinOp(expr left, operator op, expr right)
| UnaryOp(unaryop op, expr operand)
| Lambda(arguments args, expr body)
| IfExp(expr test, expr body, expr orelse)
| Dict(expr* keys, expr* values)
| Set(expr* elts)
| ListComp(expr elt, comprehension* generators)
| SetComp(expr elt, comprehension* generators)
| DictComp(expr key, expr value, comprehension* generators)
| GeneratorExp(expr elt, comprehension* generators)
-- the grammar constrains where yield expressions can occur
| Await(expr value)
| Yield(expr? value)
| YieldFrom(expr value)
-- need sequences for compare to distinguish between
-- x < 4 < 3 and (x < 4) < 3
| Compare(expr left, cmpop* ops, expr* comparators)
| Call(expr func, expr* args, keyword* keywords)
| FormattedValue(expr value, int? conversion, expr? format_spec)
| JoinedStr(expr* values)
| Constant(constant value, string? kind)

-- the following expression can appear in assignment context
| Attribute(expr value, identifier attr, expr_context ctx)
| Subscript(expr value, expr slice, expr_context ctx)
| Starred(expr value, expr_context ctx)
| Name(identifier id, expr_context ctx)
| List(expr* elts, expr_context ctx)
| Tuple(expr* elts, expr_context ctx)

-- can appear only in Subscript
| Slice(expr? lower, expr? upper, expr? step)

比如,BinOp(expr left, operator op, expr right) 表示了二元运算这个语法,left 表示左侧的表达式,op 表示二元运算符,right 表示右侧表达式。
同理 ListComp(expr elt, comprehension* generators) 中 elt 表示其中列表推导的元素,而 generator 则表示生成器子句

再来看 TWCTF 这道题,它的检查中写了:

1
'Subscript': ['value'],

而文档中给的索引访问是 Subscript(expr value, expr slice, expr_context ctx)

因此可以发现程序并没有检测索引访问中的切片 slice,这样例如 a[…] 中的 … 部分就会被全部忽略
所以就可以在[]中藏一个eval执行我们想要的功能

寻找没有检查的节点

再来看 zjusec 这道题,通过对比可以发现所有检测的节点的子节点也都遍历了
但是再细看可以发现 FormattedValue 这个节点并没有在题目代码里出现

而且 ast.FormattedValue 属于 ast.expr,所以它既不会被检查,也不会抛出异常
看名字像是 f-string 相关,可以 dump 一下看看:

1
2
>>> ast.dump(ast.parse("f'{x}'"))
"Module(body=[Expr(value=JoinedStr(values=[FormattedValue(value=Name(id='x', ctx=Load()), conversion=-1, format_spec=None)]))], type_ignores=[])"

可以发现,f-string 是 JoinedStr,而 FormattedValue 是其中被格式化的部分

所以就可以向 f-string 的 {} 部分藏 eval 来干坏事了

其他漏洞

这个是 pysandbox12 的一种解法
python中的语法不仅有 ast.expr 一种,而且很特别的是,列表推导 ListComp 的生成器子句并不是 ast.expr,而是 ast.comprehension

1
2
>>> ast.dump(ast.parse("[x for x in range(n)]"))
"Module(body=[Expr(value=ListComp(elt=Name(id='x', ctx=Load()), generators=[comprehension(target=Name(id='x', ctx=Store()), iter=Call(func=Name(id='range', ctx=Load()), args=[Name(id='n', ctx=Load())], keywords=[]), ifs=[], is_async=0)]))], type_ignores=[])"

但是 pysandbox13 这样排除了 ast.expr :

1
2
if not isinstance(node, ast.expr):
raise Exception("not allowed "+str(node))

但是12题中并没有,所以 ast.comprehension 这个类型完全没有被检查
因此直接向生成器表达式中插入坏东西即可:

  • [x for x in [eval(...)]]

Reference

绕过AST解析的python沙箱逃逸方法

https://blog.tonycrane.cc/p/6dee32d5.html

作者

TonyCrane

发布于

2021-10-20

更新于

2021-11-30

许可协议