How We Audited a High-Traffic PHP Enterprise Stack on DigitalOcean and Mitigated Remote Code Execution (RCE) via insecure file uploads
Initial Reconnaissance and Vulnerability Discovery
Our engagement began with a deep dive into the existing infrastructure. The client, a high-traffic e-commerce platform hosted on DigitalOcean, utilized a LAMP stack with a custom PHP framework. The primary concern was a recently reported, albeit unconfirmed, vulnerability related to file uploads. Our initial reconnaissance focused on identifying all file upload endpoints within the application and understanding their handling mechanisms.
We employed a combination of static analysis of the codebase and dynamic testing using Burp Suite. The static analysis revealed several upload handlers, primarily within the user profile and product management modules. A critical observation was the lack of strict MIME type validation and, more alarmingly, insufficient sanitization of uploaded filenames. This immediately flagged potential avenues for Remote Code Execution (RCE) if an attacker could upload a script disguised as an allowed file type.
Exploiting Insecure File Uploads: The RCE Vector
The most promising vector for RCE involved uploading a PHP script. The application allowed image uploads (JPG, PNG, GIF). We hypothesized that by manipulating the filename and potentially the content, we could bypass the rudimentary checks. The key was to upload a file with a double extension, such as shell.php.jpg. While the server might serve it as an image, if the web server’s configuration was permissive, it could still interpret and execute the PHP code.
Our test payload was a simple PHP webshell designed to execute arbitrary commands. We crafted a POST request to an identified upload endpoint. The request included a file named backdoor.php.jpg. The server’s response indicated a successful upload, and the file was placed in a publicly accessible directory, typically /var/www/html/uploads/images/.
The crucial step was to verify if the uploaded file could be executed. We attempted to access the file directly via its URL: https://your-domain.com/uploads/images/backdoor.php.jpg. If the web server (in this case, Nginx) was configured to execute PHP files regardless of their apparent extension (a common misconfiguration when relying solely on file extensions for security), this would lead to RCE.
Analyzing the Web Server Configuration (Nginx)
A quick inspection of the Nginx configuration revealed the root cause of the execution vulnerability. The mime.types file was standard, but the PHP processing was configured too broadly. Specifically, the location ~ \.php$ block was responsible for passing PHP requests to the FastCGI process manager (PHP-FPM).
The problematic configuration snippet within the Nginx site configuration (e.g., /etc/nginx/sites-available/your-app) looked something like this:
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
# The following line is often the culprit for executing PHP files with non-standard extensions
# if the application logic allows it.
# In this case, the application's upload handler was the primary weakness,
# but a permissive Nginx config exacerbates it.
}
While this configuration is standard for processing .php files, the application’s failure to prevent the upload of a file *named* .php.jpg, combined with a lack of explicit MIME type checking at the Nginx level for uploaded content, created the vulnerability. The application logic was supposed to validate the MIME type and reject non-images. However, it only checked the *content* of the file for image headers, not the filename or the client-provided MIME type header.
Code-Level Analysis: The Flawed Upload Handler
Delving into the PHP codebase, we pinpointed the vulnerable upload handler. It was a typical pattern: receive the file, check its MIME type based on its content, sanitize the filename, and save it. The sanitization was insufficient, and the MIME type check was flawed.
<?php
// Simplified example of the vulnerable upload handler
if (isset($_FILES['user_image']) && $_FILES['user_image']['error'] === UPLOAD_ERR_OK) {
$fileTmpPath = $_FILES['user_image']['tmp_name'];
$fileName = $_FILES['user_image']['name'];
$fileSize = $_FILES['user_image']['size'];
$fileType = $_FILES['user_image']['type']; // Client-provided, often unreliable
// Basic check for allowed image types based on content
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($fileTmpPath);
if (!in_array($mimeType, $allowedTypes)) {
// Error: Invalid file type
echo "Error: Invalid file type.";
} else {
// Insufficient filename sanitization
$safeFileName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName));
$uploadDir = '/var/www/html/uploads/images/';
$destPath = $uploadDir . $safeFileName;
if (move_uploaded_file($fileTmpPath, $destPath)) {
echo "File uploaded successfully: " . $safeFileName;
} else {
echo "Error moving file.";
}
}
}
?>
The critical flaws here were:
- Reliance on
$_FILES['user_image']['type']which is client-controlled and easily spoofed. - The
finfocheck was performed *after* the file was already in a temporary location, but the filename sanitization was weak. It did not prevent double extensions or execution-oriented characters. - The application did not enforce a strict whitelist of *allowed final extensions* after sanitization. It allowed
.jpg, but the uploaded file wasbackdoor.php.jpg, and the sanitization only replaced invalid characters, not the double extension.
Mitigation Strategy: Layered Security Controls
To address this, we implemented a multi-layered defense strategy:
1. Strict Filename Sanitization and Whitelisting
The PHP upload handler was refactored to enforce a strict whitelist of allowed file extensions and to generate a truly safe filename. Instead of relying on the original filename, we generated a unique name (e.g., using `uniqid()`) and appended a validated extension.
<?php
// Refactored and secure upload handler
if (isset($_FILES['user_image']) && $_FILES['user_image']['error'] === UPLOAD_ERR_OK) {
$fileTmpPath = $_FILES['user_image']['tmp_name'];
$originalFileName = $_FILES['user_image']['name'];
$fileSize = $_FILES['user_image']['size'];
// 1. Validate MIME type using finfo (more reliable)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($fileTmpPath);
// 2. Define allowed MIME types and corresponding extensions
$allowedMimeExtensions = [
'image/jpeg' => '.jpg',
'image/png' => '.png',
'image/gif' => '.gif',
];
if (!array_key_exists($mimeType, $allowedMimeExtensions)) {
// Error: Invalid file type
error_log("Upload failed: Invalid MIME type - " . $mimeType);
die("Error: Invalid file type.");
}
$allowedExtension = $allowedMimeExtensions[$mimeType];
// 3. Generate a truly safe filename (e.g., UUID or timestamp + random)
// Using uniqid for simplicity, but a more robust UUID generator is recommended for production.
$safeFileName = uniqid('img_', true) . $allowedExtension;
$uploadDir = '/var/www/html/uploads/images/';
$destPath = $uploadDir . $safeFileName;
// 4. Move the file
if (move_uploaded_file($fileTmpPath, $destPath)) {
echo "File uploaded successfully: " . htmlspecialchars($safeFileName);
} else {
error_log("Upload failed: Could not move file to " . $destPath);
die("Error: File upload failed.");
}
}
?>
2. Web Server Configuration Hardening (Nginx)
We modified the Nginx configuration to explicitly disallow the execution of PHP files in the upload directory. This is a crucial defense-in-depth measure, preventing the web server itself from interpreting PHP code even if the application logic were to fail again.
location /uploads/images/ {
# Deny execution of PHP files in this directory
location ~ \.php$ {
deny all;
return 403; # Forbidden
}
# Optional: If serving static files, add directives for caching etc.
# expires max;
# add_header Cache-Control public;
}
This configuration block ensures that any request ending in .php within the /uploads/images/ path will be immediately denied by Nginx, regardless of how the file was named or uploaded.
3. Content Security Policy (CSP)
While not directly preventing the upload, a robust Content Security Policy can mitigate the impact of RCE by restricting what resources the browser can load and execute. We implemented a strict CSP to prevent inline scripts and the execution of scripts from untrusted domains.
# Example CSP header in Nginx add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; object-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline';" always;
This policy, for instance, would prevent an attacker from leveraging an RCE vulnerability to inject malicious JavaScript that executes from a different domain.
4. Regular Security Audits and Automated Scanning
Beyond immediate fixes, we recommended establishing a schedule for regular, in-depth security audits of the codebase and infrastructure. Integrating automated security scanning tools (SAST and DAST) into the CI/CD pipeline is crucial for catching such vulnerabilities early in the development lifecycle.
Post-Mitigation Verification
After implementing the changes, we re-tested the identified upload endpoints with various malicious payloads, including double extensions, scripts disguised as images, and files with executable content. All attempts to upload and execute code were successfully blocked by either the application logic or the Nginx configuration.
The audit confirmed that the combination of secure coding practices for file handling and hardened web server configurations effectively mitigated the RCE vulnerability. This case study underscores the importance of treating all user-supplied input, especially file uploads, as untrusted and implementing defense-in-depth strategies.