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.
| Function | Status in PHP 8.5 | What 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.
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.