Wordpress SQLi

There won’t be an intro, let us jump to the problem.

This is the wordpress database abstraction prepare method code:

public function prepare( $query, $args ) {
if ( is_null( $query ) )
// This is not meant to be foolproof — but it will catch obviously incorrect usage.
if ( strpos( $query, ‘%’ ) === false ) {
_doing_it_wrong( ‘wpdb::prepare’, sprintf( __( ‘The query argument of %s must have a placeholder.’ ), ‘wpdb::prepare()’ ), ‘3.9.0’ );
$args = func_get_args();
array_shift( $args );
// If args were passed as an array (as in vsprintf), move them up
if ( isset( $args[0] ) && is_array($args[0]) )
$args = $args[0];
$query = str_replace( “‘%s’”, ‘%s’, $query ); // in case someone mistakenly already singlequoted it
$query = str_replace( ‘“%s”’, ‘%s’, $query ); // doublequote unquoting
$query = preg_replace( ‘|(?<!%)%f|’ , ‘%F’, $query ); // Force floats to be locale unaware
$query = preg_replace( ‘|(?<!%)%s|’, “‘%s’”, $query ); // quote the strings, avoiding escaped strings like %%s
array_walk( $args, array( $this, ‘escape_by_ref’ ) );
return @vsprintf( $query, $args );

From the code there are 2 interesting unsafe PHP practices that could guide towards huge vulnerabilities towards wordpress system.

Before we jump to the SQLi case I’ll cover another issue. This issue is rised from following functionality:

if ( isset( $args[0] ) && is_array($args[0]) )
$args = $args[0];

This means that if you have something like this:

$wpdb->prepare($sql, $input_param1, $sanitized_param2, $sanitized_param3);

then if you control the $input_param1 e.g. is part of the $input_param1 = $_REQUEST[“input”], this means that you can add your own values for the remaining parameters. This could mean nothing in some cases, but in some cases could easy lead to RCE having on mind nature and architecture of the wp itself.

SQLi vulnerability

In order to achieve SQLi in wp framework based on this prepare method we must know how core PHP function of this method works. It is vspritf which is in fact sprintf. This means that $query is format string and $args are parameters => directives in the format string define how the args will be placed in the format string e.g. query. Very, very important feature of sprintf are swapping arguments :)

As extra there we have the following lines of code:

$query = str_replace( “‘%s’”, ‘%s’, $query ); // in case someone mistakenly already singlequoted it
$query = str_replace( ‘“%s”’, ‘%s’, $query ); // doublequote unquoting
$query = preg_replace( '|(?<!%)%f|' , '%F', $query ); // Force floats to be locale unaware
$query = preg_replace( '|(?<!%)%s|', "'%s'", $query ); // quote the strings, avoiding escaped strings like %%s

e.g. will replace any %s into '%s'.

From everything above we got following conclusion: If we are able to put into $query some string that will hold %1$%s then we can salute our SQLi => after prepare method is called then we will have an extra 'into query, because %1$%s will become %1$'%s' and after sprintf will become $arg[1]'.

For now this is just theory and most probably improper usage of the prepare method, but if we find something interesting in the wp core than nobody could blame the lousy developers who don’t follow coding standards and recomendations from the API docs.

Most interesting function is delete_metadata function and this function perform the desired actions from description above and when it is called with all of the 5 parameters set and $meta_value != “” and $delete_all = true; then we have our working POC e.g.

if ( $delete_all ) {
$value_clause = ‘’;
if ( ‘’ !== $meta_value && null !== $meta_value && false !== $meta_value ) {
$value_clause = $wpdb->prepare( “ AND meta_value = %s”, $meta_value );
$object_ids = $wpdb->get_col( $wpdb->prepare( “SELECT $type_column FROM $table WHERE meta_key = %s $value_clause”, $meta_key ) );

$value_clause will hold our input, but we need to be sure $meta_value already exists in the DB in order this SQLi vulnerable snippet is executed — remember this one.

This delete_metadata function called with desired number of parameters is called in wp_delete_attachment function and this function is called in wp-admin/upload.php where $post_id_del input is value taken directly from $_REQUEST. Let us check the wp_delete_attachment function and its constraints before we reach the desired line e.g. delete_metadata( ‘post’, null, ‘_thumbnail_id’, $post_id, true );
The only obstacle that prevents this code to be executed is the following:

if ( !$post = $wpdb->get_row( $wpdb->prepare(“SELECT * FROM $wpdb->posts WHERE ID = %d”, $post_id) ) )
return $post;

but again due the nature of sprintf and %d directive we have bypass => attachment_post_id %1$%s your sql payload.
Here I’ll stop for today (see you tomorrow with part 2: https://medium.com/websec/wordpress-sqli-poc-f1827c20bf8e), because in order authenticated user that have permission to create posts to execute successful SQLi attack need to insert the attachment_post_id %1$%s your sql payload as _thumbnail_id meta value.

Fast fix

for this use case (if you allow `author` or bigger role to your wp setup): 
At the top of the wp_delete_attachment function, right after global $wpdb; add the following line: $post_id = (int) $post_id;

Impact for the wp eco system

This unsafe method have quite huge impact towards wp eco system. There are affected plugins. Some of them already were informed and patched their issues, some of them put credits, some not. Another ones have pushed `silent` patches, but no one cares regarding safety of all. In the next writings of this topic I’ll release most common places/practices where issues like this ones occurs and will release the vulnerable core methods beside pointed one, so everyone can help this issue being solved.

Responsible disclosure

This approach is more than responsible disclosure and I’ll reffer to the paragraph for the impact and this H1 report https://hackerone.com/reports/179920


If you are wp developer or wp host provider or wp security product provider with valuable list of clients, we offer subscription list and we are exceptional (B2B only).