HackTheBox [Desires] WriteUp

代码审计

这是道 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 ,但是感觉还是挺复杂的,如果是黑盒测试大概率是测不出来的。

评论