最近在研究Golang的代码审计,分享一个SQL注入中经典的宽字节注入问题。
该漏洞的利用条件是:
MySQL库/表均采用GBK编码(必须GBK,GB2312不行);
客户端go-sql-driver/mysql驱动DSN配置 charset=gbk&interpolateParams=true
这里面有一个关键参数interpolateParams,他的官方解释为:interpolateParams
interpolateParams
Type: bool
Valid Values: true, false
Default: false If interpolateParams
is true, placeholders (?
) in calls to db.Query()
and db.Exec()
are interpolated into a single query string with given parameters. This reduces the number of roundtrips, since the driver has to prepare a statement, execute it with given parameters and close the statement again with interpolateParams=false
.
This can not be used together with the multibyte encodings BIG5, CP932, GB2312, GBK or SJIS. These are blacklisted as they may introduce a SQL injection vulnerability !
翻译大致意思为:
如果 interpolateParams
设置为 true
,则在调用 db.Query()
和 db.Exec()
时,占位符(?)会被给定的参数插值到一个单一的查询字符串中。减少了交互次数,因为当 interpolateParams
设置为 false
时,驱动程序需要预编译语句,用给定的参数执行语句,然后再关闭这条语句。
此功能不能与多字节编码 BIG5、CP932、GB2312、GBK 或 SJIS 一起使用。这些编码被列为黑名单,因为它们可能会引入 SQL 注入漏洞!
说白了就是平常预编译都是在服务端做的,当 interpolateParams设置为true时,会在客户端将SQL预编译好发送给服务端,减少了交互次数,提升了性能。
所以关注的重点是客户端如何做的预编译?先准备一段代码:
package main
import (
"SQLi/model"
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
"log"
)
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func GetDB(username, password string) (*sql.DB, error) {
//ALTER DATABASE go_sec_labs CHARACTER SET = gbk COLLATE = gbk_chinese_ci;
//ALTER TABLE `user` CONVERT TO CHARACTER SET gbk COLLATE gbk_chinese_ci;
db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(localhost:3306)/go_sec_labs2?charset=gbk&interpolateParams=true", username, password))
if err != nil {
return nil, err
}
return db, nil
}
func (repo *UserRepository) SelectByName(name string) ([]model.User, error) {
query := "SELECT id, name, age, sex, password FROM user WHERE name = ?"
rows, err := repo.db.Query(query, name)
if err != nil {
log.Println(err)
return nil, err
}
defer rows.Close()
return repo.orm2User(rows)
}
func (repo *UserRepository) orm2User(rows *sql.Rows) ([]model.User, error) {
var users []model.User
for rows.Next() {
var user model.User
if err := rows.Scan(&user.Id, &user.Name, &user.Age, &user.Sex, &user.Password); err != nil {
log.Printf("Failed to scan row: %v", err)
continue
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
return nil, err
}
return users, nil
}
func main() {
// 初始化数据库连接
db, err := GetDB("username", "password")
if err != nil {
log.Fatalf("Get db error: %v", err)
}
defer db.Close() // 确保程序结束时关闭数据库连接
userRepo := NewUserRepository(db)
users, err := userRepo.SelectByName("\xDF' or 1=1 -- ")
if err != nil {
log.Fatalf("SelectByName error: %v", err)
}
for _, user := range users {
fmt.Println(user)
}
} 执行预编译的代码位于go-sql-driver/mysql@v1.8.1/connection.go的interpolateParams方法,将断点打在这个方法内部,由于SQL注入主要针对输入为字符串的情况,所以将断点打在case string:这里:
此时的buf的内容是SQL 去除了占位符 ? ,此时要在name = 之后拼接一个字符串,它要先在字符串的左边拼一个单引号,接下来会进入escapeStringBackslash函数,对payload中的敏感字符做转义。
因为接下来要操作buf有效长度之后的区域,所以此处做了一个扩容,增加的容量是payload长度×2:
我们的payload是13字节,所以扩容之后的长度变为了60 + 13 × 2 = 86:
我们的payload中只有一个敏感字符单引号 ',所以直接看这里case '\'':
此处将单引号放到buf后+1的位置,然后再在前面加了个转义符\,此时payload中的 ' 就被替换成了 \'。
从escapeStringBackslash函数返回后,这里再在buf末尾添加一个单引号 ',用于闭合289行添加的单引号:
至此,经过预编译的SQL就变为了:
按理来讲,payload中的单引号 ' 已经被转义,不会闭合前面的引号,但问题在于我们在payload前面构造了一个字节0xDF,可以用Wireshark抓包,看看实际 发送到服务端的SQL是:
0x5C是 \ 的ASCII码,由于我们采用了GBK编码,我们构造的0xDF刚好和0x5C刚好构成了GBK中的一个汉字“運 ”:
所以实际发送到服务端的SQL可看作:
SELECT id, name, age, sex, password FROM user WHERE name = '運' or 1=1 -- ' 用于转义的 \ 被吃掉了,造成了注入。
之前官方解释说interpolateParams=true时可以减少交互次数,我们来看看怎么回事。先将interpolateParams设为false,执行一次查询,用Wireshark抓包:
可以看到有三次请求,
第一次Prepare Statement,此时发送的SQL还是带占位符 ? 的 :
第二次 Execute Statement,将payload发送给服务端:
第三次 Close Statement,将这条语句关闭:
在上面的过程中,预编译是在服务端做的。
而将interpolateParams设置为true之后,再次抓包可见: 只有一次Query 请求,直接将预编译好的SQL发送给服务端,减少了交互次数就提高了性能。
最后于 3小时前
被米龙·0xFFFE编辑
,原因: