失效的假设 “这个位置只是把值填进去”
所属组 W1 · 注入
前置 第 1 篇:注入的一般形式,尤其是"谁是解析器"那一问
本篇要抬高的认知 前两篇注入的后果是读写数据;这一篇的后果是在你的进程里执行任意代码(RCE)
复现环境 Python 3 标准库,无外部依赖

一、你以为在填值,其实在求值

运营提了个再普通不过的需求:通知文案要支持变量。

你好 {name},你这个月的余额是 {balance}。

你找了个模板机制,把 {name} 替换成真实姓名。上线,好用。这个位置在你心里是个填空——把一个值放进去,仅此而已。

第 1 篇讲过一个排查三问,第一问是"谁是解析器",而且说它最容易答错。这一篇就是那一问的正面展开:你以为这里没有解析器,其实有一个,而且它解析的不是查询、不是命令,是代码。

先看一个连你天天用的标准库都藏着这台机器的例子。

str.format 都能读到全局密钥

# 你以为 "{}".format(x) 只是把值填进去,其实它能沿属性和索引在对象之间走动
class User:
    def __init__(self, name):
        self.name = name
        self.role = "user"

API_KEY = "SK-LIVE-7F3A9C21"          # 模块级密钥,本不该被一个通知模板看到
u = User("alice")

print("正常   :", "你好 {u.name}".format(u=u))
print("越界读 :", "{u.role}".format(u=u))                                  # 读到没打算暴露的字段
print("读密钥 :", "{u.__init__.__globals__[API_KEY]}".format(u=u))         # 一路走到全局命名空间
正常   : 你好 alice
越界读 : user
读密钥 : SK-LIVE-7F3A9C21

看第三行。一个通知模板,读出了模块级的 API_KEY

它没用任何"危险字符",没有引号、没有分号——第 1 篇说过的那些统统用不上。它做的只是沿着对象往里走uu.__init__ → 它的 __globals__ → 里面的 API_KEYstr.format 允许在 {} 里写属性访问和索引,而属性访问一旦开放,整张对象图就都连通了:从任何一个对象,你都能爬到它所在模块的全局命名空间。

打个比方

你让助理"把张三的电话号码报给我"。他翻开通讯录,念出一串数字。这是取值

现在换个说法:你让他"顺着张三这条记录,把能找到的东西报给我"。他会怎么办?张三 → 他的公司 → 公司的账户 → 账户的密码……他会一路往下走,走到你根本没想让他去的地方。

这两句话在你听来差不多,都是"报点东西给我"。可对执行的人来说,第一句是查一个字段,第二句是给了他一张地图。

⭐ 关键不在"Python 的 format 有这个特性",而在那句失效的假设:你以为 {u.name} 是"取名字",它其实是"求值 u.name 这个表达式"。 取值和求值,在你眼里长一样,在解析器眼里是两码事——后者能走的地方,远比你想让它走的多。

⚠️ 这一节第一次读会有点别扭,因为"从一个对象爬到全局变量"这件事不符合日常直觉。如果暂时想不通那条路径怎么走通的,先记住结论就行:只要开放了属性访问,整张对象图就是连通的。 后面几节不依赖这条路径的细节。

这台机器有个名字,而且名字起得很实在:Java 里那个最常被注入的表达式引擎叫 OGNL,全称 Object-Graph Navigation Language——对象图导航语言。它干的就是上面这件事:从一个对象出发,在对象图里导航。

二、从"读"到"接管"

上一节还只是"读"。表达式引擎真正让人睡不着的地方在于:求值不止能读,还能执行。

很多模板/表达式功能,是开发者图省事、直接拿一个"表达式求值器"接上去的。下面这十行就是这种反模式的最小标本——一个用 eval 实现 {{ ... }} 的玩具模板引擎:

import re, os

# 一个"支持变量的通知模板"引擎。开发者图省事,用 eval 实现 {{ ... }}——
# 于是给一个本该只填值的地方,加上了求值能力。
def render(template, **vars):
    def sub(m):
        return str(eval(m.group(1), {}, vars))     # <- 就是这一行
    return re.sub(r"\{\{(.+?)\}\}", sub, template)

print("设计意图  :", render("你好 {{name}}", name="alice"))
print("探测求值  :", render("结果 {{ 7*7 }}"))                        # 49 => 在求值,不是填值
pid = render("{{ os.getpid() }}", os=os)
print("够着 os   :", "模板读到的 pid 与直接调用一致:", pid == str(os.getpid()))
print("这意味着  : 能求值就能触及运行时,等价于可执行任意代码")
设计意图  : 你好 alice
探测求值  : 结果 49
够着 os   : 模板读到的 pid 与直接调用一致: True
这意味着  : 能求值就能触及运行时,等价于可执行任意代码

三步,每一步都往前一层:

{{name}}      设计意图:填一个名字
{{ 7*7 }}     → 49    这不是填值。这里在【求值】。
                      (7*7 是真实世界里 SSTI 的经典探测手法:
                       如果页面回显 49,就说明命中了一个表达式引擎)
{{os.getpid}} → 够得着 os 模块

一旦够得着 os,就等价于能执行任意系统命令。这里只打印了 pid、没真的去开 shell——但"能读到 pid"和"能删你的库"隔的只是换一个表达式,能力边界已经被跨过了

现在可以把三篇注入的后果排在一起,看这台机器凶在哪:

SQL 注入      → 读写"这个数据库账号能碰到"的数据
命令注入      → 以"这个进程的权限"跑系统命令
表达式注入    → 在"这个进程内部"求值任意代码
              = 命令注入的超集:进程能干的它都能干,
                而且常常绕过了只盯着 shell 的那些防御

⚠️ 这就是为什么表达式注入(在 Java 世界常表现为 OGNL / SpEL 注入、在模板里表现为 SSTI)几乎总是被评为严重(Critical):它的默认后果不是泄露一张表,是别人拿到了你进程的控制权

三、换个引擎,同一台机器

第一节那台机器,换到不同框架里只是换了个求值器的名字,入口的模式一模一样——一个你以为只在填值的地方,接着一个会求值的引擎。下面是它们的形态(只为说明触发点长什么样,都是已公开多年、早已修复的写法,不是可用的攻击串):

框架 / 场景      求值器      被求值的东西藏在哪
------------------------------------------------------------
Struts 2         OGNL        HTTP 参数名 / 出错时回显的字段
Spring           SpEL        某些注解、查询、表达式配置(CVE-2022-22963 是这一类)
Log4j            JNDI 查找   ★ 一条被写进日志的普通字符串
模板引擎(SSTI)   引擎自带     Jinja2/Freemarker/Velocity 的 {{ }} / ${ }

最值得单看的是 Log4Shell(2021 年 12 月),因为它把"填值处藏着求值器"推到了极端:

你写的代码:  log.info("用户登录:" + userInput)
你以为在做:  往日志里记一行字
实际发生的:  Log4j 会对日志内容里的 ${...} 做「查找」并求值

于是当 userInput 是形如   ${jndi:ldap://…/x}   的字符串时——
  ① 记日志这个动作触发了 JNDI 查找
  ② JNDI 可以【从远程加载并实例化一个对象】
  ③ = 远程代码执行

⭐ 注意它比第一节又狠了一层。str.format 只能读进程里已经有的东西;JNDI 能从网络上拉来新代码加载进来。所以连"我进程里没有危险的类"都不再是安全边界。

而写下 log.info(...) 的那个人,脑子里在做的事是——记一行日志。没有比这更无辜的操作了。这正是它波及整个行业的原因:每个人都在记日志。

它出现过的地方

这三起是同一台机器的三次点火,展开在事件分析里:

2017.03   Struts / CVE-2017-5638    OGNL,触发点在 Content-Type 头 → Equifax,约 1.47 亿人
2021.12   Log4Shell / CVE-2021-44228 JNDI,触发点在任意一条被记录的日志
2022.03   Spring4Shell/CVE-2022-22965 表单数据绑定能走到对象图深处的类加载器属性

⚠️ 一个提醒:这三起分别属于三个框架、三个求值器,但第 1 篇的定义一个字没变——一个不该解析的位置,接了一个会解析的引擎。会写代码的人把它们当成三个 CVE 去背,理解机制的人把它们当成同一件事的三个实例。

四、为什么这类引擎会存在,又为什么反复失守

你可能会问:既然这么危险,为什么框架还要在填值的地方塞一个求值器?

因为它确实好用。“配置里能写一句表达式"“日志里能自动展开变量"“模板里能算个小计”——每一个都是实打实的便利。表达式引擎不是 bug,是卖点

问题在于第 1 篇 §四 那句话,这里是它最锋利的版本:

便利的代价,是在一个原本没有语法的地方,引入了语法。

而且这类引擎的语法特别强——不是"能拼一句 SQL"那种强,是"能求值任意代码"那种强。于是三件事叠加,就注定反复失守:

① 求值器藏在你想不到的位置   记日志、拼错误消息、绑定表单字段
② 它的能力远超场景所需       你只想算个小计,它却能加载任意类
③ 触发点极其日常            越日常,越没人会在那儿设防

第 ② 条是这一篇和前两篇最大的不同。SQL 注入里,解析器的能力和场景大致相称——你在操作数据库,它给你数据库的能力。表达式注入里,能力和场景严重不匹配:你只想填个名字,它却给了你整个运行时。这个落差就是全部风险的来源。

五、防御

排序和前两篇一致:能从根上不引入求值,就不要指望"把求值关小”。

① 首选:这个位置根本不需要求值器

绝大多数"变量替换"需求,要的是纯文本替换,不是求值。把会求值的引擎换成不会求值的机制:

要做的事              别用(会求值)            改用(只替换/只读值)
--------------------------------------------------------------------
通知模板填变量        eval / 通用表达式引擎     纯占位符替换,值当字符串塞进去
                                              (对应第一节:别让 {x} 能访问属性)
结构化日志            把用户数据拼进消息文本     用参数化日志:把用户数据作为
                                              独立字段传入,不进插值管线
配置里的"动态值"      SpEL 之类的表达式         普通配置项 + 代码里 if/else

判断标准很简单:问这个位置"最坏能求值到什么”。如果答案是"任意代码",而你的需求只是"填个名字",那就是能力错配,换机制。

② 必须求值时:关沙箱,并假设沙箱会被绕过

有些场景确实需要用户提供表达式(如低代码平台的公式)。那么:

· 用专门的、最小能力的表达式引擎,而不是通用 eval / 通用 OGNL
· 白名单允许的函数与属性,默认拒绝一切对象图导航(尤其 __class__ / 类加载 这类跳板)
· 断网:求值环境不应有发起网络请求、加载远程类的能力(这条能挡住 Log4Shell 那一类)

⚠️ 沙箱是减损不是根治。SSTI 沙箱逃逸是一门持续更新的手艺——历史上几乎每个"安全的模板沙箱"都被逐个绕过过。所以沙箱之外仍要配 ①、③。

③ 纵深:把"能力和场景的落差"补上

最小权限运行         进程别用 root / 别给它用不到的网络出口
关掉用不到的功能      Log4j 那种:显式禁用 JNDI 查找(`log4j2.formatMsgNoLookups` 一类开关)
依赖收敛与告警        表达式引擎的高危版本要能被依赖扫描盯住

Log4Shell 期间,真正快速止血的动作恰恰是 ③ 里那个开关——因为这个功能 99% 的人根本用不到,它却默认开着。这回到 §四 的 ②:能力远超场景,关掉多余能力就是最高性价比的防御。

六、这些防御的边界

① “换成纯替换"不总是免费的。 有些系统的模板早已深度依赖表达式能力(大量存量文案里写着计算逻辑),一刀切换会破坏功能。这类要分级迁移,且迁移期内必须靠 ②③ 兜住。

② 沙箱与"逃逸"是持续对抗。 上面说过,别把某个版本的沙箱当终点。任何"某模板引擎的沙箱是安全的"说法都要带版本号,且当作"截至该版本已知无公开逃逸”,而不是"证明安全"。

③ 关功能开关依赖版本。formatMsgNoLookups 这类缓解开关,在不同版本里的名字、默认值、是否彻底,都不一样——Log4Shell 早期就出现过"以为关了其实没关全"的情况。涉及具体版本的缓解措施,必须查该版本的官方公告,本篇不给固定结论。

关于时效:这一篇的机制部分(填值处藏求值器 → 对象图导航 → RCE)稳定不变;但具体某框架某版本的触发点和缓解开关高度依赖版本,是这一组里时效性最强的内容。读到任何具体版本号,都要去官方公告核对。

七、本篇小结

前两篇:解析器读查询/命令 → 后果是读写数据。
这一篇:解析器读代码本身 → 后果是接管进程(RCE)。

⭐ 失效的假设:"这个位置只是把值填进去"
   真相:这里接了一个会【求值】的引擎,取值和求值在你眼里一样,在它眼里不一样
   连 str.format 都能沿对象图读到全局密钥;eval 模板能直接够到 os

⭐ 表达式注入 = 命令注入的超集
   OGNL(对象图导航) / SpEL / JNDI(还能远程加载代码) / 模板 SSTI —— 同一台机器换引擎
   Struts、Log4Shell、Spring4Shell 是它的三次点火,机制是同一个

防御优先级:
   ① 这个位置根本不需要求值器 → 换成纯文本替换 / 参数化日志(首选)
   ② 必须求值 → 最小能力引擎 + 白名单 + 断网,且假设沙箱会被绕过
   ③ 纵深 → 最小权限、关掉用不到的功能(Log4Shell 的止血键就在这)

核心判断:问这个位置"最坏能求值到什么"。
   若答案是"任意代码"、而需求只是"填个名字",就是能力错配。

思考题

  1. 第一节的 str.format 能读到 API_KEY,靠的是从 u 爬到 __globals__。如果把 API_KEY 改成函数内的局部变量,还读得到吗?由此说明"把密钥放哪"和"关掉对象图导航"哪个才是根治。
  2. §二 的玩具引擎用 eval(expr, {}, vars),第二个参数(globals)传了空字典。为什么这挡不住攻击者够到 os?(提示:想想内建函数和 __import__ 从哪来。)
  3. Log4Shell 的 ${jndi:ldap://…} 比第一节的 str.format 危险在哪一个具体能力上?为什么"我进程里没有危险的类"对它不成立?
  4. §四 说表达式注入的特征是"能力和场景严重不匹配"。用这个标准判断:一个只需要展示用户昵称的页面,用 Jinja2 的 {{ }} 直接渲染用户昵称,属于哪一类错配?正确做法是什么?
  5. 有人建议"过滤掉 ${{{__ 这些危险序列来防表达式注入"。用第 1 篇 §五 的四种失效方式,说明这为什么又是"过滤"的老路。
  6. §五 ③ 说 Log4Shell 的止血键是"关掉一个 99% 的人用不到的功能"。在你负责的系统里,找一个"默认开着、但你其实用不到"的强能力(表达式、反序列化、动态加载皆可),说明关掉它的代价和收益。
  7. 结构化日志(把用户数据作为独立字段传入,而不是拼进消息文本)为什么能同时挡住 Log4Shell 这类注入日志伪造?把它和第 2 篇"参数化保护的是拼接点"对照,它们是不是同一个思路?

相关第 1 篇:注入的一般形式 · 第 2 篇:SQL 注入 · Web 安全机制篇 · 事件分析