Typecho 内置的反垃圾评论功能,尚能饭否?

老早之前写文介绍过站点的垃圾评论情况,当时提到的一些防护措施里,除了利用插件实现关键词拦截外,还提到了一个『Typecho的反垃圾评论系统』,表现为后台设置里的一个小开关。

这个开关最早于何时引入,已经不太可考(除非去翻GitHub上的提交记录),但这并不妨碍笔者在吃饱了没事干的情况下分析一下其工作原理(至少是最显眼之处的工作原理),从而为后续站点维护提供更多信息。比如,这玩意有不小的概率会和缓存系统产生冲突。

就是这个小开关就是这个小开关

1. 前言

关于垃圾评论,笔者之前也讨论过,用现代的话说就是『蹭流量蹭热度的』,在有一定知名度的网站的评论区,发布灌水内容,或者是指向外部网站的链接,以实现污染站点或引流的目的。

自从『评论区』存在的第一天起,垃圾评论在互联网上可以说是完全没有消失过,毕竟无利不起早,每一条垃圾评论背后,发送者都是能得到一定的利益的。最明显的例子:在撰写本文的时候,站点上还收到了新鲜的垃圾评论:

这条评论很有趣,第四大节还会继续分析这条评论很有趣,第四大节还会继续分析

因此,常用的CMS系统,都针对这个情况有所准备,最常见的是评论区相关的Hook,用户可以编写垃圾评论防护插件,也可以到插件市场等地方下载安装第三方插件,比如著名的Akismet,就是供WordPress使用的插件。当然如果你用的是静态博客,那么所使用的第三方评论系统(例如Disqus)也有不少是支持垃圾评论过滤的。

info:关于Akismet

很有趣的一点是,Akismet的API文档是对外开放的,所以理论上你完全可以按照文档,去写一个适配Typecho的同样功能的插件。甚至根据资料来看,Typecho官方也曾经宣传过类似产物,但可能因年久失修,有说法称其已失效,而且下载地址也不太好找了,所以建议从头写,或找其他人开发的版本。

不过,本站并未使用Akismet,因为他本质上算是一个付费产品。虽然不是不能理解付费的目的(毕竟判断垃圾评论,是要靠他的云服务里的算法的),但毕竟笔者目前还没有稳定收入,实在是不太想再额外增加一笔年支出(一年6刀)到一个并不算太迫切,且通过简单方法可以挡住绝大部分垃圾评论的领域。虽然从技术上来说,官方确实对非商业用途的站点提供免费选项(在定价页面把滑动条拉到最左边即可),然而,此时具体还得细分为『不能有广告,不出售服务,不推广任何企业』,也就是完全不能有途径盈利。本站显然不满足此需求(右侧有AFF区),硬要上的话恐怕引来合规风险,因此只能放弃。

最后多说一句,Akismet这个定价页面给人的感觉就不太舒服:滑动条右侧有一个小黄脸,跟Windows扫雷那个小黄脸一样欠抽,他会根据你选择的不同价格而露出不同表情:钱越多,笑脸笑得越开心;但反过来,如果你选择最低付费档位,这个小黄脸就完全是一副扑克脸;选择免费套餐的话,小黄脸上最后的一点黄色也没了,惨白惨白的。拜托,大哥,我tm在准备给你打钱欸,你能不能给我点正反馈......

我们今天的主角,就与上面的插件略有不同。正如引言中的图片所示,这是内置在Typecho核心的反垃圾评论功能。应该说,笔者看到他的第一眼,就留下了挥之不去的印象:因为和别的反垃圾评论系统不同,他不仅是内置的,而且显得太简洁了,简洁到只有一个开关,开就是开,关就是关,没有策略配置,没有外部API配置,打开后也没观察到博客页面有什么新东西。虽然有个词语叫『简洁美』,但简洁到如此程度的时候,很难让人不对其中原理和效果产生怀疑。

正好,最近正在对博客进行缓存改造,一堆和缓存有关的小实验正在进行中,因此也就顺便来看一下,这个内置反垃圾评论到底是何方神圣。下面会先用两个大节分析前端和后端代码,如果您对结论(包括『在今天是否还适用?』)比较感兴趣,可以直接跳转到第四大节。

2. 像乱码的js

其实上面说的『没观察到博客页面有什么新东西』并不准确,应该说,确实有添加了新东西,只是一般情况下看不到罢了。随便打开一篇博文,查看网页源代码,可以发现在<head>块内,多出来了一段看起来像是乱码的js:

注意看下半部分注意看下半部分

很显然,这就是反垃圾评论的核心,接下来我们就来先分析一下他。不过,这段代码因为经过混淆,显得太长了,直接抄过来的话,页面滚动起来很麻烦,因此下面就不放完整的源代码了,只是给一些关键节点,并且会改写为尽量减少需要页面滚动的形式,因此会与源代码有所出入。如果有需要,您可以直接通过浏览器查看页面源代码。

进入反垃圾评论的代码之后,先定义了两个变量:

const events = ['scroll', 'mousemove', 'keyup', 'touchstart'];
let added = false;

这里定义了一些动作,比如滚动页面等,可以合理猜测,下面必然存在检测此类行为的代码。

接下来就是添加事件监听器:DOM加载完毕后,通过ID定位到评论提交表单(如果ID指向的元素不是评论表单,则在里面继续查),随后执行如下代码:

const input = document.createElement('input');

input.type = 'hidden';
input.name = '_';
input.value = '..........'; // 请注意此处

if (form) {
    function append() {
        if (!added) {
            form.appendChild(input);
            added = true;
        }
    }

    for (const event of events) {
        window.addEventListener(event, append);
    }
}

很显然,这里生成了一个<input type="hidden">标签,将其键设置为_(单个下划线),并设计为在检测到上面提到的几个动作时,才把这个标签插入到评论表单里,因此就实现了动作检测。标签的作用是在提交评论的时候添加一个新字段,于是不难猜测,这个字段就是某种token,后台通过校验是否存在此token,及其合法性,并在校验失败时拒绝提交,来挡掉垃圾评论。

请注意上述代码中那个显眼的省咯号。从逻辑来看,那里本来应该放置这个input标签的值,也就是校验提交合法性的token,然而如果你在阅读源代码,就会发现此处并非直接把token写在里面,而是通过一段写成立即执行函数(IIFE)且混淆过的的js来赋值。

进入到这个IIFE里面,第一眼可以看到定义了两个混淆过的变量。

如果没有语法高亮,甚至更难看如果没有语法高亮,甚至更难看

如果手头有可以执行js的环境,再配合一点人工观察,对这段代码进行解混淆也不是难事。只是这个混淆结果并非一成不变,而是在页面加载时由后台动态计算。例如,对某次动态生成的代码解混淆(包括清除无用注释与连接符,重命名变量)之后,可以整理得到如下结果:

(function() {
    // token与replacePosition 已遮蔽
    var token = 'd******b2dd****echMRc32*******'
    var replacePosition = [[11, 22], [33, 44]];

    for (var i = 0; i < replacePosition.length; i++) {
        token = token.substring(0, replacePosition[i][0]) 
            + token.substring(replacePosition[i][1]);
    }

    return token;
})();

原来长得很吓人的代码,其实就是这么简单。其主要操作可以概括为:『给定一个token,和指定的替换位置,使用substring(),去除掉原token里面的一部分字符串。全部替换完,也就拿到了真实token』。最后把这个token插入到评论表单即可。

前端部分的代码到这里就分析完了。说白了,他检测的是两个维度:

  • 是否有页面交互行为:正常浏览页面的人,来到评论区之前,肯定会触发这些动作(移动到评论区文本框,点一下鼠标聚焦,输入文本),而SPAM机器人就未必了,因此可以通过动作检测,来挡掉一部分傻乎乎的机器人。
  • 是否有js运行能力:要求客户端具有 JavaScript 执行能力,因为定义变量的过程夹杂着大量的注释与无用代码,变量名也是混淆过的。js引擎当然可以无视注释,也不在乎你变量名叫什么。但如果试图通过正则来提取,就很容易出现误判结束符的情况,得不到正确的token值。

至于这么检测行不行,先按下不表,到第四大节的时候再统一讨论,现在我们先来看后端是如何生成这一段代码的。

3. 后端的逻辑

Typecho是PHP写的,所以现在请出我们的老朋友:XDebug。

3.1 质询代码何处来?

这一段反垃圾评论的代码,是Typecho核心自己在<head>输出的。一般而言,不论站点使用何种主题,打开负责<head>的文件,都能找到类似这样的代码:

<!-- Typecho内置函数,输出系统的Header -->
<!-- $this,指的是Widget/Archive -->
<?php $this->header(); ?>

下一个断点,就可以在/var/Widget/Archive.php找到这个函数。

在接着分析之前,注意一件很有趣的事情:观察代码发现,这个header()函数实际上支持接收一个参数(即$rule,格式为不包含问号的Query String),来覆写其内部的一些默认行为,观察里面的$allows可以发现,其中一个允许被修改的选项就是antiSpam,默认情况下取值为1。

能覆写成什么呢?接着往下滚动,大概在1190行,就能找到输出反垃圾评论代码的部分。可以看到,代码进去就是判断反垃圾是否启用,以及antiSpam是否为1。如果不为1,就将其视为字符串,拼接到下面的script的标签中再输出,用来表示第三方反垃圾评论代码的路径。因此,如果某个主题自行实现了功能更强的反垃圾评论代码(当然,要满足一定规范,这个在第四大节也会继续介绍),完全可以在主题文件内部通过传入参数的方式来添加,在不必大费周章改核心代码的同时,依然可以通过后台评论设置来开关此功能。

接下来,就是输出代码的部分了。清理掉无关逻辑之后可改写如下:

if ($needToAntiSpam) {
    # 生成需要的函数
    $shuffled = Common::shuffleScriptVar(
        $this->security->getToken(
            $this->request->getRequestUrl()
        )
    );
    # 然后在固定的代码块中注入 $shuffled 代码
    # 并拼接到 $header 中
    # 类似这样:
    $header .= "input.value = {$shuffled};"
}

根据第二大节的分析可知,$shuffled表现为一个立即执行函数,其作用就是计算隐藏标签的值,因此我们重点来关注生成这个函数的代码。注意到,这里依此调用了三个函数:

# 按调用栈顺序写出
$this->request->getRequestUrl();
$this->security->getToken();
Common::shuffleScriptVar();

第一个,按官方说法是『获取当前完整的请求url』,拼接字符串而已,没什么好讲的。

第二个函数,位于同一目录下的Security.php,这里直接抄过来:

# 获取token
public function getToken(?string $suffix): string {
    return md5($this->token . '&' . $suffix);
}

不难看出,就是把内部的token拼接上指定后缀(本例中就是页面的完整URL),做MD5计算。

token变量在整个文件里就只有这个地方用上了,因此可以直接跳到上面看初始化这个变量的代码:针对匿名用户,token就等于secret,登录用户则还会在此基础上再拼接登录码(就是在cookies里面那个)以及用户UID。

# 初始化函数
public function execute() {
    $this->token = $this->options->secret;
    if ($this->user->hasLogin()) {
        $this->token .= '&'.$this->user->authCode.'&'.$this->user->uid;
    }
}

那么,这个secret又是何方神圣呢?Typecho的代码,命名规范与文档注释写还是很好的,看到从options取值,就可以直接跳转到同目录下的Options.php;看到开头列出了$secret却没有发现文件内有其他引用的代码,那么就可以直接去数据库的options表查字段了。果然,在数据库里,我们找到了secret的值:

已打码处理已打码处理

这个字段的值,是在初次安装 Typecho 的时候,由install.php负责注入的 32 字符长的随机字符串。后续执行中,这个字段就不再变动了,可以视作是每个站点都不同的常量。因此,第二个函数的作用是,根据页面的完整 URL,生成该站点独有的 token,URL 不变,token 也就不变。

第三个函数最为有趣,具体位于/var/Typecho/Common.php。函数名shuffleScriptVar接受一个参数$value,作用就是生成混淆后的赋值代码,或者说按照原文注释,『给javascript赋值加入扰码设计』。也就是说,『js执行完这一堆代码后,能得到那个$value。老实说,我其实很好奇,当初编写这个代码的老哥,甚至可以说是编写这类代码的老哥,到底是怎么想出来的——当年肯定没有AI,所以写代码的人至少也得精通PHP和JavaScript,甚至搞不好还得掌握一些数据结构和编译原理,才能利用上一些边边角角的特性。

这段代码,怎么说呢,实现得确实很巧妙。照例,这里不会把全部代码贴上来,而是只贴关键部分,但会把注释部分补齐,让大家都能体会到代码之美。但即便如此,最终的代码还是长了些,因此这里折叠起来,供有需要的朋友阅读。

点击展开
// 函数运行时传入 $value 作为参数
$length = strlen($value); 
$max = 3;
$offset = 0;
$result = [];
$cut = [];

// 遍历整个输入字符串
// 对其进行混淆处理
while ($length > 0) {
    // 先随机生成取单次的取值长度
    // 以及随机生成一段字符串,作为混淆字符串
    $len = rand(0, min($max, $length));
    $rand = "'" . self::randString(rand(1, $max)) . "'";

    if ($len > 0) {
        // 单次取值长度不为零,说明是『取有效字符串』
        // 因此从某一个偏移量开始,取固定长度原字符串
        $val = "'" . substr($value, $offset, $len) . "'";
        // 并有对半开的概率,选择两种混淆方式之一
        $result[] = rand(0, 1) ? "//{$rand}\n{$val}" : "{$val}//{$rand}\n";
    } else {
        // 单次取值长度为零,说明是『插入无效字符』
        // 继续摇骰子选择混淆方式
        if (rand(0, 1)) {
            // 有对半开的概率,选择这两种混淆方式
            // 此时是在生成js注释,因此不影响最终结果
            $result[] = rand(0, 1) ? "''///*{$rand}*/{$rand}\n" : "/* {$rand}//{$rand} */''";
        } else {
            // 有对半开的概率,选择这两种混淆方式
            $result[] = rand(0, 1) ? "//{$rand}\n{$rand}" : "{$rand}//{$rand}\n";
            // 此时生成的无效字符就会被插入到最终代码里
            // 因此需要记录插入位置到 $cut 中
            // 让前端执行js去移除
            $cut[] = [$offset, strlen($rand) - 2 + $offset];
        }
    }

    // 推进指针,直到整个字符串处理完毕
    $offset += $len;
    $length -= $len;
}

// 接着随机生成前端代码里的变量名
$name = '_' . self::randString(rand(3, 7));
$cutName = '_' . self::randString(rand(3, 7));

// 将上面混淆后的字符串拼接为赋值语句
$var = implode('+', $result);

// 记录好的插入位置变成合法js字面量
$cutVar = json_encode($cut);

// 拼接并输出最终代码
return "(function () {
    var {$name} = {$var}, {$cutName} = {$cutVar};
    
    for (var i = 0; i < {$cutName}.length; i ++) {
        {$name} = {$name}.substring(0, {$cutName}[i][0]) + {$name}.substring({$cutName}[i][1]);
    }

    return {$name};
})();";

说白了,生成代码的过程,就是对字符串的不同部分进行不同处理方式的过程:

  • 要么就是,截取一块有效字符串,按不同方式拼接上js注释;
  • 要么就是,生成一段无效字符串,然后要么按不同方式变形为js注释,要么按不同方式插入到有效字符串中,并记录插入位置

这样生成的代码交给js引擎后,引擎会无视掉所有注释,然后根据记录下来的插入位置,去除掉有效字符串里的无效字符,最终得到的就是未经混淆的token值。

3.2 校验代码在何方?

有了以上知识,我们知道,判断当前评论是否为垃圾评论,一个很重要的因素就是『是否存在此token值』。接下来我们就来看看系统是怎么实现的。Typecho处理评论的文件位于/var/Widget/Feedback.php,和Trackback等其他反馈方式捆在一起。第一次接触可能比较难找,不过没关系,实在找不到的话,继续用Xdebug在路由分发函数处下断点跟踪即可。

找到这个文件里的comment()之后,跟踪就会发现,具体实现反垃圾评论的代码放置在位于同一目录下的Security.php,打开发现,这个函数实现得也很简单:

public function protect() {
    if ($this->enabled
        && $this->request->get('_') != $this->getToken($this->request->getReferer())
    ) {
        $this->response->goBack();
    }
}

校验逻辑很简单,单纯只是比对前端传来的token,与后端计算的token值是否符合而已。只要符合,就判定为通过了前端的反垃圾代码要求(有页面交互,有js执行),允许继续进行评论操作。

自此,后端的生成代码与校验代码,都分析完了。

4. 尚能饭否?

上面提了这么久的第四大节,现在终于可以兑现承诺了。

简单结论就是:2026年的今天,这个功能有点用,但作用不多。

要得出这个结论,我们需要先了解一点历史。因为无法确定这个功能加入到Typecho的具体时间,我们算保守点,10年前,也就是2016年。那时候发送垃圾评论的已经是机器人/爬虫为主了,一般来说,都是全网爬取(或者到聚合站等地方爬取)博客网站,然后执行以下任意一个操作:

  • 如果是常用CMS,就对着这个站点的评论API直接提交
  • 非常用CMS,就下载页面,扫描里面长得像评论表单的东西,例如目标地址带有『comment』字样的表单,分析表单结构,然后根据这个结构来提交垃圾评论

在这样的环境下,这种反垃圾评论的代码是非常有效的,因为当时的爬虫有很大概率不会执行js,也无法用简单的正则表达式提取混淆后的token,所以自然无法通过校验,也就被拦在了门外。

然而,现在已经是2026年了,能够执行js的爬虫已经稀松平常,无头浏览器(例如Selenium)也是爬虫的老朋友了,因此上述简单的代码,已经无法再拦住具有新装备的爬虫——毕竟这只是一段普通的js而已,没有性能要求,没有内存要求,也没有类似『请选择图中所有的风系小男孩』这样的验证码,只要是一个合格的js引擎,都能把token给解出来。

另外,如果你还记得第一节图中那个发机场广告的垃圾评论,笔者分析Nginx日志发现,他是从某个博客聚合站点进来首页,点开第一篇文章,并发送垃圾评论的,不排除是有真人在后面发评论的可能性。碰到这种真人发垃圾评论的情况,Typecho内置的反SPAM功能自然是无效的,验证码当然也是无效的,只能依靠关键词拦截等方式拦住了。

所以结论就是:在2026年,这个功能关了不会立刻出事,开了没有什么坏处,毕竟能挡一点是一点(尤其是挡住用简单方法广撒网的机器人),就当作是进入高级判定代码之前的低成本过滤器了。但千万要记住,不要指望光靠他就能打遍天下无敌手,必要的其他措施(如评论审核插件,验证码)依然是不可或缺的。然而如果你打算给站点适配全页缓存,那就需要小心一些,因为这段代码是动态生成的,要么降低了缓存命中率,要么代码变得一成不变——虽然从结果来看,代码不变不会造成太大问题,但毕竟失去了『动态生成』这一点,我不好说会不会引入别的什么安全风险。

最后再补充一点:如果你想用上面说的『添加覆写参数,来引入自己的反垃圾评论代码』,需要注意的一点是:你可以用你喜欢的任何方式来校验,唯一的要求就是在最终提交评论的时候把那个token也提交上去,就视为通过检测。不过老实说,感觉还不如从其他方面入手比较好,毕竟有提供评论Hook,在Hook处判别垃圾评论,兴许会更好些。

5. 写在最后

简单分析了一下这段代码,至少知道了他不是什么神奇的东西。

如果落到对缓存系统的适配上来,老实说,事情依然没有这么简单。也许需要引入一些类似的低成本筛选措施(例如像上面说的,覆写掉内置代码),才能放心地给站点上缓存。

哎,真是麻烦。

(完)


木头箱子脆脆,但是这样正好

如无特殊声明,本站内容遵循 CC BY-NC-SA 4.0 协议

转载请注明出处并保留作者信息,谢谢!

本站由 搬瓦工VPS 强力驱动

none
最后修改于:2026年06月12日 00:11

添加新评论

提醒:『评论回复邮件提醒』功能正在测试中
评论后,如果站长有回复,会有邮件通知