Unrestricted File Upload in PHP Applications

Critical Risk File Upload & Path Traversal
file-uploadphprceweb-shellcode-executionvalidation

What it is

A critical vulnerability where web applications allow users to upload files without proper validation, enabling attackers to upload malicious scripts and achieve remote code execution. This is particularly dangerous in PHP applications where uploaded PHP files can be directly executed by the web server.

<?php
// VULNERABLE: Complete lack of validation
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['upload'])) {
    $uploadDir = 'uploads/';
    $fileName = $_FILES['upload']['name'];
    $uploadPath = $uploadDir . $fileName;
    
    // No validation whatsoever
    if (move_uploaded_file($_FILES['upload']['tmp_name'], $uploadPath)) {
        echo "<p>File uploaded successfully: <a href='$uploadPath'>$fileName</a></p>";
        echo "<p>Access your file at: http://example.com/$uploadPath</p>";
    } else {
        echo "<p>Upload failed.</p>";
    }
}
?>

<html>
<body>
    <h2>File Upload</h2>
    <form method="post" enctype="multipart/form-data">
        <input type="file" name="upload" required>
        <input type="submit" value="Upload File">
    </form>
</body>
</html>

<!-- 
Attacker exploitation:
1. Create malicious PHP file: shell.php
   Content: <?php system($_GET['cmd']); ?>
2. Upload shell.php
3. Access: http://example.com/uploads/shell.php?cmd=whoami
4. Execute arbitrary commands
-->
<?php
class SecureFileUpload {
    private $uploadDir;
    private $maxFileSize = 5 * 1024 * 1024; // 5MB
    private $allowedTypes = [
        'image/jpeg' => 'jpg',
        'image/png' => 'png', 
        'image/gif' => 'gif',
        'application/pdf' => 'pdf'
    ];
    
    public function __construct() {
        // Store uploads outside web root
        $this->uploadDir = '/var/app_data/uploads/';
        if (!is_dir($this->uploadDir)) {
            mkdir($this->uploadDir, 0755, true);
        }
    }
    
    public function handleUpload() {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['upload'])) {
            return ['success' => false, 'message' => 'No file uploaded'];
        }
        
        $file = $_FILES['upload'];
        
        try {
            $this->validateFile($file);
            $safeFileName = $this->sanitizeAndStore($file);
            
            return [
                'success' => true, 
                'message' => 'File uploaded successfully',
                'fileId' => $safeFileName
            ];
        } catch (Exception $e) {
            return ['success' => false, 'message' => $e->getMessage()];
        }
    }
    
    private function validateFile($file) {
        // Check for upload errors
        if ($file['error'] !== UPLOAD_ERR_OK) {
            throw new Exception('Upload error: ' . $file['error']);
        }
        
        // Check file size
        if ($file['size'] > $this->maxFileSize) {
            throw new Exception('File too large. Maximum size: ' . ($this->maxFileSize / 1024 / 1024) . 'MB');
        }
        
        // Verify actual MIME type using file content
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mimeType = $finfo->file($file['tmp_name']);
        
        if (!array_key_exists($mimeType, $this->allowedTypes)) {
            throw new Exception('File type not allowed: ' . $mimeType);
        }
        
        // Additional validation for images
        if (strpos($mimeType, 'image/') === 0) {
            $imageInfo = getimagesize($file['tmp_name']);
            if ($imageInfo === false) {
                throw new Exception('Invalid image file');
            }
        }
        
        // Check for embedded PHP code in file content
        $content = file_get_contents($file['tmp_name']);
        if (preg_match('/<\?php|<\?=|<script/i', $content)) {
            throw new Exception('Potentially malicious content detected');
        }
    }
    
    private function sanitizeAndStore($file) {
        // Generate safe filename
        $extension = $this->allowedTypes[mime_content_type($file['tmp_name'])];
        $safeFileName = uniqid('upload_', true) . '.' . $extension;
        $destination = $this->uploadDir . $safeFileName;
        
        // Move file
        if (!move_uploaded_file($file['tmp_name'], $destination)) {
            throw new Exception('Failed to store file');
        }
        
        // Set secure permissions
        chmod($destination, 0644);
        
        return $safeFileName;
    }
    
    // Secure file serving
    public function serveFile($fileId) {
        $filePath = $this->uploadDir . basename($fileId); // Prevent path traversal
        
        if (!file_exists($filePath)) {
            http_response_code(404);
            exit('File not found');
        }
        
        // Verify file is in allowed directory
        $realPath = realpath($filePath);
        $realUploadDir = realpath($this->uploadDir);
        
        if (strpos($realPath, $realUploadDir) !== 0) {
            http_response_code(403);
            exit('Access denied');
        }
        
        // Set secure headers
        header('Content-Type: application/octet-stream');
        header('Content-Disposition: attachment; filename="' . basename($fileId) . '"');
        header('X-Content-Type-Options: nosniff');
        
        readfile($filePath);
    }
}

// Usage
$uploader = new SecureFileUpload();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $result = $uploader->handleUpload();
    echo json_encode($result);
    exit;
}
?>

<html>
<body>
    <h2>Secure File Upload</h2>
    <form method="post" enctype="multipart/form-data">
        <p>Allowed files: Images (JPG, PNG, GIF) and PDF documents only</p>
        <p>Maximum size: 5MB</p>
        <input type="file" name="upload" accept=".jpg,.jpeg,.png,.gif,.pdf" required>
        <input type="submit" value="Upload File">
    </form>
</body>
</html>

💡 Why This Fix Works

The vulnerable code allows any file type to be uploaded to a web-accessible directory, enabling RCE attacks. The secure version implements multi-layered validation, stores files outside web root, and serves them through a controlled mechanism.

# VULNERABLE: Default Apache configuration allowing script execution
<VirtualHost *:80>
    DocumentRoot /var/www/html
    
    # Upload directory allows script execution
    <Directory "/var/www/html/uploads">
        Options Indexes FollowSymLinks
        AllowOverride All
        Require all granted
        
        # PHP execution enabled (dangerous!)
        AddHandler application/x-httpd-php .php
    </Directory>
    
    # No file size limits
    # No MIME type restrictions
</VirtualHost>

# This allows uploaded PHP files to be executed directly
# SECURE: Hardened Apache configuration
<VirtualHost *:80>
    DocumentRoot /var/www/html
    
    # Secure upload directory configuration
    <Directory "/var/www/html/uploads">
        # Disable script execution completely
        Options -Indexes -ExecCGI
        AllowOverride None
        Require all granted
        
        # Remove all script handlers
        RemoveHandler .php .phtml .php3 .php4 .php5 .pl .py .jsp .asp .sh .cgi
        
        # Prevent execution of common script extensions
        <FilesMatch "\.(php|php3|php4|php5|phtml|pl|py|jsp|asp|sh|cgi)$">
            Order Allow,Deny
            Deny from all
        </FilesMatch>
        
        # Force download for all files
        <FilesMatch ".*">
            Header set Content-Disposition "attachment"
            Header set X-Content-Type-Options "nosniff"
        </FilesMatch>
    </Directory>
    
    # Global security headers
    Header always set X-Content-Type-Options nosniff
    Header always set X-Frame-Options DENY
    Header always set Content-Security-Policy "default-src 'self'"
    
    # File upload limits
    LimitRequestBody 5242880  # 5MB limit
</VirtualHost>

# Additional security in main config
# Disable dangerous functions in php.ini:
# disable_functions = exec,passthru,shell_exec,system,proc_open,popen
# file_uploads = On
# upload_max_filesize = 5M
# post_max_size = 5M
# max_file_uploads = 5

💡 Why This Fix Works

Proper web server configuration is crucial for preventing uploaded files from being executed. The secure configuration disables script execution in upload directories and implements multiple layers of protection.

// VULNERABLE: Client-side only validation
function uploadFile() {
    const fileInput = document.getElementById('fileUpload');
    const file = fileInput.files[0];
    
    // Weak client-side validation only
    if (!file.name.match(/\.(jpg|jpeg|png|gif)$/i)) {
        alert('Only image files allowed!');
        return false;
    }
    
    // Direct form submission - can be bypassed
    document.getElementById('uploadForm').submit();
}

// Attacker can easily bypass by:
// 1. Disabling JavaScript
// 2. Intercepting request with Burp Suite
// 3. Crafting direct HTTP POST request
// 4. Renaming malicious.php to malicious.jpg
// SECURE: Enhanced client-side validation with server-side verification
class SecureFileUpload {
    constructor() {
        this.allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
        this.maxSize = 5 * 1024 * 1024; // 5MB
        this.setupEventListeners();
    }
    
    setupEventListeners() {
        const fileInput = document.getElementById('fileUpload');
        fileInput.addEventListener('change', this.validateFile.bind(this));
        
        const form = document.getElementById('uploadForm');
        form.addEventListener('submit', this.handleSubmit.bind(this));
    }
    
    async validateFile(event) {
        const file = event.target.files[0];
        if (!file) return;
        
        // Client-side pre-validation for UX
        const errors = [];
        
        // Check file size
        if (file.size > this.maxSize) {
            errors.push(`File too large. Maximum size: ${this.maxSize / 1024 / 1024}MB`);
        }
        
        // Check MIME type
        if (!this.allowedTypes.includes(file.type)) {
            errors.push(`File type not allowed: ${file.type}`);
        }
        
        // Read file header for additional validation
        try {
            const isValid = await this.validateFileHeader(file);
            if (!isValid) {
                errors.push('Invalid file format detected');
            }
        } catch (e) {
            errors.push('File validation failed');
        }
        
        if (errors.length > 0) {
            this.showErrors(errors);
            event.target.value = ''; // Clear input
        } else {
            this.showSuccess('File validation passed');
        }
    }
    
    async validateFileHeader(file) {
        return new Promise((resolve) => {
            const reader = new FileReader();
            reader.onload = function(e) {
                const arr = new Uint8Array(e.target.result).subarray(0, 4);
                let header = '';
                for (let i = 0; i < arr.length; i++) {
                    header += arr[i].toString(16);
                }
                
                // Check magic bytes
                const validHeaders = {
                    'ffd8ff': 'image/jpeg',
                    '89504e47': 'image/png',
                    '47494638': 'image/gif',
                    '25504446': 'application/pdf'
                };
                
                const isValid = Object.keys(validHeaders).some(magic => 
                    header.startsWith(magic)
                );
                resolve(isValid);
            };
            reader.readAsArrayBuffer(file.slice(0, 4));
        });
    }
    
    async handleSubmit(event) {
        event.preventDefault();
        
        const formData = new FormData(event.target);
        const uploadButton = document.getElementById('uploadButton');
        
        uploadButton.disabled = true;
        uploadButton.textContent = 'Uploading...';
        
        try {
            const response = await fetch('/secure-upload.php', {
                method: 'POST',
                body: formData,
                headers: {
                    'X-Requested-With': 'XMLHttpRequest',
                    'X-CSRF-Token': document.querySelector('[name=csrf_token]').value
                }
            });
            
            const result = await response.json();
            
            if (result.success) {
                this.showSuccess(result.message);
                event.target.reset();
            } else {
                this.showErrors([result.message]);
            }
        } catch (error) {
            this.showErrors(['Upload failed: Network error']);
        } finally {
            uploadButton.disabled = false;
            uploadButton.textContent = 'Upload File';
        }
    }
    
    showErrors(errors) {
        const errorDiv = document.getElementById('errors');
        errorDiv.innerHTML = errors.map(error => `<p class="error">${error}</p>`).join('');
        errorDiv.style.display = 'block';
    }
    
    showSuccess(message) {
        const errorDiv = document.getElementById('errors');
        errorDiv.innerHTML = `<p class="success">${message}</p>`;
        errorDiv.style.display = 'block';
    }
}

// Initialize when DOM loads
document.addEventListener('DOMContentLoaded', () => {
    new SecureFileUpload();
});

💡 Why This Fix Works

While client-side validation improves user experience, it must be supplemented with robust server-side validation. This implementation adds file header validation and secure AJAX upload handling.

Why it happens

The most critical flaw is accepting any file type without validation. Applications that don't check file extensions, MIME types, or file contents allow attackers to upload executable scripts like PHP web shells, which can then be accessed via direct URL requests to execute arbitrary commands on the server.

Root causes

No File Type Validation

The most critical flaw is accepting any file type without validation. Applications that don't check file extensions, MIME types, or file contents allow attackers to upload executable scripts like PHP web shells, which can then be accessed via direct URL requests to execute arbitrary commands on the server.

Preview example – PHP
<?php
// VULNERABLE: No file type checking
if (isset($_FILES['upload'])) {
    $uploadDir = 'uploads/';
    $uploadFile = $uploadDir . basename($_FILES['upload']['name']);
    
    if (move_uploaded_file($_FILES['upload']['tmp_name'], $uploadFile)) {
        echo "File uploaded successfully: " . $uploadFile;
    }
}
// Attacker uploads: shell.php containing <?php system($_GET['cmd']); ?>
// Then accesses: /uploads/shell.php?cmd=whoami

Client-Side Only Validation

Relying solely on client-side validation (JavaScript) provides no real security as attackers can easily bypass it using tools like Burp Suite or by crafting direct HTTP requests. Server-side validation is essential because client-side controls can be completely circumvented by malicious users.

Preview example – HTML
<!-- VULNERABLE: Only client-side validation -->
<script>
function validateFile() {
    const file = document.getElementById('fileInput').files[0];
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
    
    if (!allowedTypes.includes(file.type)) {
        alert('Only image files allowed!');
        return false;
    }
    return true;
}
</script>
<!-- Attacker bypasses by intercepting request and changing file content -->

Inadequate File Content Inspection

Checking only file extensions or MIME types is insufficient as these can be easily spoofed. Attackers can rename malicious PHP files with image extensions or manipulate HTTP headers to bypass basic validation. True file content analysis and magic byte verification are necessary for robust security.

Preview example – PHP
<?php
// VULNERABLE: Only checking extension
function isImageFile($filename) {
    $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif'];
    $fileExtension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
    return in_array($fileExtension, $allowedExtensions);
}

// Attacker uploads: malicious.php.jpg
// Contains: <?php system($_GET['cmd']); ?>
// Server may still execute it as PHP if misconfigured

Executable Upload Directory

Storing uploaded files in web-accessible directories where scripts can be executed is extremely dangerous. Even with proper validation, if the upload directory allows script execution, attackers may find ways to bypass filters and achieve code execution through various techniques like double extensions or server misconfigurations.

Preview example – PHP
<?php
// VULNERABLE: Uploading to web-accessible, executable directory
$uploadDir = $_SERVER['DOCUMENT_ROOT'] . '/uploads/'; // Web accessible
$uploadFile = $uploadDir . $_FILES['upload']['name'];

// .htaccess in upload directory allowing PHP execution:
// <Files *.php>
//     SetHandler application/x-httpd-php
// </Files>

// Even "image.php.gif" might execute as PHP on misconfigured servers

Fixes

1

Implement Comprehensive Server-Side Validation

Always perform rigorous server-side validation including file extension whitelisting, MIME type verification, and actual file content analysis using magic bytes. Never trust client-provided information and implement multiple layers of validation to ensure only legitimate files are accepted.

View implementation – PHP
<?php
function validateUploadedFile($file) {
    // Check file size
    if ($file['size'] > 5 * 1024 * 1024) { // 5MB limit
        throw new Exception('File too large');
    }
    
    // Whitelist allowed extensions
    $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
    $fileExtension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    if (!in_array($fileExtension, $allowedExtensions)) {
        throw new Exception('File type not allowed');
    }
    
    // Verify MIME type
    $allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($file['tmp_name']);
    if (!in_array($mimeType, $allowedMimeTypes)) {
        throw new Exception('Invalid file content');
    }
    
    return true;
}
2

Store Files Outside Web Root

Upload files to directories outside the web server's document root to prevent direct access and execution. Serve files through a controlled script that can enforce additional security checks, access controls, and prevent direct script execution even if validation is bypassed.

View implementation – PHP
<?php
// Store uploads outside web root
$uploadDir = '/var/app_data/uploads/'; // Outside /var/www/html
$safeFilename = uniqid() . '_' . basename($file['name']);
$uploadPath = $uploadDir . $safeFilename;

// Serve files through controlled script
function serveFile($fileId) {
    // Verify user permissions
    if (!isAuthorized($fileId)) {
        http_response_code(403);
        exit;
    }
    
    $filePath = '/var/app_data/uploads/' . $fileId;
    if (file_exists($filePath)) {
        header('Content-Type: application/octet-stream');
        header('Content-Disposition: attachment; filename="file"');
        readfile($filePath);
    }
}
3

Implement File Scanning and Sanitization

Use antivirus scanning, content filtering, and file sanitization techniques to detect and remove malicious content. For images, consider re-encoding them to strip potentially embedded scripts. Implement size limits, filename sanitization, and quarantine suspicious files for manual review.

View implementation – PHP
<?php
function sanitizeAndScanFile($tempFile, $originalName) {
    // Sanitize filename
    $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', $originalName);
    $safeName = uniqid() . '_' . $safeName;
    
    // For images, re-encode to strip metadata and embedded content
    if (isImageFile($tempFile)) {
        $image = imagecreatefromstring(file_get_contents($tempFile));
        if ($image !== false) {
            $cleanFile = '/tmp/clean_' . $safeName;
            imagejpeg($image, $cleanFile, 85); // Re-encode as JPEG
            imagedestroy($image);
            return $cleanFile;
        }
    }
    
    // Virus scanning (example with ClamAV)
    $scanResult = shell_exec("clamscan --stdout " . escapeshellarg($tempFile));
    if (strpos($scanResult, 'FOUND') !== false) {
        throw new Exception('Malware detected');
    }
    
    return $tempFile;
}
4

Configure Secure Upload Environment

Configure the web server to disable script execution in upload directories, implement proper .htaccess rules, set restrictive file permissions, and use a Content Security Policy (CSP) to prevent inline script execution. Regular security audits of upload functionality are essential.

View implementation – APACHE
# .htaccess in upload directory - disable script execution
<FilesMatch "\.(php|php3|php4|php5|phtml|pl|py|jsp|asp|sh|cgi)$">
    Order Allow,Deny
    Deny from all
</FilesMatch>

# Remove handler associations
RemoveHandler .php .phtml .php3 .php4 .php5

# Prevent directory listing
Options -Indexes

# Set security headers
Header always set X-Content-Type-Options nosniff
Header always set Content-Security-Policy "default-src 'none'; img-src 'self'"

# File permissions (in deployment script)
chmod 644 uploaded_files/*
chown www-data:www-data uploaded_files/*

Detect This Vulnerability in Your Code

Sourcery automatically identifies unrestricted file upload in php applications and many other security issues in your codebase.