Virts

Virts

私は可愛いです 🥰 お金をください ❤️
telegram
github
email
steam
douban

HackTheBox [Desires] WriteUp

image

Code Audit#

This is a Challenge question, categorized as Web, but it feels a bit like source code auditing. First, we obtained the source code and found the directory structure is as follows:

.
├── 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

The service folder contains a web app written in Golang, while the sso folder contains a login module implemented in Node, providing an HTTP login interface.

Functional Logic#

First, let's outline the general logic. After a user registers, they can log in to the website and use the upload feature to upload compressed files to /app/service/uploads, where the compressed file name will be renamed using UUID.

The uploaded files will then be decompressed, and the decompressed files will be placed in the /app/service/files/{username} folder. At this point, the decompressed files will not undergo any processing, such as renaming or modifying the extension.

Login Logic#

The login logic is implemented in a rather unconventional way, so it seems likely that the breakthrough point is here and needs to be carefully audited.

First, in the registration module, there is a key piece of code in the RegisterHandler function:

if strings.ContainsAny(credentials.Username, "/.\\") {
	return utils.ErrorResponse(c, "Invalid Username", http.StatusBadRequest)
}

This restricts usernames during registration from containing the characters ., \, and /, which means that usernames with any path cannot be written to the database, but this restriction is actually ineffective.

Next is the login module. Since the LoginHandler function is quite important, I will paste it in full:

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")
}

First, we can see that the generation logic for session has issues; it is entirely based on the timestamp, which can potentially be brute-forced. Of course, this would require an admin-privileged user to log in and knowledge of the username to exploit. In this challenge, there should not be any other users logged in with admin privileges, so it cannot be exploited for now.

The PrepareSession function is quite interesting; it only does one thing: it uses the username as the key and writes the generated Session as the value into Redis.

CreateSession saves the generated session as a file in /tmp/sessions/{username}. The session file format is JSON, aiming to change the current user's role to admin.

{"username":"test","id":1,"role":"user"}

Finally, there is a middleware function SessionMiddleware, which is called before accessing any path under /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()
}

The general function is to retrieve the session content using the username from the Cookies. The code here also has issues; it can retrieve any user's session. This means that as long as there is a user with admin privileges and knowledge of the username, they can access /user/admin, but for this question, there isn't such a user.

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
}

In GetSession, it first queries the cookies' username field from Redis. If there is no such user, it will return directly. If the query is successful, it will find the session from /tmp/sessions/{username} and return it.

Exploitation#

By analyzing the key pieces of code above, we can derive an exploitation chain to obtain the flag from /user/admin.

First, register a user named test. At this point, you can normally use the upload feature, but accessing /user/admin will prompt:

image
Prepare a JSON file with the following content for the session we constructed:

{"username":"test","id":1,"role":"admin"}

Then, we need to prepare a piece of code to generate the Session based on the timestamp. Here, I directly used Golang to ensure it matches the server-generated one. I set it to generate a Session 10 seconds before:

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

After that, make a POST request to the /login interface, setting the username field to ../../../../../../../app/service/files/test, aiming to save this username in Redis, because the PrepareSession function will execute regardless of whether the login is successful, so this value will definitely be written to Redis, and the value will be a Session ID.

image

After the request is completed, immediately execute the previously prepared Golang script to generate the Session ID:

image

Then rename the prepared Session file to the generated Session ID above and upload it. After the upload is complete, the server will decompress it and save it in /app/service/files/test.

Now you can request /user/admin:

image
Since Redis already has the key ../../../../../../../app/service/files/test, it can pass the first check in the middleware, and the second lookup path uses the method:

os.ReadFile(filepath.Join("/tmp/sessions", username, sessionID))

However, the username is actually controllable via Cookies, so it will not look in /tmp/sessions but rather in /app/service/files/test to find the file we just uploaded. If found, it will directly read the uploaded session file.

Since we do not know what the server-generated Session ID is, if it fails, we need to re-upload and test each one. In theory, it is possible to write a script to automate the uploads.

Conclusion#

Although this question is rated as Easy, it still feels quite complex. If it were a black-box test, it would likely not be discovered.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.