【技术解读】【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时,如果该字段的值是“零值”,就不输出这个字段。

omitempty在这里只会影响json序列化,不会影响json反序列化。
基础知识3 - Go的xml序列化/反序列化
在Go的标准库中有处理xml序列化和反序列化的库,encoding/xml。
- API:xml.Unmarshal/Marshal
![image]()
基础知识4- Go的yaml序列化/反序列化
处理yaml序列化和反序列化,就得使用第三方开源库了,如:gopkg.in/yaml.v3。

攻击场景1:(Un)Marshaling unexpected data (序列化/反序列化非预期数据)
攻击场景1.1:修改非预期的属性数据

如图,User对象的isAdmin属性是不希望被用户输入去控制的。
攻击场景1.2:读取非预期的属性数据

如图,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

Semgrep规则:

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

可以看到,只有xml会报错,yaml、json在反序列化时均可以将-作为键名。
如果要让xml不报错,可以写成<A:->的方式,如下图:

安全防护实践
创建一个临时结构,该结构仅包含你可以设置的字段,然后通过某种方式进行映射。

其实这种方式也很好理解。平时在企业里作代码审计的时候也是,接口接收参数是一个对象,但接口实现的时候只会取某些必要的属性值进行后续的逻辑处理。
攻击场景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解释器的情况

参考:https://bishopfox.com/blog/json-interoperability-vulnerabilities
(印象中这篇文章入选了Portswigger的2021 top 10 web hacking techniques)
可以看到,绝大部分都是选择最后一个重复key。
启示:所以,当图中列出的这7种选择第一个重复key的组件,和其余选择最后一个重复key的组件在组合使用时,就可能存在解析差异而导致的绕过漏洞。
(2) xml、yaml

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

可以看到,go的json解析器,对key的大小写不敏感。
Go的json、xml、yaml解析器在key的大小写字符处理的比较:
如下图,可以看到,只有json解析器对大小写不敏感,xml、yaml解析器都是对大小写敏感的。

另外,还可以是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等效的字符ſ:

验证下:
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"}

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

攻击场景3:Data format confusion(数据格式混淆)
现实世界上一个该类型的漏洞案例 - CVE-2020-16250


即使是JSON格式,Go的XML解析器非常宽松,允许XML的开头和结尾都有垃圾数据。
作者总结了4种在Go中利用这种模式的技巧
(1):Unknown keys

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

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

如图可以看到,在Go中,json、xml、yaml解析器中,xml解析器是最宽松的,在(json/xml/yaml)格式数据的(外部的)尾部,只有xml是可以插入垃圾数据的。
但如果使用的是Go中的NewDecoder API,则结果有点不同,json、xml的NewDecoder API是可以正确解析尾部包含垃圾数据的,如下图:

(4):Constructing a palyglot

Tip: JSON是YAML的子集

这里有个Tip:每个JSON文件都是一个有效的YAML文件。
所以可以构造出在json、xml、yaml解析都会有效的数据,但解析的结果会有所不同,如下图:
-
形式1
![image]()
-
形式2
![image]()
缓解方式
1、Disallowing Unknown fields

2、Disallowing trailing garbage data

3、Disallowing case insensitive matching

4、Putting it all together

但是,这并非最好的方案,因为很慢!
5、一个新的API:encoding/json/v2

总结






浙公网安备 33010602011771号