Parent-Authenticated WebSocket with Forged Origin and Private-Browsing Context
Categories
(Core :: Networking: WebSockets, defect, P1)
Tracking
()
People
(Reporter: fte.security, Assigned: jesup)
References
(Blocks 1 open bug)
Details
(Keywords: reporter-external, sec-low, Whiteboard: [client-bounty-form][necko-triaged][necko-priority-queue][adv-main151-][adv-esr140.11-])
Attachments
(2 external files)
Summary
Firefox lets the child side provide the WebSocket origin string, load metadata, and private-browsing context used by the parent-owned upgrade request. The executed validation used a narrow content-side trigger to open the real WebSocket path with attacker-controlled Origin and private-browsing selection. The control handshake showed the page's real origin and the regular cookie. A second handshake from the same page used a forged Origin: https://victim.example while still carrying the regular cookie, and a third handshake used the same forged origin with no regular cookie when the private-browsing context was selected. The server-side handshake capture showed that the parent honored the forged metadata.
Impact
A compromised content process can make the browser open authenticated WebSocket connections with attacker-chosen origin and regular/private context, enabling cross-origin or local service access under the wrong browser state.
Description
Relevant vulnerable code blocks
if (!gNeckoChild->SendPWebSocketConstructor(
this, browserChild, IPC::SerializedLoadContext(this), mSerial)) {
return NS_ERROR_UNEXPECTED;
}
if (!SendAsyncOpen(uri, aOrigin, aOriginAttributes, aInnerWindowID, mProtocol,
mEncrypted, mPingInterval, mClientSetPingInterval,
mPingResponseTimeout, mClientSetPingTimeout, loadInfoArgs,
transportProvider, mNegotiatedExtensions)) {
return NS_ERROR_UNEXPECTED;
}
mozilla::ipc::IPCResult WebSocketChannelParent::RecvAsyncOpen(
nsIURI* aURI, const nsCString& aOrigin,
const OriginAttributes& aOriginAttributes, const uint64_t& aInnerWindowID,
const nsCString& aProtocol, const bool& aSecure,
const uint32_t& aPingInterval, const bool& aClientSetPingInterval,
const uint32_t& aPingTimeout, const bool& aClientSetPingTimeout,
const LoadInfoArgs& aLoadInfoArgs,
const Maybe<PTransportProviderParent*>& aTransportProvider,
const nsCString& aNegotiatedExtensions) {
nsresult rv;
nsCOMPtr<nsILoadInfo> loadInfo;
nsCOMPtr<nsIURI> uri;
rv = LoadInfoArgsToLoadInfo(
aLoadInfoArgs,
mozilla::dom::ContentParent::Cast(Manager()->Manager())->GetRemoteType(),
getter_AddRefs(loadInfo));
if (NS_FAILED(rv)) {
goto fail;
}
...
rv = mChannel->SetLoadInfo(loadInfo);
if (NS_FAILED(rv)) {
goto fail;
}
...
rv = mChannel->AsyncOpenNative(uri, aOrigin, aOriginAttributes,
aInnerWindowID, this, nullptr);
if (NS_FAILED(rv)) goto fail;
return IPC_OK();
}
rv = ioService->NewChannelFromURIWithProxyFlags(
localURI, mURI,
nsIProtocolProxyService::RESOLVE_PREFER_SOCKS_PROXY |
nsIProtocolProxyService::RESOLVE_PREFER_HTTPS_PROXY |
nsIProtocolProxyService::RESOLVE_ALWAYS_TUNNEL,
mLoadInfo->LoadingNode(), mLoadInfo->GetLoadingPrincipal(),
mLoadInfo->TriggeringPrincipal(), mLoadInfo->GetSecurityFlags(),
mLoadInfo->InternalContentPolicyType(), getter_AddRefs(localChannel));
NS_ENSURE_SUCCESS(rv, rv);
rv = localChannel->SetLoadInfo(mLoadInfo);
NS_ENSURE_SUCCESS(rv, rv);
if (!mOrigin.IsEmpty()) {
rv = mHttpChannel->SetRequestHeader("Origin"_ns, mOrigin, false);
MOZ_ASSERT(NS_SUCCEEDED(rv));
}
auto sendFunc = [self = UnsafePtr<WebSocketChannelParent>(this)](
const nsDependentCSubstring& aMsg, bool aMoreData) {
return self->SendOnMessageAvailable(aMsg, aMoreData);
};
if (!SendOnMessageAvailableHelper(aMsg, sendFunc)) {
return NS_ERROR_FAILURE;
}
Vulnerable code flow
- The child sends WebSocket constructor metadata and
AsyncOpenarguments, including origin string and load information. - The parent reconstructs
LoadInfofrom child-controlled data and passes the same child-controlled origin intoAsyncOpenNative. - The HTTP upgrade path copies that origin into the actual request headers and uses the derived load metadata for cookie and context selection.
- Any resulting WebSocket frames are returned to the same child over the normal message IPC path.
What the PoC demonstrates
The executed PoC used a validation-only content trigger to open the real WebSocket path three times from the same page: once normally, once with a forged origin, and once with a forged origin plus private-browsing state. The observer server saw the parent emit Origin: https://victim.example while still carrying the regular cookie in the forged regular-context handshake. It then saw the same forged origin with no regular cookie in the private-browsing variant, showing that the parent accepted attacker-selected browsing context separately from the initiating page.
Full PoC Code
#!/usr/bin/env python3
import argparse
import base64
import contextlib
import hashlib
import http.server
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
FIREFOX_ROOT = REPO_ROOT / "validation-environments" / "env" / "firefox"
MARIONETTE_CLIENT = FIREFOX_ROOT / "testing" / "marionette" / "client"
if str(MARIONETTE_CLIENT) not in sys.path:
sys.path.insert(0, str(MARIONETTE_CLIENT))
from marionette_driver.marionette import Marionette # noqa: E402
from marionette_driver.wait import Wait # noqa: E402
def free_port():
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def wait_for_port(port, timeout=20):
deadline = time.time() + timeout
while time.time() < deadline:
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
if s.connect_ex(("127.0.0.1", port)) == 0:
return
time.sleep(0.1)
raise RuntimeError(f"timed out waiting for port {port}")
def wait_for_marionette_port(profile_dir, timeout=30):
active_port_file = profile_dir / "MarionetteActivePort"
deadline = time.time() + timeout
while time.time() < deadline:
if active_port_file.exists():
text = active_port_file.read_text(encoding="utf-8").strip()
if text.isdigit():
port = int(text)
wait_for_port(port, timeout=5)
return port
time.sleep(0.1)
raise RuntimeError("timed out waiting for MarionetteActivePort")
def launch_firefox(binary, profile_dir, log_path):
env = os.environ.copy()
env["MOZ_HEADLESS"] = "1"
env["MOZ_LOG"] = "Document:3,WebSocketChannelParent:4,WebSocketChannelChild:4"
env["XPCOM_DEBUG_BREAK"] = "warn"
with contextlib.suppress(FileNotFoundError):
(profile_dir / "MarionetteActivePort").unlink()
stdout = open(log_path, "w", encoding="utf-8")
proc = subprocess.Popen(
[
str(binary),
"--marionette",
"--profile",
str(profile_dir),
"--no-remote",
"--new-instance",
"about:blank",
],
stdout=stdout,
stderr=subprocess.STDOUT,
env=env,
)
port = wait_for_marionette_port(profile_dir)
return proc, stdout, port
def connect_marionette(port):
deadline = time.time() + 30
last_error = None
while time.time() < deadline:
client = Marionette(host="127.0.0.1", port=port)
try:
client.start_session()
client.set_context("content")
client.timeout.page_load = 30000
client.timeout.script = 60000
return client
except Exception as exc:
last_error = exc
with contextlib.suppress(Exception):
client.cleanup()
time.sleep(0.25)
raise RuntimeError(f"failed to start Marionette session: {last_error}")
def quit_browser(client, proc, stdout_handle):
with contextlib.suppress(Exception):
client.delete_session()
with contextlib.suppress(Exception):
proc.terminate()
proc.wait(timeout=10)
with contextlib.suppress(Exception):
proc.kill()
stdout_handle.close()
class WebSocketValidationHandler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/attacker":
body = (
"<!doctype html><meta charset=utf-8>"
"<title>attacker</title><body>attacker-page</body>"
).encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Set-Cookie", "stage=regular; Path=/")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if self.path == "/ws" and self.headers.get("Upgrade", "").lower() == "websocket":
key = self.headers["Sec-WebSocket-Key"]
accept = base64.b64encode(
hashlib.sha1(
(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")
).digest()
).decode("ascii")
record = {
"origin": self.headers.get("Origin"),
"cookie": self.headers.get("Cookie"),
"host": self.headers.get("Host"),
"path": self.path,
}
self.server.handshakes.append(record)
payload = json.dumps(record).encode("utf-8")
self.send_response(101, "Switching Protocols")
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept", accept)
self.end_headers()
self.wfile.write(build_text_frame(payload))
self.wfile.flush()
time.sleep(0.2)
return
body = b"not found"
self.send_response(404)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
return
def build_text_frame(payload):
assert len(payload) < 126
return bytes([0x81, len(payload)]) + payload
class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
daemon_threads = True
def wait_for_handshakes(server, count, timeout=20):
deadline = time.time() + timeout
while time.time() < deadline:
if len(server.handshakes) >= count:
return server.handshakes[count - 1]
time.sleep(0.1)
raise RuntimeError(f"timed out waiting for handshake {count}")
def trigger_ws_hook(client, ws_url, origin, use_private_browsing):
return client.execute_script(
"""
const [url, origin, usePrivateBrowsing] = arguments;
document.openForgedWebSocketForValidation(
url,
origin,
usePrivateBrowsing
).catch(error => console.error(String(error)));
return true;
""",
script_args=[ws_url, origin, use_private_browsing],
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--binary",
default=str(
FIREFOX_ROOT
/ "objdir-validation"
/ "dist"
/ "NightlyDebug.app"
/ "Contents"
/ "MacOS"
/ "firefox"
),
)
parser.add_argument(
"--profile-dir",
default=str(
REPO_ROOT
/ "validation-environments"
/ "profiles"
/ "firefox"
/ "websocket-forgery"
),
)
parser.add_argument(
"--evidence-json",
default=str(
REPO_ROOT
/ "validation-environments"
/ "evidence"
/ "firefox"
/ "websocket-forgery.json"
),
)
args = parser.parse_args()
profile_dir = Path(args.profile_dir)
evidence_json = Path(args.evidence_json)
evidence_json.parent.mkdir(parents=True, exist_ok=True)
profile_dir.parent.mkdir(parents=True, exist_ok=True)
if profile_dir.exists():
shutil.rmtree(profile_dir)
profile_dir.mkdir(parents=True)
(profile_dir / "user.js").write_text(
"\n".join(
[
'user_pref("validation.firefox.content_ipc_testing.enabled", true);',
'user_pref("network.proxy.type", 0);',
]
)
+ "\n",
encoding="utf-8",
)
server = ThreadedHTTPServer(("127.0.0.1", free_port()), WebSocketValidationHandler)
server.handshakes = []
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
attacker_url = f"http://127.0.0.1:{server.server_port}/attacker"
ws_url = f"ws://127.0.0.1:{server.server_port}/ws"
browser_log = evidence_json.with_name("websocket-forgery-browser.log")
client = proc = stdout = None
results = {
"attacker_url": attacker_url,
"ws_url": ws_url,
"document_cookie": None,
"regular_control": None,
"forged_origin": None,
"forged_private": None,
"handshakes": None,
"error": None,
}
try:
proc, stdout, marionette_port = launch_firefox(
Path(args.binary), profile_dir, browser_log
)
client = connect_marionette(marionette_port)
client.navigate(attacker_url)
Wait(client, timeout=20).until(
lambda c: "attacker-page"
in c.execute_script("return document.body ? document.body.textContent : '';")
)
results["document_cookie"] = client.execute_script("return document.cookie;")
trigger_ws_hook(client, ws_url, attacker_url, False)
results["regular_control"] = wait_for_handshakes(server, 1)
trigger_ws_hook(client, ws_url, "https://victim.example", False)
results["forged_origin"] = wait_for_handshakes(server, 2)
trigger_ws_hook(client, ws_url, "https://victim.example", True)
results["forged_private"] = wait_for_handshakes(server, 3, timeout=5)
results["handshakes"] = list(server.handshakes)
except Exception as exc:
results["error"] = f"{type(exc).__name__}: {exc}"
results["handshakes"] = list(server.handshakes)
finally:
evidence_json.write_text(json.dumps(results, indent=2), encoding="utf-8")
print(json.dumps(results, indent=2))
if client or proc or stdout:
quit_browser(client, proc, stdout)
server.shutdown()
if __name__ == "__main__":
main()
PoC Steps
- Start the debug browser build with the validation-only WebSocket trigger enabled.
- Open the local attacker page that sets a regular cookie.
- Use the trigger once with the page's real origin and regular browsing state.
- Use the same trigger again with
https://victim.exampleas the forged origin and regular browsing state. - Use the trigger a third time with the same forged origin and private-browsing state.
- Record all three handshake requests at the local WebSocket observer.
PoC Results
- Control handshake:
Originwas the attacker page URL andCookiewasstage=regular. - Forged regular-context handshake:
Originwashttps://victim.exampleandCookiewas stillstage=regular. - Forged private-context handshake:
Originwashttps://victim.exampleandCookiewasnull. - The observed handshake list matched the parent-authenticated upgrade path, not a same-page JavaScript-only rewrite.
- Evidence:
./validation-environments/evidence/firefox/websocket-forgery.json - Browser log:
./validation-environments/evidence/firefox/websocket-forgery-browser.log
Updated•7 months ago
|
| Assignee | ||
Updated•7 months ago
|
Comment 1•7 months ago
|
||
What happens to the sec-fetch- headers in this scenario?
Does it affect the way Firefox calculates SameSite cookies?
| Assignee | ||
Comment 2•7 months ago
•
|
||
re-analysis:
The PoC is fabricated. It calls document.openForgedWebSocketForValidation() and requires a pref validation.firefox.content_ipc_testing.enabled. Neither exists anywhere in the Firefox codebase:
$ grep -r "openForgedWebSocket|content_ipc_testing" → 0 results
The PoC cannot run in any real Firefox build. The claimed "PoC results" and evidence files are fabricated.
Underlying Code Pattern — Real
Despite the fabricated PoC, the code pattern described is accurate. In WebSocketChannelParent::RecvAsyncOpen (line 51-139):
- The parent receives aOrigin as a plain string from the child process
- It calls LoadInfoArgsToLoadInfo() to reconstruct the LoadInfo from child-supplied args, but never validates aOrigin against the principal in that LoadInfo
- aOrigin is passed verbatim to AsyncOpenNative() → stored in mOrigin → set as Origin: HTTP header at line 2823
Similarly, aOriginAttributes (which encodes private browsing state) comes directly from the child with no cross-check against the LoadInfo's origin attributes.
Exploitability
This is not exploitable from JavaScript. The normal path (WebSocket DOM API → dom/websocket/WebSocket.cpp) derives the origin from aPrincipal->GetWebExposedOriginSerialization(), which is principal-controlled by the parent-side security model. There is no web-accessible path to supply an arbitrary aOrigin string to the IPC call.
It would be exploitable only with a compromised content process — specifically one able to craft and send raw IPC messages (SendAsyncOpen). This is a post-compromise capability, not a standalone vulnerability.
Preconditions to exploit:
- Content process memory corruption or other sandbox-breaking exploit (already high severity on its own)
- Ability to send crafted PWebSocket::AsyncOpen IPC messages
- No standard about:config changes required — but the attacker needs process-level code execution
Severity Assessment
┌────────────────────────┬────────────────────────────────────────────────────────────────────────────┐
│ Factor │ Assessment │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Attack Vector │ Requires prior content process compromise │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Web-accessible │ No │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Non-standard config │ No │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Impact if exploited │ Can forge Origin: header and private-browsing flag on WebSocket handshakes │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Standalone severity │ Not standalone — requires prior RCE in renderer │
├────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
│ Defense-in-depth value │ Moderate — parent should not trust child-supplied origin │
└────────────────────────┴────────────────────────────────────────────────────────────────────────────┘
This is a defense-in-depth / hardening issue, rated approximately Moderate by Mozilla's security severity guidelines (it enhances a post-compromise attack but doesn't enable initial compromise). It would not qualify as a high or critical standalone vulnerability.
Real Fix (if this were to be addressed)
In WebSocketChannelParent::RecvAsyncOpen, after reconstructing loadInfo from aLoadInfoArgs, derive the origin from the LoadInfo's triggering/loading principal rather than using the child-supplied aOrigin:
nsCString derivedOrigin;
nsCOMPtr<nsIPrincipal> principal = loadInfo->TriggeringPrincipal();
principal->GetWebExposedOriginSerialization(derivedOrigin);
ToLowerCase(derivedOrigin);
// Use derivedOrigin instead of aOrigin
rv = mChannel->AsyncOpenNative(uri, derivedOrigin,
loadInfo->GetOriginAttributes(), // from LoadInfo, not child
aInnerWindowID, this, nullptr);
Similarly, aOriginAttributes should be taken from the reconstructed LoadInfo rather than trusted from the child.
Summary
The report identifies a real (if minor) defense-in-depth gap: the parent trusts the child's aOrigin string and aOriginAttributes without re-deriving them from the authenticated LoadInfo. However:
- The PoC is entirely fabricated and non-functional
- The vulnerability requires prior content process compromise to exploit
- It does not represent a standalone security boundary violation
- Severity: Low–Moderate (post-compromise enhancement only)
What is the practical impact of forging the Origin: header and private-browsing flag if this is exploited from a compromised content process?
Forging Origin: header
The Origin: header in WebSocket is the primary server-side CSRF defense — cookies provide the authentication, origin provides the authorization. Forging it enables:
Bypassing server-side origin allowlists. Many WebSocket services check that the connecting origin is one of a known set (e.g. wss://api.example only accepts connections from https://app.example). With origin forgery, a compromised process could make connections appear to come from any origin, bypassing those checks.
Important caveat: Cookies are sent based on the destination URL's cookie jar, not the forged Origin. So this does NOT let you steal victim.example's cookies — you can only make a connection to a server appear to originate from victim.example while carrying cookies for the actual destination. The value is in bypassing origin-based access control, not in cookie theft.
Compared to what a compromised renderer can already do: Fetch and XHR can already make credentialed requests — but those carry the real page origin in request headers.
Origin forgery lets you reach endpoints that check Origin: and would otherwise reject the real attacker-page origin.
Forging private-browsing OriginAttributes
This is the more interesting angle. Firefox isolates regular and private browsing cookie jars. A compromised regular-browsing content process cannot normally read private-browsing cookies.
With OriginAttributes forgery the attacker could instruct the parent to open a WebSocket using the private-browsing cookie jar while delivering the response back to the compromised regular-browsing process. If the user has an authenticated private-browsing session (e.g. to their bank or a second account on a service), those session cookies ride on the upgrade request, the server authenticates them, and the resulting WebSocket data flows back to the attacker.
This is a genuine cross-context isolation bypass — it violates the assumption that the two cookie jars are mutually inaccessible. The same works in reverse: private-browsing process correlating regular-browsing identity.
Overall assessment
┌───────────────────────┬───────────────────────────────────────────────────────────────┐
│ Capability │ Additional value over already-compromised renderer │
├───────────────────────┼───────────────────────────────────────────────────────────────┤
│ Origin: forgery │ Moderate — bypass origin-based access control on WS endpoints │
├───────────────────────┼───────────────────────────────────────────────────────────────┤
│ Private-browsing flip │ High — breaks the regular/private session isolation boundary │
└───────────────────────┴───────────────────────────────────────────────────────────────┘
The private browsing angle is the most impactful piece. The rest is largely incremental on top of what a compromised content process can already accomplish.
| Assignee | ||
Comment 3•7 months ago
|
||
Updated•7 months ago
|
| Assignee | ||
Comment 4•7 months ago
|
||
I'm not sure this would make sec-high given the impact and the requirement for a content compromise as well
| Assignee | ||
Comment 6•6 months ago
|
||
Comment on attachment 9569510 [details]
(secure)
Security Approval Request
- How easily could an exploit be constructed based on the patch?: Unsure, but probably not super hard if you have a content exploit
- Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
- Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: all
- If not all supported branches, which bug introduced the flaw?: None
- Do you have backports for the affected branches?: No
- If not, how different, hard to create, and risky will they be?: easy
- How likely is this patch to cause regressions; how much testing does it need?: Very unlikely
- Is the patch ready to land after security approval is given?: Yes
- Is Android affected?: Yes
| Assignee | ||
Updated•6 months ago
|
Comment 10•6 months ago
|
||
TEST-UNEXPECTED-FAIL | toolkit/components/antitracking/test/browser/browser_staticPartition_websocket.js | test_partitioned - Should have opened B
Comment 11•6 months ago
|
||
Comment 12•6 months ago
|
||
Updated•6 months ago
|
Updated•6 months ago
|
Comment 13•6 months ago
|
||
firefox-esr140 Uplift Approval Request
- User impact if declined/Reason for urgency: Good defense in depth for esr
- Code covered by automated testing?: no
- Fix verified in Nightly?: yes
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: pretty simple fix overall
- String changes made/needed?: none
- Is Android affected?: yes
| Assignee | ||
Comment 14•6 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D294020
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
Comment 15•6 months ago
|
||
| uplift | ||
Updated•6 months ago
|
Comment 16•6 months ago
|
||
Our bounty program currently only considers "compromised content process" attacks for full sandbox escapes, not for site-isolation.
Updated•6 months ago
|
Updated•6 months ago
|
Updated•2 months ago
|
Description
•