保哥笔记

Discuz 提交表单提示请求来路不正确或表单验证串不符的完整排查记录

保哥这两年帮朋友维护过几个老 Discuz 论坛,几乎每隔一段时间就会撞上同一个报错:在前台注册、发帖、回帖或者修改个人资料的时候,页面突然弹出一行红字——“抱歉,您的请求来路不正确或表单验证串不符,无法提交”。第一次见到这行字的人,基本都会以为是被攻击了,其实多数情况下只是表单的合法性校验没有通过。下面这篇文章我把自己踩过的坑、查过的源码、以及最后真正修好的方案都整理成一份排查记录,方便后面再遇到的时候可以直接照着对,也省得每次都重新翻一遍论坛里那些零碎的老帖子。

这个报错到底是 Discuz 在校验什么

要弄清楚为什么会蹦出这条提示,得先从 Discuz 的提交流程说起。Discuz 的几乎所有写操作都会经过一个统一的入口函数:submitcheck()。这个函数定义在 source/class/helper/helper_form.php 里,逻辑非常清晰,主要做三件事:第一步是取出本次 POST 上来的 formhash 字段;第二步是调用全局 formhash() 函数现场再算一遍当前用户、当前会话应该有的 formhash;第三步是比较两边是否一致,并根据 referer 判断请求是不是来自本站自己。只要这三步里任何一步对不上,Discuz 就抛出我们看到的那条提示。所以这不是一个孤立的报错,而是一个统一的请求合法性闸门。我自己排查的时候,会先在脑子里把这三件事过一遍,再决定从哪一头开始查,比胡乱重装快得多。

下面这段代码是 Discuz X3 系列里 submitcheck() 的核心比较逻辑,我把它精简了一下方便看:

function submitcheck($var, $allowget = 0, $seccodecheck = 0, $secqaacheck = 0) {
    if (!getgpc($var)) {
        return false;
    }
    if ($allowget || (
        $_SERVER['REQUEST_METHOD'] == 'POST'
        && !empty($_POST['formhash'])
        && $_POST['formhash'] == FORMHASH
        && empty($_SERVER['HTTP_X_FLASH_VERSION'])
        && $_SERVER['HTTP_REFERER']
        && preg_replace("/https?:\\/\\/([^\\:\\/]+).*/i", "\\1", $_SERVER['HTTP_REFERER'])
           == preg_replace("/([^\\:]+).*/", "\\1", $_SERVER['HTTP_HOST'])
    )) {
        // 通过校验,进入业务逻辑
        return true;
    } else {
        showmessage('submit_invalid');
    }
}

看到 showmessage('submit_invalid') 没有?那条中文提示就是从这里抛出来的。理解了这一点,后续每一步排查都可以对应到上面具体的某一行,而不是面对一个黑盒子瞎试。FORMHASH 这个常量是 Discuz 在每次请求初始化时根据用户编号、当前时间、authkey、用户代理等多个变量算出来的。任何一个组成因子在两次请求之间发生了变化,结果都会对不上。理解了组成方式,你就能反推出排查的方向。

排查第一步:先核对服务器和本地的时间

这是最容易被忽略、但出现频率最高的根因。formhash 的计算里掺了会话时间戳和 cookie 失效时间,如果服务器时区或者系统时间漂得比较远,浏览器在客户端拿到的 cookie 已经过期,但 PHP 这边算 hash 时又用的是本地时间,两边算出来的字符串自然对不上。我自己服务器跑的是 CentOS 7 和 Debian 11 两套,碰到这类问题的时候第一件事就是先 SSH 进去敲一行查看当前时间和时区:

date
timedatectl

如果发现时区不是 Asia/Shanghai,或者时间和真实时间差超过几分钟,就赶紧同步:

timedatectl set-timezone Asia/Shanghai
systemctl enable --now chronyd
chronyc -a makestep

同步完之后到 Discuz 后台把缓存清一遍:工具菜单里的更新缓存全部勾上更新一次。我有一次帮朋友查这个报错,前后翻了一个多小时代码,最后发现是机房刚迁移过,服务器时间慢了快二十分钟,时间一对,问题立刻消失。还有一种少见但真实存在的情况,是虚拟机时间在宿主机休眠之后没有自动校准,重启 chronyd 之后即可恢复。所以这一步我建议放在所有排查的最前面做,几乎不耗成本却能筛掉一大半疑难杂症。

排查第二步:检查模板里的 formhash 隐藏域

第二个常见原因,是自定义模板或者改过的插件页面把 formhash 隐藏域漏掉了。Discuz 默认的所有写操作表单都会带上这一行:

<input type="hidden" name="formhash" value="{FORMHASH}" />

模板编译之后,{FORMHASH} 会被替换成当前用户的真实 hash。如果某个模板是从老版本扒下来的,或者被前端同事手抖删掉了,那么提交时 POST 里压根就没有 formhash 字段,submitcheck() 第一关就过不去。排查方法很直接:F12 打开浏览器调试,切到网络面板,提交一次表单,找到失败的那个 POST 请求,看负载或者表单数据里是不是有 formhash 这一项。没有就说明模板缺这一行,把它补回去就行了。

补的时候有几个细节要注意。首先,如果是论坛主题模板,要写在 <form> 标签内部,不能放在 </form> 之后;其次,{FORMHASH} 必须用大括号包裹,让 Discuz 模板引擎去解析;最后,如果你的页面使用了 AJAX 异步提交,记得把 formhash 一并塞进 data 字段,不能光在表单里有,发请求时却没传过去。我自己习惯封装一个工具函数:

function dzPost(url, payload) {
    payload.formhash = $('input[name=formhash]').val();
    return $.post(url, payload);
}

这样不管什么页面调用,都不会再忘记带上 formhash,省心很多。

排查第三步:跨域、反代和 HTTPS 引发的 referer 校验失败

第三种我遇到过好几次,是部署架构变了之后才出现的。比如前面套了一层 Nginx 反代、或者从 HTTP 升级到 HTTPS、或者绑了一个新的二级域名。这些情况下 HTTP_REFERERHTTP_HOST 的协议、端口、域名可能不一致,submitcheck() 里那段正则匹配就会判不通过。排查这类问题,我习惯让 PHP 把这两个值打出来看一眼。在 helper_form.php 顶部临时加一段调试代码:

file_put_contents(
    DISCUZ_ROOT . './data/log/submit_debug.log',
    date('Y-m-d H:i:s') . ' | '
    . 'REFERER=' . ($_SERVER['HTTP_REFERER'] ?? '-') . ' | '
    . 'HOST=' . ($_SERVER['HTTP_HOST'] ?? '-') . ' | '
    . 'PROTO=' . ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '-') . PHP_EOL,
    FILE_APPEND
);

复现一次报错,再去日志文件里看输出,基本一眼就能看出来到底是 referer 没传过来,还是域名不一致。如果是反代导致的,记得在 Nginx 配置里把这几行加上:

proxy_set_header Host              $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;

并且让 PHP 信任反代过来的协议头,这样后端 $_SERVER['HTTP_HOST'] 才不会和浏览器看到的域名错开。如果你做了 HTTPS 卸载,记得在站点配置里启用 define('DISCUZ_USE_FORWARDED_HEADERS', true); 这种自定义常量,让 Discuz 知道自己在反代后面。调试完别忘了把临时的那段写日志代码删掉,或者套一个 IP 白名单只让自己看到,避免日志被生产流量冲爆。

排查第四步:插件、应用中心包覆盖了核心文件

还有一种比较隐蔽的情况,是装了某些应用中心的插件之后,插件包里的 class_member.phphelper_form.php 把核心文件覆盖了。这种问题最常见的表现是:注册、登录、发帖里只有某一个功能报错,其他全正常。这时候我的做法是把官方原版包里的对应文件下载下来,和现网的对比一次。Linux 下用 diff 就够了:

diff -u /tmp/discuz-origin/source/class/class_member.php \
        /var/www/html/source/class/class_member.php

diff -u /tmp/discuz-origin/source/class/helper/helper_form.php \
        /var/www/html/source/class/helper/helper_form.php

如果输出里有奇怪的逻辑改动,比如多了一段写死的 return true、或者 submitcheck 被替换成了 if (isset($_POST)) 这种偷懒写法,那基本就是插件干的好事。把原版文件覆盖回去,再到后台禁用可疑插件,问题一般就消失了。临时绕开校验也不是不行,但我个人非常不推荐在线上长期保留 if (isset($_POST)) 这种写法,formhash 是 Discuz 防 CSRF 的核心防线,关掉它等于让论坛在裸奔,被人在论坛外挂一个隐藏表单就能让所有登录用户帮着发广告或改密码。我建议至少加上一层 referer 校验或者加图形验证码作为兜底。

排查第五步:当所有方法都失败,重装是最后的兜底

如果上面四步都查过了还是没好,那基本是数据库或者文件权限层面出了更深的问题。我自己有一次接手的站,前任运维不知道动过什么,连日志都看不出端倪,最终是用了一套相对干净的重装流程才搞定:

# 备份 uc_server、附件、数据表
mysqldump -uroot -p discuz > /root/backup/discuz_$(date +%F).sql
tar czf /root/backup/data_$(date +%F).tgz /var/www/html/data /var/www/html/uc_server

# 清空源代码目录,保留 data、config、uc_server
cd /var/www/html
find . -maxdepth 1 ! -name 'data' ! -name 'config' ! -name 'uc_server' \
        ! -name '.' ! -name '..' -exec rm -rf {} +

# 解压官方 upload 目录到当前位置
unzip -o /root/Discuz_X3.5_SC_UTF8.zip -d /tmp/dz
cp -a /tmp/dz/upload/. /var/www/html/

# 修正权限
chown -R www-data:www-data /var/www/html
find /var/www/html -type d -exec chmod 755 {} \;
find /var/www/html -type f -exec chmod 644 {} \;
chmod -R 777 /var/www/html/data /var/www/html/uc_server/data

注意 dataconfiguc_server/data 这三个目录我是保留下来不覆盖的,里面是用户上传、配置、UC 通信密钥,删了就要重新对接 UCenter,那个过程比修这条报错痛苦十倍。重装完进后台再走一遍工具菜单下的更新缓存,多数老站到这一步就彻底干净了。如果还有问题,再去检查数据库表是否完整,特别是 pre_common_setting 这张表里 authkey 这一行,它是计算 formhash 的核心密钥之一。

我自己最后是怎么修好的

回到最初让我头大的那个站,五种方法挨个试下来,真正起作用的是服务器时间不同步加上一个第三方注册插件覆盖了 class_member.php 这两个原因叠在一起。先同步时间、再用官方版本覆盖掉 class_member.php、最后到后台禁用那个插件,整个论坛立刻安静了。如果当时上来就直接重装,虽然也能修好,但会丢掉很多用户自己的二次开发,得不偿失。所以我现在面对这条提示,永远都按上面的顺序走:先时间,再模板,再 referer,再插件,最后才是重装,省时间也省心。把这个顺序记下来比死记每条命令都有用,遇到陌生站也能从容下手。

保哥还想多说一句。Discuz 走过这么多年,社区里大量的教程其实都是在反复推荐第四种和第五种方法,也就是改源码绕开校验、或者直接重装。这两种做法都能立刻让报错消失,但本质上都是把现象掩盖掉,并没有真正定位到根因。运维做久了你会发现一个规律:越是第一时间想着重装的人,后面遇到同类问题的频率反而越高,因为他从没有真正搞清楚这套机制是怎么工作的。所以我自己写下这份排查记录,不只是给读者看,也是给未来某天忘记细节的自己留一份地图。每次遇到提示都先把这五步走一遍,至少在最后一步重装之前,自己心里清楚每一个排除的环节都意味着什么。

另外补充一个我自己常用的辅助技巧:在排查过程中如果不确定到底是哪一步出错,可以临时打开 Discuz 自带的调试模式。在 config/config_global.php 里把 $_config['debug'] 设成 2,然后访问任意一个失败页面,它会把详细的错误调用栈打印出来,比纯靠猜要快得多。调完记得改回 0,否则线上用户每次报错都能看到调用栈,等于把内部代码暴露给所有人,是一个明显的安全隐患。还有就是当站点用的是宝塔面板时,记得到面板里的网站设置中查看是否启用了所谓的防火墙模式,部分宝塔插件会拦截带特定参数的 POST 请求,导致 formhash 字段在到达 PHP 之前就被剥掉,这种情况只能去面板白名单里把对应路径放行,否则你怎么改 Discuz 都没用。

常见问题

Q1:报错只在某一台电脑上出现,其他电脑都正常,是怎么回事?
A:基本可以锁定是客户端时间或者浏览器 cookie 的问题。先把那台电脑的系统时间同步一下,再清掉对应站点的 cookie 重新登录,多数情况下立刻就好。如果还不行,换一个浏览器试试,可能是浏览器扩展(比如某些防追踪类插件)把 referer 给清掉了,部分广告拦截扩展也会做这种动作。

Q2:手机 App 提交也报这个错,要怎么处理?
A:移动端通常走的是 api/ 目录下的接口,校验逻辑略有不同,一般是 formhash 没通过 App 端的请求头带上来。可以在服务端临时打日志看 $_POST$_SERVER,确认 App 是不是传了正确的字段。早期版本的 Discuz App 还有一些已知 bug,建议先升级到最新的应用中心版本,再决定是否要改源码。

Q3:能不能直接关掉 formhash 校验图省事?
A:技术上能做到,但保哥强烈不推荐。formhash 是 Discuz 抵御跨站请求伪造攻击的关键,关掉之后任何一个挂马页面都能让你的会员在不知情的情况下发广告、改密码、甚至转账。线上环境如果一定要临时绕开,请只对单个动作放行,并且加上 IP 白名单或验证码兜底,事后立刻恢复。

Q4:Discuz Q 或者更新版本还有这个问题吗?
A:Discuz Q 已经停止维护,新版本的 Discuz X3.5 仍然保留了 formhash 机制,只是把部分接口改成了 JSON 提交,校验路径稍有变化。本文里的五步排查思路对 X3.4、X3.5 仍然适用,只是部分文件路径要换成新版的位置,必要时打开新版源码搜索 submitcheck 关键字快速定位即可。