openai / openai/codex-plugin-cc

Predictable os.tmpdir() fallback state dir (0755) + unvalidated broker.json lets a co-located user MITM the Codex IPC and force arbitrary process-kill / file-delete

Open
#521 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
33.3k
Forks
2.3k
PR merge metrics
No merged PRs in 30d

Description

Repo: github.com/openai/codex-plugin-cc

Version: 1.0.6 (main = db52e28 at time of writing; code present there)

Applies to: shared multi-user hosts where os.tmpdir() resolves to a world-writable /tmp (the Linux default). Does not apply on macOS, where os.tmpdir() is a per-user 0700 $TMPDIR.

Class: insecure use of a shared temp directory + trusting its contents unvalidated (CWE-377 / CWE-379 / CWE-427). Local, precondition-gated, defense-in-depth.

Summary

When CLAUDE_PLUGIN_DATA is unset, the plugin keeps its per-workspace runtime state (broker.json, state.json, job files) in a path-predictable directory under os.tmpdir(), and creates that directory world-traversable (mode 0755). It then trusts every field it reads back from broker.json and state.json without checking the directory's ownership or permissions and without validating the fields. On a shared host where os.tmpdir() is a world-writable /tmp, a different local user who knows or guesses the victim's checkout path can pre-create that directory and plant a poisoned broker.json. That yields, executed under the victim's uid: a man-in-the-middle of the Codex app-server IPC (read the repo diff and prompts sent for review, relay forged results), an arbitrary process-group SIGTERM, and deletion of arbitrary victim-writable files. No code execution.

Preconditions

Attacker A and victim V are different local uids on one host. V's checkout is, for example, /home/v/app.

  1. os.tmpdir() is world-writable (/tmp, mode 1777). Linux default.
  2. The plugin runs in a context where CLAUDE_PLUGIN_DATA is unset, so the os.tmpdir() fallback is selected. #59 shows this is reached in practice: the Bash-invoked /codex:setup writes to the temp dir because the variable is not set in that context. The exact set of invocations that use the fallback depends on how CLAUDE_PLUGIN_DATA is propagated (see also #338).
  3. A can compute the directory name, which requires knowing V's checkout path. On shared hosts these are usually predictable (/home/<user>/<repo>).
  4. A wins a first-to-create race on the directory before V's first plugin run in that workspace. The /tmp sticky bit does not prevent creating a not-yet-existing subdirectory.

Root cause

resolveStateDir (plugins/codex/scripts/lib/state.mjs:41-43) picks os.tmpdir()/codex-companion/<slug>-<hash> whenever CLAUDE_PLUGIN_DATA is unset, where <hash> is sha256(realpath(workspace)).slice(0,16), so the name is deterministic and attacker-computable.

ensureStateDir (state.mjs:54-56) creates that tree with fs.mkdirSync(dir, { recursive: true }) and no mode, so it lands at 0755 (world-readable and traversable). The live broker session directory, by contrast, is created correctly with fs.mkdtempSync at 0700 (broker-lifecycle.mjs:16), so only the persisted pointer files in the predictable directory are exposed, not the live socket.

The plugin then trusts the contents of that directory whenever it operates against the fallback:

  1. IPC man-in-the-middle. ensureBrokerSession (broker-lifecycle.mjs:114-117) reads broker.json and, if the attacker-supplied endpoint is reachable, adopts it as the live broker for the session. Subsequent Codex JSON-RPC (the repo diff and prompts under review) is delivered to that endpoint, and A relays or forges responses such as a "review passed" verdict.
  2. Arbitrary process-group kill. On teardown, broker.json's pid is handed to terminateProcessTree (session-lifecycle-hook.mjs:105-112), which runs process.kill(-pid, "SIGTERM") (process.mjs:101), a group signal against an attacker-chosen pid.
  3. Arbitrary file deletion. teardownBrokerSession (broker-lifecycle.mjs:182-195) calls fs.unlinkSync on the pidFile, logFile, and unix socket path taken from broker.json. Separately, saveState (state.mjs:111) unlinks job.logFile taken from state.json.

Impact

Under the victim's uid, a co-located attacker gets: confidentiality and integrity loss of Codex reviews (read the code and prompts sent to Codex, return a forged clean verdict), a SIGTERM against any process group they name (local denial of service), and deletion of any file the victim can unlink. A poisoned state.json can also flip persisted config such as stopReviewGate.

Steps to reproduce (Linux, two local users)

  1. As A, compute the state directory name and pre-create it:
dir=/tmp/codex-companion/app-$(printf %s /home/v/app | sha256sum | cut -c1-16)
mkdir -p "$dir"
  1. As A, run a listener that impersonates the broker, and plant a poisoned broker.json (set pid to a target process group, pidFile and logFile to victim-owned files):
python3 -c 'import socket,os;\
 os.path.exists("/tmp/a.sock") and os.unlink("/tmp/a.sock");\
 s=socket.socket(socket.AF_UNIX); s.bind("/tmp/a.sock"); s.listen();\
 print("listening");\
 [__import__("threading").Thread(target=lambda c=c: None).start() for c in iter(lambda: s.accept()[0], None)]' &

cat > "$dir/broker.json" <<'JSON'
{"endpoint":"unix:/tmp/a.sock","pid":12345,"pidFile":"/home/v/app/keep.txt","logFile":"/home/v/app/notes.txt","sessionDir":"/tmp/codex-companion/none"}
JSON
  1. As V, use the plugin in /home/v/app in a context where CLAUDE_PLUGIN_DATA is unset (for example the /codex:setup or slash-command Bash path per #59).
  2. Observe, as uid V: the Codex IPC connects to A's socket, keep.txt and notes.txt are unlinked, and process group 12345 receives SIGTERM.

Suggested fix

All three parts are needed to fully close the class.

  1. Create the fallback state directory private. Pass { recursive: true, mode: 0o700 } to the mkdirSync in ensureStateDir (state.mjs:55) and saveBrokerSession (broker-lifecycle.mjs:91), matching the 0700 mkdtempSync already used for the live session dir.
  2. Verify the directory before trusting it. On load, lstat the state dir and the broker.json / state.json files and refuse them if they are not owned by the current uid or are group/other-writable. Read with O_NOFOLLOW to defeat symlink swaps.
  3. Validate the fields before acting. Require endpoint, pid, pidFile, logFile, and sessionDir to resolve inside the plugin-created 0700 session directory (realpath containment) before dialing, killing, or unlinking, and liveness- and ownership-check any pid read from disk before kill.

As additional hardening, consider failing closed when CLAUDE_PLUGIN_DATA is unset rather than silently using a shared temp directory.

Related issues

Same predictable temp directory and broker.json / state.json, different angle: #59 (setup writes the temp dir while the hook reads the persistent dir), #487 (stale broker.json poisons runs on persistent machines), #338 (CLAUDE_PLUGIN_DATA scoping), #517 (concurrent writers wipe job state). This report is about the filesystem permissions and unvalidated trust of that directory across local users, which those do not cover.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with resolveStateDir and ensureStateDir in plugins/codex/scripts/lib/state.mjs, then trace broker.json handling in broker-lifecycle.js, session-lifecycle-hook.mjs, and process.mjs. Use the Linux two-user reproduction to verify the fallback directory and persisted fields are rejected when unsafe. Done means the fallback state cannot be pre-created or modified by another user, and untrusted broker or state fields cannot drive IPC, process termination, or file deletion.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.