XSS 相关的安全问题在 WEB 安全中扮演着重要角色,不容忽视。对 Typecho 类的博客程序而言,与用户的交互比较少,注意留言、搜索两块的数据回显可能会造成的 XSS 问题即可。

评论相关数据交互

对于评论交互而言,用户可以留下用户名、邮箱、url地址和评论内容。其他如ip、UserAgent等内容不会回显这里不关注(如果看到这里考虑到了SQL注入相关请参看上一篇文章)。所以相关代码只保留关键部分如下:

//检验格式
$validator = new Typecho_Validate();
$validator->addRule('author', 'required', _t('必须填写用户名'));
$validator->addRule('author', 'xssCheck', _t('请不要在用户名中使用特殊字符'));
$validator->addRule('author', array($this, 'requireUserLogin'), _t('您所使用的用户名已经被注册,请登录后再次提交'));
$validator->addRule('author', 'maxLength', _t('用户名最多包含150个字符'), 150);

if ($this->options->commentsRequireMail && !$this->user->hasLogin()) {
    $validator->addRule('mail', 'required', _t('必须填写电子邮箱地址'));
}

$validator->addRule('mail', 'email', _t('邮箱地址不合法'));
$validator->addRule('mail', 'maxLength', _t('电子邮箱最多包含150个字符'), 150);

if ($this->options->commentsRequireUrl && !$this->user->hasLogin()) {
    $validator->addRule('url', 'required', _t('必须填写个人主页'));
}
$validator->addRule('url', 'url', _t('个人主页地址格式错误'));
$validator->addRule('url', 'maxLength', _t('个人主页地址最多包含255个字符'), 255);

$validator->addRule('text', 'required', _t('必须填写评论内容'));

$comment['text'] = $this->request->text;

其中邮箱地址格式采用正则匹配非常安全,那么到这里很显然,重点关注用户名检测的 xssCheck 规则和个人地址的 url 规则即可,而评论内容没有任何检测。下面一个个看:

用户名相关数据流

这里主要关注 xssCheck 和最后展示的方式两方面。前者过程如下:

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

可见是首先针对可能的,十进制和十六进制的字符编码进行替换操作,全部替换为对应的字符,然后对替换后的字符串进行检测,如果出现了 /(\(|\)|\\\|"|<|>|[\x00-\x08]|[\x0b-\x0c]|[\x0e-\x19]|' . "\r|\n|\t" . ')/ 正则匹配中的特殊字符,则格式验证失败。同时最后的输出是直接进行输出,输出点为文本节点:

if ($this->url && $autoLink) {
    echo '<a href="' , $this->url , '"' , ($noFollow ? ' rel="external nofollow"' : NULL) , '>' , $this->author , '</a>';
} else {
    echo $this->author;
}

很显然,在这种情况下是没有办法构造用户名数据进行 XSS 攻击的。

用户 url 相关数据流

首先关注其输出点,上一段代码已经展示出来了。与用户名一样也是直接输出的,那么同样的检测格式的过程就非常重要了,否则也是存储型 XSS ,检测过程如下:

public static function url($str)
{
    $parts = @parse_url($str);
    if (!$parts) {
        return false;
    }

    return isset($parts['scheme']) &&
    in_array($parts['scheme'], array('http', 'https', 'ftp')) &&
    !preg_match('/(\(|\)|\\\|"|<|>|[\x00-\x08]|[\x0b-\x0c]|[\x0e-\x19])/', $str);
}

首先利用内置函数 parse_urldoc) 进行 “URL-components” 分解,如果格式错误分解失败则直接判定格式检测失败。同时限定 scheme 只能为 http / https / ftp 三者之一,且不能出现特殊字符,可以说是很严格了。同样的,这里依旧很安全。

评论内容相关数据流

前面已经知道,评论内容是直接非空即合格并入库的,如果处理不好会造成非常严重的问题。评论内容的输出点同样是文本节点,内容获取的关键代码如下:

/**
 * 获取当前评论内容
 *
 * @access protected
 * @return string
 */
protected function ___content()
{
    $text = $this->parentContent['hidden'] ? _t('内容被隐藏') : $this->text;

    $text = $this->pluginHandle(__CLASS__)->trigger($plugged)->content($text, $this);
    if (!$plugged) {
        $text = $this->options->commentsMarkdown ? $this->markdown($text)
            : $this->autoP($text);
    }

    $text = $this->pluginHandle(__CLASS__)->contentEx($text, $this);
    return Typecho_Common::stripTags($text, '<p><br>' . $this->options->commentsHTMLTagAllowed);
}

很明显的是,评论内容可以支持 MarkDown ,但是安全起见最后会进行 tags 过滤只允许设置中允许的标签,过滤过程如下:

/**
 * 去掉字符串中的html标签
 * 使用方法:
 * <code>
 * $input = '<a href="http://test/test.php" title="example">hello</a>';
 * $output = Typecho_Common::stripTags($input, <a href="">);
 * echo $output;
 * //display: '<a href="http://test/test.php">hello</a>'
 * </code>
 *
 * @access public
 * @param string $html 需要处理的字符串
 * @param string $allowableTags 需要忽略的html标签
 * @return string
 */
public static function stripTags($html, $allowableTags = NULL)
{
    $normalizeTags = '';
    $allowableAttributes = array();

    if (!empty($allowableTags) && preg_match_all("/\<([_a-z0-9-]+)([^>]*)\>/is", $allowableTags, $tags)) {
        $normalizeTags = '<' . implode('><', array_map('strtolower', $tags[1])) . '>';
        $attributes = array_map('trim', $tags[2]);
        foreach ($attributes as $key => $val) {
            $allowableAttributes[strtolower($tags[1][$key])] = 
                array_map('strtolower', array_keys(self::__parseAttrs($val)));
        }
    }

    self::$_allowableAttributes = $allowableAttributes;
    $html = strip_tags($html, $normalizeTags);
    $html = preg_replace_callback("/<([_a-z0-9-]+)(\s+[^>]+)?>/is",
        array('Typecho_Common', '__filterAttrs'), $html);

    return $html;
}

正如代码注释所述,会对结果的标签及其属性进行过滤,前半段根据配置构造配置数组,后半段则进行提取检测与过滤,后台配置样例如下图所示:

Typecho评论区内容后台设置样例图

提一下,其中 Typecho_Common::__parseAttrs 是通过状态转移进行的解析,不符合规矩的数据是直接忽略丢弃的不要和我一样费时间去考虑逻辑问题,因为直接丢弃了...这里不一一跟进分析,重点关注以下部分:

/**
 * __filterAttrs  
 * 
 * @param mixed $matches 
 * @static
 * @access public
 * @return bool
 */
public static function __filterAttrs($matches)
{
    if (!isset($matches[2])) {
        return $matches[0];
    }

    $str = trim($matches[2]);

    if (empty($str)) {
        return $matches[0];
    }

    $attrs = self::__parseAttrs($str);
    $parsedAttrs = array();
    $tag = strtolower($matches[1]);

    foreach ($attrs as $key => $val) {
        if (in_array($key, self::$_allowableAttributes[$tag])) {
            $parsedAttrs[] = " {$key}" . (empty($val) ? '' : "={$val}");
        }
    }

    return '<' . $tag . implode('', $parsedAttrs) . '>';
}

可见其是依次对配置所允许的标签的属性性进行检测,如果某项属性使得 in_array($key, self::$_allowableAttributes[$tag]) 成立则构成了键值对存进 $parsedAttrs 数组,最后通过 '<' . $tag . implode('', $parsedAttrs) . '>' 构造最后的过滤结果。这里可能会想到,如果 Typecho_Common::__parseAttrs() 中的状态有逻辑缺陷导致某种情况下成对的引号被解析作为了某项属性的 $val 导致最后构造的结果属性值溢出造成XSS,正如前面提到的,该函数通过状态转移进行属性解析,不符合的情况都是直接丢弃,据我的分析是不存在这种情况,感兴趣的老哥可以去分析一下。不过这里存在一个其他问题,对博主而言,在评论区配置的时候,允许链接、引用等常见的标签的情况还是很常见的,后台界面也直接提示了用户(碰巧发现这里多了个冒号,不过前几天刚好有老哥指出来删除了):

Typecho评论区后台设置提示允许标签样例图

发现的问题

由上所述,Typecho 对于其中所可能产生的问题并未作出防御处理。当然,要是用户允许 <script> 等标签就无话可说了,但是对于链接的 href 属性就很常见了,如果允许用户评论链接,则评论如: <a href="javascript:alert('xss')">让你特别想点击的内容</a> 的内容就会造成问题(后台评论管理区域也有一样的问题):

Typecho评论区允许a标签的XSS缺陷例子

当然了,这个问题并不算大问题,一来允许标签的用户估计不算多,二来需要触发机制,因为没有办法构造数据逃逸属性的成对引号,虽然这里是HTML区域可以通过字符编码来在属性值中夹杂引号,但浏览器解析的时候依旧会当作属性的一部分,如下所示:

期望输入:
https://www.baidu.com" onload="javascript:console.log('test');
编码结果:
&#104;&#116;&#116;&#112;&#115;&#58;&#47;&#47;&#119;&#119;&#119;&#46;&#98;&#97;&#105;&#100;&#117;&#46;&#99;&#111;&#109;&#34;&#32;&#111;&#110;&#108;&#111;&#97;&#100;&#61;&#34;&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#99;&#111;&#110;&#115;&#111;&#108;&#101;&#46;&#108;&#111;&#103;&#40;&#39;&#116;&#101;&#115;&#116;&#39;&#41;&#59;

运行结果:

字符编码突破引号无用功图

总的来说这不是一个大问题,但是毕竟后台设置区域有提示范例且登录后台可以直接编辑 php 文件,且我也刚好看到了允许a标签的老哥的博客,试了一下的确有问题,所以考虑之后对应修改一下提出了PR,希望这个小缺陷能早日完善。

搜索相关数据流

对于搜索数据的处理流程稍显曲折,仅就数据能否通过检测并回显这一点而言非常严格。一来,数据会在 POST 到服务端之后进行处理,得出处理结果之后构造进 URL 并跳转至该 URL ,数据会回显在 URL (相当于此时以 GET 方式获得,格式、符号支持有限);二来,数据会经过七七八八的处理、过滤、重组,这里就不一一分析了,且处理结果如果与 GET 方式获得的原结果不同则会继续跳转;三来,就默认主题而言没有任何JS流程与 URL 相关。四来,默认主题会回显具体搜索内容,其他主题不一定。这里经过分析无法形成 XSS 攻击,但是我认为整体处理流程比较奇怪,就不跟进记录了,也难怪很多老哥会提出一些相关问题:

Typecho搜索

其他相关问题

对于XSS的防御,Typecho 其实有很多相关的代码片段,以上分析的只是一部分,可以在源码中搜索 XSS 去进行针对性分析。其实之前分析的时候发现了另外一个 XSS 相关的问题,不过对安全暂时没发现有啥影响,感兴趣的老哥可以去看一下:

fix Typecho_Common::removeXSS

Typecho XSS 防御相关问题 分析总结

在程序不复杂,交互不多的情况下防范 XSS 攻击不算太困难,但是一旦构造复杂起来,还是很有挑战的。