Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 12 / 18 40 MIN

Database and code execution

File Inclusion, Deserialisation and eval

The three ways a WordPress plugin executes code it was handed, why escaping cannot help, and how to audit your own code for the path that reaches one.

A plugin ships a print stylesheet picker. The setting is a filename, it is validated against the extension list, and it is stored in an option. Rendering the site builds a path from the option and hands it to include. A customer with the right capability uploads a file called print.txt containing PHP, points the setting at a path that reaches it, and the file runs with the privileges of the web user.

Nothing on that path is unusual, and no escaping function would have helped. The first two lessons in this module were about stopping data from becoming SQL. This one is about stopping data from becoming code. A query language has a grammar you can escape your way out of one value at a time; a file path, a serialised string and a callback name do not. By the time they reach the sink, the only question left is whether you chose them.

include reads nothing into your variables, it executes what it finds

The intuition people carry from other languages is that including a file is a form of reading. In PHP it is a form of running: the file is parsed as PHP, and any <?php block inside it executes in the current scope, with access to every variable in it. That is why a variable inside include is a code execution bug and not a disclosure bug.

Three properties combine into the exploit. The path can point outside the intended directory, so a file uploaded as text can be reached. The extension does not matter, so a check for .php on the way in is not a check on the way out. And PHP's stream wrappers mean the path does not have to be a path at all. Here is the loader from a real-shape plugin:

function m4_render_part( $name ) {
    ob_start();
    include __DIR__ . '/parts/' . $name;
    return ob_get_clean();
}

foreach ( array( 'hello.php', '../secret.txt' ) as $name ) {
    echo m4_render_part( $name );
}
TEMPLATE: hello
plain text file, not PHP: SECRET

The second line is the important one. secret.txt was a text file containing a PHP block, and it executed. The extension was never consulted, because include does not have an opinion about extensions. The ../ walked out of the parts directory, which basename() would have prevented and an extension allowlist would not.

Wrappers add a second dimension. PHP will include from data://, php://input and remote URLs when allow_url_include is on, which turns a path-shaped request value into arbitrary code with no file on disk. That directive is off by default and deprecated as of PHP 8.5, which is exactly the kind of thing not to rely on: defaults differ between hosts, and the fix belongs in your code.

If a template name must be dynamic, resolve it and check the result

An allowlist of names is the strongest answer: map the request value to a file you ship and refuse anything not in the map. When the set of templates is genuinely open, resolve the path with realpath() and confirm the result is inside the directory you meant to serve. In the loader above, realpath() on hello.php returns a path under the parts directory and realpath() on ../secret.txt returns one outside it, so containment catches the traversal. Compare resolved paths, not strings: a search for ../ in the request misses symlinks, encoded traversal and wrapper prefixes.

unserialize turns data into method calls, and the calls are the vulnerability

unserialize() looks like the inverse of serialize() and the symmetric naming hides the asymmetry. Serialising is a pure read: it walks a value and writes text. Unserialising constructs objects, and constructing an object is not a passive act in PHP. The engine calls magic methods along the way, and the properties those methods read are properties the attacker chose. The class of bug is called PHP object injection, and a demonstration needs a class with a magic method, because the magic method is the part that runs without anybody calling it:

class M4_Cleanup {
    public $target = '';
    public function __destruct() {
        printf( "[destructor] target was '%s'\n", $this->target );
    }
}

$payload = 'O:10:"M4_Cleanup":1:{s:6:"target";s:13:"wp-config.php";}';

echo "A: is_serialized()     -> "; var_dump( is_serialized( $payload ) );
echo "B: maybe_unserialize() -> "; var_dump( maybe_unserialize( $payload ) );
echo "C: allowed_classes=false -> ", get_class( unserialize( $payload, array( 'allowed_classes' => false ) ) ), "\n";
echo "D: json_decode()       -> ", get_debug_type( json_decode( '{"target":"wp-config.php"}', true ) ), "\n";
A: is_serialized()     -> bool(true)
B: maybe_unserialize() -> object(M4_Cleanup)#1069 (1) {
  ["target"]=>
  string(13) "wp-config.php"
}
   [destructor] target was 'wp-config.php'
C: allowed_classes=false -> __PHP_Incomplete_Class
D: json_decode()       -> array

Line B is the whole bug in four words. The attacker's string became an object of a class in your codebase, with the attacker's property value, and the destructor ran at a moment nobody scheduled. The proof of concept prints a line; the same mechanism with a different class calls unlink(), writes an option, or makes a request.

Any class loaded in the process, or autoloadable, is a candidate gadget, and you cannot enumerate them from your own plugin directory. A class that calls a method on one of its own properties from __destruct() hands the attacker a further step, because that property is also attacker-chosen. No check added to one class fixes object injection. Not unserialising untrusted input does.

allowed_classes is the surgical fix, and JSON is the structural one

PHP gives unserialize() a second parameter for this. Passing array( 'allowed_classes' => false ) makes the function construct no objects at all: any object in the payload becomes an instance of __PHP_Incomplete_Class and its magic methods never run. Passing a list of class names allows exactly those and blocks the rest. Line C above is the first form, and it is right when the data legitimately contains objects and you know which classes are involved.

For data that arrived from a request, change the format instead. json_decode() does not construct objects from the input, cannot call a method, and has no equivalent of a gadget chain. Line D returns an array. If you control both ends of the format, JSON removes the class of bug rather than mitigating it.

Where that is not possible, check that the data is yours before decoding it. WordPress does this in render_block_core_legacy_widget(): the block carries a base64 payload and a hash, and the hash is verified with hash_equals( wp_hash( $serialized_instance ), $attributes['instance']['hash'] ) before unserialize() is called. An attacker who cannot produce a valid hash cannot reach the decode. The two properties compose: integrity says the data came from you, and the class restriction says what may be built even if it did.

PHP 8 removed the two functions that used to make this easy

Two of the classic code execution sinks in PHP no longer exist, and knowing which is which saves time on both sides of a review.

FunctionStatus in PHP 8.5What the behaviour is now
create_function() Removed. Deprecated in 7.2, gone in 8.0. function_exists( 'create_function' ) returns false. Rewrite call sites as closures or named functions.
eval() A language construct, not a function. It still executes. It cannot be called through a variable function, so $f = 'eval'; $f( $code ); raises "Call to undefined function eval()".
assert() with a string No longer evaluates its argument as code. Assertions compile out when zend.assertions is -1, the production default, so a string assertion is unreliable in both directions.
preg_replace() with /e The modifier was removed in PHP 7.0. Use preg_replace_callback(). A pattern carrying /e no longer compiles.

The practical consequence is that eval() is the only direct string-to-code function left, and the reason to care is not that plugins use it. It is that eval() is what an attacker reaches for after getting a value into a string, so a review should treat the presence of the string as the finding.

WordPress hands you three more sinks with the same shape

The language-level sinks have equivalents in the WordPress API, and these are the ones that show up in plugin code because the API encourages them.

Unserialising user meta. maybe_unserialize() in wp-includes/functions.php calls @unserialize( trim( $data ) ) with no allowed_classes argument. You do not have to call it yourself, either: get_option() passes stored values through maybe_unserialize(), and get_metadata_raw() does the same for post, user, term and comment meta. Any path that stores a request value into an option or into meta, and reads it back, has an unserialise step in the middle that you did not write.

The write half hides it further. update_user_meta() runs the value through maybe_serialize(), which wraps a string that is not already serialised and leaves a serialised string alone:

$payload = 'O:10:"M4_Cleanup":1:{s:6:"target";s:13:"wp-config.php";}';
update_user_meta( $user_id, 'm4_import_payload', $payload );
stored raw: s:56:"O:10:"M4_Cleanup":1:{s:6:"target";s:13:"wp-config.php";}";
read back type: string

The naive reading is that the plugin is safe, because the read returned a string. The row holds a serialised string whose content is a serialised object, and the next read of that key runs maybe_unserialize() again. The finding is the read path in core combined with a write path that accepted a request value.

Callbacks from input. call_user_func(), call_user_func_array(), add_action() and add_filter() all take a callable. If a request value ends up in that position, the request decides what runs. The route through a shortcode is short: shortcode_atts() merges the attributes with the defaults and performs no sanitisation, so a callback name in an attribute arrives intact at the call.

$atts = shortcode_atts(
    array( 'format' => 'esc_html', 'value' => '' ),
    array( 'format' => 'system', 'value' => 'echo INJECTED' )
);
// {"format":"system","value":"echo INJECTED"}
call_user_func( $atts['format'], $atts['value'] );
INJECTED

A callback name is not a string, it is a choice from a set, so an allowlist is the correct representation of the value. Core applies that representation in the REST API. A route argument declared as 'type' => 'string' accepts any string, so an argument that names a callback and carries only a type constraint is unvalidated input with a schema in front of it. Adding 'enum' makes core reject anything outside the list before your callback runs:

loose schema:  rest_validate_value_from_schema() -> true
               callback result -> <I>X</I>
tight schema:  rest_validate_value_from_schema() -> rest_not_in_enum
               message: formatter is not one of esc_html and esc_attr.

Hook names built from input. An action or filter name assembled from a request value is a related bug with a smaller blast radius: the attacker chooses where in the flow their callback runs, which is enough to skip a check or to run before one, even when the callback itself is yours. Register the hook on a fixed name and pass the variable part as an argument:

// Not this: the registration name is attacker-chosen.
add_action( "m4_saved_{$_GET['context']}", 'm4_after_save' );

// This: one hook, and the context is data.
add_action( 'm4_saved', 'm4_after_save', 10, 2 );
function m4_after_save( $object_id, $context ) {
    // $context has already been checked against an allowlist by the caller.
}

Auditing for code execution means walking backwards from the sink

Grepping for eval finds nothing in most plugins, which is why this class of bug survives review. Work in the other direction and start from the sinks, because they are a small list you can enumerate. For each one, ask a single question: can a value reach this position that I did not construct? If not, stop reasoning about it. If yes, or if you cannot tell, you have a finding, and the rest of the work is deciding which of the three fixes applies. The sinks worth a dedicated pass are include, include_once, require and require_once; unserialize() and every call to maybe_unserialize(), get_option() and the meta getters that can decode a stored value; eval(); and the callable positions in call_user_func(), call_user_func_array(), add_action(), add_filter(), usort(), array_map() and register_rest_route().

Then trace each sink back to its origin: follow the variable through assignments, function arguments and array keys until it reaches a request superglobal, a database read, a file, a remote response, or a literal you wrote. Anything that touches the first four is attacker-influenced. The trace is where review time should go, because the sink list is the same in every plugin and the path is not.

Two habits make it reliable. Read the function that produced the value rather than trusting its name, since a function called get_template() may return a sanitised slug or a raw parameter. And when the trace reaches a WordPress API, check whether it sanitises or only passes through: shortcode_atts() merges, sanitize_text_field() transforms, esc_sql() escapes for one context, and none of them validate a set of permitted values.

Lab 35 minutes

Below is a settings screen handler from a plugin that imports orders from a spreadsheet. Read it and produce three things: the list of code execution sinks in it, the input that reaches each one, and a fixed version.

add_action( 'admin_post_m4_import', 'm4_handle_import' );

function m4_handle_import() {
    $user_id  = get_current_user_id();
    $template = sanitize_text_field( $_POST['template'] );
    $payload  = wp_unslash( $_POST['payload'] );
    $callback = $_POST['on_complete'];

    update_user_meta( $user_id, 'm4_import_template', $template );
    update_user_meta( $user_id, 'm4_import_payload', $payload );

    $rows = maybe_unserialize( get_user_meta( $user_id, 'm4_import_payload', true ) );

    include plugin_dir_path( __FILE__ ) . 'templates/' . get_user_meta( $user_id, 'm4_import_template', true ) . '.php';

    call_user_func( $callback, $rows );
}

For each sink, say whether the value reaching it is attacker-chosen and why. Then rewrite the handler so that no request value reaches a sink, and state which of the three fixes you used for each one and why the alternatives were worse.

Show the solution and the reasoning

There are three sinks and all three are reachable. The first is the include. The template name passes through sanitize_text_field(), which strips tags and whitespace and does nothing about traversal or about which names are permitted, so ../../../../wp-content/uploads/2026/04/print.txt survives to the concatenation. The value is read back from meta with no further check, the appended extension is irrelevant to include, and the file is uploaded content in a writable directory.

The second is maybe_unserialize(). The payload arrives as a string, update_user_meta() stores it as a serialised string, and the handler decodes it, which is where an object in the payload becomes an object in memory. The next read of that key would decode it anyway, through the meta getter. Sanitising would not help: the value is well formed and becomes dangerous when it is decoded, not when it is stored.

The third is call_user_func() with $_POST['on_complete'] untouched, a direct call to whatever the request names.

A fixed handler changes the format and the shapes rather than adding checks:

add_action( 'admin_post_m4_import', 'm4_handle_import' );

function m4_handle_import() {
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( 'Not allowed.', 403 );
    }
    check_admin_referer( 'm4_import' );

    $user_id = get_current_user_id();

    // Fix 1: the template is a key in a map we wrote, not a path fragment.
    $templates = array(
        'orders' => 'orders.php',
        'refunds' => 'refunds.php',
    );
    $key = sanitize_key( $_POST['template'] ?? '' );
    if ( ! isset( $templates[ $key ] ) ) {
        wp_die( 'Unknown template.', 400 );
    }

    // Fix 2: the payload is JSON, which constructs no objects.
    $rows = json_decode( wp_unslash( $_POST['payload'] ), true );
    if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $rows ) ) {
        wp_die( 'Malformed payload.', 400 );
    }
    update_user_meta( $user_id, 'm4_import_rows', wp_json_encode( $rows ) );

    // Fix 3: the callback is chosen from a list, not named by the request.
    $handlers = array(
        'notify' => 'm4_notify_admins',
        'silent' => null,
    );
    $on_complete = sanitize_key( $_POST['on_complete'] ?? 'silent' );
    $callback    = $handlers[ $on_complete ] ?? null;

    $path = plugin_dir_path( __FILE__ ) . 'templates/' . $templates[ $key ];
    include $path;

    if ( is_callable( $callback ) ) {
        call_user_func( $callback, $rows );
    }
}

Three decisions in that code are worth defending. The template is a key in an array whose values are filenames this plugin ships, so the attacker chooses between two known files rather than contributing to a path. That is the previous lesson's column allowlist applied to a filesystem path, and it is stronger than realpath() containment because a new file cannot join the set without a code change.

The payload changed format rather than gaining a check. JSON cannot express an object, so there is no gadget chain to reach, and the json_last_error() test rejects a malformed body instead of letting a partial decode become an empty array the rest of the handler trusts. Calling unserialize( $payload, array( 'allowed_classes' => false ) ) would also work, and would be right if the data had to stay in PHP's serialised form for an existing export.

The callback uses a map from a request value to a callable the plugin owns. Note is_callable() as the last check rather than the first: it answers "can this be called", and system is callable, so it is a safety net for a refactor mistake rather than the control.

The capability check and nonce are not part of the three sinks, but a handler a subscriber can reach turns a lower-severity bug into a higher-severity one.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.