FlawOpen

flawopen.com/Incidents/equifax-struts-ognl-rce-july-2017

● Equifax · CVE-2017-5638 · July 2017 · CVSS 9.8 · 严重
安全研究 · FlawOpen

Equifax 数据泄露:近 1.5 亿人受影响

CVE-2017-5638 允许攻击者通过 Content-Type 标头在 Equifax 未修补的 Struts 门户注入 OGNL。攻击持续约 76 天,近 1.5 亿人的数据受到影响。[1], [4]

💡 通俗易懂的原理解析 (ELI5)

想象一种邮件系统:它把被拒收的寄件标签放进错误提示。如果提示把标签当作可执行的指令模板,寄件人就能让系统照着指令操作,而不是只报告地址错误。Struts 当时已有修复,但 Equifax 将通知发到了过时的管理员名单,网络扫描也未能发现该门户上的漏洞。[1], [4]

核心概念与专有名词

OGNL
对象图导航语言,Struts 用来读取和设置 Java 对象属性的表达式语言。把攻击者的文本当作 OGNL 求值,就等于运行攻击者的代码。[1], [6]
Jakarta multipart 解析器
Struts 中处理文件上传的组件。它在拒绝格式错误的 Content-Type 时会构造一条错误消息,而这条消息随后会被求值。[1], [3], [2]
表达式注入(CWE-917)
不可信输入进入了解释器要求值的表达式,数据因此变成了指令。[6]
资产清单
记录每个系统上运行哪些软件及其版本。没有它,就无法把补丁通知和需要打补丁的机器对应起来。[4]

框架映射

事件演进时间线

2017-03-07

Apache 发布安全公告 S2-045 以及修复版本 Struts 2.3.32 和 2.5.10.1。[1]

2017-05-13

Equifax 后来向 GAO 表示,另一场入侵于 5 月 13 日开始,攻击者从此开始提取个人数据;此前已经发生过一次未经授权的访问,但当时没有窃取数据。[4]

2017-07-29

Equifax 在为一台流量检测设备更新过期证书后,发现了可疑流量。[4]

2017-09-07

Equifax 对外公布数据泄露事件。GAO 后来报告称,至少 1.455 亿美国人的数据和近 100 万美国境外人士的数据受到影响。[4]

攻击执行流程分解

步骤 1

伪造的 Content-Type 请求头

攻击者发送一个上传请求,其 Content-Type 请求头里放的不是媒体类型,而是一个 OGNL 表达式。[1]

步骤 2

错误消息带上了请求头

Jakarta 解析器拒绝该请求头,并构造一条包含请求头原始文本的本地化错误消息。[1], [3], [2]

步骤 3

消息被求值

Struts 会对这条消息中的表达式求值,于是攻击者的 OGNL 以 Web 应用的权限作为 Java 代码运行。[1], [3], [2], [6]

步骤 4

缓慢而隐蔽的数据外泄

攻击者利用数据存储库中发现的凭据,从门户关联的 3 个数据库扩展到另外 48 个与门户无关的数据库。GAO 报告称攻击者执行了约 9,000 次查询,攻击持续约 76 天;过期证书导致加密流量无法被检查。[4]

源代码对比:漏洞与安全实现

示意性的 Java 代码,并非 Struts 源代码;Struts 2.3.32 和 2.5.10.1 中的真实修复改变了 multipart 错误消息的构造方式。加固版本使用固定的消息键,只把请求头作为位置参数传入,因此它只会被格式化为文本,绝不会成为表达式的一部分。它还会把任何不像媒体类型的值替换为占位符,这样即使日后修改消息,也无法让请求头重新进入被求值的字符串。[3], [2], [6]

存在漏洞的实现
import com.opensymphony.xwork2.TextProvider;
import javax.servlet.http.HttpServletRequest;

public final class UploadErrors {
    private final TextProvider text;   // resolves messages and evaluates expressions in them

    public UploadErrors(TextProvider text) {
        this.text = text;
    }

    public String describe(HttpServletRequest request) {
        String contentType = request.getHeader("Content-Type");
        // 未处理的请求头会成为待求值消息的一部分。
        return text.getText("Invalid content type: " + contentType);
    }
}
加固后的安全修复
import com.opensymphony.xwork2.TextProvider;
import java.util.Locale;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;

public final class UploadErrors {
    private static final Pattern MEDIA_TYPE = Pattern.compile(
        "^[a-z]+/[a-z0-9.+-]+(;\\s*[a-z0-9-]+=[a-z0-9._-]+)*$");
    private final TextProvider text;

    public UploadErrors(TextProvider text) {
        this.text = text;
    }

    public String describe(HttpServletRequest request) {
        String contentType = request.getHeader("Content-Type");
        boolean wellFormed = contentType != null && contentType.length() <= 256
            && MEDIA_TYPE.matcher(contentType.toLowerCase(Locale.ROOT)).matches();
        // 使用固定的消息键;请求头只能作为位置参数传入,
        // 且仅当其格式符合媒体类型时才传入。
        return text.getText("upload.invalid.content.type",
            new String[] { wellFormed ? contentType : "(malformed)" });
    }
}

工程与系统安全加固清单

参考来源