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
File What it does Operator interest
index.php Front controller - all dynamic page requests route through here Implicit - every URL like /?p=42 ultimately runs through this
wp-login.php Login form (and password reset flow) Primary auth surface; brute-force target
xmlrpc.php XML-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 handlers Post-auth attack surface; theme/plugin editors live here
wp-cron.php Time-based job runner triggered on requests Sometimes exploitable when scheduled tasks process untrusted data
wp-trackback.php Trackback handler (mostly defunct, occasional CVE) Rarely useful but check if present
wp-signup.php New blog signup on multisite installs If 200 OK and the form is enabled, multisite - different config surface
readme.html Version banner Discloses WP version on installs that didn’t delete it
license.txt License text Sometimes contains version info
wp-config-sample.php Template config Confirms 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”
File Why
wp-config.php The looting prize - DB host/user/pass/name + secret keys
.htaccess Rewrite rules, sometimes credentials in RewriteCond for HTTP basic
wp-admin/.htaccess Per-directory access rules - sometimes IP allowlists or HTTP basic creds
wp-content/uploads/ Recent uploads - sometimes accidentally uploaded sensitive files
wp-content/debug.log Created 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:

Role Can do
Administrator Everything: install/edit plugins and themes, edit any post, manage users, modify site settings. This is the role you want.
Editor Publish and edit any post (including others’), manage comments, manage categories/tags
Author Publish and edit their own posts, upload media
Contributor Write and edit their own posts but cannot publish them
Subscriber Read 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.

With You can
Subscriber Profile editing - that’s about it. Sometimes XSS in profile fields propagates.
Author Upload media (the file-upload attack surface in File Upload applies).
Editor Edit any post; if any post template runs PHP-style shortcodes that themes register, you may have shortcode-based code execution.
Administrator Theme 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 path Maps to
/ index.php
/wp-login.php wp-login.php (login form)
/wp-admin/ wp-admin/ (dashboard, post-auth)
/xmlrpc.php xmlrpc.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=1 index.php, redirects to that user’s archive (user-enum primitive)
/readme.html readme.html (version banner on old installs)
Goal First file to inspect after RCE
DB credentials wp-config.php
Auth cookie forgery keys wp-config.php (AUTH_KEY, SECURE_AUTH_KEY, etc.)
Hashed passwords DB: wp_users.user_pass
Recent admin activity DB: wp_users.user_activation_key, wp_usermeta
Installed plugins wp-content/plugins/ directory listing
Site URL / multisite check DB: wp_options.siteurl, wp_options.home