Go语言解析器中的意外安全陷阱

本文深入分析Go语言JSON、XML和YAML解析器中的安全陷阱,包括意外数据暴露、解析器差异和数据格式混淆等攻击场景,提供实际漏洞案例和缓解措施,帮助开发者构建更安全的应用程序。

Go语言解析器中的意外安全陷阱

在Go应用程序中,解析不可信数据会创建一个危险的攻击面,这在实践中经常被利用。在我们的安全评估中,我们反复利用Go的JSON、XML和YAML解析器中的意外行为来绕过身份验证、规避授权控制并从生产系统中泄露敏感数据。

这些不是理论问题——它们已导致记录在案的漏洞,如CVE-2020-16250(Google的Project Zero发现的Hashicorp Vault身份验证绕过)以及我们在客户参与中的众多高影响发现。

解析在Go中

  • encoding/json 版本 go1.24.1
  • encoding/xml 版本 go1.24.1
  • yaml.v3 版本 3.0.1(最流行的第三方Go YAML库)

我们将使用JSON作为示例,但所有三个解析器都有与我们看到的API等效的API。

这些解析器提供两个主要功能:

  • Marshal(序列化):将Go结构体转换为各自的格式字符串
  • Unmarshal(反序列化):将格式字符串转换回Go结构体

Go使用结构体字段标签来自定义解析器应如何处理各个字段。这些标签包括:

  • 用于序列化/反序列化的键名
  • 修改行为的可选逗号分隔指令(例如,omitempty标签选项告诉JSON序列化程序如果字段为空,则不将其包含在JSON输出字符串中)
1
2
3
4
5
type User struct {
    Username string `json:"username_json_key,omitempty"`
    Password string `json:"password"`
    IsAdmin  bool   `json:"is_admin"`
}

要将JSON字符串解组到上面显示的User结构中,我们必须对Username字段使用username_json_key键,对Password字段使用password键,对IsAdmin字段使用is_admin键。

1
2
3
4
5
6
7
8
u := User{}
_ = json.Unmarshal([]byte(`{
    "username_json_key": "jofra",
    "password": "qwerty123!",
    "is_admin": "false"
}`), &u)
fmt.Printf("Result: %#v\n", u)
// Result: User{Username:"jofra", Password:"qwerty123!", IsAdmin:false}

这些解析器还提供基于流的替代方案,这些方案在io.Reader接口上操作,而不是字节切片。此API非常适合解析流数据,例如HTTP请求体,使其成为HTTP请求处理中的首选。

攻击场景1:(解)封送意外数据

有时,您需要限制可以封送或解封送结构的哪些字段。

让我们考虑一个简单的示例,其中后端服务器有一个用于创建用户的HTTP处理程序,另一个用于在身份验证后检索该用户。

创建用户时,您可能不希望用户能够设置IsAdmin字段(即从用户输入中解封送该字段)。

同样,在获取用户时,您可能不希望返回用户的密码或其他秘密值。

我们如何指示解析器不要封送或解封送字段?

没有标签的字段

首先看看如果不设置JSON标签会发生什么。

1
2
3
type User struct {
    Username string
}

在这种情况下,您可以使用其名称解封送Username字段,如下所示。

1
2
_ = json.Unmarshal([]byte(`{"Username": "jofra"}`), &u)
// Result: User{Username:"jofra"}

这是有充分文档记录的,大多数Go开发人员都知道这一点。让我们看另一个例子:

1
2
3
4
5
type User struct {
    Username string `json:"username,omitempty"`
    Password string `json:"password,omitempty"`
    IsAdmin  bool
}

上面的IsAdmin字段是否会被解封送?一个资历较浅或分心的开发人员可能会假设不会,从而引入安全漏洞。

误用-标签

要告诉解析器不要(解)封送特定字段,我们必须添加特殊的- JSON标签!

1
2
3
4
5
type User struct {
    Username string `json:"username,omitempty"`
    Password string `json:"password,omitempty"`
    IsAdmin  bool   `json:"-,omitempty"`
}

让我们试试看!

1
2
_ = json.Unmarshal([]byte(`{"-": true}`), &u)
// Result: main.User{Username:"", Password:"", IsAdmin:true}

哦,糟糕,我们仍然能够设置IsAdmin字段。我们错误地复制粘贴了,omitempty部分,这导致解析器在提供的JSON输入中查找-键。

虽然这种行为容易出错且好处微乎其微(能够命名字段为-),但它在JSON包文档中有记录:

“作为一个特例,如果字段标签是”-",该字段总是被省略。注意,名为"-“的字段仍然可以使用标签”-,“生成。”

XML和YAML解析器的操作类似,但有一个关键区别:XML解析器将<->标签视为无效。要解决此问题,我们必须为-符号添加XML命名空间前缀,例如<A:->

这次让我们做对。

1
2
3
4
5
type User struct {
    Username string  `json:"username,omitempty"`
    Password string  `json:"password,omitempty"`
    IsAdmin  bool    `json:"-"`
}

终于!现在,IsAdmin字段无法被解封送。

误用omitempty

我们之前发现的另一个非常简单的错误配置是开发人员错误地将字段名称设置为omitempty。

1
2
3
4
5
6
type User struct {
    Username string `json:"omitempty"`
}
u := User{}
_ = json.Unmarshal([]byte(`{"omitempty": "a_user"}`), &u)
// Result: User{Username:"a_user"}

如果将JSON标签设置为omitempty,解析器将使用omitempty作为字段的名称(如预期)。当然,一些开发人员曾尝试使用它来设置字段中的omitempty选项,同时保留默认名称。

与前面的示例相反,这个示例不太可能产生安全影响,并且应该易于通过测试检测,因为任何使用预期字段名称序列化或反序列化输入的尝试都会失败。

攻击场景2:解析器差异

如果您使用不同的JSON解析器解析相同的输入,而它们对结果有分歧,会发生什么?更具体地说,Go解析器中的哪些行为允许攻击者"可靠地"触发这些差异?

作为一个示例,让我们使用以下采用微服务架构的应用程序:

  • 接收所有用户请求的代理服务
  • 由代理服务调用的授权服务,用于确定用户是否有足够的权限完成其请求
  • 由代理服务调用的多个业务逻辑服务以执行业务逻辑

在此第一种流程中,常规非管理员用户尝试执行UserAction,这是他们被允许执行的操作。

在此第二种流程中,相同的常规用户尝试执行AdminAction,这是他们被禁止执行的操作。

最终,以下流程是因为服务对用户尝试执行的操作存在分歧。

授权服务,使用不同的编程语言或非默认Go解析器,将解析UserAction并授予用户执行操作的权限,而使用Go默认解析器的代理服务将解析AdminAction并将其代理到不正确的服务。剩下的问题是:我们可以使用哪些有效负载来实现此行为?

重复字段

我们将探索的第一个差异攻击向量是重复键。当您的JSON输入两次具有相同的键时会发生什么?这取决于解析器!

在Go中,JSON解析器将始终采用最后一个。无法阻止此行为。

1
2
3
4
5
_ = json.Unmarshal([]byte(`{
    "action": "Action1",
    "action": "Action2"
}`), &a)
// Result: ActionRequest{Action:"Action2"}

这是大多数解析器的默认行为。

不区分大小写的键匹配

Go的JSON解析器不区分大小写地解析字段名称。无论您写action、ACTION还是aCtIoN,解析器都将它们视为相同!

1
2
3
4
_ = json.Unmarshal([]byte(`{
    "aCtIoN": "Action2"
}`), &a)
// Result: ActionRequest{Action:"Action2"}

这是有文档记录的,但非常不直观,无法禁用它,而且几乎没有其他解析器具有此行为。

更糟糕的是,正如我们上面看到的,您可以有重复的字段,并且仍然选择后者,即使大小写不匹配。

1
2
3
4
5
_ = json.Unmarshal([]byte(`{
    "action": "Action1",
    "aCtIoN": "Action2"
}`), &a)
// Result: ActionRequest{Action:"Action2"}

这违反了文档,文档说:

“要将JSON解组到结构体中,Unmarshal将传入的对象键与Marshal使用的键(结构体字段名称或其标签)匹配,优先选择完全匹配,但也接受不区分大小写的匹配。”

您甚至可以使用Unicode字符!

攻击场景3:数据格式混淆

对于最后的攻击场景,让我们看看如果您用XML解析器解析JSON文件,或使用任何其他格式与不正确的解析器会发生什么。

让我们以CVE-2020-16250为例,这是Hashicorp Vault在其AWS IAM身份验证方法中的绕过。这个漏洞是由Google的Project Zero团队发现的。

让我们看看三种不同的行为,这些行为使得用错误的Go解析器解析文件成为可能,并构建一个多语言文件,该文件可以用Go的JSON、XML和YAML解析器解析,并为每个解析器返回不同的结果。

未知键

默认情况下,JSON、XML和YAML解析器不会阻止未知字段——传入数据中与目标结构体中的任何字段不匹配的属性。

前导垃圾数据

在三个解析器中,只有XML解析器接受前导垃圾数据。

尾随垃圾数据

同样,只有XML解析器接受任意尾随垃圾数据。

例外是使用解析器的Decoder API处理流数据,在这种情况下,JSON解析器接受尾随垃圾数据。这是一个未计划修复的未解决问题。

构建多语言文件

我们如何组合我们到目前为止看到的所有行为来构建一个多语言文件:

  • 可以被Go的JSON、XML和YAML解析器解析
  • 为每个解析器返回不同的结果

一个非常有用的信息是JSON是YAML的子集:每个JSON文件也是有效的YAML文件。

缓解措施

我们如何最小化这些风险并使JSON解析更严格?我们希望:

  • 防止在JSON、XML和YAML中解析未知键
  • 防止在JSON和XML中解析重复键
  • 防止在JSON中不区分大小写的键匹配(这一点尤其重要!)
  • 防止在XML中解析前导垃圾数据
  • 防止在JSON和XML中解析尾随垃圾数据

不幸的是,JSON只提供一个选项来使其解析更严格:DisallowUnknownFields。顾名思义,此选项禁止输入JSON中的未知字段。YAML通过KnownFields(true)函数支持相同的功能,虽然有一个为XML实现相同功能的提案,但被拒绝了。

JSONv2

为了被广泛采用并在大规模上解决问题,此功能需要在库级别实现并默认启用。这就是JSON v2的用武之地。它目前只是一个提案,但已经投入了大量工作,希望很快发布。它在许多方面改进了JSON v1,包括:

  • 禁止重复名称
  • 执行区分大小写的匹配
  • 包括RejectUnknownMembers选项,即使默认未启用
  • 包括UnmarshalRead函数来处理来自io.Reader的数据,验证是否找到EOF,禁止尾随垃圾数据

开发者的关键要点

  • 默认实施严格解析。对JSON使用DisallowUnknownFields,对YAML使用KnownFields(true)
  • 跨边界保持一致性。当在多个服务中处理输入时,通过始终使用相同的解析器或实施额外的验证层(如上面显示的strictJSONParse函数)来确保一致的解析行为。
  • 关注JSON v2。密切关注Go的JSON v2库的开发,它通过更安全的JSON默认值解决了许多这些问题。
  • 利用静态分析。使用我们提供的Semgrep规则来检测代码库中的一些易受攻击的模式,特别是误用-标签和omitempty字段。
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计