Skip to content

Unauthenticated SSRF via default-open forward proxy (no destination blocklist) #422

Description

@Zhaodl1

1. Report Metadata

Field Value
Project aiocoap — Python CoAP library
Title Unauthenticated SSRF via default-open forward proxy (no destination blocklist)
Affected component aiocoap/proxy/server.pyForwardProxy.apply_redirection
Affected function ForwardProxy.apply_redirection (and raise_unless_safe)
Affected role CoAP forward proxy server (aiocoap-proxy --forward)
Tested version 0.4.17.post0 (git f867dc4, 0.4.17-178-gf867dc4, 2026-04-25)
PoC path <repo>/vuln_ssrf_forward_proxy/poc.py
Suggested CWE CWE-918 (Server-Side Request Forgery)

2. Executive Summary

aiocoap ships a forward CoAP proxy (aiocoap-proxy --forward) that, by design,
forwards any incoming CoAP request to the destination named in the request's
Proxy-Scheme / Uri-Host / Uri-Port options. Note that --forward must be
explicitly enabled, but once enabled, the proxy defaults to listening on all
available interfaces with no authentication and no destination filtering. The
proxy performs no destination-address filtering whatsoever: there is no blocklist for loopback
(127.0.0.0/8, ::1), link-local (169.254.0.0/16, fe80::/10), private
(10/8, 192.168/16, 172.16/12), or cloud-metadata (169.254.169.254)
destinations. An unauthenticated remote attacker who can reach the proxy can
therefore pivot it onto internal services that are not directly reachable from
the attacker's network position and exfiltrate the response payload.

This is a classic Server-Side Request Forgery (SSRF) exposure. In a cloud
deployment the canonical abuse would be fetching the instance-metadata endpoint
(169.254.169.254) to steal IAM credentials, but that only works if the target
metadata service is reachable over CoAP (the proxy's protocol); the same pivot
is unconditional against any internal CoAP service, including loopback-only
administrative services, and the proxy returns the upstream response body
verbatim to the attacker, so any internal CoAP service that exposes sensitive
data is readable.

The vulnerability is a dangerous default rather than a logic inversion:
forwarding to arbitrary hosts is the proxy's job. The defect is the absence of
the SSRF guard that every forward proxy must implement, combined with a
default-open, unauthenticated listener. Deployments that restrict the proxy
to trusted clients, or that place it behind authenticated DTLS/OSCORE, are not
affected by the remote-unauthenticated variant — but any client that is
authorized to use the proxy can still pivot it onto any destination, so the
missing blocklist is a defect in all configurations.

Severity is HIGH: pre-auth in the default deployment, trivial to exploit
with a single CoAP packet, and the protocol-permitting cloud-metadata abuse path
leads directly to credential theft and lateral movement.

3. Vulnerability Overview

The forward proxy builds the outbound remote directly from the attacker-
controlled Uri-Host / Uri-Port options and connects out, returning the
upstream response verbatim:

@<repo>/aiocoap/proxy/server.py:340-362
class ForwardProxy(Proxy):
    def apply_redirection(self, request):
        request = request.copy()
        if request.opt.proxy_uri is not None:
            raise NoUriSplitting
        if request.opt.proxy_scheme is None:
            return super().apply_redirection(request)
        if request.opt.uri_host is None:
            raise IncompleteProxyUri

        raise_unless_safe(
            request,
            (
                numbers.OptionNumber.PROXY_SCHEME,
                numbers.OptionNumber.URI_HOST,
                numbers.OptionNumber.URI_PORT,
            ),
        )

        request.remote = message.UndecidedRemote(
            request.opt.proxy_scheme,
            util.hostportjoin(request.opt.uri_host, request.opt.uri_port),
        )

raise_unless_safe only checks that the option types present are in the
allowed set — it never inspects the address value:

@<repo>/aiocoap/proxy/server.py:55-81
def raise_unless_safe(request, known_options):
    """Raise a BAD_OPTION CanNotRedirect unless all options in request are
    safe to forward or known"""

    known_options = set(known_options).union(
        {
            numbers.OptionNumber.URI_HOST,
            numbers.OptionNumber.URI_PORT,
            numbers.OptionNumber.URI_PATH,
            numbers.OptionNumber.URI_QUERY,
            numbers.OptionNumber.BLOCK1,
            numbers.OptionNumber.BLOCK2,
            numbers.OptionNumber.OBSERVE,
        }
    )

    unsafe_options = [
        o
        for o in request.opt.option_list()
        if o.number.is_unsafe() and o.number not in known_options
    ]
    if unsafe_options:
        raise CanNotRedirectBecauseOfUnsafeOptions(unsafe_options)

The current code does call ipaddress.ip_address(...) against uri_host (in the
full method at lines 369–378), but only to detect unbracketed IPv6 literals and
emit a deprecation warning; it never checks whether the address falls in a
reserved range, and there is no blocklist or allowlist mechanism for loopback,
link-local, private, or cloud-metadata destinations. The proxy then renders the
upstream response back to the attacker via the standard Proxy.render path. The PoC exercises exactly this path with
Uri-Host=127.0.0.1, Uri-Port=5684, and an internal metadata-style path,
and receives the internal server's secret payload through the proxy.

4. Proof of Concept

The PoC (<repo>/vuln_ssrf_forward_proxy/poc.py)
is self-contained and does not require any network access beyond the
loopback interface. It:

  1. Starts a fake "internal metadata" CoAP server on 127.0.0.1:5684 that
    returns a stand-in cloud-credential blob on
    /latest/meta-data/iam/security-credentials/.
  2. Starts the real aiocoap forward proxy on 127.0.0.1:5683 via
    aiocoap.cli.proxy.Main(["--forward", "--bind", "127.0.0.1:5683"]).
  3. As an unauthenticated attacker, builds a single CoAP GET with
    proxy_scheme="coap", uri_host="127.0.0.1", uri_port=5684, and the
    metadata path, sends it to the proxy, and prints the returned payload.

The malicious request construction:

request = aiocoap.Message(code=aiocoap.GET)
request.unresolved_remote = proxy_addr          # 127.0.0.1:5683 (the proxy)
request.opt.proxy_scheme = "coap"
request.opt.uri_host = INTERNAL_HOST            # 127.0.0.1 (loopback)
request.opt.uri_port = INTERNAL_PORT            # 5684
request.opt.uri_path = ["latest", "meta-data", "iam", "security-credentials", ""]
response = await client.request(request).response

5. Build & Run

Working directory: <repo>/vuln_ssrf_forward_proxy.

# One-time: create a Python >= 3.11 environment and install aiocoap
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e "<repo>[oscore]"   # oscore extra not strictly required for S1,
                                  # but is for the S3 PoC in the sibling folder

# Run the PoC
python poc.py

Reproduction environment: Linux x86_64, Python 3.11.15, aiocoap
0.4.17.post0 (git f867dc4), cbor2 5.7.1, lakers-python 0.6.2, cryptography
49.0.0. No network access required (loopback only).

6. Impact

Attacker position: any unauthenticated network client that can send UDP
to the proxy's CoAP port (default 5683). If the proxy is exposed to the
Internet, the attacker is remote; if it is on a LAN, the attacker is any
local/LAN host.

Capabilities gained:

  • Cloud credential theft (protocol-dependent): pivot the proxy to a
    CoAP-speaking cloud metadata endpoint, such as
    coap://169.254.169.254:80/latest/meta-data/iam/security-credentials/<role>.
    If the endpoint is reachable over CoAP, the proxy can return IAM-style
    credentials. In practice, major cloud metadata services speak HTTP, so this
    exact path is only viable when the metadata endpoint is also available over
    CoAP (or through a protocol translator); the underlying SSRF pivot, however,
    still exposes any internal CoAP service to the attacker.
  • Loopback service access: reach administrative or debug services
    bound to 127.0.0.1 that are not exposed on external interfaces
    (databases, metrics, control ports).
  • Internal host/port scanning: by varying Uri-Host and
    Uri-Port and observing response timing / codes, map the internal
    network and service topology.
  • Link-local / private-network reachability: pivot onto
    fe80::/10, 169.254.0.0/16, 10/8, 192.168/16, 172.16/12
    destinations.

Privileges gained: none on the proxy host itself, but the attacker
gains the proxy's network position — which, by definition, is more
trusted than the attacker's own position. The proxy effectively
becomes a network pivot.

Scope of affected deployments: any operator that runs
aiocoap-proxy --forward reachable from an untrusted network without
an additional authentication/authorization layer. Deployments behind
authenticated DTLS/OSCORE where every authorized client is fully
trusted are not affected by the unauthenticated variant, but the
missing blocklist still permits any authorized client to pivot the
proxy onto arbitrary internal destinations.

vuln_ssrf_forward_proxy.zip

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions