Skip to content

Security: Residual SSRF on InferenceUrl — DNS/rebind + validator redirect (incomplete fix after #505/#534) #1470

Open @Aphelios01-sdk opened 2026-07-18 03:05 UTC 2 comments Updated 2026-07-20 02:30 UTC

Summary

Residual SSRF after prior paid mitigations (v0.2.7 / v0.2.8):

Prior fix What it covered What remains broken
PR #534 TA client no redirects (NewNoRedirectClient) Direct dial to DNS→private IP still allowed
PR #505 ValidateURLWithSSRFProtection rejects literal private IPs No DNS resolve; hostnames always pass

Any participant-controlled InferenceUrl hostname (or public URL that redirects) can force validator DAPIs (and other sinks) to connect to loopback, link-local metadata (169.254.169.254), or RFC1918 during mandatory payload retrieval / PoC validation / TA executor forwarding.

This is incomplete remediation, not a greenfield finding.


Severity (self-assessment)

High — protocol-mandated HTTP from validator hosts to attacker-controlled URL; host infrastructure compromise (IMDS, localhost admin, internal network). Not direct on-chain fund drain.


Architecture / threat model

Attacker sets InferenceUrl (hostname / rebind / open redirect)
   Chain: Participant.InferenceUrl
   ┌────┴─────────────────┬──────────────────┐
   ▼                      ▼                  ▼
Validator payload     TA → executor       PoC off-chain
retrieval (primary)   POST                proof HTTP
DEFAULT http.Client   NoRedirectClient    multi-worker
FOLLOWS REDIRECTS     still dials DNS IP

Attacker preconditions

  • Can set/update InferenceUrl via MsgSubmitNewParticipant / unfunded variant while OpenRegistrationPermission allows, or
  • Already has a hostname URL on-chain → DNS rebinding needs no new tx

Victim: any honest host running DAPI that validates / TAs / PoC-validates against that participant.


Root cause (registration gate)

inference-chain/x/inference/utils/signature_and_url_validation.go:

func ValidateURLWithSSRFProtection(fieldName, raw string) error {
    // scheme http/https + host non-empty
    if isLocalhost(host) { return err }
    ip := net.ParseIP(host)
    if ip != nil {
        if isPrivateIP(ip) { return err }
    }
    return nil // hostname path: NEVER resolves DNS
}

Called from:

  • types/message_submit_new_participant.go ValidateBasic
  • types/message_submit_new_unfunded_participant.go ValidateBasic

Unit tests only cover: public URL, localhost, 192.168.0.1no hostname/DNS cases
(signature_and_url_validation_test.go).

Matrix (registration)

URL Gate result Notes
http://127.0.0.1/ REJECT literal
http://169.254.169.254/ REJECT literal
http://localtest.me/ ACCEPT DNS → 127.0.0.1
http://ssrf.attacker.tld/ ACCEPT attacker DNS
http://0x7f000001/ / decimal IP strings ACCEPT* ParseIP fails → hostname
http://[::ffff:127.0.0.1]/ REJECT mapped IPv6 parsed

*Depends on OS resolver if those “hostnames” resolve.


URL write path

keeper/msg_server_submit_new_participant.go:

  • Create participant with Url
  • Update existing: existing.InferenceUrl = msg.Url (same permission gate)

DAPI registers PublicUrl via participant_registration.go.

If registration later closes, create/update txs fail — rebinding still works for hostnames already stored.


HTTP sinks (all participant InferenceUrl)

1) Validator payload retrieval — primary

validateInferenceAndSendValMessage
  → retrievePayloadsWithRetry  // up to 10 attempts, long backoff
      → RetrievePayloadsFromExecutor
          → Participant.InferenceUrl from chain
          → BuildPayloadRequestURL(..., "v1/inference/payloads", id)
          → FetchPayloadsHTTP(payloadRetrievalClient, ...)
// decentralized-api/internal/validation/payload_retrieval.go
var payloadRetrievalClient = &http.Client{
    Timeout: 30 * time.Second,
    // no CheckRedirect → default Go client follows up to 10 redirects
    // no DialContext IP ACL
}

Also sends validator identity headers (X-Validator-Address, signature, epoch) to whatever is dialed.

2) Transfer Agent → executor

getExecutorForRequest → GetRandomExecutor → executor.InferenceUrl
POST via NewNoRedirectClient  // redirects blocked (#534)

Direct DNS → private IP still dials.

3) PoC off-chain validator

decentralized-api/poc/validator.go — loads InferenceUrl, parallel proof HTTP workers.

4) Devshard

Reuses FetchPayloadsHTTP / BuildPayloadRequestURL.

Not a fix: proxy sidecar

proxy/sidecar resolves DNS and skips private IPs only when building nginx whitelist — does not stop DAPI egress SSRF.


Attack chains

A — DNS hostname (register/update)

  1. Point ssrf.attacker.tld169.254.169.254 / internal IP / loopback
  2. MsgSubmitNewParticipant{ Url: "http://ssrf.attacker.tld" }
  3. Passes ValidateBasic
  4. When peers validate / TA / PoC-hit this host → SSRF

B — DNS rebinding (no chain update)

  1. Register hostname with public A record
  2. During validation window, flip A to private (low TTL)
  3. No on-chain re-validation at fetch time

C — Redirect residual (validator payload client only)

  1. InferenceUrl = http://attacker.example/open (public, accepts registration)
  2. Attacker responds 302 Location: http://127.0.0.1:9200/admin/v1/config (or IMDS)
  3. TA client will not follow; payloadRetrievalClient will
  4. Can turn this into localhost admin/config impact on validators without publishing admin ports

Lab proof (safe, local)

Using public DNS name localtest.me127.0.0.1:

  1. http://127.0.0.1:PORT/...REJECT (literal)
  2. http://localtest.me:PORT/...ACCEPT (hostname)
  3. Validator-style HTTP GET retrieves loopback service body (exfiltrates lab secret)

Runnable replica (research package): registration gate replica + e2e fetch against local victim service.


Impact

Impact Detail
Confidentiality Cloud IMDS, internal HTTP, localhost admin/config on validators
Integrity Pivot into host networks; secondary compromise of DAPI/node
Availability Hang/slow validators on attacker-controlled responses; retry amplification (×10)
Scale Every honest validator that retrieves payloads becomes an SSRF client

Required remediation

  1. Dial-time deny-list (mandatory): shared safe http.Client that resolves, rejects loopback/link-local/RFC1918/ULA, dials only public IPs
  2. Apply to all sinks: payloadRetrievalClient, PoC proof client, TA client (defense-in-depth)
  3. Disable redirects on payload client or re-check Location host/IPs with the same policy
  4. Registration-time resolve is optional extra; not sufficient alone (rebinding)
  5. Regression tests: hostname→127.0.0.1, hostname→169.254.169.254, public→302 private, IPv4-mapped IPv6, decimal/hex host strings

Sketch

// DialContext: LookupIP → reject isPrivateIP → dial public only
// CheckRedirect: ErrUseLastResponse OR validate Location with same policy

Evidence index

Path Role
inference-chain/x/inference/utils/signature_and_url_validation.go Gate gap
inference-chain/x/inference/utils/signature_and_url_validation_test.go Thin tests
inference-chain/x/inference/types/message_submit_new_participant.go ValidateBasic
inference-chain/x/inference/keeper/msg_server_submit_new_participant.go Create/update URL
decentralized-api/internal/validation/payload_retrieval.go Client + sink
decentralized-api/internal/validation/inference_validation.go Retry loop
decentralized-api/internal/server/public/post_chat_handler.go TA NoRedirect + executor URL
decentralized-api/poc/validator.go PoC sink
inference-chain/app/upgrades/v0_2_8/upgrades.go Prior bounty / PR #505 #534 context

Disclosure

  • Static analysis + local lab only; no production hosts targeted.
  • If public issues are not preferred, please close and accept via HackerOne (gonka.ai report vulnerability); we can re-file privately with PoC artifacts.

💬 Comments (2)

@Aphelios01-sdk commented 2026-07-18 03:12 UTC

Deep dive (follow-up analysis)

Historical context (residual after prior SSRF fixes)

From inference-chain/app/upgrades/v0_2_8/upgrades.go bounty notes:

  • PR #534 — blocked redirect-based SSRF on the Transfer Agent HTTP client (NewNoRedirectClient).
  • PR #505 — added ValidateURLWithSSRFProtection for InferenceUrl (literal private IPs + timeouts).

This report is a residual / incomplete-fix issue, not a greenfield class:

Layer Status after #505/#534
Registration string check Literal private IPs only — no DNS resolve
TA client redirects Fixed (CheckRedirectErrUseLastResponse)
Validator payloadRetrievalClient Still default http.Client → follows redirects
Dial-time IP ACL on any sink Missing
Re-validate URL at fetch time Missing (DNS rebinding works without new txs)

Full sink map (participant InferenceUrl → HTTP)

  1. Validator payload retrieval (primary)
    validateInferenceAndSendValMessageretrievePayloadsWithRetry (up to 10 attempts) → RetrievePayloadsFromExecutor
    BuildPayloadRequestURL(InferenceUrl, "v1/inference/payloads", id)FetchPayloadsHTTP
    Client: payloadRetrievalClient = &http.Client{Timeout: 30s}no CheckRedirect, no dial ACL.

  2. Transfer Agent → executor
    getExecutorForRequestexecutor.InferenceUrlPOST via NewNoRedirectClient
    Redirects blocked; direct DNS→private IP still dials.

  3. PoC off-chain validator
    poc/validator.go loads Participant.InferenceUrl and hits it via proof HTTP client (parallel workers).

  4. Devshard paths reuse FetchPayloadsHTTP / BuildPayloadRequestURL.

Proxy sidecar does resolve DNS and skips private IPs — but only for nginx whitelist generation, not for DAPI egress.

Attack chains

A. DNS hostname (registration-time)
MsgSubmitNewParticipant{Url: "http://ssrf.attacker.tld"} with A/AAAA169.254.169.254 / RFC1918 / loopback.
ValidateBasic accepts because net.ParseIP(hostname) == nil.

B. DNS rebinding (no chain update)
Register hostname with public A record; later flip A to private during validation windows. No re-check at fetch.

C. Redirect residual (validator only)
Public InferenceUrl returns 302 Location: http://127.0.0.1:9200/admin/v1/config (or IMDS).
TA client will not follow; payloadRetrievalClient will follow → can turn #1470 into localhost admin/config impact on validators.

Control-flow gap (source)

// signature_and_url_validation.go
ip := net.ParseIP(host)
if ip != nil {
    if isPrivateIP(ip) { return err }
}
return nil // hostname path never resolves

Unit tests only cover public URL, localhost, and 192.168.0.1 — no hostname/DNS cases.

URL write path

Existing participants can update InferenceUrl via the same MsgSubmitNewParticipant (when OpenRegistrationPermission allows).
Even if registration is later closed, rebinding still works for already-stored hostnames.

Lab proof (safe)

localtest.me127.0.0.1:

  1. Literal http://127.0.0.1:... → REJECT
  2. http://localtest.me:... → ACCEPT
  3. Validator-style GET retrieves loopback service content

Required fix (defense-in-depth)

  1. Dial-time deny-list (resolve → reject loopback/link-local/RFC1918/ULA) in a shared safe http.Client.
  2. Apply to all sinks: payload retrieval, PoC client, (defense-in-depth) TA client.
  3. Disable redirects on payload client or re-check Location host/IPs.
  4. Regression tests: hostname→127.0.0.1, hostname→169.254.169.254, public→302 private, IPv4-mapped IPv6.

Happy to provide a draft patch or HackerOne-formatted write-up if public issues are not the preferred channel.

@Ryanchen911 commented 2026-07-20 02:30 UTC

I'd like to pick this up. The residual-SSRF analysis matches what I see in the code — the real fix has to be at dial-time (registration-time DNS resolution alone can't stop rebinding), via a shared SSRF-safe HTTP client applied to all participant-InferenceUrl sinks, plus disabling redirects on the payload retrieval client.

@tcharchian could you assign this to me?


🔄 Auto-synced from Issue #1470 every hour.