Typecho编码安全及Hook机制浅析
in Code-Audit with 0 comment

Typecho编码安全及Hook机制浅析

in Code-Audit with 0 comment

Typecho简介

Typecho是一个轻量级的博客系统,从编码的角度来看,与其说Typecho是一个博客程序,不如说它更像一个框架。Typecho摈弃了传统的MVC架构,采用自创的Widget为基本,以组件的形式构建整个应用。正是由于Typecho优秀的编码和架构,学习Typecho的编程思想是很有帮助的。本博客也是采用Typecho搭建。

Typecho基本特征

1,单一入口
2,自封装ORM,及数据库适配
3,路由分发,URL重写
4,自创Widget,所有功能基于Widget
5,Action层,代替传统Cotroller
6,Hook机制插件,模版支持

typecho运行流程浅析

自动载入

首先从install.php分析:
判断config.inc.php是否存在,不存在即进入install程序,安装成功后会生成配置文件config.inc.php.在install.php中定义一系列常量后,将常用的路径加入PHP包含路径:

/** 设置包含路径 */
@set_include_path(get_include_path() . PATH_SEPARATOR .
    __TYPECHO_ROOT_DIR__ . '/var' . PATH_SEPARATOR .
    __TYPECHO_ROOT_DIR__ . __TYPECHO_PLUGIN_DIR__);

载入常用Common函数后运行初始化程序Typecho_Common::init()
在出事化中做了什么呢?

/**
     * 程序初始化方法
     *
     * @access public
     * @return void
     */
    public static function init()
    {
        /** 设置自动载入函数 */
        spl_autoload_register(array('Typecho_Common', '__autoLoad'));

        /** 兼容php6 */
        if (function_exists('get_magic_quotes_gpc') && get_magic_quotes_gpc()) {
            $_GET = self::stripslashesDeep($_GET);
            $_POST = self::stripslashesDeep($_POST);
            $_COOKIE = self::stripslashesDeep($_COOKIE);

            reset($_GET);
            reset($_POST);
            reset($_COOKIE);
        }

        /** 设置异常截获函数 */
        set_exception_handler(array('Typecho_Common', 'exceptionHandle'));
    

设置自动载入函数为__autoLoad,对输入参数进行深度转义,设置异常解惑函数。
然后看看__autoLoad函数

public static function __autoLoad($className)
    {
        @include_once str_replace(array('\\', '_'), '/', $className) . '.php';
    }

将路径分割符切换为_这样以来,就能对Typecho中的类进行自动载入。而不需要在调用某个类的时候去require了。
Typecho将重要的类保存在var/Typecho,var/Widget等目录下。例如在index.php中调用Widget类下的Widget函数

/** 初始化组件 */
Typecho_Widget::widget('Widget_Init')

程序将自动引入var/typecho目录下的Widget类文件。以_连接,程序将自动引入。
之后install安装程序适配数据库,根据用户输入生成配置文件并初始化数据库。安装程序做了两件事,生成config.inc.php,载入数据库驱动并初始化数据库。

入口文件

install.php生成config.inc.php后,初始化操作便交由config文件完成。然后在入口文件调用Typecho_widget时自动载入相关类。
Widget类是整个系统的核心类(抽象类),他抽象出其他功能。Typecho_Widget::widget方法,widget方法其实是一个工厂方法,他接受一个类名,初始化输入输出对象,一并传入目标类名,执行了目标类的构造方法和execute方法,并将实例化的类保存在self::$_widgetPool数组中。
以index.php文件中为例,工厂方法初始化了var/widget/Init.php中的Widget_Init类,并且该工厂方法使用单例模式初始化了request对象和response对象。初始化Widget_Init类之后执行了其execute方法,该方法调用$this->widget('Widget_Options')即它又初始化Widget_Options类,Widget_Options类从数据库中读出了user = 0的所有系统配置,并将Widget_Options保存在局部变量$options里,进行了一系列的初始化工作。并在Init.php中初始化路由和缓存。

Typecho_Router::setPathInfo($pathInfo);
/** 初始化路由器 */
Typecho_Router::setRoutes($options->routingTable);
/** 初始化插件 */
Typecho_Plugin::init($options->plugins);
/** 初始化回执 */
$this->response->setCharset($options->charset);
$this->response->setContentType($options->contentType);
/** 初始化时区 */
Typecho_Date::setTimezoneOffset($options->timezone);
/** 开始会话, 减小负载只针对后台打开session支持 */
if ($this->widget('Widget_User')->hasLogin()) {
      @session_start();
}
/** 监听缓冲区 */
ob_start();

此时系统初始化完成。

注册插件及其初始化

目前大部分插件都采用的是钩子(Hook)机制,typecho也不例外,在index.php中Typecho_Plugin::factory('index.php')->begin()就是通知挂载到index.php这个事件的插件可以执行。Typecho_Plugin::factory('index.php')返回了Typecho_Plugin的实例,构造函数中确定了唯一的句柄,即'index.php',紧接着执行了该实例的begin()方法,由于该方法不存在,所以调用了魔术方法__call,最后由__call方法执行所有在这个插件点挂载的插件。详细的Hook机制在后续的目录中详解。

路由分发

Typecho的路由实现是将路由表序列化之后保存在数据库中,使用正则进行路径匹配。而路由表在程序安装初始化的时候就序列化并存储在数据库中。路由器类使用子数组中的正则逐个匹配pathinfo中的路径,如果匹配成功,立即初始化并执行该类对应的action。以根路径为例,如果使用正则表达式匹配成功,系统会新建Widget_Abstract类并执行其render方法,这个方法渲染页面显示首页信息。

Typecho安全编码

SQL注入过滤

Typecho自建了一套ORM数据库访问操作层,并针对不同数据库编写了不同数据库的适配器。那么它在执行SQL查询的时候是如何防止SQL注入的问题?
由于在MySQL的PDO驱动下支持了预编译模式从而有效防止SQL注入。这里查看普通MySQL驱动,即php内置函数mysql_query是如何封装和防御的。
在自封装mysql连接查询驱动中自定义了两个转义函数:

 /**
     * 引号转义函数
     *
     * @param string $string 需要转义的字符串
     * @return string
     */
    public function quoteValue($string)
    {
        return '\'' . str_replace(array('\'', '\\'), array('\'\'', '\\\\'), $string) . '\'';
    }

    /**
     * 对象引号过滤
     *
     * @access public
     * @param string $string
     * @return string
     */
    public function quoteColumn($string)
    {
        return '`' . $string . '`';
    }

而在上层中Typecho_Db_Query类将需要查询的语句预组装过滤,几乎一切的过滤操作在此类中进行。

/**
     * 过滤数组键值
     *
     * @access private
     * @param string $str 待处理字段值
     * @return string
     */
    private function filterColumn($str)
    {
        $str = $str . ' 0';
        $length = strlen($str);
        $lastIsAlnum = false;
        $result = '';
        $word = '';
        $split = '';
        $quotes = 0;

        for ($i = 0; $i < $length; $i ++) {
            $cha = $str[$i];

            if (ctype_alnum($cha) || false !== strpos('_*', $cha)) {
                if (!$lastIsAlnum) {
                    if ($quotes > 0 && !ctype_digit($word) && '.' != $split
                    && false === strpos(self::KEYWORDS, strtoupper($word))) {
                        $word = $this->_adapter->quoteColumn($word);
                    } else if ('.' == $split && 'table' == $word) {
                        $word = $this->_prefix;
                        $split = '';
                    }

                    $result .= $word . $split;
                    $word = '';
                    $quotes = 0;
                }

                $word .= $cha;
                $lastIsAlnum = true;
            } else {

                if ($lastIsAlnum) {

                    if (0 == $quotes) {
                        if (false !== strpos(' ,)=<>.+-*/', $cha)) {
                            $quotes = 1;
                        } else if ('(' == $cha) {
                            $quotes = -1;
                        }
                    }

                    $split = '';
                }

                $split .= $cha;
                $lastIsAlnum = false;
            }

        }

        return $result;
    }

之后将过滤后的语句送给相应的adapter执行,从而阻止sql注入的产生。

XSS过滤

那么Typecho如何防御XSS漏洞呢?
对于反射型的XSS漏洞,Typecho在初始化路由之后,执行路由匹配时执行了一系列检查,其中就包括XSS的检测.。

/**
     * 对xss字符串的检测
     *
     * @access public
     * @param string $str
     * @return boolean
     */
    public static function xssCheck($str)
    {
        $search = 'abcdefghijklmnopqrstuvwxyz';
        $search .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
        $search .= '1234567890!@#$%^&*()';
        $search .= '~`";:?+/={}[]-_|\'\\';

        for ($i = 0; $i < strlen($search); $i++) {
            // ;? matches the ;, which is optional
            // 0{0,7} matches any padded zeros, which are optional and go up to 8 chars

            // &#x0040 @ search for the hex values
            $str = preg_replace('/(&#[xX]0{0,8}'.dechex(ord($search[$i])).';?)/i', $search[$i], $str); // with a ;
            // &#00064 @ 0{0,7} matches '0' zero to seven times
            $str = preg_replace('/(&#0{0,8}'.ord($search[$i]).';?)/', $search[$i], $str); // with a ;
        }

        return !preg_match('/(\(|\)|\\\|"|<|>|[\x00-\x08]|[\x0b-\x0c]|[\x0e-\x19]|' . "\r|\n|\t" . ')/', $str);
    }

另外Typecho的所有访问及输出操作都由RequestResponse对象产生。在进行Request接受参数时候执行了一系列的过滤操作。其中就包含removeXSS,safeUrl等。相当于在全局输入进行了过滤操作。

    /**
     * 支持的过滤器列表
     *
     * @access private
     * @var string
     */
    private static $_supportFilters = array(
        'int'       =>  'intval',
        'integer'   =>  'intval',
        'search'    =>  array('Typecho_Common', 'filterSearchQuery'),
        'xss'       =>  array('Typecho_Common', 'removeXSS'),
        'url'       =>  array('Typecho_Common', 'safeUrl'),
        'slug'      =>  array('Typecho_Common', 'slugName')
    );

那么Typecho是如何防止存储型XSS漏洞呢?
前面分析过,由于Request对象是全局的,在获取输入时就已经进行了初试过滤,而在进行评论时又进行了一次通用过滤,不过这次过滤更倾向于业务逻辑上的过滤,如垃圾评论等等。

   /**
     * 通用过滤器
     *
     * @access public
     * @param array $value 需要过滤的行数据
     * @return array
     */
    public function filter(array $value)
    {
        $value['date'] = new Typecho_Date($value['created']);
        $value = $this->pluginHandle(__CLASS__)->filter($value, $this);
        return $value;
    }

CSRF防御

Typecho进行CSRF防御采用了authcode随机字符串的方式防止CSRF。另外在官方发布中也获取到

可以看到在低版本中几乎没有CSRF漏洞的踪迹。在cookies也同样看到了加密后的authcode。

在typecho登录后便从数据库获取authCode并设为cookies,在后台操作时以验证session和authCode作为凭据。

 if ($user && $hashValidate) {

            if (!$temporarily) {
                $authCode = function_exists('openssl_random_pseudo_bytes') ?
                    bin2hex(openssl_random_pseudo_bytes(16)) : sha1(Typecho_Common::randString(20));
                $user['authCode'] = $authCode;

                Typecho_Cookie::set('__typecho_uid', $user['uid'], $expire);
                Typecho_Cookie::set('__typecho_authCode', Typecho_Common::hash($authCode), $expire);

                //更新最后登录时间以及验证码
                $this->db->query($this->db
                ->update('table.users')
                ->expression('logged', 'activated')
                ->rows(array('authCode' => $authCode))
                ->where('uid = ?', $user['uid']));
            }

权限管理

在Typecho中分别设置了5个等级的权限供多个用户共用一个博客。他们的权限从高到低分别是:

管理员(administrator),
编辑(editor),
贡献者(contributor),
关注者(subscriber),
访问者(visitor).

其权限设置与wordpress有一些相同之处,但是扩展方法不尽相同。对用户的权限操作以及用户信息获取,都封装在了Widget_User组件中。

登录安全

Typecho并没有使用验证码之类的方式去组织穷举或暴力破解,而采用的是登录时延时3秒的方式去防止穷举。那么这种方式真的安全吗?我们来测试一下。

 /** 比对密码 */
        if (!$valid) {
            /** 防止穷举,休眠3秒 */
            sleep(3);

            $this->pluginHandle()->loginFail($this->user, $this->request->name,
            $this->request->password, 1 == $this->request->remember);

            Typecho_Cookie::set('__typecho_remember_name', $this->request->name);
            $this->widget('Widget_Notice')->set(_t('用户名或密码无效'), 'error');
            $this->response->goBack('?referer=' . urlencode($this->request->referer));
        }

QQ20180503-183230@2x.png
当线程足够大的时候,每一个线程阻塞3秒后,再进行其他穷举。因此这种方式并不是安全的。只是针对低线程下防止穷举进行了处理。

Typecho反序列化漏洞

这个漏洞被更多的人说为一个预置后门,分析代码也很有后门的味道。这里不管是怎么样的,只关心它的原理。
install.php中,263行出现

<?php
                                    $config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
                                    $type = explode('_', $config['adapter']);
                                    $type = array_pop($type);
                                    $type = $type == 'Mysqli' ? 'Mysql' : $type;
                                    $installDb = $db;

获取cookies中base64编码后的序列化对象,之后进行反序列化。
不过要进行这项操作必须满足:

//判断是否已经安装
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['host'] = "{$parts['host']}:{$parts['port']}";
    }

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

需要满足referer是本站,且finish参数不为空。
前面的博客中对反序列化漏洞进行了分析。php在反序列化时会执行相应的魔术方法,而最常用的就是__destruct()是在对象被销毁的时候自动调用,__Wakeup在反序列化的时候自动调用,__toString()是在调用对象的时候自动调用。在全局搜索__weakup__destruct之类的发现不可利用。在搜索__toString魔术方法后在typecho_feedrequest类中会找到。如何利用呢?
FEF4E730-8565-484A-8B67-DB05711020F7.png

如果在反序列化时将$config作为一个存有对象的数组,由于在这里对象被当作字符串,将数组中adapter作为一个类,从而触发了这个类的__toString方法。
而在Feed.php中的__toString方法中:

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

通过循环获取类私有属性_items数组中的值获取$item['auther']->screenName的值.这里就可以将$item['auther']构造为一个对象。当一个对象访问不可访问的属性时候会调用一个魔术方法__get,哪里有魔术方法呢?再进行全局搜索一下。
在/var/Typecho/Request.php发现一个可利用的__get魔术方法。

/**
     * 获取实际传递参数(magic)
     *
     * @access public
     * @param string $key 指定参数
     * @return mixed
     */
    public function __get($key)
    {
        return $this->get($key);
    }

跟进get方法

/**
     * 获取实际传递参数
     *
     * @access public
     * @param string $key 指定参数
     * @param mixed $default 默认参数 (default: NULL)
     * @return mixed
     */
    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);
    }

跟进_applyFilter:

/**
     * 应用过滤器
     *
     * @access private
     * @param mixed $value
     * @return mixed
     */
    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;
    }

在这里发现了array_map和call_user_func.我们就利用这个函数进行代码执行。
在这里梳理一下逻辑:
反序列化时构造为数组,数组中是包含_toString方法的Feed对象。在_toString方法中item['author']->screeName将其构造为一个私有属性,这样当调用时就会自动执行_get魔术方法。在魔术方法中调用链调用了call_user_func函数。而其参数在我们构造时是可控的。如此就可以进行任意代码执行。
关于exp的构造和ob_start()缓冲网上的文章已经很详细,这里不再重复。

Typecho SSRF漏洞

Typecho 的SSRF漏洞是出于对PingBack协议的支持造成的。在var/Widget/XmlRpc.php中:

/**
     * pingbackPing
     *
     * @param string $source
     * @param string $target
     * @access public
     * @return void
     */
    public function pingbackPing($source, $target)

此函数实例化Typecho_Http_Client对象并调用其get方法:

public static function get()
{
   $adapters = func_get_args();
   if (empty($adapters)) {
       $adapters = array();
       $adapterFiles = glob(dirname(__FILE__) . '/Client/Adapter/*.php');
       foreach ($adapterFiles as $file) {
           $adapters[] = substr(basename($file), 0, -4);
       }
   }
   foreach ($adapters as $adapter) {
       $adapterName = 'Typecho_Http_Client_Adapter_' . $adapter;
       if (Typecho_Common::isAvailableClass($adapterName) && call_user_func(array($adapterName, 'isAvailable'))) {
           return new $adapterName();
       }
   }
   return false;
}

get方法根据不同适配器选择client发送方式,在XmlRpc.php调用client(curl或socket)发送http请求:

curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_PORT, $this->port);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

Hook机制浅析

index.php中注册一个初始化插件,等待其他插件挂载到此插件点上。当激活一个插件时,由于navBar属性不存在,会执行set方法,然后会将插件信息序列化之后存储在数据库option的plugin下,从而成功激活。
Typecho_Plugin::factory('index.php')返回了Typecho_Plugin的实例,构造函数中确定了唯一的句柄,即'index.php',紧接着执行了该实例的begin()方法,由于该方法不存在,所以调用了魔术方法__call,最后由__call方法执行所有在这个插件点挂载的插件。
_call方法

 public function __call($component, $args)
    {
        $component = $this->_handle . ':' . $component;
        $last = count($args);
        $args[$last] = $last > 0 ? $args[0] : false;
    
        if (isset(self::$_plugins['handles'][$component])) {
            $args[$last] = NULL;
            $this->_signal = true;
            foreach (self::$_plugins['handles'][$component] as $callback) {
                $args[$last] = call_user_func_array($callback, $args);
            }
        }
    
        return $args[$last];
    }

__set方法

 public function __set($component, $value)
    {
        $weight = 0;

        if (strpos($component, '_') > 0) {
            $parts = explode('_', $component, 2);
            list($component, $weight) = $parts;
            $weight = intval($weight) - 10;
        }
        
        $component = $this->_handle . ':' . $component;

        if (!isset(self::$_plugins['handles'][$component])) {
            self::$_plugins['handles'][$component] = array();
        }

        if (!isset(self::$_tmp['handles'][$component])) {
            self::$_tmp['handles'][$component] = array();
        }

        foreach (self::$_plugins['handles'][$component] as $key => $val) {
            $key = floatval($key);

            if ($weight > $key) {
                break;
            } else if ($weight == $key) {
                $weight += 0.001;
            }
        }

参考文章

https://blog.phpgao.com/typecho_source_code_plugin.html
https://segmentfault.com/a/1190000000454392
https://segmentfault.com/a/1190000000449033
https://blog.phpgao.com/typecho_source_code_init.html
https://paper.seebug.org/424/
https://blog.phpgao.com/typecho_source_code_dispatch.html
https://blog.phpgao.com/typecho_source_code_business_logic.html

Responses