【技术解读】【WebSec】【CloudSec】【Go】Go Parser Footguns - Vasco Franco

前言

这是国外安全会议 Insomni'hack 2025的一个议题<Go Parser Footguns>
视频地址:https://www.youtube.com/watch?v=IlTPXh6umpY

基础知识

基础知识1 - Go的json序列化/反序列化

在Go的标准库中有处理json序列化和反序列化的库,encoding/json

  • API 1:json.Unmarshal/Marshal - 反序列化/序列化
    image

  • API 2:json.NewDecoder(r).Decode(v) / json.NewEncoder(w).Encode(v) - 反序列化/序列化
    image

基础知识2 - omitempty的作用

type User struct {
	Username  string `json:"username"`
	Firstname string `json:"name,omitempty"`
}

omitempty在这里的作用是:在序列化为json时,如果该字段的值是“零值”,就不输出这个字段。
image

omitempty在这里只会影响json序列化,不会影响json反序列化。

基础知识3 - Go的xml序列化/反序列化

在Go的标准库中有处理xml序列化和反序列化的库,encoding/xml

  • API:xml.Unmarshal/Marshal
    image

基础知识4- Go的yaml序列化/反序列化

处理yaml序列化和反序列化,就得使用第三方开源库了,如:gopkg.in/yaml.v3
image

攻击场景1:(Un)Marshaling unexpected data (序列化/反序列化非预期数据)

攻击场景1.1:修改非预期的属性数据

image
如图,User对象的isAdmin属性是不希望被用户输入去控制的。

攻击场景1.2:读取非预期的属性数据

image
如图,User对象的Password属性是不希望被用户读取的。

存在漏洞的代码

  • 漏洞代码1
import (
	"encoding/json"
	"fmt"
)

type User struct {
	Username  string `json:"username"`
	Firstname string `json:"name,omitempty"`
	IsAdmin   bool
}

func main() {
	u := User{}
	_ = json.Unmarshal([]byte(`{"username":"jofra","name":"Vasco","isadmin":true}`), &u)

	fmt.Printf("%#v\n", u)
}

程序输出结果:

main.User{Username:"jofra", Firstname:"Vasco", IsAdmin:true}
  • 漏洞代码2
import (
	"encoding/json"
	"fmt"
)

type User struct {
	Username  string `json:"username"`
	Firstname string `json:"name,omitempty"`
	IsAdmin   bool   `json:"omitempty"`
}

func main() {
	u := User{}
	_ = json.Unmarshal([]byte(`{"username":"jofra","name":"Vasco","omitempty":true}`), &u)

	fmt.Printf("%#v\n", u)
}

程序输出结果:

main.User{Username:"jofra", Firstname:"Vasco", IsAdmin:true}

可以看到json:"omitempty"这么写,在Go里会把omitempty看作是json的key名,对应User的IsAdmin属性。
所以IsAdmin可以被外部输入修改。

  • 漏洞代码3
import (
	"encoding/json"
	"fmt"
)

type User struct {
	Username  string `json:"username"`
	Firstname string `json:"name,omitempty"`
	IsAdmin   bool   `json:"-,omitempty"`   //或者写成这样:`json:"-,"` 同理,也是存在漏洞的,可被反序列化后篡改
}

func main() {
	u := User{}
	_ = json.Unmarshal([]byte(`{"username":"jofra","name":"Vasco","-":true}`), &u)

	fmt.Printf("%#v\n", u)
}

程序输出结果:

main.User{Username:"jofra", Firstname:"Vasco", IsAdmin:true}

可以看到这个示例,尽管开发者本意是想用-符号使IsAdmin属性不可被json反序列化篡改,但是json:"-,omitempty"这样写,
在Go里其实是表示,json反序列化时,-会作为json的key,这个key对应User的IsAdmin属性,所以IsAdmin可以被外部输入修改。

安全的代码

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Username  string `json:"username"`
	Firstname string `json:"name,omitempty"`
	IsAdmin   bool   `json:"-"`
}

func main() {
	u := User{}
	_ = json.Unmarshal([]byte(`{"username":"jofra","name":"Vasco","-":true}`), &u)
	// _ = json.Unmarshal([]byte(`{"username":"jofra","name":"Vasco","IsAdmin":true}`), &u)

	fmt.Printf("%#v\n", u)
}

程序输出结果:

main.User{Username:"jofra", Firstname:"Vasco", IsAdmin:false}

可以看到IsAdmin属性被保护起来了,因为json:"-"这种情况下,在Go的JSON处理中,标签"-"是一个特殊标记,表示该字段在JSON序列化和反序列化过程中应被完全忽略。

其实官方文档是有提到这一点的,见:https://pkg.go.dev/encoding/json#Marshal
image

Semgrep规则:

image

json、xml、yaml在处理-,omitempty时的异同

image

可以看到,只有xml会报错,yaml、json在反序列化时均可以将-作为键名。

如果要让xml不报错,可以写成<A:->的方式,如下图:
image

安全防护实践

创建一个临时结构,该结构仅包含你可以设置的字段,然后通过某种方式进行映射。
image
其实这种方式也很好理解。平时在企业里作代码审计的时候也是,接口接收参数是一个对象,但接口实现的时候只会取某些必要的属性值进行后续的逻辑处理。

攻击场景2:Parser differentials(解析器差异)

攻击场景2.1:Duplicate keys

(1) json

import (
	"encoding/json"
	"fmt"
)

type ActionRequest struct {
	Action string `json:"action"`
}

func main() {
	a := ActionRequest{}
	_ = json.Unmarshal([]byte(`{"action":"create","action":"update"}`), &a)

	fmt.Printf("%#v\n", a)
}

在这种情况下,Go会选择最后一个重复key,所以输出结果是:

main.ActionRequest{Action:"update"}

其它语言的json解释器的情况

image
参考:https://bishopfox.com/blog/json-interoperability-vulnerabilities
(印象中这篇文章入选了Portswigger的2021 top 10 web hacking techniques)

可以看到,绝大部分都是选择最后一个重复key。

启示:所以,当图中列出的这7种选择第一个重复key的组件,和其余选择最后一个重复key的组件在组合使用时,就可能存在解析差异而导致的绕过漏洞。

(2) xml、yaml

image

可以看到,在Go中,json(内置的encoding/json库)、xml(内置的encoding/xml库)都是选择最后一个重复key,而yaml则会抛异常。

攻击场景2.2:Case insensitive key matching

image
可以看到,go的json解析器,对key的大小写不敏感。

Go的json、xml、yaml解析器在key的大小写字符处理的比较:

如下图,可以看到,只有json解析器对大小写不敏感,xml、yaml解析器都是对大小写敏感的。
image

另外,还可以是unicode字符。等效的unicode字符,可通过fuzz得到。
这里直接让Cursor写了一个测试程序,代码如下:

import (
	"encoding/json"
	"fmt"
	"testing"
)

// ActionRequest 是我们要测试的结构体
type ActionRequest struct {
	Action string `json:"aktions"`
}

// TestUnicodeEquivalence 系统地测试所有Unicode字符,找出与's'等效的字符
func TestUnicodeEquivalence(t *testing.T) {
	// 定义要测试的Unicode范围
	ranges := []struct {
		start int
		end   int
		name  string
	}{
		{0x0250, 0x02AF, "国际音标扩展"},
		{0x0370, 0x03FF, "希腊字母和科普特字母"},
		{0x0400, 0x04FF, "西里尔字母"},
		{0x1E00, 0x1EFF, "拉丁文扩展附加"},
		{0x0080, 0x024F, "拉丁文扩展和欧洲字母"},
		{0x2000, 0x206F, "一般标点"},
		{0x20A0, 0x20CF, "货币符号"},
		{0x2100, 0x214F, "字母符号"},
		{0x2150, 0x218F, "数字形式"},
		{0x2190, 0x21FF, "箭头"},
		{0x2200, 0x22FF, "数学运算符"},
		{0xFF00, 0xFFEF, "全角ASCII和半角片假名/平假名"},
		{0x1D400, 0x1D7FF, "数学字母数字符号"},
		// 添加更多您感兴趣的范围...
	}

	// 原始的正确键名
	originalKey := "aktions"

	// 计数器
	var found int

	// 系统地遍历每个范围
	for _, r := range ranges {
		t.Logf("测试Unicode范围: %s (%X-%X)", r.name, r.start, r.end)

		for codePoint := r.start; codePoint <= r.end; codePoint++ {
			// 跳过代理对区域和无效代码点
			if (codePoint >= 0xD800 && codePoint <= 0xDFFF) || codePoint > 0x10FFFF {
				continue
			}

			// 跳过标准字符's'和'S'
			if codePoint == 's' || codePoint == 'S' {
				continue
			}

			char := rune(codePoint)

			// 构建测试键名,替换最后一个字符
			testKey := originalKey[:len(originalKey)-1] + string(char)

			// 构建JSON字符串
			jsonStr := fmt.Sprintf(`{"%s":"Action_1","%s":"Action_2"}`, originalKey, testKey)

			// 尝试解析
			a := ActionRequest{}
			err := json.Unmarshal([]byte(jsonStr), &a)

			// 如果解析成功,并且Action字段的值是"Action_2",说明这个字符可以替代's'
			if err == nil && a.Action == "Action_2" {
				found++
				t.Logf("发现等效字符 #%d: '%c' (U+%04X)", found, char, codePoint)
				fmt.Printf("发现等效字符: '%c' (U+%04X)\n", char, codePoint)
				fmt.Printf("JSON: %s\n", jsonStr)
				fmt.Printf("结果: %#v\n\n", a)
			}
		}
	}

	t.Logf("总共发现 %d 个与's'等效的Unicode字符", found)
}

运行结果如下,找到了与s等效的字符ſ
image

验证下:

import (
	"encoding/json"
	"fmt"
)

type ActionRequest struct {
	Action string `json:"aktions"`
}

func main() {
	a := ActionRequest{}
	_ = json.Unmarshal([]byte(`{"aktions":"Action_1","aKtionſ":"Action_2"}`), &a)

	fmt.Printf("%#v\n", a)
}

运行结果如下:

main.ActionRequest{Action:"Action_2"}

image

现实世界上一些该类型的漏洞案例

image

攻击场景3:Data format confusion(数据格式混淆)

现实世界上一个该类型的漏洞案例 - CVE-2020-16250

image
image

即使是JSON格式,Go的XML解析器非常宽松,允许XML的开头和结尾都有垃圾数据。


作者总结了4种在Go中利用这种模式的技巧

(1):Unknown keys

image

如图可以看到,在Go中,默认情况下,json、xml、yaml解析器都允许未知的key。
因此可以在未知key中隐藏一些数据。

(2):Leading garbage data

image

如图可以看到,在Go中,json、xml、yaml解析器中,xml解析器是最宽松的,在(json/xml/yaml)格式数据的(外部的)最开始,只有xml是可以插入垃圾数据的。

(3):Trailing garbage data

image

如图可以看到,在Go中,json、xml、yaml解析器中,xml解析器是最宽松的,在(json/xml/yaml)格式数据的(外部的)尾部,只有xml是可以插入垃圾数据的。

但如果使用的是Go中的NewDecoder API,则结果有点不同,json、xml的NewDecoder API是可以正确解析尾部包含垃圾数据的,如下图:
image

(4):Constructing a palyglot

image

Tip: JSON是YAML的子集

image
这里有个Tip:每个JSON文件都是一个有效的YAML文件。

所以可以构造出在json、xml、yaml解析都会有效的数据,但解析的结果会有所不同,如下图:

  • 形式1
    image

  • 形式2
    image

缓解方式

1、Disallowing Unknown fields

image

2、Disallowing trailing garbage data

image

3、Disallowing case insensitive matching

image

4、Putting it all together

image

但是,这并非最好的方案,因为很慢!

5、一个新的API:encoding/json/v2

image

总结

image

Reference

https://www.youtube.com/watch?v=IlTPXh6umpY

posted @ 2025-05-13 22:34  wh03ver-momo  阅读(64)  评论(0)    收藏  举报