Skip to content
Nano Security
← Back to all posts

Minted URLs, untrusted fetches: full-read SSRF through on-chain NFT metadata

Abdullah Ahmed · Founder & CEOssrf / nft / solana / cloud8 min read

Summary

A backend service operated by a major browser vendor — the NFT metadata proxy behind its wallet's collectibles view — let any Solana user make it fetch an arbitrary URL and read the response back through a public, unauthenticated API.

The bug is a familiar class in unfamiliar clothing: server-side request forgery (CWE-918), where the "user input" is on-chain NFT metadata. Solana NFTs carry a json_uri field, chosen by whoever mints the token, pointing at the off-chain JSON that describes it. When the proxy normalizes an NFT for display and finds the on-chain name, symbol, description and image all empty, it falls back to fetching that URI — with no scheme allowlist, no private-address blocking, and no hostname validation anywhere in the call path.

Because the fetched JSON is parsed and reflected into the API response's name, description and image_url fields, this is a full-read SSRF, not a blind one. The canonical target is the cloud instance metadata service at 169.254.169.254 — the same fetch-the-credentials chain behind the Capital One breach, reached through a JSON field on a blockchain instead of a web hook. We reported it privately, demonstrated it end-to-end against the production path, and the vendor fixed it by removing the code path outright. Vendor, project and report details are redacted here; the class-level detail is what defenders need to check their own stacks.

Impact

The primary risk in any cloud deployment running a first-generation instance metadata service is IAM credential theft. The attacker mints one NFT pointing at the metadata service, the proxy fetches it server-side, and the IAM role listing comes back reflected in the API response. A second NFT pointed at the role's credential path returns a temporary access key, secret and session token — and from there, whatever the instance's IAM role permits: object storage, secrets managers, databases, internal APIs. This is the same attack chain behind the Capital One breach, and it requires nothing but the ability to mint a token worth about a dollar.

Secondary impacts compound it. Internal HTTP services reachable from the proxy's network — container orchestrator APIs, caches, admin panels — could be read through the same primitive. A metadata field in the API response echoed the raw fetch target unconditionally, leaking internal addresses even when the response body failed to parse. And every outbound request carried observability headers that disclosed deployment details — environment names, release identifiers — to whoever controlled the target endpoint.

We assessed the finding as High severity:

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N   →   8.6 High

Network vector, no privileges, no user interaction, scope changed, high confidentiality impact. The vendor ultimately triaged it at Medium for its own deployment — its metadata-service and egress posture blunted the worst-case chain — while still acknowledging the exfiltration potential and shipping a code-level fix. Both numbers are honest, and the gap between them is itself a lesson: code-level severity and deployed-environment impact are different questions, and defenders should track both.

Root cause

The vulnerable pattern is a metadata-enrichment fallback. When the proxy normalizes a Solana NFT for display and the on-chain record carries no name, symbol, description or image, it fetches the token's off-chain JSON:

if not any([name, symbol, description, image_url]):
    async with create_http_client() as client:
        # json_uri is set by the NFT minter — attacker-controlled
        response = await client.get(asset.content.json_uri)
        raw = parse(response)
        name = name or raw.name
        description = description or raw.description
        image_url = image_url or raw.image

Every element of that snippet is wrong in a specific, instructive way:

  • On-chain fields are user input. Anyone can mint a Solana NFT for roughly a dollar, and the json_uri — the URL the token points at — is chosen at mint time. Treating it as trusted metadata is the original sin: it is a user-supplied redirect target with extra steps.
  • The trigger condition is attacker-controlled. The fallback only fires when all four display fields are empty. An attacker doesn't need to hunt for a malformed collection — they mint one with empty strings and a null image, and the vulnerable branch executes on demand.
  • No validation anywhere in the call path. No scheme allowlist, so plain HTTP to link-local addresses sails through. No private-IP blocking, no hostname checks. The fetch is fully attacker-directed.
  • The HTTP client retries. The factory wrapped the request in a retry transport — several attempts with exponential backoff. Polite to slow internal services, which is to say: more reliable exfiltration.
  • The response is reflected. The fetched JSON is parsed and folded into the API response fields. This is what separates a full-read SSRF from a blind one: the attacker reads what the server reads.
  • The endpoints are unauthenticated. Four public API routes shared the same normalization transform. No key, no session, nothing standing between an attacker and the fetch.

How it was found

The finding itself was pattern-matching: reading an open-source NFT proxy, looking for every place the server fetches a URL derived from user-controllable data, and finding a fetch whose input came from a blockchain. The craft was in the evidentiary discipline — because the hard part of this class of report is not finding the bug, it is making it impossible to wave off.

Vendors routinely close SSRF reports with "our deployment blocks this," and several earlier reports against this exact code path had died exactly that death: code-path demonstrations, no end-user impact. What moved this one to a shipped fix was a demonstration the deployment argument could not absorb:

  1. Manufacture the trigger on mainnet. Mint a real NFT with empty metadata and a json_uri under our control. Cost: roughly a dollar. No testnet indexing gaps to argue about — the token exists on the chain the production indexer reads.
  2. Drive the production path through the product's own UI. Open the wallet's collectibles view with the minting wallet. The product's backend resolves the NFT, finds the empty fields, and fetches our URI — server-side, from the production deployment.
  3. Watch the request land. The endpoint we controlled received the fetch, headers and all, proving the outbound request traversed the vendor's infrastructure.
  4. Show the response reflected back. Query the same public endpoints directly and watch our data return inside the API response fields. Full-read, confirmed, across all four unauthenticated routes.

The rule generalizes: when you report an SSRF, the demonstration that survives triage is end-to-end through the feature real users touch. Code-path theory loses to a request log, every time.

The fix

If you run anything that fetches URLs derived from user input — NFT metadata, webhook callbacks, import-by-URL features — close the class, not the instance.

1. Validate before every fetch. Allowlist the scheme, resolve the hostname, and refuse private and link-local space — including IPv6, which naive blocklists forget:

from urllib.parse import urlparse
import ipaddress, socket

ALLOWED_SCHEMES = {"https"}
BLOCKED = [
    ipaddress.ip_network("127.0.0.0/8"),
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("169.254.0.0/16"),  # link-local / cloud metadata
    ipaddress.ip_network("::1/128"),
    ipaddress.ip_network("fe80::/10"),
    ipaddress.ip_network("fc00::/7"),
]

def validate_metadata_url(url: str) -> str:
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError(f"disallowed scheme: {parsed.scheme}")
    host = parsed.hostname or ""
    for _, _, _, _, sockaddr in socket.getaddrinfo(host, None):
        ip = ipaddress.ip_address(sockaddr[0])
        if any(ip in net for net in BLOCKED):
            raise ValueError(f"blocked address: {ip}")
    return url

One caveat the snippet alone will not save you from: validate-then-fetch re-resolves DNS at fetch time, so a hostname that answers with a public IP during validation and a private IP during the fetch — classic DNS rebinding — slips through the gap. Pin the resolved address for the actual request, or enforce the allowlist at the egress layer, where resolution and connection are the same decision.

2. Enforce IMDSv2 on every instance and container. The session-token handshake defeats GET-based SSRF against the metadata service entirely, independent of application code.

3. Block metadata egress at the network layer. Application pods have no business talking to 169.254.169.254; security-group and network-policy rules should say so, so a single missed code path does not become an incident.

4. Stop reflecting raw URLs. The API echoed the original metadata URL in a response field unconditionally — an internal-topology oracle even when exfiltration fails. Sanitize it or drop it.

5. Or delete the path. The vendor's ultimate fix was removing the fallback fetch outright — accepting that some malformed NFTs render without enrichment rather than keep a code path whose failure mode is "fetch an attacker-chosen URL." For a low-value convenience feature, that is a legitimate architectural answer, and arguably the best one.

Timeline

The finding was reported privately with a local end-to-end reproduction attached. The vendor initially pushed back: earlier reports on the same path had shown only the code path, never an impact on end users or the production deployment. We returned with the mainnet-minted NFT and a demonstration driven through the wallet's own backend, plus the direct API path, and the picture changed — triaged, severity negotiated against the deployment's mitigations, fixed by removing the code path entirely, and resolved with a bounty. About two weeks from report to fix. Vendors, projects, dates and identifiers stay redacted; the class-level detail here is everything a defender needs to check their own stack.

Lessons for defenders

  • On-chain data is user input. json_uri and tokenURI are redirect targets you do not control. Route them through every SSRF control you would apply to a user-typed URL.
  • Empty-field fallbacks are attack surface. The attacker chose when the vulnerable branch executed, by choosing what to mint. Audit every "if the field is empty, fetch it from somewhere" path in your codebase.
  • Full-read is a different severity class than blind. Reflection turns an internal port scanner into a data exfiltration channel. Never reflect fetched content — or the fetch URL — verbatim.
  • Service-layer protections do not travel with the code. Open-source backends get self-hosted, forked and embedded. The only fix that protects everyone downstream is the one in the code — or the deleted code path.
  • Validation without pinning is TOCTOU. DNS rebinding eats naive hostname allowlists. Resolve once, connect to what you resolved, or enforce at egress.
  • End-to-end through the product beats a code-path argument. When you report, demonstrate the consumer path a real user triggers. It is the difference between "interesting theory" and "fix shipping this sprint."

If your backend fetches URLs your users can influence — NFT metadata, webhooks, import-by-URL — this is the first thing we look for on an engagement, and it is usually the fastest finding in the report.