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
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.
os.tmpdir()is world-writable (/tmp, mode 1777). Linux default.- The plugin runs in a context where
CLAUDE_PLUGIN_DATAis unset, so theos.tmpdir()fallback is selected. #59 shows this is reached in practice: the Bash-invoked/codex:setupwrites to the temp dir because the variable is not set in that context. The exact set of invocations that use the fallback depends on howCLAUDE_PLUGIN_DATAis propagated (see also #338). - A can compute the directory name, which requires knowing V's checkout path. On shared hosts these are usually predictable (
/home/<user>/<repo>). - A wins a first-to-create race on the directory before V's first plugin run in that workspace. The
/tmpsticky 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:
- IPC man-in-the-middle.
ensureBrokerSession(broker-lifecycle.mjs:114-117) readsbroker.jsonand, if the attacker-suppliedendpointis 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. - Arbitrary process-group kill. On teardown,
broker.json'spidis handed toterminateProcessTree(session-lifecycle-hook.mjs:105-112), which runsprocess.kill(-pid, "SIGTERM")(process.mjs:101), a group signal against an attacker-chosen pid. - Arbitrary file deletion.
teardownBrokerSession(broker-lifecycle.mjs:182-195) callsfs.unlinkSyncon thepidFile,logFile, and unix socket path taken frombroker.json. Separately,saveState(state.mjs:111) unlinksjob.logFiletaken fromstate.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)
- 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"
- As A, run a listener that impersonates the broker, and plant a poisoned
broker.json(setpidto a target process group,pidFileandlogFileto 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
- As V, use the plugin in
/home/v/appin a context whereCLAUDE_PLUGIN_DATAis unset (for example the/codex:setupor slash-command Bash path per #59). - Observe, as uid V: the Codex IPC connects to A's socket,
keep.txtandnotes.txtare unlinked, and process group 12345 receives SIGTERM.
Suggested fix
All three parts are needed to fully close the class.
- Create the fallback state directory private. Pass
{ recursive: true, mode: 0o700 }to themkdirSyncinensureStateDir(state.mjs:55) andsaveBrokerSession(broker-lifecycle.mjs:91), matching the 0700mkdtempSyncalready used for the live session dir. - Verify the directory before trusting it. On load,
lstatthe state dir and thebroker.json/state.jsonfiles and refuse them if they are not owned by the current uid or are group/other-writable. Read withO_NOFOLLOWto defeat symlink swaps. - Validate the fields before acting. Require
endpoint,pid,pidFile,logFile, andsessionDirto resolve inside the plugin-created 0700 session directory (realpath containment) before dialing, killing, or unlinking, and liveness- and ownership-check anypidread from disk beforekill.
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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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