Skip to content

Anatomy

WordPress has a small core, a handful of always-present PHP entry points, and one big directory where everything interesting lives: wp-content/. Plugins are PHP files in wp-content/plugins/<name>/, themes are PHP files in wp-content/themes/<name>/, and uploads land in wp-content/uploads/. Every operator-relevant path follows this structure.

/var/www/html/
├── index.php # all dynamic requests route through here
├── wp-config.php # DB creds, salts - the looting prize
├── wp-login.php # login page (form-based auth)
├── xmlrpc.php # XML-RPC API - brute-force/SSRF target
├── wp-admin/ # admin dashboard
├── wp-includes/ # core libraries
└── wp-content/
├── plugins/<plugin>/ # plugin code - usually contains vulnerabilities
├── themes/<theme>/ # theme code - RCE target via theme editor
└── uploads/<year>/<month>/ # media uploads, often directory-indexed

Success indicator: you can mentally map any WordPress URL to a filesystem path in seconds. target.com/wp-content/plugins/foo/bar.php/var/www/html/wp-content/plugins/foo/bar.php.

After a standard install on Linux/Apache/MySQL/PHP, the webroot (usually /var/www/html/) contains:

.
├── index.php
├── license.txt
├── readme.html
├── wp-activate.php
├── wp-admin/
├── wp-blog-header.php
├── wp-comments-post.php
├── wp-config.php
├── wp-config-sample.php
├── wp-content/
├── wp-cron.php
├── wp-includes/
├── wp-links-opml.php
├── wp-load.php
├── wp-login.php
├── wp-mail.php
├── wp-settings.php
├── wp-signup.php
├── wp-trackback.php
└── xmlrpc.php
FileWhat it doesOperator interest
index.phpFront controller - all dynamic page requests route through hereImplicit - every URL like /?p=42 ultimately runs through this
wp-login.phpLogin form (and password reset flow)Primary auth surface; brute-force target
xmlrpc.phpXML-RPC API endpoint (legacy; partly replaced by REST)User enum, brute-force without rate limit, SSRF via pingback.ping
wp-admin/Dashboard, admin functions, AJAX handlersPost-auth attack surface; theme/plugin editors live here
wp-cron.phpTime-based job runner triggered on requestsSometimes exploitable when scheduled tasks process untrusted data
wp-trackback.phpTrackback handler (mostly defunct, occasional CVE)Rarely useful but check if present
wp-signup.phpNew blog signup on multisite installsIf 200 OK and the form is enabled, multisite - different config surface
readme.htmlVersion bannerDiscloses WP version on installs that didn’t delete it
license.txtLicense textSometimes contains version info
wp-config-sample.phpTemplate configConfirms WP install but contains no real secrets

Documents and dotfiles you’ll look for after RCE

Section titled “Documents and dotfiles you’ll look for after RCE”
FileWhy
wp-config.phpThe looting prize - DB host/user/pass/name + secret keys
.htaccessRewrite rules, sometimes credentials in RewriteCond for HTTP basic
wp-admin/.htaccessPer-directory access rules - sometimes IP allowlists or HTTP basic creds
wp-content/uploads/Recent uploads - sometimes accidentally uploaded sensitive files
wp-content/debug.logCreated if WP_DEBUG_LOG=true; contains stack traces with sensitive paths
wp-content/backup*/, wp-content/backups/, wp-content/uploads/backup*/Various backup plugins put dumps here, often world-readable

Several security plugins rename the login page to make brute-force harder by obscurity. Common patterns:

/wp-admin/login.php
/wp-admin/wp-login.php
/login.php
/wp-login.php
/admin/
/admin-login/
/secret-login-<random>/

When wp-login.php returns 404 but the rest of the site is clearly WordPress, the page has been moved. Try common renames; check the WPS Hide Login plugin’s known patterns; if all else fails, fall back to xmlrpc.php (which is harder to “hide” because plugins depend on it).

wp-config.php is the single highest-value file on a WordPress server. It contains:

<?php
/** Database settings */
define( 'DB_NAME', 'wp_production' );
define( 'DB_USER', 'wp_user' );
define( 'DB_PASSWORD', 'P4ssw0rd_db' );
define( 'DB_HOST', 'localhost' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
/** Authentication keys and salts - used for cookie signing */
define( 'AUTH_KEY', '<random 64-char string>' );
define( 'SECURE_AUTH_KEY', '<random 64-char string>' );
define( 'LOGGED_IN_KEY', '<random 64-char string>' );
define( 'NONCE_KEY', '<random 64-char string>' );
define( 'AUTH_SALT', '<random 64-char string>' );
define( 'SECURE_AUTH_SALT', '<random 64-char string>' );
define( 'LOGGED_IN_SALT', '<random 64-char string>' );
define( 'NONCE_SALT', '<random 64-char string>' );
/** Table prefix - relevant if you're SQL injecting */
$table_prefix = 'wp_';
/** Debug - can leak verbose info if true */
define( 'WP_DEBUG', false );
define( 'WP_DEBUG_LOG', false );
define( 'WP_DEBUG_DISPLAY', false );
/** Absolute filesystem path */
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
require_once ABSPATH . 'wp-settings.php';

What this gives you when you can read it:

  • DB credentials - direct database access. Read the wp_users table for password hashes; modify it to insert your own admin account.
  • DB host - sometimes points to an internal database server reachable for further lateral movement.
  • Auth keys / salts - used to sign auth cookies. Knowing them means you can forge wordpress_logged_in_* cookies for any user.
  • Table prefix - needed for SQL injection payloads against the WP database; the default wp_ is overridden in some hardened installs (wp_xy12_) and SQLi payloads need to match.

wp-config.php is read-protected at the filesystem level for the web user, but it’s typically readable by it (since PHP needs to include it). An arbitrary file read primitive (e.g., LFI in a vulnerable plugin) usually gets it.

wp-content/
├── index.php # blank placeholder, prevents directory indexing if Apache obeys it
├── plugins/
│ ├── index.php # same - placeholder
│ ├── akismet/ # bundled plugin
│ ├── hello.php # the legendary "Hello Dolly" stub plugin
│ └── <plugin>/ # one directory per installed plugin
└── themes/
├── index.php
└── <theme>/ # one directory per installed theme

Each plugin is a self-contained directory. Standard layout:

plugins/<plugin>/
├── <plugin>.php # main plugin file with the standard header
├── readme.txt # plugin description, version, requires/tested wp versions
├── includes/, inc/, lib/ # supporting PHP files
├── assets/, public/, css/, js/ # static assets
└── languages/ # i18n .po/.mo files

The plugin’s main file has a header like:

/*
* Plugin Name: Mail Masta
* Plugin URI: https://mail-masta.com/
* Description: Email subscription and newsletter management
* Version: 1.0
* Author: ...
* License: GPL2
*/

The Version: field is the canonical source for plugin version. Combine this with the version-fingerprinting techniques in the next page.

Themes are like plugins but for visual presentation. Standard layout:

themes/<theme>/
├── style.css # theme header lives in CSS comments
├── functions.php # arbitrary PHP that runs on every page load
├── index.php # template fallback
├── 404.php, header.php, footer.php, sidebar.php # template parts
├── single.php, page.php, archive.php # post-type templates
└── assets/, css/, js/, images/ # static

functions.php is full-power PHP that loads on every page. The theme editor (admin-only) lets you write to it directly - that’s the canonical admin-to-RCE path.

style.css opens with a header:

/*
Theme Name: Twenty Twenty-Three
Theme URI: https://wordpress.org/themes/twentytwentythree/
Author: the WordPress team
Description: ...
Version: 1.2
*/

Same pattern as plugins - Version: is the truth.

uploads/
└── YYYY/
└── MM/
└── <filename>

Year/month subdirectories are auto-created on upload. Three things to check here:

  1. Directory indexing. If Apache Options +Indexes is set, you can browse the entire upload tree without authentication. Common finding.
  2. Backup plugin droppings. Many backup plugins write ZIP/SQL/TAR files into uploads/ or uploads/backups/. Database dumps with hashed passwords have leaked this way for years.
  3. Accidentally uploaded sensitive files. Editors sometimes attach a customer-data spreadsheet to a draft post; the upload is web-accessible immediately regardless of whether the post is published.

WordPress has five built-in user roles, with the role determining what the user can do post-login:

RoleCan do
AdministratorEverything: install/edit plugins and themes, edit any post, manage users, modify site settings. This is the role you want.
EditorPublish and edit any post (including others’), manage comments, manage categories/tags
AuthorPublish and edit their own posts, upload media
ContributorWrite and edit their own posts but cannot publish them
SubscriberRead content, edit their profile. The default for anyone who self-registers when users_can_register is on.

Multisite installs add a sixth role, Super Admin, which has authority across all sites in the network.

WithYou can
SubscriberProfile editing - that’s about it. Sometimes XSS in profile fields propagates.
AuthorUpload media (the file-upload attack surface in File Upload applies).
EditorEdit any post; if any post template runs PHP-style shortcodes that themes register, you may have shortcode-based code execution.
AdministratorTheme editor, plugin upload, plugin editor, install new plugin from a ZIP - multiple direct paths to RCE.

This means an Author-level compromise is sometimes enough to chain to RCE via an upload-bypass technique in the uploads cluster. Administrator is the cleanest path, but lower roles aren’t useless.

For reference when you have shell access:

Webroot: /var/www/html/ (Apache default)
/usr/share/nginx/html/ (nginx default)
/srv/www/wordpress/ (some distros)
/home/<user>/public_html/ (shared hosting)
Apache config: /etc/apache2/sites-enabled/*.conf
/etc/apache2/conf-enabled/*.conf
nginx config: /etc/nginx/sites-enabled/*
/etc/nginx/conf.d/*.conf
PHP config: /etc/php/<version>/apache2/php.ini
/etc/php/<version>/fpm/php.ini
MySQL data: /var/lib/mysql/
/var/lib/mysql/<wp_db_name>/wp_users.MYD (MyISAM table data)
WP logs: wp-content/debug.log (if WP_DEBUG_LOG)
Apache logs: /var/log/apache2/access.log, error.log
PHP logs: /var/log/php_errors.log, /var/log/apache2/error.log

Many of these are read-protected; an arbitrary file read primitive will most reliably get you wp-config.php, .htaccess, wp-content/debug.log (sometimes), and /etc/passwd if the daemon’s chroot doesn’t block it.

URL pathMaps to
/index.php
/wp-login.phpwp-login.php (login form)
/wp-admin/wp-admin/ (dashboard, post-auth)
/xmlrpc.phpxmlrpc.php (XML-RPC API)
/wp-json/wp/v2/...REST API (handled by index.php with rewrites)
/wp-content/plugins/<x>/wp-content/plugins/<x>/
/wp-content/themes/<x>/wp-content/themes/<x>/
/wp-content/uploads/YYYY/MM/...wp-content/uploads/YYYY/MM/...
/?author=1index.php, redirects to that user’s archive (user-enum primitive)
/readme.htmlreadme.html (version banner on old installs)
GoalFirst file to inspect after RCE
DB credentialswp-config.php
Auth cookie forgery keyswp-config.php (AUTH_KEY, SECURE_AUTH_KEY, etc.)
Hashed passwordsDB: wp_users.user_pass
Recent admin activityDB: wp_users.user_activation_key, wp_usermeta
Installed pluginswp-content/plugins/ directory listing
Site URL / multisite checkDB: wp_options.siteurl, wp_options.home