')。
## 有没有现成的更专业的 DedeCMS 后台美化模板?
有。GitHub 上有 dedecms-admin-layui、dedecms-bootstrap 这类开源主题,整套替换 dede/templets/ 目录。但接管整个后台改动较大,本文方案是单页面美化更可控。
## 新版 DedeBIZ 是否还需要这个美化?
DedeBIZ(DedeCMS 的商业延续版)后台 UI 已经现代化,不需要本文方案。本文专门针对 DedeCMS 5.7 原版。
## 美化后能否影响后台登录与权限?
不影响。本文只改 dede/templets/diy_list.htm 这一个模板文件,与 dede/login.php、权限校验逻辑完全独立。
## DedeCMS自定义表单必填项校验加固版:变量变量安全风险、HTML5校验、蜂蜜罐与reCAPTCHA协同
- URL:https://zhangwenbao.com/dedecms-custom-form-settings-required-items-2.html
- 分类:织梦CMS教程
- 发布:2019-09-29 | 更新:2026-06-02
- 摘要:DedeCMS自定义表单默认不校验必填,社区流传的修法依赖PHP可变变量,反而埋下能覆盖数据库密码的高危坑。本文给出加固版服务端校验:字段名白名单、邮箱手机号格式校验、蜂蜜罐反爬、同IP频率限制和reCAPTCHA v3集成。
- 关键词:织梦自定义表单,DedeCMS表单,plus/diy.php,HTML5表单,蜂蜜罐
> **TLDR**:摘要:DedeCMS自定义表单默认不校验必填,社区流传的修法依赖PHP可变变量,反而埋下能覆盖数据库密码的高危坑。本文先讲清这个隐患,给出加固版服务端校验的完整代码、HTML5的现代前端校验、服务端与前端协同设计,再讲与reCAPTCHA和蜂蜜罐的协同、提交成功后的跳转、跨版本兼容和表单提交日志的留存。
> 摘要:DedeCMS自定义表单默认不校验必填,社区流传的修法依赖PHP可变变量,反而埋下能覆盖数据库密码的高危坑。本文先讲清这个隐患,给出加固版服务端校验的完整代码、HTML5的现代前端校验、服务端与前端协同设计,再讲与reCAPTCHA和蜂蜜罐的协同、提交成功后的跳转、跨版本兼容和表单提交日志的留存。
DedeCMS 的"自定义表单 (https://zhangwenbao.com/dedecms-empties-the-custom-form-content.html)"功能让站长能在前台收集用户提交(咨询、报名、留言等),后台 → 核心 → 自定义表单管理可以可视化建表。但默认表单没有必填项校验——用户提交空白表单也能成功,结果后台收一堆空数据。社区流传的修补方法是改 /plus/diy.php 加服务端校验,或前端 jQuery 校验。两种方法 2026 年都有需要重新审视的地方:服务端的写法有 变量变量($$field)安全风险、前端的 jQuery 在现代浏览器已是冗余依赖。
这一篇把 DedeCMS 自定义表单必填项校验讲透:原社区代码的安全隐患、加固版的服务端校验、纯前端的现代浏览器原生校验(HTML5 required + JS)、二者协同设计、与 reCAPTCHA / 蜂蜜罐反爬虫的协同、提交后跳转优化、跨版本(V5.7 SP1/SP2 + DedeBIZ)兼容、迁移现代 CMS 的等价做法。
## 社区代码的安全隐患:变量变量 $$field
原社区流传的服务端校验代码核心:
$requireds = explode(',', $required);
foreach($requireds as $field) {
if($$field == '') { // ← 变量变量
showMsg('带*号的为必填内容', '-1');
exit();
}
}
$$field 是 PHP 的变量变量语法——如果 $field = "name",那么 $$field 等价于 $name。这个特性配合 DedeCMS 的全局变量注入机制(register_globals=on 或 DedeCMS 自家的全局注入),允许通过 $_POST['name'] 自动变成 $name。但这同时是个安全坑:
- 开 register_globals 的 PHP 5.x 老站:攻击者可以伪造任意变量名,覆盖应用内部变量。但 PHP 5.4 起 register_globals 已被移除,新站点无此风险。
- DedeCMS 全局注入:DedeCMS 自带 foreach($_POST as $k => $v) ${$k} = $v; 这种危险代码(在 common.inc.php 里)。攻击者构造 POST: required=cfg_db_pwd&cfg_db_pwd=hacked 能覆盖数据库密码全局变量。
- 变量名校验缺失:原代码没验证 $field 是不是合法的字段名——攻击者可以传 required=xxx;DROP TABLE 这种值,foreach 进去虽不直接 SQL 注入,但破坏程序流。
正确的写法不要用变量变量,直接读 $_POST:
if (!empty($required)) {
$requireds = is_array($required) ? $required : explode(',', $required);
foreach ($requireds as $field) {
// 严格校验字段名(仅字母数字下划线)
if (!preg_match('/^[a-zA-Z0-9_]+$/', $field)) continue;
$val = isset($_POST[$field]) ? trim($_POST[$field]) : '';
if ($val === '') {
showMsg('带*号的为必填内容,请正确填写:' . htmlspecialchars($field), '-1');
exit();
}
}
}
这个写法相比社区版本:① 不用变量变量,避免依赖 DedeCMS 的全局注入;② 字段名加白名单正则,防止注入;③ 错误提示告知具体哪个字段没填,对用户更友好。
## 加固服务端校验:完整代码
放在 /plus/diy.php 找到 $dede_fields = empty($dede_fields) ? '' : trim($dede_fields); 行,下面加:
// === 自定义表单必填项与基础校验 ===
// 1. 必填项检查
if (!empty($required)) {
$requireds = is_array($required) ? $required : explode(',', $required);
foreach ($requireds as $field) {
if (!preg_match('/^[a-zA-Z0-9_]+$/', $field)) continue;
$val = isset($_POST[$field]) ? trim($_POST[$field]) : '';
if ($val === '') {
showMsg('带 * 号的为必填内容,请正确填写', '-1');
exit();
}
}
}
// 2. 邮箱字段格式校验
if (isset($_POST['email']) && $_POST['email'] !== '') {
if (!filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
showMsg('邮箱格式不正确', '-1');
exit();
}
}
// 3. 手机号简单校验(中国大陆)
if (isset($_POST['mobile']) && $_POST['mobile'] !== '') {
if (!preg_match('/^1[3-9]\d{9}$/', $_POST['mobile'])) {
showMsg('手机号格式不正确', '-1');
exit();
}
}
// 4. 蜂蜜罐反爬虫(hidden 字段,正常用户不填,爬虫填了就拦)
if (isset($_POST['url']) && $_POST['url'] !== '') {
// 静默拦截,不告诉爬虫具体原因
showMsg('提交失败', '/');
exit();
}
// 5. 提交频率限制(同 IP 30 秒内只能提交 1 次)
$cacheKey = 'diy_submit_' . md5($_SERVER['REMOTE_ADDR']);
$cacheFile = DEDEDATA . '/cache/' . $cacheKey;
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
showMsg('提交太频繁,请 30 秒后重试', '-1');
exit();
}
file_put_contents($cacheFile, time());
这套加固版做了 5 件事:① 必填校验;② 邮箱格式校验;③ 手机号校验;④ 蜂蜜罐反爬;⑤ 提交频率限制。比社区版本只做必填的安全防御提升一个量级。
## 纯前端校验:现代浏览器 + HTML5
2026 年的浏览器已经原生支持表单校验,不需要 jQuery。直接用 HTML5 required 属性:
HTML5 表单校验的优势:
- 无 JS 依赖——浏览器原生支持,不用引 jQuery;
- 多语言友好——浏览器按用户语言显示错误提示(中文用户看到中文);
- 可访问性好——屏幕阅读器能读 required 标签,无障碍 (https://zhangwenbao.com/website-accessibility-seo-optimization-guide.html)体验更佳;
- 禁用 JS 也能提交——服务端兜底,防 JS 关闭场景。
## 自定义错误提示
HTML5 默认错误提示是浏览器自带的("请填写此字段" 等),可以自定义:
oninvalid 设错误文案,oninput 在用户重新输入时清空错误。这两个事件让自定义提示与原生校验机制结合。
## 服务端 + 前端协同设计
正确的做法是两端都校验:
场景 | 前端 | 服务端 |
用户体验 | 实时反馈,不需要等服务器 | 提交后才知道错 |
禁用 JS / 直接 POST | 失效 | 仍能挡住 |
性能 | 减少无效请求到服务器 | 无差别 |
安全 | 不可信(用户能改 DOM 绕过) | 可信,最后防线 |
结论:前端用 HTML5 增强用户体验,服务端校验是不能省的最终防线。任何只做前端不做服务端校验的表单都不安全。
## 与 reCAPTCHA / 蜂蜜罐的协同
## 蜂蜜罐(Honeypot)
原理:在表单里加一个对正常用户隐藏的字段(CSS position: absolute; left: -9999px),机器人爬虫填表时会无脑填所有字段,正常用户因为看不到所以不填。服务端检查这个字段:
- 空 = 正常用户,放行;
- 非空 = 爬虫,静默拦截(不告诉它具体哪个字段有问题,让它继续浪费配额)。
蜂蜜罐对 80%+ 的低端爬虫有效,且对正常用户零打扰——不需要点验证码。
## Google reCAPTCHA v3
对剩余的高端爬虫(能识别蜂蜜罐的),加 reCAPTCHA v3。它在背后给每个用户打分(0-1,1 是真人,0 是爬虫),不需要用户点击:
// 服务端校验
$token = $_POST['recaptcha_token'] ?? '';
$secret = 'YOUR_SECRET_KEY';
$resp = file_get_contents("https://www.google.com/recaptcha/api/siteverify?secret={$secret}&response={$token}");
$data = json_decode($resp, true);
if (!$data['success'] || $data['score'] < 0.5) {
showMsg('请稍后重试', '-1');
exit();
}
注意:reCAPTCHA 需要 Google 服务可访问——国内用户可能因网络问题加载失败,建议用国产替代(极验、网易易盾、阿里云人机验证)。
## 提交成功后的跳转优化
原代码用 showMsg() 显示提示后跳转,体验不佳。优化:
// 提交成功后跳转到独立感谢页(更专业)
header('Location: /thank-you.html?from=' . urlencode($_POST['name'] ?? ''));
exit();
感谢页可以放:① 提交成功提示;② 客户经理联系方式;③ 引导用户去看其他相关产品/文章;④ 触发 Google Analytics 转化事件(电商漏斗追踪)。
## GA / 百度统计的转化追踪
在感谢页 thank-you.html 里加:
这样 Google Analytics 能跟踪每次表单提交的转化漏斗,结合广告投放的 CPA / ROAS (https://zhangwenbao.com/roas-roi-advertising-guide.html) 计算。
## 跨版本兼容(V5.7 / SP1 / SP2 / DedeBIZ)
/plus/diy.php 在 DedeCMS 各版本里逻辑大致一致,但行号略有差异:
版本 | $dede_fields trim 行 | 建议插入位置 |
V5.7 | ~ 第 38 行 | 第 39 行后 |
V5.7 SP1 | ~ 第 40 行 | 第 41 行后 |
V5.7 SP2 | ~ 第 42 行 | 第 43 行后 |
DedeBIZ V6 | 已合并必填校验 | 无需手改 |
升级 DedeCMS 时这个改动会被覆盖——记得升级前备份 /plus/diy.php。
## 与防垃圾留言的纵深防御
必填校验只挡空表单,挡不了"机器人填假数据"。完整的防垃圾留言要做:
- 必填项 + 格式校验(本文)
- 蜂蜜罐(隐藏字段)
- 提交频率限制(同 IP 30 秒)
- reCAPTCHA / 极验(人机判断)
- 关键词黑名单(拦"代开发票"、"刷流量"等垃圾文本)
- fail2ban(动态拉黑高频提交 IP)
- WAF(Cloudflare / 阿里云)
每多一层就少一个数量级的垃圾留言。中小站点至少做前 4 层。
## 迁移到现代 CMS 的等价做法
DedeCMS 自定义表单 | WordPress | Hexo / 静态站 |
plus/diy.php | Contact Form 7 / Gravity Forms / WPForms | Formspree / Netlify Forms / Vercel Edge |
必填校验 | 插件原生支持 | HTML5 + 服务端 webhook |
反垃圾 | Akismet | Formspree honeypot 内置 |
邮件通知 | 插件原生 | Formspree 邮件转发 |
WordPress 上这些功能都是装个插件即可,不用改源码。Hexo 等静态站点用第三方 SaaS 服务(Formspree 免费 50 次/月)。
## 表单提交日志的留存
除了写入 dede_diyforms 数据库表,建议同时记录到日志:
// 记录提交日志(追加到 logs/form_submit.log)
$logEntry = sprintf(
"[%s] [%s] %s\n",
date('Y-m-d H:i:s'),
$_SERVER['REMOTE_ADDR'],
json_encode([
'name' => $_POST['name'] ?? '',
'email' => $_POST['email'] ?? '',
'mobile' => $_POST['mobile'] ?? '',
'message' => mb_substr($_POST['message'] ?? '', 0, 200, 'UTF-8'),
'ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 100),
], JSON_UNESCAPED_UNICODE)
);
file_put_contents(DEDEDATA . '/logs/form_submit.log', $logEntry, FILE_APPEND | LOCK_EX);
日志的好处:① 数据库被攻击时还有副本;② 配 fail2ban 直接读日志识别垃圾 IP;③ 后期数据分析(哪些时段提交多、什么 UA 提交多)。
## 常见问题解答
## 改完 plus/diy.php 没生效?
① 确认文件保存了;② DedeCMS 后台 → 系统 → 系统设置 → 更新缓存(虽然 plus 不走模板缓存,但保险起见全清);③ 浏览器开 F12 看提交时是否真请求到 plus/diy.php(路径对不对);④ 看 PHP error_log 有没有报错。
## required 隐藏字段的字段名要按什么命名?
按你后台自定义表单建立时的"数据字段名"。比如建表单时"姓名"对应数据字段名 name,"邮箱"对应 email,那 required 就写 name,email。字段名严格区分大小写。
## 表单提交后页面跳转到 plus/diy.php 但页面空白?
多半是 PHP 报错且 display_errors=Off。先打开 display_errors 看报错——大概率是某个 require 路径错或 include 失败。生产环境调试完务必关回 display_errors,避免暴露路径信息给攻击者。
## 能不能给不同表单设不同的必填项规则?
能。在 /plus/diy.php 里读 $_POST['diyid'](自定义表单 ID),按 ID 走不同规则:if ($diyid == 1) { ... } elseif ($diyid == 2) { ... }。或者把规则配置写到数据库的扩展字段,让管理员后台编辑。
## 用 reCAPTCHA 后 SEO 受影响吗?
不直接影响。Google 不会因为页面用了自家 reCAPTCHA 给 SEO 加分(也不扣分)。但 reCAPTCHA 加载约 200KB JS,对 LCP 有约 50-100ms 影响。生产建议仅在表单页加 reCAPTCHA,不要全站加。
## 蜂蜜罐字段叫什么名字最有效?
选爬虫"看到一定会填"的字段名:url、website、company、fax 等。爬虫的填表逻辑是按字段名匹配自带字典,常见字段名几乎必填。但要避免和真实表单里的同名字段冲突。
## 提交频率限制用 IP 还是 cookie?
都用更稳。IP 限制对 NAT 后的多用户共享 IP 不友好(误伤);cookie 限制可被清除绕过。生产建议组合:① IP 限频较宽(30 秒 1 次);② cookie 配合标识"已提交过"(24 小时内不能重复);③ 用 Redis 存计数器,性能更好。
## 能拦截使用 cURL 等工具直接提交吗?
用 cURL 等工具能绕过前端校验直接 POST。要拦:① 服务端必填校验(绕不过);② 蜂蜜罐 + reCAPTCHA + IP 限频组合;③ 检查 User-Agent 是否包含 cURL/Wget/Python 等爬虫特征字符串。但攻击者改 UA 容易,最终防御还是看服务端的逻辑校验。
## 表单提交日志要不要清理?
要。logs/form_submit.log 长期不清会越来越大,建议 logrotate:每天滚动 + 保留 30 天 + 超期归档到云存储(OSS / S3)。日志体量大时(每天几万次提交)走结构化日志(JSON Lines)+ 上 ELK 或 Loki,比 grep 文本快得多。
## 能在表单页加防止 F12 调试的代码吗?
不建议。这种"反调试"对正常开发者是骚扰,对攻击者完全无效(任何反调试都能在浏览器层面绕过)。要保护表单不被滥用,靠服务端校验 + 速率限制 + WAF + Honeypot 组合,不要靠"防 F12"这种心理防御。
## 权威参考资料
## DedeCMS自定义表单一键清空与ID重置实战
- URL:https://zhangwenbao.com/dedecms-empties-form-data.html
- 分类:织梦CMS教程
- 发布:2019-09-29 | 更新:2026-06-02
- 摘要:DedeCMS自定义表单测试数据要清空、ID跳号要重置怎么办?本文区分TRUNCATE与DELETE在DDL/DML层面的本质差别,详解InnoDB自增策略在5.7/8.0的差异,并给出多表批量清空存储过程、附件文件清理、binlog恢复等实战要点。
- 关键词:织梦自定义表单,DedeCMS数据维护,MySQL优化
> **TLDR**:摘要:DedeCMS自定义表单的测试数据要清空、ID跳号要重置,得先分清TRUNCATE与DELETE在DDL和DML层面的本质差别。本文讲清InnoDB自增策略在5.7与8.0的内部行为、操作前必做的三件事、正式执行的完整SQL流程,再给DELETE后单独重置自增ID、多表批量清空脚本、附件文件清理、未读消息与统计计数的善后,附binlog恢复。
> 摘要:DedeCMS自定义表单 (https://zhangwenbao.com/decdecms-custom-form-list-css-beautification.html)的测试数据要清空、ID跳号要重置,得先分清TRUNCATE与DELETE在DDL和DML层面的本质差别。本文讲清InnoDB自增策略在5.7与8.0的内部行为、操作前必做的三件事、正式执行的完整SQL流程,再给DELETE后单独重置自增ID、多表批量清空脚本、附件文件清理、未读消息与统计计数的善后,附binlog恢复。
我做织梦站点的运维已经有些年头了,期间帮客户清理过的自定义表单 (https://zhangwenbao.com/dedecms-custom-form-verification-mobile.html)不下几十次。每次遇到测试数据混在生产数据里、或是表单ID跳号严重不好看的情况,客户总希望能把数据一键清空,并且让新提交的记录从ID=1重新开始。这篇文章就把我多年攒下来的实战经验整理出来,从原理到操作再到风险规避,把织梦DEDECMS自定义表单数据清空这件事讲透。
## 为什么需要清空DEDECMS自定义表单数据
我接手的不少织梦项目,都会遇到一个尴尬场景:开发阶段为了测试表单提交流程,往往会反复提交几十上百条样例数据。等到正式上线那一天,后台的dede_diyform1表里塞满了“测试1”“test”“张三 13800138000”这类垃圾数据。如果不清掉,新数据混在里面,统计、导出、推送都会受影响。
更让人头疼的是ID跳号问题。织梦默认的自定义表单使用了InnoDB的自增主键,一旦你普通DELETE删除测试数据,下一条新提交的记录ID不会从1开始,而是接着之前的最大值往上加。客户拿到Excel一看,第一条数据ID是137,怎么解释都解释不通。这就是我为什么坚持用TRUNCATE TABLE而不是DELETE FROM的核心原因。
还有一种情况比较隐蔽:站点被采集脚本盯上后,攻击者会通过表单批量灌水,几个小时就能塞进几万条无效记录。这种时候清空表单不仅是清洁工作,更是应急止损。我去年给一家做装修资讯的客户处理过类似事故,TRUNCATE一执行,5万多条灌水数据0.3秒就清干净了。
## TRUNCATE与DELETE的本质差别
很多新手会问我:“保哥,DELETE FROM不也能删完所有数据吗?”从结果上看好像差不多,但底层机制差得太远了。
-- 方式一:DELETE 逐行删除,写入 binlog,自增不重置
DELETE FROM `dede_diyform1`;
-- 方式二:TRUNCATE 直接重建表结构,自增归零,速度极快
TRUNCATE TABLE `dede_diyform1`;
DELETE是DML语句,会逐行扫描并写入事务日志,删除几万条数据可能要跑几十秒甚至更久,而且每一行都会触发可能存在的触发器。TRUNCATE是DDL语句,MySQL会直接drop掉原表然后按照原始结构重建一张空表,所以速度跟表里有0行还是5万行几乎没关系,都在毫秒级。
更关键的是自增ID。TRUNCATE会把自增计数器重置为1,这恰好是我们想要的效果。如果你只是想保留表结构、清掉数据并让ID从1重新开始,TRUNCATE是唯一干净利落的方案。
不过TRUNCATE也有一些限制要注意:它无法回滚(在MySQL默认配置下不能用事务包裹后再撤销),也不能带WHERE条件。所以执行前一定要再三确认表名、确认是不是真的要清空全部数据。
## InnoDB自增策略的内部行为
很多人对DELETE之后自增不重置的现象感到困惑。这背后是InnoDB存储引擎的设计哲学:AUTO_INCREMENT是一个独立的计数器,不和具体行绑定。每次INSERT时计数器自增然后给新行赋值,DELETE只是删除行,计数器不动。
具体到MySQL的内部实现,InnoDB的AUTO_INCREMENT在5.7和8.0版本上行为不一样:
- MySQL 5.7:自增计数器存在内存里,MySQL重启会回到“当前表里最大ID + 1”的位置。这意味着如果你DELETE FROM diyform1清空数据后SHOW CREATE TABLE显示AUTO_INCREMENT=137,重启MySQL后再看会变成AUTO_INCREMENT=1——但这是不可控的,绝对不能依赖。
- MySQL 8.0:自增计数器持久化到redo log,重启不会重置。这意味着DELETE之后即使重启MySQL,计数器仍然停在原来的位置。
所以无论哪个版本,要确定性地把自增重置为1,都必须用TRUNCATE或者显式执行ALTER TABLE diyform1 AUTO_INCREMENT=1。我自己更推荐TRUNCATE,因为它一次完成“清数据 + 重置自增”两件事,没有竞态窗口。
## 操作前必做的三件事
我在执行任何TRUNCATE之前,习惯性会做三件事,这些年从来没出过事故,全靠这套流程顶着。
## 第一件事:定位diyid
织梦的自定义表单不是只有一张表,而是按你创建表单的顺序生成dede_diyform1、dede_diyform2、dede_diyform3这样的多张表。每张表对应一个diyid。如果你直接照着别人教程里的dede_diyform1操作,结果可能把别人的留言表清空了。正确做法是:
SELECT diyid, name, edithtml FROM dede_diyforms;
这条SQL会列出所有表单和对应的diyid。比如你看到“在线咨询”对应的diyid是2,那要清的就是dede_diyform2,不是1。这条命令花5秒,能避免一辈子的事故。
## 第二件事:备份目标表
哪怕你确信里面全是垃圾数据,也要养成备份的习惯。命令很简单:
mysqldump -u 用户名 -p 数据库名 dede_diyform2 > diyform2_backup_$(date +%Y%m%d_%H%M).sql
备份文件保留在服务器上至少30天。我经历过一次客户后悔——他在执行TRUNCATE 24小时后突然想起里面有一条三个月前的真实询盘记录被一起清掉了,幸亏当时备份还在,从SQL里捞出来恢复成功。如果当时没备份,那条业务损失就找不回来了。
## 第三件事:确认是否有外键依赖
织梦默认设计的dede_diyform*表是孤立的,没有外键约束。但有些站长会自己加,比如把表单数据和会员表关联,建立userid字段的外键。这种情况下直接TRUNCATE会报错:“Cannot truncate a table referenced in a foreign key constraint”。
排查方法:
SELECT
TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME
FROM
information_schema.KEY_COLUMN_USAGE
WHERE
REFERENCED_TABLE_NAME = 'dede_diyform2';
如果返回非空,说明有其他表引用了你要清的表,要先临时禁用外键检查再操作:
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE dede_diyform2;
SET FOREIGN_KEY_CHECKS = 1;
## 正式执行:完整SQL流程
三件事都做完之后,开始正式执行。我用一个完整的SQL块包起来:
-- 第一步:再次确认要清的表
SELECT COUNT(*) AS rows_before FROM dede_diyform2;
-- 第二步:禁用外键检查(如果有外键依赖的话)
SET FOREIGN_KEY_CHECKS = 0;
-- 第三步:执行 TRUNCATE
TRUNCATE TABLE dede_diyform2;
-- 第四步:恢复外键检查
SET FOREIGN_KEY_CHECKS = 1;
-- 第五步:验证清空成功
SELECT COUNT(*) AS rows_after FROM dede_diyform2;
SHOW CREATE TABLE dede_diyform2 \G
第五步的SHOW CREATE TABLE是最关键的验证——返回结果里应该看到AUTO_INCREMENT=1。如果不是1,说明TRUNCATE没成功(可能是表上有未释放的锁),需要排查原因。
执行完之后立刻在前台提交一条测试表单,看新记录的ID是不是1。是1就说明清空和重置都成功了。
## 除了TRUNCATE还有哪些场景需要DELETE
有些场景TRUNCATE不能用,必须用DELETE。归纳几种典型的:
场景一:只清掉某段时间的数据,保留其他。比如想清掉2023年之前的旧记录,保留2024年起的新数据,这种带条件的删除TRUNCATE做不到,只能用DELETE加WHERE:
DELETE FROM dede_diyform2 WHERE addtime < UNIX_TIMESTAMP('2024-01-01');
注意织梦默认的时间字段是Unix时间戳(INT类型),所以条件要用UNIX_TIMESTAMP()包一下。
场景二:清掉特定IP的灌水数据。有时候攻击者用同一个IP灌水,可以精准清理:
DELETE FROM dede_diyform2 WHERE ip = '1.2.3.4';
场景三:清掉空内容或符合某种pattern的垃圾。比如所有name字段为空或长度小于2的记录:
DELETE FROM dede_diyform2 WHERE name IS NULL OR LENGTH(name) < 2;
这些DELETE场景下,自增ID不会重置,但通常这也不是问题——因为是部分删除而不是清空,留下的数据还在用原来的ID编号。
## DELETE之后单独重置自增ID
如果你已经用DELETE清空了表,事后又想把自增重置为1,可以单独执行:
ALTER TABLE dede_diyform2 AUTO_INCREMENT = 1;
这条命令在表为空时直接生效;如果表不为空,MySQL会忽略小于当前最大ID的值——也就是说,如果表里还有ID=137的记录,ALTER TABLE ... AUTO_INCREMENT=1会被静默忽略,下一条新记录的ID还是138。
所以这条命令的正确使用顺序必须是:先DELETE FROM dede_diyform2;再ALTER TABLE dede_diyform2 AUTO_INCREMENT=1;,反过来不行。
## 批量清空多张表的脚本
如果一个站点有10个表单(diyform1到diyform10)都要清空,一条一条手敲容易漏。我用一段动态SQL批量处理:
DELIMITER //
CREATE PROCEDURE clear_all_diyforms()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE tname VARCHAR(64);
DECLARE cur CURSOR FOR
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'dede_diyform%';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
SET FOREIGN_KEY_CHECKS = 0;
OPEN cur;
read_loop: LOOP
FETCH cur INTO tname;
IF done THEN LEAVE read_loop; END IF;
SET @sql = CONCAT('TRUNCATE TABLE `', tname, '`');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur;
SET FOREIGN_KEY_CHECKS = 1;
END //
DELIMITER ;
CALL clear_all_diyforms();
DROP PROCEDURE clear_all_diyforms;
这段存储过程会自动找到当前数据库里所有以dede_diyform开头的表并TRUNCATE。执行完毕后用DROP PROCEDURE清理掉,保持数据库干净。
第一次跑这个脚本前,把所有dede_diyform*表都用mysqldump备份下来。这个脚本是“核武器级”的清理,做错了恢复成本很高。
## 常被忽略的“未读消息”与统计计数
清空表单数据时容易忽略一件事:DedeCMS的后台首页和"消息中心"模块会显示“未读自定义表单提交”的数量徽标,这个数字不是从dede_diyform*表实时算出来的,而是缓存在dede_member_msg或dede_sysconfig表里。TRUNCATE了表单本身但没清这些计数缓存,后台徽标会一直显示“您有N条未读”,点进去发现是空列表。
解决方法是同步清掉相关计数:
-- 清表单提交相关的消息记录
DELETE FROM dede_member_msg WHERE msgtype = 'diyform';
-- 重置 sysconfig 里相关计数
UPDATE dede_sysconfig SET value = 0 WHERE varname LIKE '%diyform%count%';
-- 清掉后台缓存
DELETE FROM dede_admin_message WHERE message LIKE '%表单%';
这几条语句在不同DedeCMS版本上字段名可能略有差异,执行前先DESC dede_member_msg;看看实际字段。
## 导出表单数据后再清空的标准流程
有些表单数据虽然过时但有商业价值,比如三年前的客户咨询信息,业务方希望先导出存档再清空。我推荐的标准流程:
第一步:用mysqldump导出为可读SQL备份。命令前面已经讲过,注意加--complete-insert --skip-extended-insert参数让导出文件每行一条INSERT,便于人工查看和部分恢复。
第二步:用SELECT INTO OUTFILE导出CSV供业务方查看。
SELECT * FROM dede_diyform2
INTO OUTFILE '/tmp/diyform2_archive.csv'
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n';
这条命令需要MySQL的FILE权限,secure_file_priv变量必须配置成允许写入的目录。导出的CSV用Excel打开做去重、过滤、分类,完成业务交接后再回去执行TRUNCATE。
第三步:保留两份独立备份至少90天。一份放在本地服务器,一份压缩后传到OSS或对象存储做异地备份。我的客户里有过一次教训:本地服务器硬盘故障导致备份SQL丢失,而清空操作已经做了三天了——幸好我额外传了一份到OSS,否则那批价值数十万的销售线索就找不回来了。
## 清空之后的善后工作
TRUNCATE只清了表,但织梦的一些缓存可能还指向旧数据。要做几件事保证后台显示和前端表现都正确。
第一件:清掉DedeCMS的内置缓存。登录后台 → 系统 → 数据库管理 → 优化数据库表,或者直接删data/cache/目录下的所有缓存文件。
第二件:刷新前台静态页。如果你的站点开启了静态生成,表单清空后相关的统计数字(如"已收到XX条留言")需要重新生成静态页才能更新。
第三件:通知第三方推送服务。如果你接了消息推送(短信、邮件、钉钉、企微)触发表单提交通知,验证清空后的新提交是否能正确触发推送。
这三件事做完,整个清空流程才算完整收尾。
## 常见问题解答
## TRUNCATE之后能用binlog恢复数据吗?
能,但前提是MySQL开启了binlog并且binlog里包含了TRUNCATE之前的INSERT记录。具体步骤:先mysqlbinlog --start-datetime='清空前时间' --stop-datetime='清空时间' /var/log/mysql/mysql-bin.000001 > recovery.sql导出原始SQL,然后从recovery.sql里筛出和目标表相关的INSERT语句,重新执行就能恢复。但这种恢复只针对insert操作,update/delete历史不一定能完整还原。
## 表里有上百万条数据TRUNCATE会不会很慢?
不会。TRUNCATE是DDL操作,不论表里有多少行,执行时间都在毫秒级——它本质上是drop表再重建一张同结构的空表,跟原表数据量无关。我自己测过的最大表是2300万行,TRUNCATE耗时不到1秒。但要注意:TRUNCATE会持有元数据锁,期间所有访问该表的查询会阻塞,所以高并发场景下要选择业务低峰期执行。
## 清空diyform之后,附件文件还在/uploads目录里怎么办?
需要单独清理。织梦的自定义表单如果有上传字段,文件会被保存到/uploads/diyform/目录下,TRUNCATE只清掉数据库记录,物理文件还在。建议在TRUNCATE之前先SELECT出所有附件路径备份,然后再用rm命令清掉对应文件。或者更简单粗暴:直接rm -rf /uploads/diyform/*清掉整个目录(前提是确认目录里全是要清的内容)。
## 多张表单中的一张被错误TRUNCATE了怎么紧急恢复?
三个步骤:第一,立刻停止所有可能往这张表写入的服务(关掉前台表单接收页面),避免新数据覆盖恢复路径;第二,从最新的mysqldump备份恢复,命令是mysql -u root -p 数据库名 < diyform2_backup_xxx.sql;第三,如果备份没有最新数据但开了binlog,把备份时间到TRUNCATE时间之间的binlog重放回去:mysqlbinlog --start-datetime='备份时间' --stop-datetime='TRUNCATE时间' /var/log/mysql/mysql-bin.* | mysql -u root -p 数据库名。
## 清空表单之后能不能从前台模板里彻底隐藏旧的统计数字?
可以。织梦模板里如果用了{dede:sql}标签查询表单总数,TRUNCATE之后该标签会自动返回0或空。但如果模板里写死了一个数字(比如"我们已收到3万条留言"),那是硬编码,得手动改模板文件。检查方法:在主题目录搜grep -r "diyform" templets/找出所有引用。
## TRUNCATE需要什么权限?普通用户能执行吗?
需要DROP权限。MySQL默认的application user一般只授予了SELECT/INSERT/UPDATE/DELETE,没有DROP权限——这种情况下执行TRUNCATE会报“Access denied”。要么用root账户登录,要么临时给application user授予DROP权限:GRANT DROP ON 数据库.* TO 'user'@'host';,操作完再REVOKE回收。
## TRUNCATE之后alter自增能不能从一个非1的值开始?
能。ALTER TABLE dede_diyform2 AUTO_INCREMENT=10000会让下一条新记录从ID 10000开始。这个用法在“业务上希望ID看起来比较大”(比如订单号、客户编号)的场景比较有用。但要注意起始值不能小于当前表里实际的最大ID(如果表非空),否则会被MySQL静默忽略。
## 权威参考资料
## 织梦DedeCMS表单防垃圾提交:手机号、IP校验加双层防重复
- URL:https://zhangwenbao.com/dedecms-custom-form-verification-mobile.html
- 分类:织梦CMS教程
- 发布:2019-09-29 | 更新:2026-06-01
- 摘要:DedeCMS自定义表单要防垃圾提交,得把验证做全。本文覆盖preg_match手机号正则的锚点陷阱、GetIP结合CF头的可信IP方案、HTTP_REFERER兼容兜底、Cookie加数据库双层防重复、SQL注入与XSS防御、蜜罐与Turnstile反垃圾配方,并对比Discuz、WordPress同类机制。
- 关键词:织梦自定义表单,DedeCMS安全,表单验证,防垃圾
> **TLDR**:摘要:DedeCMS自定义表单要防垃圾提交,得把验证做全,验证顺序也很关键。本文先讲四个核心字段的设计,给出手机号正则校验、自动注入IP与time与domain、Cookie弱校验加后端数据库强校验的双层防重复,再补SQL注入与XSS防御、CAPTCHA与蜜罐反垃圾的进阶手段,附真实站点的反垃圾数据和与其他CMS的对比。
> 摘要:DedeCMS自定义表单要防垃圾提交,得把验证做全,验证顺序也很关键。本文先讲四个核心字段的设计,给出手机号正则校验、自动注入IP与time与domain、Cookie弱校验加后端数据库强校验的双层防重复,再补SQL注入与XSS防御、CAPTCHA与蜜罐反垃圾的进阶手段,附真实站点的反垃圾数据和与其他CMS的对比。
织梦DedeCMS的自定义表单 (https://zhangwenbao.com/dedecms-prevents-duplicate-submission-of-custom-forms.html)功能基础够用但功能孤零零——你建了个"售前咨询"表单,访客把名字和电话填了提交了,你拿到一条记录就只有名字和电话。但生产环境里你需要更多:手机号必须是合法的,得知道访客IP方便后续黑名单,得记录提交时间和来源URL(哪个落地页转化的),还得防止同一个人疯狂刷10次浪费销售时间。这篇文章给一套完整可落地的解决方案,覆盖手机号正则校验、IP抓取、时间记录、来源URL、Cookie防重复提交,并给出我在3个客户站上踩过的坑和加强版本。
## 表单字段设计:4个核心字段缺一不可
第一步是在DedeCMS后台"核心 → 自定义表单"里添加4个字段:
字段名 | 类型 | 用途 | 是否前台填写 |
telephone | varchar(20) | 访客手机号,校验格式 | 是 |
ip | varchar(50) | 记录访客IP(v4或v6) | 否,后端自动写 |
domain | varchar(255) | 来源URL,定位落地页 | 否,后端自动写 |
time | varchar(20) | 提交时间(人类可读格式) | 否,后端自动写 |
注意ip字段宽度建议给50字符。IPv4最长15字符,但越来越多家庭宽带分配IPv6(最长39字符),加上未来可能补充ASN信息或地理位置,给宽点不费空间。time字段我推荐用varchar而不是datetime——便于后期统一格式化展示,且不被MySQL时区设置影响。
## 核心思路:验证顺序为什么至关重要
新手最常见的错误是把Cookie防重复检查放在手机号校验之前。后果是访客随手输错手机号点提交,浏览器Cookie被设置上"已提交"标记,他纠错重新输入正确号码再提交时被拒绝——只能让访客手动清浏览器Cookie,体验糟糕,转化率直接腰斩。
正确的执行顺序:
- 先校验手机号格式合法性(合法才有必要继续)。
- 校验非空字段、必填项。
- 注入字段值(IP、time、domain)。
- 查询数据库是否已存在同手机号或同IP的提交(强校验)。
- 检查Cookie防重复标记(弱校验,作为前置拦截层)。
- 写入数据库。
- 设置Cookie标记成功提交。
这个顺序保证了"只有合法且未重复的提交才会被Cookie标记",避免了用户因为输错而被永久拦截。
## 手机号正则校验
找到DedeCMS自定义表单提交处理文件plus/diy.php,在表单数据接收后、写库前加入:
// 验证手机号
if ( ! preg_match( '/^1[3-9]\d{9}$/', $telephone ) ) {
echo "";
exit();
}
这条正则^1[3-9]\d{9}$是中国大陆11位手机号的标准模式:以1开头,第2位是3到9,后面9位是任意数字。注意几个细节:
- 第2位用[3-9]不要用[3456789]。两者等价但前者更简洁,可读性高。早期教程里写[3456789]是因为有人怕区间表示法兼容性问题,2026年早不存在。
- 不要漏掉锚点^和$。否则输入"abc13912345678def"也能通过校验,因为里面包含合法手机号子串。我亲自检查过3个DedeCMS站发现都有这个漏洞,导致垃圾数据混入。
- 港澳台、海外号码需要单独处理。如果你的业务有外贸需求,正则要扩展到/^(?:\+?86)?1[3-9]\d{9}$|^(?:\+?852|853|886)\d{8,10}$|^\+\d{6,15}$/这种形式。
- 提示语本身用英文双引号外、中文单引号内,避免引号嵌套报错。
建议同时在前端用JavaScript做一次同样的正则校验,让用户在输入时就得到反馈,不必等到提交后才知道格式错。前后端双重校验是表单设计的基本规范——前端体验、后端兜底。
## 自动注入IP、time、domain
在diy.php处理字段循环里加入:
// 自动写入访客IP
if ( $fieldinfo[0] == 'ip' ) {
${$fieldinfo[0]} = GetIP();
}
// 自动写入提交时间(年月日时分秒)
if ( $fieldinfo[0] == 'time' ) {
${$fieldinfo[0]} = date( 'Y/m/d H:i:s' );
}
// 自动写入来源URL
if ( $fieldinfo[0] == 'domain' ) {
${$fieldinfo[0]} = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : '';
}
## GetIP()函数的内部细节
DedeCMS自带的GetIP()函数(在include/common.func.php)默认逻辑会按下面顺序取IP:
- HTTP_X_FORWARDED_FOR(CDN或代理转发链最左端)
- HTTP_CLIENT_IP(更老的代理头)
- REMOTE_ADDR(直连IP)
这个顺序在没有CDN的环境下没问题,但如果你站点跑在Cloudflare、阿里云CDN、宝塔反代后,需要小心:X-Forwarded-For头是HTTP头,可以被客户端伪造。攻击者随便发一个X-Forwarded-For: 8.8.8.8就能让你的GetIP()记录假IP。如果你只是用IP记录访客来源不做严格安全决策,影响不大;如果用IP做黑名单或限频,必须在Web服务器层做校验:只信任来自CDN白名单IP段的XFF头,其他直接忽略走REMOTE_ADDR。Nginx里通过set_real_ip_from+real_ip_header配置即可。
对于Cloudflare用户,更准确的做法是直接读$_SERVER['HTTP_CF_CONNECTING_IP']——这是Cloudflare独有的非公开header,无法被伪造。改造后的GetIPSafe函数:
function GetIPSafe() {
if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
return $_SERVER['HTTP_CF_CONNECTING_IP'];
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
## 时间格式选择的考量
date('Y/m/d H:i:s')输出"2026/05/10 19:34:21"。我建议改成更标准的'Y-m-d H:i:s'(用横杠分隔),后期导出Excel更容易被识别为日期类型。如果你做跨时区业务,记录UTC时间避免时区混乱:gmdate('Y-m-d H:i:s')。同时建议补一列timestamp(int)方便后端排序与计算时间差。
## HTTP_REFERER的兼容性陷阱
HTTP_REFERER头不是必填的,浏览器在以下情况下不会发送:
- 从HTTPS页面跳转到HTTP页面(隐私保护)。
- 用户开了浏览器隐私模式或装了广告/隐私拦截插件。
- HTML里设置了。
- 访客直接在地址栏输入URL或从书签打开。
所以domain字段经常会出现空值,写库前要做isset()判空,否则会触发PHP Notice。如果你需要更可靠的来源追踪,建议在前端用JavaScript往hidden字段写document.referrer,再加个URL参数?utm_source=xxx做兜底,比单靠HTTP_REFERER稳。
## Cookie防重复提交(弱校验层)
原版代码:
// 检测游客是否已提交过表单
if ( isset( $_COOKIE['VOTE_MEMBER_IP'] ) ) {
if ( $_COOKIE['VOTE_MEMBER_IP'] == $_SERVER['REMOTE_ADDR'] ) {
echo "";
exit();
} else {
setcookie( 'VOTE_MEMBER_IP', $_SERVER['REMOTE_ADDR'], time() * $row['spec'] * 3600, '/' );
}
} else {
setcookie( 'VOTE_MEMBER_IP', $_SERVER['REMOTE_ADDR'], time() * $row['spec'] * 3600, '/' );
}
这段代码有几个隐患需要修正:
问题1:过期时间表达式严重错误。time() * $row['spec'] * 3600会把当前时间戳(约17亿)乘以一个数,远超int范围导致Cookie时间溢出。正确写法是time() + $row['spec'] * 3600(加号不是乘号)——当前时间往后推$spec小时。
问题2:Cookie作为唯一拦截层不够用。客户端可以删除Cookie或换浏览器绕过。Cookie应该作为第一层"软拦截"提升体验(让正常用户感知到"已提交过"),核心防重复必须靠后端数据库查询。
问题3:Cookie名易被识别。VOTE_MEMBER_IP这种通用名容易被反爬虫脚本识别和清除,建议改成项目相关的随机字符串如biaoge_form_token_a8f3。
修正后的代码:
$cookie_key = 'biaoge_form_token_a8f3';
$cookie_ttl = ( isset( $row['spec'] ) ? (int) $row['spec'] : 24 ) * 3600;
if ( isset( $_COOKIE[ $cookie_key ] )
&& $_COOKIE[ $cookie_key ] == md5( GetIPSafe() . $telephone ) ) {
echo "";
exit();
}
setcookie( $cookie_key, md5( GetIPSafe() . $telephone ), time() + $cookie_ttl, '/', '', isset( $_SERVER['HTTPS'] ), true );
改进点:用md5(IP+手机号)作为Cookie值,不暴露原始IP;setcookie加上HttpOnly(最后一个true)和Secure标志,防XSS窃取;统一用GetIPSafe替代原来的REMOTE_ADDR。
## 后端数据库强校验(必须有的兜底层)
Cookie防重复对清Cookie或换浏览器的"硬刷"无效,所以必须在写库前查一次数据库:
$dsql = $GLOBALS['dsql'];
$telephone_safe = addslashes( $telephone );
$ip_safe = addslashes( GetIPSafe() );
$check_sql = "SELECT id FROM #@__diyform WHERE telephone='{$telephone_safe}' AND time > '"
. date( 'Y/m/d H:i:s', strtotime( '-24 hours' ) ) . "' LIMIT 1";
$row_dup = $dsql->GetOne( $check_sql );
if ( ! empty( $row_dup ) ) {
echo "";
exit();
}
这段查询过去24小时内同手机号的提交记录。能阻止:换浏览器、清Cookie、用代理换IP的反复提交。但还有2点要补:
- 给telephone字段加索引:ALTER TABLE dede_diyform ADD INDEX idx_telephone (telephone);,否则记录变多后查询会慢。
- 对IP做并发限频:单IP在5分钟内提交超过3次直接拉黑,应对脚本攻击。可以用SELECT COUNT(*) ... WHERE ip='..' AND time > ...的形式实现。
## SQL注入与XSS防御
DedeCMS 5.7默认开启了FilterAll对GET/POST全局过滤,但自定义表单的处理逻辑老旧,建议显式做防御:
- 所有写入数据库的字段都过addslashes()或$dsql->EscapeString(),避免SQL注入 (https://zhangwenbao.com/dedecms-message-board-edit-inc-php-injection-vulnerability-repair-method.html)。永远不要直接拼接用户输入到SQL。
- 所有展示到前端的字段用htmlspecialchars($value, ENT_QUOTES, 'UTF-8')转义,防XSS。后台审核员看到的留言列表如果没转义,访客提交一段
## DedeCMS自定义表单防重复提交:5种实战方案
- URL:https://zhangwenbao.com/dedecms-prevents-duplicate-submission-of-custom-forms.html
- 分类:织梦CMS教程
- 发布:2018-11-23 | 更新:2026-05-16
- 摘要:DedeCMS表单怎么防重复提交?本文给出基于session绑定IP的核心代码、30分钟冷却窗口实现、PHP 5.6到7以上的token生成兼容方案、Nginx与Cloudflare反代的真实IP透传配置、织梦升级覆盖的auto_prepend_file兜底,以及集群部署的Redis session切换。
- 关键词:织梦自定义表单,网站安全,DedeCMS,织梦,PHP开发
> **TLDR**:摘要:DedeCMS默认表单有安全空白,本文给五种防重复提交方案叠加用。从session绑IP的基础拦截,到30分钟时间窗冷却、CSRF token防脚本伪造、前端按钮置灰防双击、thank-you页禁缓存,再讲反向代理与CDN场景下的真实IP处理、auto_prepend_file兜底应对织梦升级覆盖、集群部署的Redis session切换,附性能压测和排错清单。
> 摘要:DedeCMS默认表单有安全空白,本文给五种防重复提交方案叠加用。从session绑IP的基础拦截,到30分钟时间窗冷却、CSRF token防脚本伪造、前端按钮置灰防双击、thank-you页禁缓存,再讲反向代理与CDN场景下的真实IP处理、auto_prepend_file兜底应对织梦升级覆盖、集群部署的Redis session切换,附性能压测和排错清单。
这是我维护一批老客户企业站时反复用到的一套实战方案。织梦DedeCMS至今还在大量企业官网跑着,自定义表单 (https://zhangwenbao.com/dedecms-empties-the-custom-form-content.html)是询盘、报名、留言这类核心组件,但出厂的/plus/diy.php没有任何防重复提交逻辑——用户多点几次、网络抖一下、误回退页面再 F5,后台就会出现一连串完全相同的脏数据。这篇文章把基于session+IP的5层加固方案整理成可落地代码,每一层都标注修改位置、踩坑点、性能影响、与运营动作的协同,让你直接抄回去用,并附上对集群部署、Cloudflare CDN、织梦升级覆盖等真实场景的处理方法。所有代码片段中的尖括号都用HTML实体替代以避免被strip_tags吃掉。
## 问题根因:织梦默认表单的安全空白
打开/plus/diy.php这个处理自定义表单提交的核心脚本,会发现它的逻辑非常简陋——拿到POST的字段,按模型配置写库,跳转成功页。整个过程没有任何"重复请求"判断:没有CSRF token、没有IP限速、没有提交频率控制、没有nonce机制。这种"信任前端"的设计带来 4 类典型故障:
第一类,弱网下用户连点。中国大陆移动网络在地铁、电梯、隧道等场景常见 3-5 秒延迟,用户点了提交后界面没反应,下意识又点一次,浏览器把同一份表单数据再发一次,后台就会写入两条记录。这种"无感的重复提交"是真实用户最常见的造脏数据来源。
第二类,浏览器回退键 F5。提交成功跳转到 thank-you 页面后,用户回退到原表单页再按 F5,浏览器会弹"是否重新提交表单"对话框,用户随意点确定就会再次提交。如果 thank-you 页面没禁用缓存,回退甚至不会触发对话框,直接重新 POST。
第三类,CDN 缓存与回源不一致。某些客户的网管在配置宝塔、阿里云 CDN 时不小心把表单页路径也加了缓存,用户回退页面命中缓存版本,再次提交也会出现完全相同的订单。
第四类,低级爬虫脚本。看到 /plus/diy.php 这个公开 POST 入口的爬虫极多——一晚上几百条垃圾数据是常态,包括各种英文乱码、广告链接、SQL 注入测试、XSS 探针。
## 方案 1:session+IP 基础拦截(核心方案)
## 修改位置
打开 /plus/diy.php,定位到大约第 58 行附近的字段处理段(不同织梦版本可能在第 50-70 行之间,搜$dede_fields定位)。
## 核心代码
if(!empty($dede_fields))
{
session_start();
$cur_ip = GetIP();
if ($cur_ip == $_SESSION['form_ip']) {
showmsg('您已经提交过了,无需重复提交!', '/', 0, 10000);
exit;
} else {
$_SESSION['form_ip'] = $cur_ip;
}
$fieldarr = explode(';', $dede_fields);
// 后续保持原样
}
GetIP()是织梦自带的工具函数,封装在include/common.func.php里,会按顺序检查HTTP_X_FORWARDED_FOR、HTTP_CLIENT_IP、REMOTE_ADDR三个 header。showmsg是织梦自带的提示函数,第三个参数 0 表示不自动跳转,第四个参数 10000 表示提示展示 10 秒。
## 为什么用 session+IP 而不是单 IP
这是新人最常问的问题。直接拿 IP 做对比也能拦重复,但缺点明显:IP 是公网共享资源——一个写字楼几百号人共用一个 NAT 出口 IP 是常态、移动 4G/5G 用户在同一基站下共用一批出口 IP、企业园区往往用统一公网。如果纯 IP 拦截,第一个提交后所有同 IP 的真实用户都会被错杀。
session 的作用是绑定到具体浏览器会话(依赖 Cookie 里的 PHPSESSID)。同一用户、同一浏览器才能命中前一次写入的$_SESSION['form_ip']。换浏览器、换设备、清 Cookie,session 重新开始,不会被错杀。
IP 在这里的作用是"二级保险"——如果用户开了 N 个标签页同时提交(共享 session),IP 比对能确认是同一物理位置发出的。
## 方案 2:时间窗口冷却(基础方案的进阶)
方案 1 的"同 session 永久不能再提交"过于激进——用户可能 2 小时后想重新询盘新内容。给 session 加时间戳,控制冷却窗口能解决这个问题。
session_start();
$cur_ip = GetIP();
$now = time();
$last = isset($_SESSION['form_last_time']) ? (int)$_SESSION['form_last_time'] : 0;
if ($cur_ip == $_SESSION['form_ip'] && ($now - $last) < 1800) {
showmsg('您刚刚提交过,请稍后再试。', '/', 0, 10000);
exit;
}
$_SESSION['form_ip'] = $cur_ip;
$_SESSION['form_last_time'] = $now;
这里 1800 是 30 分钟(秒数)。冷却窗口的选择按业务调整:纯展示官网用 1800(30 分钟);询盘价值高的 B2B 站点用 7200(2 小时);招商加盟站点甚至可以用 86400(24 小时);活动报名页用 60-120(1-2 分钟,仅挡连点不挡正常重提)。
## 方案 3:CSRF token 防止脚本伪造(与 1 叠加)
方案 1 与 2 都假设请求来自浏览器(有 session),如果攻击者直接用 curl 或 Python 脚本 POST 到/plus/diy.php,没有 session、永远不会被拦。CSRF token 是防御这一类攻击的有效手段。
## 表单页面输出 token
## diy.php 入口校验
session_start();
if (empty($_POST['_token']) || $_POST['_token'] !== $_SESSION['form_token']) {
showmsg('表单已过期,请刷新页面后重新提交。', '/', 0, 10000);
exit;
}
unset($_SESSION['form_token']);
token 用一次就销毁。远程脚本拿不到 token 就 POST 不进来。random_bytes(16)生成 16 字节高熵随机数,配合bin2hex转十六进制后是 32 位字符串,碰撞概率忽略不计(2 的 128 次方分之一)。
## 注意:织梦旧版 PHP 不支持 random_bytes
random_bytes是 PHP 7+ 的函数。如果你的服务器还在 PHP 5.6(很多老织梦站点),用openssl_random_pseudo_bytes替代:$token = bin2hex(openssl_random_pseudo_bytes(16));。再老的环境(不支持 openssl)退回到md5(mt_rand() . microtime() . session_id()),安全强度略低但够防 CSRF。
## 方案 4:前端按钮置灰+防双击(用户体验层)
服务端的 3 层防御之外,前端做一道用户体验层防护——提交按钮点击后立即置灰+提示"正在提交",避免用户连点。这一步对真实用户场景下的"无感重复"效果最直接。
5 秒超时是必要的——如果服务端响应失败(500 错误、连接断开),按钮永远卡在"正在提交"会让用户无路可走。5 秒后自动恢复,给用户重试机会,同时大多数正常请求 3-4 秒能完成,5 秒已经覆盖。
## 方案 5:thank-you 页面禁用缓存
提交成功后跳转到独立的 thank-you 页面,避免用户在原页面按 F5 触发再次 POST。在 thank-you 页面 HTML 头部加:
同时在服务端响应头加:
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
双重禁用缓存确保用户回退时浏览器不会从本地缓存恢复 POST 后的结果。某些激进的浏览器(移动端 UC、QQ 浏览器)默认会缓存所有 GET 页面,没有禁用缓存的 thank-you 页面会被错误恢复。
## 反向代理与 CDN 场景的真实 IP 处理
这一块是改完 diy.php 之后最容易踩的坑。我见过最严重的案例是一个加盟商站点改完防重复提交后,所有用户从第二天起完全无法提交——后台日志显示$cur_ip永远是127.0.0.1,因为 Nginx 没有透传真实 IP。
## Nginx 反向代理配置
在 location 块里加:
location ~ \.php$ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
这样织梦GetIP()能正确从HTTP_X_FORWARDED_FOR读到真实 IP。
## Cloudflare CDN 场景
Cloudflare 把客户端真实 IP 放在CF-Connecting-IP这个 header 里,织梦原生GetIP不识别。需要自己包一层:
function GetRealIp() {
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
return $_SERVER['HTTP_CF_CONNECTING_IP'];
}
if (!empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
return $_SERVER['HTTP_TRUE_CLIENT_IP'];
}
return GetIP();
}
然后把方案代码里的GetIP()全部替换为GetRealIp()。HTTP_TRUE_CLIENT_IP是 Cloudflare Enterprise 计划的标准 header,普通付费版仍然用CF-Connecting-IP。
## 阿里云、腾讯云 CDN
阿里云 CDN 用X-Forwarded-For透传,符合 RFC 标准;腾讯云 CDN 同样用X-Forwarded-For。两者都不需要额外处理,但要注意 X-Forwarded-For 是逗号分隔的列表(多次代理会追加),织梦GetIP()已经处理了这种情况。
## 性能影响与压测数据
很多人担心加 session 会拖慢响应。我在一个真实生产环境(4 核 8G 阿里云 ECS、PHP 7.4、Nginx 1.20、MySQL 5.7)做过压测:
未改造前:/plus/diy.php平均响应 85ms,QPS 约 240。
改造后(方案 1+2+3+5 全启用):平均响应 92ms,QPS 约 220。
性能损失约 8%-9%,主要来自 session 文件读写。如果使用 Redis 替代文件 session(推荐生产环境),性能损失降到 3% 以内。
对绝大多数企业站(QPS < 50),这种性能损失完全感知不到。访问量大的场景(QPS > 200)建议直接上 Redis session。
## 常见报错与排查清单
## 错误 1:headers already sent
session_start()必须在任何输出之前调用。如果文件顶部 BOM 没去干净(用 UTF-8 with BOM 保存的 PHP 文件会输出 3 字节 BOM),或前面有echo、空行混入,session 启动失败。织梦的入口文件common.inc.php已经做了ob_start(),多数情况下不会触发,但如果你在自定义模板里手动 echo 过东西要警惕。修复方法:用 Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html) 把所有相关 PHP 文件转成"UTF-8 无 BOM"格式重新保存。
## 错误 2:session 文件目录不可写
生产服务器上 PHP 的session.save_path默认指向/tmp或/var/lib/php/sessions。如果运维清理脚本太狠把目录权限改掉(chmod 700但 web 用户不在 owner),session 写不进去就会失效。$_SESSION['form_ip']永远拿不到上次的值,重复提交又能进来。排查方法:phpinfo() 看session.save_path,确认目录存在且 web 用户(通常是 www、nginx、apache)可写。
## 错误 3:负载均衡场景 session 不一致
多台 web 机器+负载均衡时,文件 session 默认存在各机器本地,用户被分到 A 机器存 session、再次访问被分到 B 机器读不到 session,第二次提交不被拦下。修复方法两种:第一种把session.save_handler切到 Redis(推荐);第二种用 sticky session(Nginx 的 ip_hash 模块或 lvs 的会话保持),保证同一用户始终落到同一台机器。
## 错误 4:CSRF token 跨页面共享失败
如果用户同时打开多个表单页(多个 token 写到同一 session),后写的 token 覆盖前面的,先打开的页面提交时 token 不匹配。修复方法:每个表单生成独立 token 存到 session 数组,$_SESSION['form_tokens'][$page_id] = $token,校验时查对应页面 ID 的 token。
## 织梦升级覆盖的应对
织梦官方升级包通常会替换/plus目录下的核心脚本,你的修改会被覆盖。建议两种应对:
第一种是把改动整理成 patch 文件备份。每次改完用diff -u 原 diy.php 改后 diy.php > diy_patch.txt生成补丁,升级后用patch /plus/diy.php diy_patch.txt恢复。简单但需要每次升级都手动操作。
第二种是用auto_prepend_file在 PHP 启动前自动加载防重复逻辑。在php.ini或.htaccess里:php_value auto_prepend_file "/path/to/prevent_duplicate.php"。然后在prevent_duplicate.php里检测当前请求的脚本名是不是diy.php等敏感入口,是的话执行拦截逻辑。这种方式不动织梦源码,升级后自动生效。但织梦的钩子机制相对原始,多数项目还是直接改+备份。
## 运营层面的去重补救
技术修改之外,运营也要建立去重机制。我会在每个客户后台加一段 SQL 视图:
SELECT
DATE(addtime) AS day,
email,
phone,
COUNT(*) AS submits
FROM dede_diyform_xxx
WHERE addtime > UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY))
GROUP BY day, email, phone
HAVING submits > 1;
每天凌晨跑一次,把 24 小时内同邮箱或同手机号出现 2 次以上的记录标黄,运营人员人工复核。配合上述服务端拦截,基本上能让脏数据降到忽略不计。
## 从 DedeCMS 视角看为什么不直接换 CMS
有些读者会问"织梦都被官方放弃多年了,为什么不直接迁移到 WordPress 或 Typecho?"。现实里很多老客户企业站存在以下迁移阻力:
第一,存量内容 (https://zhangwenbao.com/revise-old-content-for-aeo-ai-search-optimization.html)多。一个跑了 10 年的企业站可能有 8000+ 篇产品页、5000+ 张图片、2000+ 个 SEO 收录链接。迁移要保 URL 兼容、保 SEO 权重,工作量比"重做一个新站"还大。
第二,定制功能深。10 年里客户改过的模板、加过的功能、改过的字段都跑在织梦的逻辑里,迁到 WordPress 需要重新开发。
第三,运维已习惯。客户的内容编辑团队已经熟悉织梦后台 5-10 年,重新培训的隐性成本高。
所以"在老织梦上做安全加固"在很多场景下是比"迁移到新 CMS"更现实的选择。这套防重复提交方案是这种"现实选择"的一部分——不是技术理想最优解,但能让一个 2010 年代的 CMS 在 2026 年继续安全运行。
## 常见问题解答
## 改完diy.php之后织梦升级会不会被覆盖?
会。织梦官方升级包通常会替换 /plus 目录下的核心脚本。升级前一定要把改动备份成 patch,升级后再回贴。或者用 auto_prepend_file 把拦截逻辑放在外部文件里自动加载,不动织梦源码。但织梦的钩子机制相对原始,多数项目还是直接改加备份。建议每次升级前用 git 或者简单的备份脚本把所有自定义修改的文件打包,避免漏掉某些文件的改动。
## 用IP拦截会不会误伤公司内网用户?
会有一定概率。对内网集中提交场景比较多的客户,建议把单纯 IP 拦截改成 IP 加 UserAgent 加表单 hash 三段式比对,提升 session 区分度。或者干脆把冷却窗口从 30 分钟缩短到 60 秒,只挡连点。如果客户的内网用户经常需要批量提交(如展会现场多人填表),建议在前端加一个"我不是同一个人"的复选框,勾选后绕过 IP 限制——把判断权交给用户而不是机器。
## session在多台服务器集群下能用吗?
默认 session 存在文件里,多机房会失效。集群方案要么把 session.save_handler 切到 Redis(推荐),要么用 sticky session 保证同一用户始终落到同一台机器。织梦本身没有集群部署的官方支持,多机方案需要自己评估。Redis session 配置:php.ini 里 session.save_handler = redis、session.save_path = "tcp://your-redis-host:6379",重启 PHP-FPM 即可。Redis 同时被多台 web 机器读写不会有数据一致性问题,是织梦集群的标准方案。
## 除了改diy.php,还有哪些文件需要类似处理?
织梦还有 /plus/feedback.php(评论提交)、/plus/guestbook.php(留言板)、/plus/carbuyaction.php(购物提交)等几个公开提交入口,逻辑大同小异。可以把上面的 IP 加 session 拦截抽成一个公共函数 prevent_duplicate_submit() 放到 include/extend.func.php,在每个入口顶部调用一次,统一维护。如果你站点开了搜索 search.php,搜索操作通常不需要防重复(搜索本身是幂等的),但要防爬虫高频抓取,可加频率限制(同 IP 5 秒内最多 10 次)。
## 这套方案能不能完全防住爬虫脚本?
不能完全防住但能挡住 90% 的低级脚本。CSRF token 能挡住所有不解析 HTML 直接 POST 的脚本(占爬虫的大头);但高级爬虫会先 GET 表单页解析 token 再 POST,这种情况要叠加验证码(Google reCAPTCHA、阿里云验证码、腾讯云验证码任选)才能挡住。完全防住所有爬虫不现实,只要防御成本超过攻击者收益,对方就会去找下家。验证码的代价是降低真实用户的转化率(5%-15% 用户因看到验证码而放弃提交),是否启用要看业务对脏数据的容忍度与对转化率的敏感度。
## 提交后跳转thank-you页面能不能加用户提交的具体内容回显?
可以,但要小心 XSS。用户提交的内容里可能含 script 标签、img onerror 等攻击代码。如果直接拼接到 thank-you 页面 HTML 里渲染会触发 XSS。处理方法是用 PHP 的 htmlspecialchars 函数转义所有用户输入再输出:echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');。织梦自带的 dede_htmlspecialchars 函数也可以。这是 OWASP Top 10 里 A03 注入类攻击的常见入口,写表单功能时务必养成转义习惯。
## 有没有现成的织梦防重复提交插件?
织梦插件市场(dedecms.com 的扩展模块)有几个号称防重复提交的插件,但绝大多数已经多年没维护,部分插件还包含后门或恶意代码(2019-2021 年出现过多起)。建议不要装第三方插件,直接按本文方案修改源码更安全可控。如果团队没有 PHP 修改能力,可以付费请专业开发者做一次性修改,预算 200-500 元能搞定,比装来源不明的插件安全。
## 权威参考资料
## DedeCMS自定义表单数据怎么清空并重置自增ID
- URL:https://zhangwenbao.com/dedecms-empties-the-custom-form-content.html
- 分类:织梦CMS教程
- 发布:2018-11-22 | 更新:2026-06-02
- 摘要:DedeCMS的diyform表单想批量清空,细节不少。本文覆盖SQL命令行多语句模式、用information_schema批量生成TRUNCATE、外键约束错误的FOREIGN_KEY_CHECKS切换、TRUNCATE与DELETE的性能对比、mysqldump备份与binlog恢复,附五条健康检查清单。
- 关键词:织梦自定义表单,TRUNCATE TABLE,AUTO_INCREMENT,DedeCMS SQL工具
> **TLDR**:摘要:DedeCMS的diyform表单想批量清空,删了记录ID却不会归零。本文讲清原因,给出通过后台SQL命令行清空表单的完整步骤、一次清空多张表的批量写法、清空前必做的三件事、TRUNCATE与DELETE与DROP的区别,再讲ID没归零或后台报错怎么办、从备份恢复误清数据,附批量TRUNCATE后的健康检查清单。
> 摘要:DedeCMS的diyform表单想批量清空,删了记录ID却不会归零。本文讲清原因,给出通过后台SQL命令行清空表单的完整步骤、一次清空多张表的批量写法、清空前必做的三件事、TRUNCATE与DELETE与DROP的区别,再讲ID没归零或后台报错怎么办、从备份恢复误清数据,附批量TRUNCATE后的健康检查清单。
保哥在做织梦DEDECMS项目时经常遇到一个让人头大的需求:客户在测试阶段往自定义表单 (https://zhangwenbao.com/dedecms-prevents-duplicate-submission-of-custom-forms.html)里灌了几百上千条假数据,等正式上线那天,希望表单数据从零开始,而且新提交的记录ID要从1重新计数。如果只是用后台的删除按钮一条条勾选,不仅效率低,而且即使把所有记录都删掉,下次新提交的数据ID也不会从1开始,会接着原来的最大ID继续往后排,看上去就像数据不连续。这一篇文章保哥把多年来在织梦自定义表单 (https://zhangwenbao.com/decdecms-custom-form-list-css-beautification.html)清理上的踩坑经验整理出来,从原理、操作步骤、风险防范到常见疑问,带你彻底搞懂如何安全地清空自定义表单。
## 为什么删除记录之后ID不会归零
要理解这个问题,得先从MySQL的自增机制说起。织梦的自定义表单在数据库里实际上对应一张以 dede_diyform 为前缀的表,比如 dede_diyform1、dede_diyform2,表的主键 aid 通常是 INT 类型并设置了 AUTO_INCREMENT。MySQL在记录被DELETE删除时,会保留自增计数器的当前值,下一条插入仍然会用上次的最大值加一。
保哥早期不懂这个原理,曾经以为后台"清空所有数据"按钮可以让ID归零,结果上线后第一条客户提交的留言ID居然是837。这种细节看上去不影响业务,但当你需要把表单ID拼接到URL或者订单号里时,就会非常尴尬。所以正确的做法不是DELETE,而是TRUNCATE TABLE,这是MySQL官方推荐的"清空表 + 重置自增"的标准动作。
## AUTO_INCREMENT 计数器在 InnoDB 与 MyISAM 上的区别
这一点很多教程都会模糊带过,但实战中经常踩坑:
引擎 | 计数器存在哪 | 重启后是否丢失 | TRUNCATE 后行为 |
MyISAM | frm 文件元数据 | 不丢,持久化 | 归零到 1 |
InnoDB(MySQL 5.7-) | 内存里维护,启动时 SELECT MAX 重算 | 会,重启后被 MAX(id)+1 覆盖 | 归零到 1 |
InnoDB(MySQL 8.0+) | redo log 持久化 | 不丢 | 归零到 1 |
MySQL 5.7 InnoDB 上有个反直觉的现象:如果你 DELETE 了表里 ID=100 的最大记录,然后重启 MySQL,新插入的 ID 会变成 100 而不是 101——因为重启时 InnoDB 用 SELECT MAX(id)+1 重算。MySQL 8.0 已经修复了这个行为。织梦绝大部分站点都跑 5.7,所以 DELETE 后的 ID 行为依赖于是否近期重启过 MySQL,更要养成用 TRUNCATE 的习惯。
## 通过后台SQL命令行工具清空表单的完整步骤
织梦自带一个非常实用的SQL命令行工具,登录后台之后路径是"系统 - 系统设置 - SQL命令行工具"。保哥推荐优先用这个工具,因为它会强制使用织梦预设的数据库连接,避免你手动连数据库时连错环境(生产/测试搞混过的同行应该都懂这种痛)。
第一步,先确认你要清空的自定义表单到底是 diyform 几号。进入"核心 - 频道模型 - 自定义表单管理",列表里每一行最右边的"ID"就是 diyid。比如保哥这边的"在线留言"表 diyid 是 1,那么对应的物理表名就是 dede_diyform1。
第二步,打开 SQL 命令行工具,把下面的语句粘贴进去:
TRUNCATE TABLE `dede_diyform1`;
注意三个细节:表名要用反引号包起来;语句末尾分号必须有;前缀 dede_ 不一定固定,如果你安装时改过表前缀,就要换成自己的。点击"确定"执行,后台返回"执行成功"说明已经搞定。
第三步,回到自定义表单的前台测试页面,随便提交一条新数据,再去后台看看记录的 aid,应该就是 1 了。
## 用 SHOW CREATE TABLE 验证清空前后的 AUTO_INCREMENT 值
验证 TRUNCATE 是否真的把 AUTO_INCREMENT 归零,最直接的方法是 SHOW CREATE TABLE:
SHOW CREATE TABLE `dede_diyform1`\G
-- 输出末尾会有 AUTO_INCREMENT=837(清空前)
-- TRUNCATE 后再跑一次,AUTO_INCREMENT 字段会消失或显示 AUTO_INCREMENT=1
有些客户站后台显示"清空成功"但实际没归零,原因往往是某些二开插件在 dede_diyform1 上加了 BEFORE INSERT 触发器,从外部计数器读 ID 写回——这种情况就要找到具体触发器把它禁用或修改:
SHOW TRIGGERS LIKE 'dede_diyform1'\G
## 一次清空多张自定义表单的批量写法
如果你的站点同时有十几张自定义表单都需要清空(保哥之前接过一个二手车站,有报价、预约、贷款、试驾四张表),逐条执行TRUNCATE太啰嗦,可以写成多语句:
TRUNCATE TABLE `dede_diyform1`;
TRUNCATE TABLE `dede_diyform2`;
TRUNCATE TABLE `dede_diyform3`;
TRUNCATE TABLE `dede_diyform4`;
织梦后台的SQL命令行工具默认支持多语句模式,把上面这一段整体粘贴进去就能一次性执行。如果你想更省事,可以用 INFORMATION_SCHEMA 自动生成所有 diyform 表的清空语句:
SELECT CONCAT('TRUNCATE TABLE `', table_name, '`;') AS sql_text
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'dede_diyform%'
AND table_name <> 'dede_diyforms';
保哥习惯先用这条语句把要执行的命令查出来,肉眼确认没有清空错的表(比如 dede_diyforms 是字段定义表,绝对不能 TRUNCATE!),再把结果整体复制到 SQL 工具里执行。这一步是保哥从一次惨痛事故里总结出来的:当年我曾经误用 LIKE 'dede_diyform%' 把 dede_diyforms 也清空了,结果整套自定义表单结构全部丢失,恢复花了一个晚上。注意上面的 SQL 我特意加了 table_name 不等于 dede_diyforms 的过滤,这是从那次事故之后我固定的写法。
## 带白名单的"安全清空脚本"
给客户的运维脚本里,我会再加一层显式白名单,避免新人误删:
-- 仅允许下列表名被 TRUNCATE,超出列表则不生成 SQL
SET @whitelist = 'dede_diyform1,dede_diyform2,dede_diyform3,dede_diyform4';
SELECT CONCAT('TRUNCATE TABLE `', table_name, '`;') AS sql_text
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND FIND_IN_SET(table_name, @whitelist) > 0;
把 @whitelist 当成保险栓,任何不在显式列表里的表都不会出现在生成的 SQL 中。在客户量大、运维交接频繁的场景里,这一层保护值千金。
## 清空表单前必须做的三件事
保哥反复强调一句话:DEDECMS的SQL命令行工具是危险品,下手之前必须做足准备。
第一件事是备份数据库。最稳妥的做法是直接登录服务器执行 mysqldump:
mysqldump -uroot -p --single-transaction --quick --routines --triggers --events \
--databases dedecms_db \
> /backup/dedecms_$(date +%Y%m%d_%H%M%S).sql
各参数含义:
- --single-transaction:在一个 REPEATABLE READ 事务里读所有数据,不锁表,适合 InnoDB
- --quick:逐行流式输出,避免大表把内存撑爆
- --routines:导出存储过程和函数
- --triggers:导出触发器(默认是开的,但显式写更清晰)
- --events:导出事件调度器
如果没有SSH权限,也可以在织梦后台"系统 - 数据库备份/还原"里点一次完整备份,把生成的文件下载到本地。备份的目的不是恢复整库,而是给自己留一个反悔的余地——保哥见过太多"执行TRUNCATE之后,客户突然问 ID=237 那条客户名片去哪了"的尴尬场面。
第二件事是确认 diyid 和真实表名一致。织梦后台显示的"ID"是 diyid,对应物理表 dede_diyformN,但如果你的站经历过历史数据迁移,这个对应关系不一定可靠。保哥的做法是先到 phpMyAdmin 里 SELECT * FROM dede_diyform1 LIMIT 5; 看看里面的字段是不是你想清空的那张表的字段,再决定下一步。
第三件事是关闭前台表单提交入口或者贴一个临时维护页。否则你 TRUNCATE 完,几秒钟后又有用户提交了,aid 会变成 1,但你心里以为它是清空之后的第一条,过几天对账就会发现数据少了。最简洁的关停方法是在 Nginx 里给 /plus/diy.php 加临时 deny:
location = /plus/diy.php {
return 503 'Maintenance in progress';
}
TRUNCATE 完测试通过后再把这段 location 删掉、reload Nginx。
## TRUNCATE、DELETE、DROP三者的区别
这三个SQL关键字在中文资料里经常被混用,但行为完全不一样,保哥用一段日常比喻帮你一次记住。
-- DELETE:逐条删,可回滚,自增不重置
DELETE FROM `dede_diyform1` WHERE 1=1;
-- TRUNCATE:清空表数据,不可回滚,自增归零
TRUNCATE TABLE `dede_diyform1`;
-- DROP:连表结构一起删,必须重新建表
DROP TABLE `dede_diyform1`;
DELETE 像是把书柜里的书一本本搬出来,书柜结构和编号还在;TRUNCATE 像是把整个书柜清空再重新贴"1号格"标签;DROP 则是把书柜本身砸了。在织梦自定义表单的场景里,99%的需求都是 TRUNCATE:表结构保留、ID 重置、效率高。
## 三者的性能对比与实测耗时
操作 | 1 万行耗时 | 100 万行耗时 | binlog 体积 | 是否锁表 |
DELETE FROM | 1.8 秒 | 156 秒 | 每行一条 row event,巨大 | 行锁,可能升级 |
TRUNCATE TABLE | 0.04 秒 | 0.06 秒 | 一条 DDL,几十字节 | 瞬时元数据锁 |
DROP + CREATE | 0.12 秒 | 0.15 秒 | 两条 DDL | 瞬时元数据锁 |
TRUNCATE 在大表上几乎是常数时间——本质是 DROP 数据文件再 CREATE 新文件,跟数据量无关。DELETE 在 100 万行上要 156 秒,期间 binlog 还会膨胀几个 GB,主从复制延迟会被拉爆,绝对不要在生产用 DELETE 清表。
顺便一提,TRUNCATE 在 InnoDB 引擎下是 DDL 级别操作,不会触发 ON DELETE 触发器,也不会写入 binlog 的 row 模式记录(取决于你的 MySQL 配置),所以如果你做了主从复制或基于触发器的审计日志,记得先评估一下这两个副作用。
## 清空之后ID没归零或后台报错怎么办
保哥总结了几种常见的故障场景。
第一种:执行完TRUNCATE但新数据 aid 还是从老的最大值开始。这通常是因为你执行的是 DELETE 而不是 TRUNCATE,或者你站点开启了一种叫"ID池"的自定义插件,会从插件表里读 ID。检查一下 dede_sys_module 和插件目录有没有相关代码。
第二种:执行 TRUNCATE 时报 Cannot truncate a table referenced in a foreign key constraint。这说明你的 diyform 表被另一张表用外键引用了。织梦默认是没有外键的,但有些二开版本会加。解决方案是先临时关掉外键检查:
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE `dede_diyform1`;
SET FOREIGN_KEY_CHECKS = 1;
第三种:清空之后前台表单提交报错"字段不存在"。这往往是因为表单结构在 dede_diyforms(注意带 s)里的定义和实际表的字段不一致,跟 TRUNCATE 没关系,需要进"自定义表单管理 - 编辑"把字段重新保存一次,让织梦重新生成表结构 SQL。
第四种:阿里云 RDS / 腾讯云 CDB 上 TRUNCATE 报权限错误。云厂商的"高权限"账号默认没给 DROP / TRUNCATE 权限,必须在控制台单独申请。临时方案是用 DELETE + ALTER TABLE AUTO_INCREMENT=1 组合替代,性能差但能跑通。
第五种:执行后织梦后台显示"数据库连接已断开"。这是因为 TRUNCATE 是 DDL,会刷新表的元数据 cache,部分织梦版本对元数据变更的处理不够鲁棒。退出后台重新登录即可,数据本身没问题。
## 从备份恢复误清空数据的实战
万一你跳过了备份直接 TRUNCATE 出了事,还有两条挽救路径:
## 路径 A:从 mysqldump 备份恢复单表
# 从全库备份里抽取单表的 INSERT 数据
sed -n '/-- Dumping data for table `dede_diyform1`/,/-- Dumping/p' \
/backup/dedecms_20260511_023000.sql > /tmp/restore_diyform1.sql
# 先在 MySQL 里 TRUNCATE 当前空表,再导入
mysql -u root -p dedecms_db -e 'TRUNCATE TABLE dede_diyform1;'
mysql -u root -p dedecms_db < /tmp/restore_diyform1.sql
这种方式恢复的是备份时点的数据。如果 TRUNCATE 是凌晨 3 点做的、备份是凌晨 2 点做的,那中间 1 小时新增的数据是恢复不回来的。
## 路径 B:从 binlog 恢复 TRUNCATE 后的新增数据
如果 TRUNCATE 之后又新增了数据,路径 A 恢复完后还要把这段 binlog 重放:
mysqlbinlog --start-datetime='2026-05-11 03:00:00' --stop-datetime='2026-05-11 09:00:00' \
/var/lib/mysql/binlog.000123 \
| grep -A 20 'dede_diyform1' \
| mysql -u root -p dedecms_db
但前提是 binlog_format=ROW、且 binlog 没被自动删除。云厂商托管 MySQL 的 binlog 一般只保留 7 天,超过这个窗口就只能放弃。
## 批量 TRUNCATE 后的健康检查清单
每次大批量清空之后,保哥的 5 条健康检查清单:
- SHOW CREATE TABLE 每张表,确认 AUTO_INCREMENT 都回到 1(或没列出该字段)
- 提交一条测试数据,前台后台都能看到 aid=1
- 检查 dede_diyforms 元数据表行数没变(说明只清了数据没清结构)
- 主从复制延迟 SHOW SLAVE STATUS\G 看 Seconds_Behind_Master 是否回到 0
- 用 mysqlcheck -A 跑一遍表完整性,确认没坏表
## 常见问题解答
## TRUNCATE之后能用binlog恢复数据吗?
保哥的实测结论是:在 MySQL 5.7/8.0 默认 row 模式下,TRUNCATE 会作为 DDL 写入 binlog,但它不像 DELETE 那样保留每一行的反向 SQL,所以理论上无法直接通过 mysqlbinlog 还原行数据。最稳妥的恢复方式仍然是事前备份加事中观察。如果 TRUNCATE 之前的 INSERT 还在 binlog 里没被冲刷掉,可以反向解析这些 INSERT 重新写一遍,但操作复杂且容易出错,远不如事前备份省事。
## 织梦的SQL命令行工具支持事务吗?
不支持。SQL 工具是一次性把语句发给数据库执行,没有 BEGIN/COMMIT 的概念。即使你写 START TRANSACTION 它也只是当作一条普通语句执行,TRUNCATE 本身又是隐式提交的 DDL,所以一旦点确定就不可逆。如果你真的需要事务保护,应该走命令行 mysql 客户端或者 Navicat 这类客户端工具,手工开 BEGIN/COMMIT 包住一组 DML。
## 清空了表单数据,会不会影响后台频道模型自定义表单里的字段配置?
不会。字段配置存放在 dede_diyforms 这张元数据表里,跟具体的 dede_diyform1 数据表是分开的,TRUNCATE 数据表不会动配置。但是反过来,如果你不小心 TRUNCATE 了 dede_diyforms,那所有自定义表单的结构定义就全没了,这是真正的灾难。本文上面的安全清空脚本特意排除了 dede_diyforms,正是为了防这种事故。
## 能不能用ALTER TABLE AUTO_INCREMENT=1代替TRUNCATE?
可以,但前提是你已经把表里的所有记录都删掉了。先 DELETE FROM 再 ALTER TABLE AUTO_INCREMENT=1 这种写法会触发 DELETE 的逐行操作和 ALTER 的元数据修改,性能比 TRUNCATE 慢得多,对大表非常不友好。保哥建议除非你需要保留 binlog 行级回滚能力,否则直接用 TRUNCATE 更清爽。在云数据库受限场景(高权限账号没 TRUNCATE 权限)下,这种组合是退而求其次的方案。
## 阿里云RDS、腾讯云CDB上TRUNCATE报权限错误怎么办?
云厂商的"高权限"账号默认不给 TRUNCATE/DROP/ALTER 权限,避免误删。如果业务允许,可以在控制台开通这两个权限——阿里云在"账号管理"里可以单独勾"DDL 权限"。如果不能开通,用 DELETE 加 ALTER AUTO_INCREMENT 的组合替代。另外提醒:云数据库的 binlog 保留期一般只有 7 天,比自建 MySQL 短,恢复窗口要算好。
## TRUNCATE 会触发 ON DELETE 触发器吗?
不会。TRUNCATE 是 DDL,跳过 DELETE 触发器、跳过 ON DELETE CASCADE 外键级联、跳过 row 格式的 binlog 行事件。如果你的业务里依赖触发器记录审计日志(每删一行写一条审计),用 TRUNCATE 后会丢这些审计——清空前要先评估这一点,必要时用 DELETE 替代。
## 清空之后再 INSERT,能不能直接指定起始 aid 比如从 1001 开始?
可以。TRUNCATE 后先 ALTER TABLE dede_diyform1 AUTO_INCREMENT=1001,然后再 INSERT,新行 aid 就从 1001 开始。常见用法:客户希望表单 ID 看起来"有量",不要从 1 开始那么寒酸,就预设一个 1001 或 5001 的起跳点。但要避免在已有数据的表上 ALTER 到一个比 MAX(id) 小的值,那样 InnoDB 会自动忽略你的设定,跳回到 MAX(id)+1。
## TRUNCATE 和 mysqlcheck repair 有什么关系?
没有直接关系。TRUNCATE 是清空数据;mysqlcheck repair 是修复损坏的 MyISAM 表。但有个隐性联系:MyISAM 表在大量 INSERT/DELETE 之后碎片化严重,TRUNCATE 一次相当于重建数据文件,文件碎片消失,相当于做了一次彻底的 OPTIMIZE。InnoDB 上没有这个效果(InnoDB 的 .ibd 文件依然是新分配的)。
## 权威参考资料
## DedeCMS自定义表单怎么做必填校验?服务端、JS、HTML5三层防护
- URL:https://zhangwenbao.com/dedecms-custom-form-settings-required-items.html
- 分类:织梦CMS教程
- 发布:2018-11-21 | 更新:2026-06-02
- 摘要:DedeCMS自定义表单默认不校验必填项,也挡不住灌水。本文给出三层校验:服务端在trim后加必填与字段名格式校验防注入、原生JS不依赖jQuery做即时反馈、HTML5的required与pattern兜底,再扩展reCAPTCHA v3评分、双token CSRF防护和IP频率限制。
- 关键词:织梦自定义表单,reCAPTCHA,DedeCMS自定义表单,表单必填,前端校验
> **TLDR**:摘要:DedeCMS自定义表单默认不校验必填项,也挡不住灌水。本文给三层缺一不可的校验——服务端改plus/diy.php在trim后加必填与字段名格式校验防注入、前端原生JS不依赖jQuery做即时反馈、HTML5的required与pattern兜底,再扩展reCAPTCHA防机器人、双token的CSRF防护和IP频率限制,附常见故障排查。
> 摘要:DedeCMS自定义表单 (https://zhangwenbao.com/decdecms-custom-form-list-css-beautification.html)默认不校验必填项,也挡不住灌水。本文给三层缺一不可的校验——服务端改plus/diy.php在trim后加必填与字段名格式校验防注入、前端原生JS不依赖jQuery做即时反馈、HTML5的required与pattern兜底,再扩展reCAPTCHA防机器人、双token的CSRF防护和IP频率限制,附常见故障排查。
DedeCMS 自定义表单 (https://zhangwenbao.com/dedecms-custom-form-verification-mobile.html)(diy)模块默认所有字段都是可选的,留言、报名、咨询等场景里用户可以提交一份空表单。运营拿到一堆没有联系方式的留言完全没用。本文给出 DedeCMS 自定义表单必填项校验的三层完整方案:服务端 plus/diy.php 强制校验、前端 JS 即时反馈、HTML5 原生校验,并扩展到字段格式校验(手机号、邮箱、URL 正则)、图形验证码防机器人、CSRF token 双 token 防护、必填规则配置化、错误信息友好显示。本文与本站“DedeCMS 自定义表单 CSS 美化”(cid 822)配套使用,是同一组 diy 模块的功能加固而不是 UI 美化。
## 方案设计:三层校验缺一不可
## 为什么要三层
- 服务端校验是底线,永远不能省。攻击者构造 POST 请求绕过前端校验是基本操作。
- 前端 JS 校验是体验。用户填错某个字段时不应该等提交后才告知,要在 blur(失去焦点)时立刻红框提示。
- HTML5 校验是兜底。input type=email、required 这种属性让浏览器原生支持基础校验,无需 JS。
三层任一缺失都有问题。只做服务端:用户体验差。只做前端:黑产秒绕过。只做 HTML5:不灵活、错误提示不可定制。
## 服务端校验:plus/diy.php 改造
## 原文方案的强化版
编辑 plus/diy.php,在第 40 行附近找到:
$dede_fields = empty($dede_fields) ? '' : trim($dede_fields);
下面插入完整的必填校验逻辑:
/* ===== DIY 表单必填项校验 START ===== */
if (!empty($required)) {
$required_fields = array_filter(array_map('trim', explode(',', $required)));
$missing_fields = [];
$field_labels = [];
/* 取字段中文标签(如果传了 required_labels 参数) */
if (!empty($required_labels)) {
$labels_arr = array_filter(array_map('trim', explode(',', $required_labels)));
$field_labels = array_combine($required_fields, array_pad($labels_arr, count($required_fields), ''));
}
foreach ($required_fields as $field) {
/* 防止变量名注入:限定字符集 */
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,30}$/', $field)) {
continue;
}
$value = isset($GLOBALS[$field]) ? $GLOBALS[$field] : '';
if (is_string($value)) $value = trim($value);
if ($value === '' || $value === null || (is_array($value) && empty($value))) {
$label = !empty($field_labels[$field]) ? $field_labels[$field] : $field;
$missing_fields[] = $label;
}
}
if (!empty($missing_fields)) {
$msg = '以下字段为必填,请补充:' . implode('、', $missing_fields);
ShowMsg($msg, '-1');
exit();
}
}
/* ===== DIY 表单必填项校验 END ===== */
## 相比原方案的改进
- 统一处理多字段:原方案在 explode 之后用嵌套 if-else 处理“单字段 vs 多字段”,本版本用 array_filter 统一。
- 字段名格式校验:限定 required 参数只接受字母开头 + 字母数字下划线的合法变量名,防止攻击者传 required=";rm -rf /;" 这种注入。
- 友好错误提示:通过 required_labels 参数传入字段中文名(如“姓名,邮箱”),提示信息从“字段 name 不能为空”变成“以下字段为必填:姓名、邮箱”。
- 批量错误提示:用户一次填错多个字段时一次性显示所有缺失项,不是改一个再发现下一个错。
- 处理多种空值:trim 之后的空字符串、null、空数组都判定为“未填”。
## 表单端的传参
表单 HTML 模板里,在 form 内加两个隐藏字段:
required 字段名与 required_labels 中文名按相同顺序对齐。
## 前端 JS 即时校验
## JS 实现
把以下 JS 加到模板末尾或单独 .js 文件:
## 关键设计点
- 不依赖 jQuery:原文方案用了 jQuery,但 DedeCMS 默认不引入。纯原生 JS 减少依赖。
- 渐进式提示:blur 时校验该字段,input 时清除错误。用户改错过程中体验流畅。
- 提交时全量校验:防止用户从未 focus 过某些字段导致 blur 没触发的情况。
- 正则按字段名启发:name 含 email 的自动套邮箱正则,含 phone/mobile 的套手机号正则。无需手动配置每个字段的格式。
## HTML5 原生校验兜底
给关键字段加 HTML5 属性:
浏览器看到 required 属性,提交时如果对应字段为空会自动弹出原生提示阻止表单发送。pattern 正则做格式校验。这一层不需要 JS。
缺点:原生提示样式不可定制(不同浏览器风格不同),错误信息也是浏览器自己的话术。生产环境一般用 JS 接管校验提示,但保留 HTML5 属性作为兜底。
## 验证码防机器人
## 为什么需要验证码
必填项校验只防“空表单”,挡不住“填了垃圾内容的表单”。机器人能填上 abc@xxx.com、13800000000 这种格式合法但虚假的数据,绕过所有上面的校验。验证码是防自动化提交的最后一道防线。
## 方案 A:DedeCMS 自带验证码
diy.php 在表单提交时自动校验 vdcode 字段(如果模板里有的话)。模板里加:
plus/diy.php 默认会校验 vdcode,无需额外代码。但 DedeCMS 自带验证码只是 4 位简单字符,OCR 识别率高(自动机器人能识别 90%+)。
## 方案 B:Google reCAPTCHA
更可靠的选择是接入 reCAPTCHA v3(无感验证):
plus/diy.php 校验时调 Google API:
$token = $_POST['g-recaptcha-token'];
$secret = 'YOUR_SECRET_KEY';
$verify = file_get_contents('https://www.google.com/recaptcha/api/siteverify?secret=' . $secret . '&response=' . $token);
$data = json_decode($verify, true);
if (empty($data['success']) || $data['score'] < 0.5) {
ShowMsg('系统检测到异常请求,请稍后重试', '-1');
exit();
}
v3 评分越高越像真人,0.5 是常见阈值。
但 Google reCAPTCHA 在中国大陆访问受限,国内用户体验差。备选方案:geetest(极验)、hCaptcha、aliyun 行为验证。
## CSRF 防护
## 问题描述
攻击者在另一个网站放一段代码:
用户访问攻击者网站时浏览器会自动带上 your-site.com 的 cookie 提交表单,导致虚假提交。
## 双 token 防护
表单页加载时生成 token 写入 session,提交时校验:
// plus/diy.php 在表单页输出前
session_start();
if (empty($_SESSION['diy_csrf'])) {
$_SESSION['diy_csrf'] = bin2hex(random_bytes(16));
}
$csrf_token = $_SESSION['diy_csrf'];
// 表单 HTML
echo '';
// 提交校验
if (empty($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['diy_csrf']) {
ShowMsg('表单失效,请刷新后重试', '-1');
exit();
}
unset($_SESSION['diy_csrf']); // 一次性 token
## SameSite Cookie
另一层防护:让 PHPSESSID cookie 设 SameSite=Lax 或 Strict,浏览器不会在跨站请求时自动带 cookie。在 PHP 入口加:
session_set_cookie_params([
'samesite' => 'Lax',
'secure' => true,
'httponly' => true,
]);
session_start();
## 常见故障
## 故障 1:必填校验不生效
三个排查点:required 隐藏字段是否正确传到了 plus/diy.php;diy.php 改的代码位置是否在 dopost 分支之前;浏览器禁用了 JS 但服务端校验代码也没改对。
## 故障 2:错误提示中文乱码
diy.php 文件编码与模板编码不一致。统一改成 UTF-8 无 BOM。Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html) 转换:编码 - 转为 UTF-8(无 BOM)。
## 故障 3:必填字段填了仍提示空
多数是 trim 后空字符串。检查 input 是否有自动填充的不可见字符。或者 required 字段名拼写错(区分大小写)。
## 故障 4:reCAPTCHA 验证失败
常见:site key 与 secret key 配错(一个是前端用,一个是后端用,互不兼容);服务器到 google.com 网络不通;token 过期(超过 2 分钟)。
## 故障 5:CSRF token 校验通不过
session 没启动(session_start 没在最开头调用);token 在多标签页打开时被覆盖(每打开一次表单就生成新 token,老 token 失效)。建议改成“token 在 session 里存数组,校验后 unset 单条不影响其它”。
## 故障 6:HTML5 required 在 iOS Safari 上不弹提示
iOS Safari 对 required 属性提示样式弱(仅红色边框,无文字)。如果你需要明显提示,必须叠加 JS 校验。
## 常见问题解答
## 必填项配置在表单 HTML 里好还是在数据库里好?
HTML 隐藏字段(本文方案)维护成本低,每个表单独立配置。数据库存储更灵活但需要后台管理界面。中小项目用 HTML 即可。
## 能否给文件上传字段做必填?
能。HTML:。服务端校验:if (empty($_FILES['photo']) || $_FILES['photo']['error'] !== 0) { ShowMsg('请上传照片', '-1'); exit(); }。
## 多选 checkbox 怎么校验必填?
checkbox 至少选一个。HTML 里 required 只对单个 checkbox 生效。多选必填要 JS 写:if (!form.querySelectorAll('input[name="hobbies[]"]:checked').length) { ... }。
## 必填校验影响搜索引擎抓取吗?
不影响。校验只在提交时生效,搜索引擎只 GET 不 POST。但表单提示文字(“* 必填”)会被抓到首页 description,注意控制不要污染。
## 能否做条件必填(A 字段填了 B 字段才必填)?
能。JS 监听 A 字段变化,A 有值时给 B 加 required 属性,A 空时移除。服务端校验也按这个条件。
## 表单提交后页面跳转白屏?
多数是 ShowMsg 函数路径错误(dialog 路径找不到)。检查 plus/diy.php 顶部的 require 路径是否被改动过。
## 校验信息能否多语言?
能。把所有提示文字抽到 languages/zh_cn/diy.php,国际化时分别提供 en、ja 等版本。
## 能否限制同一 IP 频繁提交?
能。在 plus/diy.php 加 IP 频率限制:$key = 'diy_submit_' . md5($_SERVER['REMOTE_ADDR']); $count = (int)$cache->get($key); if ($count > 5) exit('提交过于频繁'); $cache->set($key, $count + 1, 60);。需要 Redis 或 Memcached 做后端。
## 验证码刷一下还是错?
session 路径问题或者验证码图与提交是不同 session 上下文(cookie 域名不一致)。检查 PHPSESSID cookie 是否在两次请求间保持一致。
## 表单提交后能否邮件通知运营?
能。在 plus/diy.php 校验通过后调 mail 函数:mail('admin@example.com', '新表单提交', '内容...');。生产环境用 PHPMailer 通过 SMTP 发更可靠。
## 权威参考资料
## DedeCMS留言板SQL注入1行addslashes修复实战
- URL:https://zhangwenbao.com/dedecms-message-board-edit-inc-php-injection-vulnerability-repair-method.html
- 分类:织梦CMS教程
- 发布:2018-07-16 | 更新:2026-05-16
- 摘要:DedeCMS留言板edit.inc.php的msg参数未做转义存在SQL注入。本文给出一行addslashes的最小补丁、grep与sqlmap验证、Nginx拦截规则与fail2ban封禁配置,并附完整应急响应案例与/plus目录加固清单。
- 关键词:织梦漏洞,织梦SQL注入,织梦留言板,DedeCMS,DedeCMS安全
> **TLDR**:摘要:DedeCMS留言板的edit.inc.php对msg参数没做转义,存在SQL注入。本文给出一行addslashes的最小补丁,用grep定位和sqlmap验证,再配Nginx拦截规则与fail2ban封禁,并附一个完整的应急响应案例和plus目录的加固清单,帮你把这个老洞快速堵上。
> 摘要:DedeCMS留言板的edit.inc.php对msg参数没做转义,存在SQL注入。本文给出一行addslashes的最小补丁,用grep定位和sqlmap验证,再配Nginx拦截规则与fail2ban封禁,并附一个完整的应急响应案例和plus目录的加固清单,帮你把这个老洞快速堵上。
保哥这些年帮客户做织梦站点维护,遇到最多的就是/plus/目录下那一堆历史悠久的入口文件留下的SQL注入 (https://zhangwenbao.com/dedecms-membership-center-pm-php-injection-vulnerability-repair-method.html)漏洞。edit.inc.php是其中一个老熟人,它隶属于留言板模块(guestbook),负责处理留言的修改和回复操作。早期版本对用户提交的msg参数没有做严格的转义,攻击者可以构造特殊的payload让数据库执行额外的SQL语句,轻则改库数据,重则脱裤拖站。
这篇笔记把我自己处理这个漏洞的完整流程整理出来,包括复现思路、补丁原理、改动位置、回归验证、以及后续的加固建议。我尽量避免空话,直接贴改动点和我自己跑过的命令,方便同样在维护老站的朋友照着做。
## 一、漏洞背景与受影响范围
这个洞最早是2017年前后被白帽提交到漏洞平台的,影响DedeCMS V5.7 SP1及之前的多个分支版本。问题文件路径固定在/plus/guestbook/edit.inc.php,触发条件是:网站启用了留言板功能(默认大多数站点都启用),且攻击者可以访问留言板提交入口。
我整理出来的判断标准很简单:
- 服务器目录里能找到/plus/guestbook.php与/plus/guestbook/edit.inc.php这两个文件
- 没有上过官方的安全补丁,或者用的是被二次开发改过的留言板模块
- 后台“系统”-“系统基本参数”里留言板相关项处于开启状态
如果这三条都命中,那么这个洞就是实打实存在的,必须当天处理掉。我自己接手过一台被植入webshell的站点,回溯日志后定位到的就是这个入口被反复探测,所以不要抱侥幸心理。
我维护的另一个客户站点(教育培训行业)启用了留言板做客户咨询入口,2019年被同行业竞争对手 (https://zhangwenbao.com/competitor-outranking-seo-analysis-strategy.html)雇人做了渗透,进入入口就是这个edit.inc.php。事后客户反思:业务上根本没人用留言板,但因为织梦默认开启,又没有专门关掉,结果成了攻击面。
## 二、漏洞成因的代码级分析
打开/plus/guestbook/edit.inc.php,定位到处理UPDATE语句的那段代码,大约在第55、56行附近,原始写法是这样的:
$dsql->ExecuteNoneQuery("UPDATE `#@__guestbook` SET `msg`='$msg', `posttime`='".time()."' WHERE id='$id' ");
ShowMsg("成功更改或回复一条留言!", $GUEST_BOOK_POS);这段代码的问题非常典型:$msg变量直接拼进SQL字符串,没有经过addslashes或参数化处理。织梦自己有一套GetCkVdValue、HtmlReplace之类的过滤函数,但这里偏偏没用。当$msg包含单引号时,整条SQL的语义就被截断,后面任意拼接的payload都会被数据库解释执行。
我做过一次内部演示,构造一个最简单的payload,比如把msg设置成xx'闭合后接子查询语句,就能把当前数据库连接用户名灌到ip字段里。如果换成UPDATE嵌套或者通过盲注读mysql.user表,危害就更直接了。
更危险的是,织梦的留言板编辑接口允许通过POST直接调用,不需要任何额外的token校验或CSRF防护。这意味着攻击者写一个简单的Python脚本,几分钟内就能爆破式打几百个站点。我曾经在Google搜inurl:plus/guestbook,搜到的结果里有相当一部分是没修过这个洞的旧站,全部公开可达。
## 三、我使用的临时修复补丁
官方补丁迟迟未出的那段时间,我和团队用的是最小改动的临时方案:在UPDATE执行前对$msg调用addslashes。改完之后这两行变成:
$msg = addslashes($msg);
$dsql->ExecuteNoneQuery("UPDATE `#@__guestbook` SET `msg`='$msg', `posttime`='".time()."' WHERE id='$id' ");
ShowMsg("成功更改或回复一条留言!", $GUEST_BOOK_POS);注意我故意保留了#@__guestbook这个表名前缀占位符,而不是写死dede_guestbook。原因是织梦允许通过config.cache.inc.php自定义表前缀,写死表名会让换前缀的站点直接报错。这是我踩过的坑,特别提一下。
保存文件之后,记得检查文件编码。织梦默认是GBK,如果用VS Code打开后顺手保存成了UTF-8,前台可能出现乱码或者干脆白屏。我现在的习惯是:在本地用Notepad++ (https://zhangwenbao.com/use-notepad-to-batch-delete-blank-lines-in-the-code.html)打开,确认编码为ANSI(GBK),改完直接覆盖上传。也可以用iconv命令做一次显式转码:iconv -f UTF-8 -t GBK edit.inc.php -o edit.inc.php.gbk 然后再覆盖。
如果你用的是FTP上传,注意有些FTP客户端默认按ASCII模式传输,会破坏中文注释。建议切换到二进制模式(FileZilla里"传输"-"传输类型"选"二进制")再上传。
## 四、验证补丁是否生效的几种方法
光改完代码还不够,我会跑一遍下面这套验证流程:
## 黑盒验证
用curl模拟一次正常的留言修改请求,把msg字段里塞一个单引号,看返回是否是ShowMsg的成功提示,以及数据库里这条留言是否原样保存了带引号的内容:
curl -X POST 'https://example.com/plus/guestbook/edit.inc.php' \
--data "action=editmsg&id=1&msg=test%27single&validate=xxxx"如果数据库里的msg字段值变成test\'single(被转义成功),说明补丁生效。如果直接报SQL语法错误,说明补丁没加上去或者位置加错了。
## 白盒检查
直接grep一下addslashes是否出现在了正确位置:
grep -n "addslashes" /www/wwwroot/example.com/plus/guestbook/edit.inc.php应该能看到$msg = addslashes($msg);这一行就在UPDATE语句之前。如果grep结果为空,说明文件没改成功,需要重新上传一次。
## 日志回溯
我习惯把nginx的access.log用awk过滤一下,看最近七天有没有针对edit.inc.php的高频请求:
awk '$7 ~ /\/plus\/guestbook\/edit.inc.php/ {print $1, $4, $7}' access.log | sort | uniq -c | sort -rn | head -20如果发现同一个IP在短时间内反复请求这个文件,几乎可以确定是有自动化扫描器在打。这种情况下除了打补丁,还得在WAF或者nginx层面把这个IP封掉。
我维护客户站点时,会再加一步:用sqlmap跑一次自动化注入扫描,确认补丁后扫描不出injectable。完整命令是sqlmap -u "目标URL" --data "action=editmsg&id=1&msg=test" --batch --level=3 --risk=2。如果sqlmap报告"all tested parameters do not appear to be injectable",才算修干净。
## 五、长期加固建议
临时补丁只是把这一个洞堵上,织梦的/plus/目录下还有不少类似设计缺陷的文件。我维护客户站点时一般会做以下几件事:
第一,把不用到的/plus/文件直接删掉或者重命名。比如recommend.php、search.php、download.php、guestbook.php这些如果业务上用不到,留着就是给攻击面送人头。我个人的清理脚本会把整个/plus/目录权限改成0750,再单独把业务用到的几个文件改回0755,这样攻击者即使知道文件路径也无法读取。
第二,在nginx层面对/plus/目录做referer和user-agent限制,至少能挡掉相当一部分自动化扫描:
location ^~ /plus/ {
if ($http_user_agent ~* (sqlmap|nikto|nmap|masscan|acunetix|wpscan)) {
return 444;
}
if ($request_method !~ ^(GET|POST)$) {
return 444;
}
}第三,给数据库账号做权限收敛。织梦默认用一个权限很高的账号连数据库,我会专门为站点建一个只对当前库有SELECT/INSERT/UPDATE/DELETE权限的账号,这样即便SQL注入被打穿,攻击者也没法LOAD_FILE或写webshell。具体MySQL命令是CREATE USER + GRANT SELECT,INSERT,UPDATE,DELETE ON dbname.* TO username。
第四,开启慢查询日志和general_log一段时间,观察是否有异常SQL。这一步对老站点尤其重要,能把潜伏的攻击行为揪出来。general_log非常占磁盘,建议只开几个小时做采样,不要长期开。
第五,认真考虑迁移。织梦DedeCMS已经停止官方维护多年,新出的高危漏洞基本都靠社区打补丁。我自己的客户站点这两年陆续迁到了Typecho或者静态化方案,长远看比一直打补丁省心得多。Typecho社区活跃、安全响应快、内容模型简单,对中小站点来说是个不错的替代品。
## 六、配套的fail2ban监控规则
修完代码之后,建议再加一道操作系统级的防护。我用fail2ban做这个,规则文件保存在/etc/fail2ban/filter.d/dedecms-guestbook.conf:
[Definition]
failregex = ^.*"(GET|POST) /plus/guestbook/edit\.inc\.php.*"(?!200)
ignoreregex =然后在/etc/fail2ban/jail.local里启用这个规则:
[dedecms-guestbook]
enabled = true
filter = dedecms-guestbook
logpath = /www/wwwlogs/example.com.log
maxretry = 5
findtime = 600
bantime = 86400意思是10分钟内同一个IP对edit.inc.php发起5次非200响应的请求,就把这个IP封禁24小时。我在客户站点上跑这条规则两年多,每月平均封禁300+个扫描IP,没有一次误封。
## 七、我自己处理过的一次完整案例
2024年初,有个客户的旧站被挂了博彩黑链。我接手之后的处理时间线大致是:
- 第一小时:拉下整站源码和数据库,diff官方原版找出被改动的文件,定位到header.php被注入了base64编码的JS
- 第二小时:检查access.log,发现/plus/guestbook/edit.inc.php在三天前被反复POST,参数里带有union select关键字
- 第三小时:确认管理员密码哈希被改动过,回溯到攻击者通过SQL注入读出旧哈希后撞库的可能性
- 第四小时:修复edit.inc.php的注入点,重置所有后台账号密码,删除/plus/下未使用的文件,配置fail2ban监控nginx日志
- 第五小时:清理黑链,验证站点功能,把整套备份做了一份冷拷贝
这个案例让我意识到,单点修补不够,必须把日志审计、权限收敛、备份策略当成一套组合拳来打。我后来给所有客户站点都做了类似的组合:补丁 + WAF + fail2ban + 数据库降权 + 定期备份,这五件事一起做才算把一个站点的安全基线建起来。
## 八、与其他漏洞修复的对比经验
DedeCMS的漏洞修复套路高度相似,掌握一种思路就能举一反三。我把过去5年修过的几个典型漏洞做个对照:
- album_add.php的mtypesid整型注入:用intval解决
- edit.inc.php的msg字符串注入:用addslashes解决(本文主题)
- search.php的keyword搜索框:用htmlspecialchars + 关键词黑名单解决
- recommend.php的aid数组注入:用array_map filter_var解决
- mytag_js.php的变量覆盖:禁用register_globals并限制include路径
- feedback.php的feedbacktype枚举:用in_array白名单校验
这套修复思路统称为"输入侧严格类型转换 + 输出侧严格转义",是PHP安全开发的基本功。织梦因为成型于早期PHP4/PHP5时代,大量代码沿用了字符串拼接的写法,用现代视角看就是漏洞密集区。
我建议接手织梦站点的运维团队,把这6个常见漏洞做成内部知识库,新人入职第一周必看。我自己团队里规定,所有交付的织梦项目都必须经过这6个漏洞的人工复测,验证通过才算交付完成。这个流程让我们过去3年零安全事件。
## 九、与官方补丁的差异说明
DedeCMS官方在2018年发布过针对guestbook模块的安全更新,但官方版本的修改思路与我用的临时补丁略有差异。官方走的是改造SQL执行函数:把所有相关的数据库操作改成参数化查询调用,避免字符串拼接。我用的addslashes临时方案虽然简单,但只能保护单引号注入,对于编码绕过等高级技巧理论上还有空间。
如果你的站点能接受较大改动,建议直接打官方补丁;如果你需要快速止血,我这套addslashes方案足够了。两者并不冲突,可以先临时补一行保命,再排期升级到官方补丁。
## 十、总结与下一步行动清单
edit.inc.php的SQL注入是织梦留言板模块的典型漏洞,修复成本极低(一行addslashes),但实际事故中却经常成为整站被攻陷的入口。这种"小洞大祸"的情形在DedeCMS这类老旧CMS里很常见。
如果你刚读完这篇笔记,建议立即按下面这个清单行动:
- 立即检查/plus/guestbook/edit.inc.php是否存在addslashes调用,没有的话当天补上
- 用sqlmap或手工curl验证补丁生效
- 检查最近30天access.log里是否有针对该路径的高频访问,发现可疑IP立即封禁
- 检查dede_admin、dede_guestbook表是否有异常数据
- 如果发现异常,立即重置所有后台账号密码,扫描webshell
- 长期:上WAF + fail2ban + 数据库降权 + 季度审计
这套流程做完,edit.inc.php这条路就算彻底堵死了。但织梦还有几十个类似的入口,需要按同样的思路一个一个梳理。安全是个长期工程,没有一劳永逸的方案,只有持续的关注和迭代。
## 常见问题解答
## 我把addslashes加上之后留言里的特殊字符会不会显示出错
不会。addslashes只是在写入数据库前把单引号、双引号、反斜杠转义。读取展示时织梦自己会调用stripslashes还原,所以前台展示效果一致。如果你发现展示出现了多余的反斜杠,多半是另外某个地方也加了一次转义,要追一下展示模板。具体追踪方法是grep -rn stripslashes 整个项目目录,确认是不是有重复转义的地方。
## 除了edit.inc.php,plus目录下还有哪些文件需要重点关注
根据我自己的维护经验,至少这几个值得审计:recommend.php(早期SQL注入)、search.php(typeArr参数注入)、download.php(任意文件下载)、mytag_js.php(变量覆盖)、guestbook.php(XSS与CSRF)。最稳妥的做法是把不用的全部移除。具体可以用find /plus -name "*.php" 列出所有文件,业务负责人逐个确认是否需要保留,不需要的直接mv到备份目录。
## 补丁打完之后还要不要升级整个DedeCMS
要看你的版本和二次开发情况。如果你的站点没改过核心,建议直接走官方最新版升级流程;如果改动很多,至少要把/include/、/dede/、/plus/三个核心目录跟官方版做diff,逐个文件对比有没有补丁缺失。我个人更倾向于把内容迁移到Typecho或者静态站,长期成本低很多。Typecho迁移工具我之前写过一篇详细的方案,整套流程跑下来一个中等规模站点(5000-10000篇文章)大约需要1天工时。
## 怎么判断站点已经被这个漏洞攻击过
三个信号:第一,access.log里/plus/guestbook/edit.inc.php被高频POST且带异常参数;第二,dede_guestbook表里出现SQL关键字(union、select、information_schema);第三,后台管理员表dede_admin的密码哈希、邮箱被异动。任何一条命中都说明站点可能已经被打穿,要走完整的应急响应流程。建议同时检查/uploads/、/data/和/templets/目录,看是否有近期被植入的可疑.php文件。
## 用WAF能不能完全替代代码层修复
不能。WAF是边界防护,能挡掉绝大多数已知payload,但攻击者总能找到新的绕过手法(编码、分块、CRLF注入等)。代码层修复是最根本的,WAF是兜底。我个人的建议是双管齐下:代码层intval/addslashes修复 + WAF规则拦截,缺一不可。只用WAF的话,一旦WAF规则失效或被绕过,站点直接裸奔;只修代码不上WAF的话,攻击者可以用大流量扫描压垮服务器。
## 修复后能否把addslashes改成参数化查询
理论上可以,但织梦的DSQL类不支持原生PDO参数化绑定,需要重写一套DSQL包装层,工程量很大。性价比不如继续用addslashes临时方案。如果你愿意做这个改造,可以参考Typecho的Db封装,逻辑相对清晰,移植到织梦也行,但需要相当的PHP经验。这是个值得投入的改造但不是紧急事项,可以排期到下一个迭代。
## fail2ban规则会不会误封正常用户
低概率会。我用了2年没遇到过一次。原因是规则只针对edit.inc.php这个特定路径,正常用户不会反复POST这个路径,触发条件是10分钟5次非200响应,门槛不低。如果你担心误封,可以把maxretry提高到10、findtime改成300,触发更宽松。也可以加一个白名单,把内部IP段加到/etc/fail2ban/jail.local的ignoreip里。
## 打完补丁多久需要重新审计一次代码
我建议每季度审计一次,每年做一次完整的渗透测试。织梦因为停更,新爆出的漏洞需要靠社区跟进,错过几次更新就可能被攻击者利用。日常审计可以用RIPS、Codiad这类PHP源码审计工具做基础扫描,年度渗透测试找专业团队做更全面的安全评估。预算有限的话,至少每年自己用sqlmap和burp suite扫一遍核心入口。
## 权威参考资料
## 织梦media_add.php任意上传漏洞怎么加固?纵深防御实战
- URL:https://zhangwenbao.com/media_add-php-in-dedecms-has-the-method-of-restoring-arbitrary-uploading-files-in-background-files.html
- 分类:织梦CMS教程
- 发布:2018-07-10 | 更新:2026-06-01
- 摘要:织梦后台的media_add.php存在任意文件上传漏洞,还能和CSRF联合利用。本文剖析攻击链、点出黑名单preg_match的五个绕过盲区,给出pathinfo白名单、finfo二次校验、hash重命名、Nginx与Apache禁uploads执行、inotify监控等七层纵深防御,附九个上传入口的统一处置。
- 关键词:织梦漏洞,Web安全,文件上传漏洞,DedeCMS加固,纵深防御
> **TLDR**:摘要:织梦后台的media_add.php存在任意文件上传漏洞,还能和CSRF联合利用。本文剖析这个洞的本质,点出网上流传修复版本的不足,给出pathinfo白名单、finfo二次校验、hash重命名、Nginx与Apache禁uploads执行等加固版,再加Web服务器层的纵深防御、处置后的全站排查清单和同类上传漏洞的统一处置流程。
> 摘要:织梦后台的media_add.php存在任意文件上传漏洞,还能和CSRF联合利用。本文剖析这个洞的本质,点出网上流传修复版本的不足,给出pathinfo白名单、finfo二次校验、hash重命名、Nginx与Apache禁uploads执行等加固版,再加Web服务器层的纵深防御、处置后的全站排查清单和同类上传漏洞的统一处置流程。
保哥前阵子在帮一个做机械加工的客户清后门,溯源到最后又一次落到了织梦那个老朋友——dede/media_add.php。这是织梦后台软件附件管理的入口,问题已经被披露超过八年,至今还有大量遗留站点没修。这篇笔记记录我这次完整的处置过程:从如何确认是这个洞被利用、到为什么网上流传的扩展名正则不够用、再到我自己加固后用到现在的版本,全部摊开讲。文章最后还把web服务器配置层、纵深防御策略、上线后的复盘清单、以及处理同类织梦上传漏洞的通用流程一次性整理出来,给还在维护织梦站的同行一个完整的应急参考。
## 这个洞的本质是什么
dede/media_add.php是织梦后台用来上传"软件"类型附件的脚本,它的设计假定凡是能进到这一步的用户都已经登录后台,所以对文件名校验非常宽松。第69行附近的原始代码大概是 $fullfilename = $cfg_basedir.$filename,这里的$filename直接来自POST里的用户输入,没做任何扩展名检查就拼到服务器物理路径上,然后move_uploaded_file把临时文件搬过去。
问题来了——只要有一个能进后台的账号,无论是默认弱口令、还是通过前面那个 inc_archives_functions.php cookie泄漏 (https://zhangwenbao.com/dedecms-inc_archives_functions-php-cookies-sql.html) 拿到的越权token,攻击者都可以传一个shell.php,落地后直接通过web访问执行。整个攻击链条只需要3步——拿到一个能登录后台的会话、构造一个multipart/form-data的POST请求、把webshell文件名塞进去——完整执行不超过10秒。
更阴险的是,这个洞经常和CSRF配合打。攻击者根本不需要登录,只要诱导一个已登录的管理员点击一个伪造的页面(带自动提交的POST表单),表单就会以管理员身份把webshell传上去。这种利用链在2018到2021年的针对性攻击里非常常见,保哥那几年至少处理过二十多起。CSRF攻击的隐蔽性在于——管理员自己都不知道发生了什么,只觉得点了一个普通链接,几秒钟后页面跳走了,没有任何提示。直到一周后服务器开始被挂博彩暗链,回头查日志才发现那次点击就是入侵起点。
## 客户站点的攻击痕迹
这次客户站点的入侵指标IOC非常清晰,按定位顺序列出:
- uploads/soft/下出现了2024xxxx-shell.php,文件大小只有800字节左右,明显是一句话木马的特征。一句话木马通常控制在1KB以内,体积大了反而容易被D盾扫出来。
- nginx access.log里有几十条POST /dede/media_add.php的记录,但referer字段是空的——正常后台操作referer必然带着/dede/media_main.php,空referer是CSRF或者curl直接发包的特征。
- 数据库dede_uploads表里多了几条mediatype=3但title是乱码的记录,说明攻击者直接通过SQL注入或者表单字段绕过创建了元数据记录。
- 上传时间窗口和后续/uploads/soft/2024xxxx-shell.php?cmd=ls的访问完全对得上,时间间隔通常在30秒以内,这是自动化攻击脚本的典型特征。
- PHP-FPM的slow log里出现了对应时间点的执行记录,调用栈包含system或exec函数,说明webshell已经实际执行了系统命令。
- 服务器进程列表里曾经出现过短暂的nc监听或wget下载,说明攻击者尝试拉取后续payload建立持久化。
保哥的处置顺序还是老规矩:先快照取证、再止血、最后修复并复盘。取证阶段最关键的是用dd命令对系统盘做一份完整镜像,避免后续操作改变文件系统状态影响事后追责。这次客户站点已经走完取证,直接进到修复环节。
## 网上流传的修复版本以及它的不足
搜索引擎里能找到的标准修复方案大致是这样:在第69行之前加一段preg_match检查,匹配到php、pl、cgi、asp、aspx、jsp、php5、php4、php3、shtm、shtml这些扩展名就拒绝。这个修复的思路是黑名单——把已知危险扩展名列出来,命中就拒。能挡住绝大多数自动化扫描器,但保哥从实战角度有几个不放心的地方。
第一个不放心点:黑名单永远是漏的。这条正则没有覆盖phtml、phar、pht、php7、phps、html配SSI、htaccess配AddType等等情况。攻击者只要换一个PHP解释器认得、但黑名单没列的扩展名,比如某些环境里把.pht或.phar也交给PHP处理,整个防御就被绕过。我自己在2022年就遇到过一次——客户用了某个标准黑名单修复,结果攻击者传了一个shell.phtml,绕过了正则但被PHP-FPM当作PHP文件解析,重新拿下整站。
第二个不放心点:正则末尾的边界处理 [^a-zA-Z0-9]+$ 看似严谨——意思是扩展名后面必须跟非字母数字才算命中——但这个写法对shell.php.这种Windows下会被自动去掉末尾点的文件名是有效的,对shell.php%00.jpg这种空字节截断也有效,但对Apache的mod_mime多扩展名解析(shell.php.x)反而失效,因为末尾不是危险扩展名结束。这种.php.x的写法在Apache老版本里如果x不是Apache已知扩展名,会回退到前一个扩展名.php来解析,等于完美绕过黑名单。
第三个不放心点:没有限制大小写绕过——其实正则带了i修饰符,这点OK。但没有限制空格、Tab、换行这种文件名里塞奇怪字符的攻击。Windows下shell.php (末尾两个空格)会被NTFS当作shell.php处理,但PHP的strpos比较时是带空格的字符串,正则匹配也会失效。这种边界字符的处理是黑名单方案永远无法穷尽的攻击向量。
第四个不放心点:黑名单方案没法防御后续可能新增的解析器。如果有一天服务器装了一个新的PHP扩展模块或者Wordpress插件,把某个新扩展名也交给PHP处理,黑名单根本不知道要更新。白名单方案则不会有这个问题——只允许列表里的扩展名,未知扩展名一律拒绝。这种"默认拒绝"思路在零信任安全模型里被反复强调,每一项允许都要明确声明,没声明的一律不许通过。
## 保哥实际部署的加固版本
我给客户落地的版本采用白名单思路,因为软件附件这个场景本身就只应该允许压缩包和文档。具体加固代码放在dede/media_add.php第69行附近,原 $fullfilename 拼接之前。代码结构是先trim文件名去除前后空格、再用pathinfo取扩展名转小写、然后比对白名单数组、最后再做一层多扩展名和控制字符的二次防御。白名单严格限定为zip、rar、7z、gz、tar、pdf、doc、docx、xls、xlsx、ppt、pptx这12种类型,覆盖99%的合法软件附件场景。
这套加固有几个关键点:用pathinfo取扩展名而不是自己写正则,避免了边界bug;白名单严格限定为软件附件场景的合理类型;额外加一层多扩展名和控制字符的检测,防止shell.zip.php这种组合拳;最后对原始 $filename 也做了trim,避免前后空格绕过。检测多扩展名用的是 substr_count($_filename, '.') 大于1的判断——正常的软件附件文件名最多只有一个点(扩展名分隔点),如果有两个或更多点就直接拒绝,能挡住绝大多数双扩展名攻击。
更进一步的加固是在PHP函数层面增加一道防御——move_uploaded_file之后立即用finfo读取实际MIME类型,与扩展名声明的类型做交叉验证。比如声明扩展名是zip,但finfo返回的MIME是text/x-php,立即删除文件并拦截请求。这一层防御能挡住"在压缩包末尾追加PHP代码"的高级绕过技巧。具体实现是 $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tmpFile); finfo_close($finfo); 然后判断$mime是否匹配预期。MIME校验的好处是它不依赖文件名,攻击者改不了文件真实内容的MIME标识。
对于文件名本身的安全处理,保哥还会在白名单通过后用一段hash重命名落地——把客户上传的原始文件名替换成 md5(uniqid().mt_rand()).$_ext 这种形式。这样做的好处是:哪怕扩展名校验有漏洞,攻击者也无法预测最终落地的文件名,没法通过web访问到自己上传的webshell。原始文件名作为title字段单独存到dede_uploads表里供后台展示,不参与磁盘路径拼接。这种"路径不可预测"是OWASP A05配置错误条目下专门提到的对抗手法。
## Web服务器层的纵深防御
光靠PHP代码层是不够的,保哥强烈建议在web server配置层再加一道。不管你的站跑在nginx还是Apache,给上传目录禁用PHP执行是最简单粗暴也最有效的方案。
nginx配置示例如下,添加在站点server块内:
location ~* ^/uploads/.*\.(php|phtml|phar|pht|php5|php7|jsp|asp|aspx|cgi|pl|py)$ {
deny all;
return 403;
}
这条规则匹配uploads目录下所有以危险扩展名结尾的请求,直接返回403。注意 ~* 是不区分大小写,能挡住.PHP这种大写绕过。规则放在所有location之前,确保优先级最高。如果有多个server块(HTTPS、HTTP、CDN源站等),每一个都要加这条,不能漏。
Apache在uploads目录下放一个.htaccess文件:
Require all denied
RemoveHandler .php .phtml .phar
这层防御的好处是:哪怕PHP代码层因为新的绕过技巧失守,最后落地的webshell也根本无法被解析执行,攻击者拿到的只是一个静态文件。这是真正意义上的纵深防御。我自己处理过一个客户站,PHP代码层的白名单被绕过了(用了一个我们没列的新扩展名),但因为nginx配置层禁止了uploads目录的PHP执行,攻击最终失败,对方只是浪费了一个0day在我们站上。
如果你的站点托管在虚拟主机或者宝塔面板上,没有nginx或Apache的直接配置权限,可以通过面板的"伪静态 (https://zhangwenbao.com/discuz-portal-list-rewrite.html)"或"自定义规则"功能加上面这条规则。宝塔面板的网站设置里有"伪静态"标签,把上面的nginx规则粘贴进去保存即可生效。宝塔面板的"安全"标签下还有一个"目录权限"开关,可以一键关闭某个目录的PHP执行权限,效果跟上面那条location规则等价但更直观。
另一个常被忽略的纵深防御是PHP-FPM的open_basedir限制。在php-fpm的pool配置里加 php_admin_value[open_basedir] = /www/wwwroot/yoursite:/tmp,这样即使攻击者拿到webshell,也只能访问站点根目录和临时目录,无法读取/etc/passwd、/var/log这些系统敏感文件。这一层是最后的防线,能限制攻击者拿到shell后的横向移动能力。
## 处置完成后的全站排查清单
修完这一个文件远远不是终点。保哥的常规收尾包括以下几件事,每一件都要做完才能算这次应急彻底关闭。
后台URL改名:把/dede/改成一段无规律字符串,比如/admin_8x9k2p/,同步改data/common.inc.php里的cfg_admin_dir。让自动化扫描器找不到入口。这一步能把后台被尝试爆破的次数降到几乎为零。改完后所有原来收藏/dede/入口的管理员要改用新地址访问。
后台二次验证:给改名后的后台目录加一层nginx basic auth或者IP白名单,运维同事固定IP访问。nginx basic auth配置示例:先用htpasswd生成密码文件,然后在location块里加 auth_basic "Restricted" 和 auth_basic_user_file /etc/nginx/.htpasswd。这样即使攻击者知道了新后台地址,也要先过一道basic auth才能访问到登录页。
uploads目录权限调整:设为755即可,禁用chmod 777这种偷懒做法。文件所有者设为www(或对应PHP-FPM运行用户),组设为www,目录权限755,文件权限644。这样即使有人在站点目录写了webshell,PHP也无法用chmod改这个文件的权限到可执行。
webshell全量排查:除了D盾这种自动化工具之外,再用 find . -name '*.php' -mtime -90 -ls 列出最近三个月修改过的PHP文件人工核对,自动化工具有时候识别不了变形得很厉害的样本。重点关注的可疑特征:文件大小异常小(小于2KB)但包含base64字符串、文件名是乱码或者纯数字、文件位于uploads或cache目录下、文件修改时间是凌晨2到5点之间。
日志归集:把nginx和PHP的日志往日志服务器或者ELK推一份,本地日志可能被攻击者擦除。具体做法是rsyslog配omfwd模块,或者直接用filebeat把日志发到远端Elasticsearch。日志保留至少90天,方便事后追溯。
定期备份核查:除了每天的全站备份,还要每周做一次"差异备份比对"——把本周备份和上周备份做diff,看是否有未授权的代码修改。这一步能在攻击发生7天内发现,避免长期潜伏。
建立监控告警:给关键文件配inotifywait监控——当dede目录、include目录、uploads目录有任何PHP文件被新增或修改时,立即发邮件或微信告警。这能让你在攻击发生几秒内就收到通知,比被动等用户反馈快得多。具体配置是 inotifywait -m -r -e create,modify,delete /www/wwwroot/site --format '%T %w%f %e' --timefmt '%F %T' 输出到日志,再用logrotate配合grep过滤PHP事件发邮件。
## 同类织梦上传漏洞的统一处置流程
media_add.php这个洞处理完之后,应该顺手把整个织梦后台的上传通道都加固一遍,否则攻击者今天传不进media_add.php,明天可能从album_add.php进来。下面这套清单是保哥用了5年、覆盖织梦5.7 SP1到SP2的完整加固范围。
需要同步加固的关键文件清单:dede/file_manage_control.php(文件管理任意操作)、dede/album_add.php(图集附件)、dede/soft_add.php(软件主体上传) (https://zhangwenbao.com/dedecms-soft_add-php-has-sql-injection-vulnerability-repair-method.html)、dede/media_main.php(多媒体管理入口)、include/dialog/select_soft_post.php(编辑器附件选择)、include/dialog/select_images_post.php(编辑器图片选择)、include/uploadsafe.inc.php(上传公共入口) (https://zhangwenbao.com/there-is-a-repair-method-for-uploading-vulnerabilities-in-uploadsafe-inc-php-in-dedecms.html)、member/uploads_edit.php(会员中心附件编辑)、include/FCKeditor整个目录(如果启用了FCK编辑器)。
这九个文件配合一起加固,整个上传通道才算闭环。加固方法都是套用前面media_add.php的白名单思路——pathinfo取扩展名、in_array比对白名单、substr_count检测多扩展名、preg_match检测控制字符。每个文件的具体行号会因版本不同有差异,但加固模板是统一的,复制粘贴改文件路径就行。
对于会员中心的上传通道(member/目录下的几个文件),加固标准要更严——因为这个入口面向所有注册会员,不需要后台权限就能访问,攻击面比后台大十倍。会员中心的白名单建议只允许jpg、jpeg、png、gif、bmp这五种图片格式,PDF和压缩包都不要开放。
加固完成后强烈建议跑一次完整的渗透测试——用awvs或者burp对所有上传入口扫描一遍,确认没有漏掉的点。市面上有免费的awvs试用版可以扫一次,不需要采购正版。如果预算允许,找一家专业的安全公司做一次正式渗透测试,费用大约5000到10000元,能给出更详细的报告。
## 事后复盘要回答的六个核心问题
每次应急处理完,保哥都会跟客户做一次结构化复盘,回答下面六个问题。这六个问题如果不能给出明确答案,说明这次事件还没有真正"结案",可能留有未发现的隐患。
攻击者是怎么进来的——要追溯到第一笔异常请求的时间点、来源IP、利用的具体漏洞。如果只能回答"反正是media_add.php被利用了"但说不清具体哪一次请求是入口,说明日志保留不全或者还有其他更早的入口没被发现。这种情况下要把日志倒查范围从默认的90天扩大到180天或更长,必要时调取CDN层和云服务商防火墙日志补全证据链。
攻击者拿到什么权限——是只能上传webshell在web用户权限下执行,还是已经通过本地提权拿到root。这个问题决定了清理的彻底程度——如果还在web用户权限,清掉webshell和加固代码就够了。如果已经root,那要重装系统才能彻底清干净。判定方法是看 /etc/passwd /etc/shadow 的修改时间、查 auth.log 里的su和sudo记录、看根目录下是否有可疑的反弹shell脚本。
数据是否泄漏——dede_admin表的密码hash是否被读取、用户表里的手机号邮箱是否被导走、订单表的支付信息是否被访问。如果有数据泄漏,按法律要求72小时内要向监管部门报告并通知用户。判定数据是否被读取要看MySQL的general_log或者binlog,没开启的话只能从nginx日志里的请求大小推测——如果某个时间点对某个表查询接口的响应包大小异常大,多半就是被批量导出了。
有没有横向扩散——同一个云服务商账号下的其他主机是否被波及、内网其他业务系统是否被访问、备份系统是否被加密。这一步常常被忽略,但很多大型勒索事件就是从一个看似不重要的小站点横向扩散到核心业务系统。判定方法是看其他主机的last登录记录、检查 .ssh/authorized_keys 是否被植入未知公钥、扫描内网开放端口看是否有异常监听。
攻击窗口期有多长——从首次入侵到完全清理用了多少天。这个数字越大,攻击者获得的横向扩散时间就越多,潜在影响面就越广。理想的窗口期是24小时以内,超过7天就算重大事件。窗口期长的常见原因是:监控告警没配、运维人手不足、定期巡检流于形式。复盘时要把这些根本原因写进改进计划,不能只修了漏洞就完事。
同类站是否还在受同样威胁——如果你管理的不止一个织梦站,要确认其他站是否也存在media_add.php漏洞、是否已被同一攻击者扫描过。这个排查能预防"修了一个站、其他站接着被打"的连环事件。具体做法是把这次的攻击者IP、UA特征、URL访问模式整理成IOC列表,到其他所有站的日志里搜一遍,命中就立即处置。
## 常见问题解答
## 业务确实需要让用户传PHP类的源码包怎么办
保哥的建议是让用户传zip或tar.gz格式,里面再装PHP文件。落地到服务器之后,源码包目录配合上面nginx那条规则,PHP永远不会被执行——它只是一个二进制文件躺在那儿,要看内容只能下载。这样既满足业务又不破坏安全边界。如果要让用户在线预览源码内容,可以解压到一个独立目录后用highlight_file函数渲染成HTML显示,渲染过程不会执行任何代码。绝对不要直接include用户上传的文件,那是另一种文件包含漏洞。
## 白名单里要不要加图片格式
这个文件media_add.php本身是织梦定义的"软件附件"入口,图片应该走media_add.php的图片分支或者专门的图集模块。混着让一个入口什么都收,反而增加攻击面。保哥的建议是不同mediatype走不同白名单,代码里用switch分一下就好。具体来说mediatype=1是图片走图片白名单(jpg/png/gif/webp)、mediatype=2是Flash走swf白名单(已经基本不用)、mediatype=3是软件走压缩包和文档白名单。每种类型独立维护白名单,互不干扰。
## 改完之后后台上传一直失败提示附件类型不在允许列表内但文件明明是zip的
保哥踩过这个坑。pathinfo在某些PHP版本下对.tar.gz这种双扩展只会返回gz,是OK的。但如果文件名带中文又是GBK编码的旧站点,pathinfo在某些环境会取不到扩展名。解决办法是上传前用mb_convert_encoding把文件名统一成UTF-8再处理。具体代码是 $_filename = mb_convert_encoding(trim($filename), 'UTF-8', 'GBK,UTF-8'); 然后再走pathinfo取扩展名。这样就能正确处理GBK编码的中文文件名。如果你的织梦站本身是GBK编码,整个站都没法直接走UTF-8,那就用iconv转码后处理,处理完再转回GBK写库。
## 还有哪些织梦后台脚本和这个洞类似必须同步加固
保哥的清单:dede/file_manage_control.php文件管理任意操作、dede/album_add.php图集附件、dede/soft_add.php软件主体上传、include/dialog/select_soft_post.php编辑器附件选择、include/uploadsafe.inc.php上传公共入口。这五个文件配合一起加固,整个上传通道才算闭环。如果启用了member模块,还要加固member/uploads_edit.php和member/album_add.php这两个会员侧入口。FCKeditor如果启用了也要加固include/FCKeditor/editor/filemanager/connectors/php/connector.php这个老牌漏洞文件。
## nginx禁止uploads执行PHP后织梦自带的图片处理脚本会不会受影响
不会。织梦的图片处理脚本(比如dede/dialog/select_images.php、include/uploadsafe.inc.php)都是放在dede或include目录下,不在uploads目录里。uploads目录只存放最终落地的用户上传文件,本来就不应该有任何PHP代码。如果你发现uploads目录下有正常的PHP文件被禁止访问,那本身就是异常情况,要排查这些PHP是怎么进去的。一个常见误判是某些织梦插件会在uploads下创建.htaccess之外的配置文件,那些不是PHP文件,不会被这条规则影响。
## 白名单加固之后会被某些自动化扫描工具误报为漏洞吗
会的概率很低。常见的扫描工具(awvs、appscan、xray)扫描上传漏洞的判定逻辑是"尝试上传一个PHP文件并看是否能在web上访问到"。我们的白名单加固直接拒绝了PHP上传,所以扫描工具会判定为"不存在上传漏洞"。如果你用的是织梦专用扫描工具(dedebbs、织梦漏洞扫描器),它们可能会通过版本号或者文件特征字符串识别"这个站是织梦",然后报"该版本存在已知漏洞"。这种误报跟实际防御效果无关——你已经修了,扫描工具只是不知道你修了。可以忽略这种基于版本号的告警。
## 加固后能不能彻底放弃升级织梦改用其他CMS
保哥的真实建议是——能换就换。织梦从2017年官方停止维护后,所有新发现的漏洞都没有官方补丁,只能靠社区或者你自己手动修。每修一个洞都是临时的,下一个洞可能在三个月后又出来。如果你的站是企业站、内容更新不频繁,迁移到WordPress或Typecho是更可持续的选择。如果是大站、有大量自定义二次开发,迁移成本可能比持续加固还高,那就只能继续走"白名单加固+纵深防御+监控告警"这三件套。无论选哪条路,都不要继续用默认配置裸跑,那是给所有自动化攻击者送大礼。
## 权威参考资料
## 织梦uploadsafe.inc.php上传漏洞怎么加固?五项校验实战
- URL:https://zhangwenbao.com/there-is-a-repair-method-for-uploading-vulnerabilities-in-uploadsafe-inc-php-in-dedecms.html
- 分类:织梦CMS教程
- 发布:2018-07-09 | 更新:2026-06-01
- 摘要:织梦的文件上传安全卡在uploadsafe.inc.php,原版只看扩展名、不验内容、解析也不严。本文给出五层防御的完整PHP代码:MIME白名单与finfo探测、getimagesize强类型校验、按点拆分查双扩展名、Nginx与Apache禁uploads解析、可疑内容扫描,附六个真实入侵案例。
- 关键词:织梦漏洞,DedeCMS安全,上传漏洞,uploadsafe,文件上传防护
> **TLDR**:摘要:织梦的文件上传安全卡在uploadsafe.inc.php,原版只看扩展名、不验内容、解析也不严。本文讲清它在上传链中的位置,给出五层防御——MIME与扩展名一致性校验、getimagesize二次验证图片真伪、文件名规范化强制重命名、Nginx与Apache禁uploads解析、可疑内容特征扫描,再附四组测试验证、全站审计判断是否已被入侵和上传漏洞历史回顾。
> 摘要:织梦的文件上传安全卡在uploadsafe.inc.php,原版只看扩展名、不验内容、解析也不严。本文讲清它在上传链中的位置,给出五层防御——MIME与扩展名一致性校验、getimagesize二次验证图片真伪、文件名规范化强制重命名、Nginx与Apache禁uploads解析、可疑内容特征扫描,再附四组测试验证、全站审计判断是否已被入侵和上传漏洞历史回顾。
DedeCMS的uploadsafe.inc.php是负责文件上传安全校验的核心文件。2010年首次披露漏洞之后官方修过几次,但2015、2018、2022年又陆续被研究者发现新的绕过方式。如果你还在维护DedeCMS 5.7 SP2或更早版本的站点,本文给出一套防御性加固方案——基于2026年公开披露过的所有变体漏洞做综合防护,把单一图片MIME校验扩展成多层防御。保哥过去4年处理过6个被这类漏洞利用过的DedeCMS站点,本文记录的是真实修复路径,不涉及攻击细节。同一批织梦后台上传漏洞还有 media_add.php任意上传漏洞的白名单加固方案 (https://zhangwenbao.com/media_add-php-in-dedecms-has-the-method-of-restoring-arbitrary-uploading-files-in-background-files.html) 可以一起部署,覆盖面更广。
覆盖范围:DedeCMS 5.7 SP2 / 5.7 UTF8 SP1 / 5.8社区维护版。前缀按官方默认dede_,目录路径以 /www/wwwroot/yoursite.com 为示例。修复前必须备份完整源代码和数据库。
## uploadsafe.inc.php在文件上传链中的位置
先理清楚DedeCMS文件上传的完整流程,才能知道为什么修这个文件能起到加固作用:
- 用户提交文件上传请求(前端表单或者编辑器调用)。
- 请求进入DedeCMS的入口PHP文件(如album_add.php、image_add.php、media_main.php等多个上传入口)。
- 这些入口都会require_once include/uploadsafe.inc.php做文件名和类型的安全过滤。
- uploadsafe.inc.php通过后才走真正的文件保存逻辑(move_uploaded_file)到uploads目录。
uploadsafe.inc.php是上传链的安全闸口。它做得好,所有上传入口都受益;它做得不严,攻击者可以从任一上传入口绕进来。所以加固这个文件是覆盖面最广的防御措施。
## 原版逻辑的三个明显缺陷
DedeCMS 5.7 SP2原版的include/uploadsafe.inc.php大致逻辑(已脱敏简化):
- 读取上传文件的 $_FILES 数组。
- 检查文件扩展名是否在白名单(jpg/png/gif/bmp/zip/rar/doc等)。
- 检查文件大小是否超限。
- 检查文件名是否含恶意字符。
- 通过则放行。
这套逻辑的核心问题:
- 仅看扩展名不看MIME:攻击者可以把PHP文件改名为evil.jpg.php或evil.php.jpg绕过简单扩展名检测。客户端声明的 $_FILES['type'] 完全可以伪造,不能作为可信源。
- 没有内容验证:即使扩展名是.jpg,文件内容可能是PHP代码——服务器解析时如果.jpg后缀触发了PHP执行(如Apache的AddHandler配置错误),漏洞被利用。GIF89a开头后追加PHP代码的伪图是经典构造。
- 对multipart/form-data边界处理不严:早期PHP版本对畸形multipart数据的解析有缝隙,攻击者构造特殊边界让PHP把恶意段当作文件名段处理。
## MIME类型与扩展名一致性校验
第一层防御是检查PHP探测到的MIME类型与扩展名是否一致。在uploadsafe.inc.php大约第42行(不同SP版本行号略有差异,用关键词搜索定位)找到扩展名检查的代码段,在其后追加:
// MIME 与扩展名一致性校验
$allowedMimeMap = [
'jpg' => ['image/jpeg', 'image/pjpeg'],
'jpeg' => ['image/jpeg', 'image/pjpeg'],
'png' => ['image/png', 'image/x-png'],
'gif' => ['image/gif'],
'bmp' => ['image/bmp', 'image/x-ms-bmp'],
'webp' => ['image/webp'],
];
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (isset($allowedMimeMap[$ext])) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$actualMime = finfo_file($finfo, $tmpFile);
finfo_close($finfo);
if (!in_array($actualMime, $allowedMimeMap[$ext], true)) {
ShowMsg('文件类型与扩展名不匹配,禁止上传', '-1');
exit;
}
}
关键细节:
- 用finfo扩展检测文件真实MIME,不要用 $_FILES['file']['type']——后者是客户端声明的可以伪造。finfo读取的是文件头部的magic number,攻击者无法在不破坏文件可读性的前提下篡改。
- 用in_array的严格模式(第三个参数true)避免类型转换攻击。PHP的弱比较会让 '0' 和 0 相等,严格模式才安全。
- 白名单包含的别名(image/pjpeg是Internet Explorer历史版本上传JPEG时的MIME,必须接受否则误伤)。
## getimagesize二次验证图片真伪
MIME类型可以通过文件头几个字节伪造(攻击者把PHP代码放在伪造的图片头之后)。第二层防御是用PHP的getimagesize函数验证文件确实是合法图片。在大约第53行追加:
// getimagesize 返回值验证
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'], true)) {
$imgInfo = @getimagesize($tmpFile);
if ($imgInfo === false || !isset($imgInfo[2])) {
ShowMsg('图片格式损坏或文件非图片,禁止上传', '-1');
exit;
}
$expectedType = [
'jpg' => IMAGETYPE_JPEG,
'jpeg' => IMAGETYPE_JPEG,
'png' => IMAGETYPE_PNG,
'gif' => IMAGETYPE_GIF,
'bmp' => IMAGETYPE_BMP,
'webp' => IMAGETYPE_WEBP,
];
if ($imgInfo[2] !== $expectedType[$ext]) {
ShowMsg('图片真实格式与扩展名不符,禁止上传', '-1');
exit;
}
}
getimagesize会真的去解析图片的尺寸和格式头部。如果文件不是合法图片或者头部被恶意拼接破坏,函数返回false。这是PHP内置的图片验证函数,对绝大多数攻击载荷都能识别。它的判断基于图片的二进制结构而非扩展名或客户端声明,攻击者要绕过必须构造一个既能让getimagesize返回真值又能携带PHP代码的复合文件,难度大幅提高。
## 文件名规范化与强制重命名
第三层防御针对的是文件名层面的绕过。比如 .php.jpg、.jpg.php、.shtml、.phtml 这类容易被服务器误解析的扩展名。在前两层校验之后追加:
// 文件名规范化
$dangerousExts = ['php', 'php3', 'php4', 'php5', 'php7', 'phtml',
'phar', 'pl', 'py', 'jsp', 'asp', 'aspx',
'sh', 'cgi', 'shtml', 'htaccess'];
$nameParts = explode('.', strtolower($filename));
foreach ($nameParts as $part) {
if (in_array($part, $dangerousExts, true)) {
ShowMsg('文件名包含危险扩展,禁止上传', '-1');
exit;
}
}
// 强制重命名为时间戳 + 随机字符串
$newName = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$filename = $newName;
核心思路:
- 把文件名按 . 拆分,检查每一段都不是危险扩展。这能拦下evil.php.jpg、evil.jpg.php等多扩展构造。
- 强制重命名为时间戳加随机串。这样即使有上传文件,攻击者也不知道最终URL是什么——降低了利用难度。这是OWASP A05配置错误条目下专门提到的对抗手法。
- 用random_bytes而不是mt_rand之类弱随机源,避免攻击者通过种子推测文件名。random_bytes是PHP 7.0+引入的密码学安全随机源,跟 /dev/urandom 等价。
## Nginx/Apache层禁止uploads目录PHP解析
前三层是PHP代码层面的防御。第四层是服务器配置层面的兜底——即使有恶意文件被上传到uploads目录,也不让服务器作为PHP执行它。
在Nginx配置文件里server块内增加:
location ^~ /uploads/ {
# uploads 目录禁止任何动态脚本执行
location ~ \.(php|php3|php4|php5|php7|phtml|phar|pl|py|jsp|asp|aspx|sh|cgi|shtml)$ {
deny all;
return 403;
}
}
Apache用户在uploads目录下放一个.htaccess文件:
Require all denied
这层防御的逻辑:
- 即使前三层防御被绕过,恶意文件落到uploads目录。
- 攻击者访问/uploads/xxx.php时,Web服务器直接返回403而不是调用PHP解析器执行。
- 恶意代码无法运行,攻击就失败了。
这是纵深防御理念的体现——单层防御不够稳,多层叠加才能在某层被绕过时依然保护住业务。配置后必须 nginx -t 验证语法、nginx -s reload 平滑生效,不要直接 service nginx restart 影响线上请求。
## 可疑内容特征扫描(按需启用)
对安全敏感的站点(电商、政企、金融),还可以在前面四层之上加第五层——上传后用内容扫描工具检测文件中是否含可疑PHP函数特征。在uploadsafe.inc.php通过所有校验后、move_uploaded_file之前插入:
// 可疑内容扫描
$content = file_get_contents($tmpFile);
$suspiciousPatterns = [
'eval(', 'assert(', 'base64_decode(',
'gzinflate(', 'str_rot13(', 'passthru(',
'system(', 'shell_exec(', 'proc_open(',
'$_POST[', '$_GET[', '$_REQUEST['
];
foreach ($suspiciousPatterns as $pat) {
if (stripos($content, $pat) !== false) {
ShowMsg('文件内容含可疑代码特征,禁止上传', '-1');
exit;
}
}
注意这层会有误伤——某些合法PSD文件、Word文档的二进制内容偶尔会包含 $_POST 这种字节序列。如果你的站点上传需求多样,这层用stripos简单匹配会拦住正常文件。可以改成检查文件类型是图片时才扫描,或者用更精细的yara规则。商业级方案直接接ClamAV或类似引擎,扫描精度比简单字符串匹配高一个量级。
## 验证修复是否生效的四组测试
修完上传一些测试样本验证:
- 正常图片样本:上传一张正常的.jpg图片。预期:成功,文件被重命名为时间戳格式,落到uploads目录。
- 双扩展名样本:上传一个改了扩展名的PHP文件(test.php改成test.jpg)。预期:被第一层MIME校验拦下,提示文件类型与扩展名不匹配。
- EXIF注入样本:上传一个尾部嵌入PHP代码的伪图片(合法图片头部 + 末尾PHP代码)。预期:被第五层内容扫描拦下(如果开启了第五层);或者文件被强制改名,攻击者无法通过URL触发执行。
- uploads直访样本:直接访问/uploads/xxx.php测试服务器层禁解析。预期:返回403。
每个测试都要在生产环境模拟做一遍,不要只在开发环境验证——服务器配置经常不一致。生产环境Nginx可能有CDN回源、可能开启了不同的fastcgi_pass,开发环境的"通过"不代表生产环境也通过。
## 全站审计判定是否已经被入侵
修复完后必须做一次全站审计,检查是否已经有恶意文件在站点里。这是修复后的第一件事。
用find命令搜可疑PHP文件:
# 在 uploads 目录及其子目录里搜含 eval 的 PHP 文件
find /www/wwwroot/yoursite.com/uploads -name "*.php" -exec grep -l "eval(" {} \;
# 搜近 90 天内修改过的 PHP 文件(可疑变更)
find /www/wwwroot/yoursite.com -name "*.php" -mtime -90 -type f
# 搜文件大小异常的图片(正常 jpg 通常小于 5MB)
find /www/wwwroot/yoursite.com/uploads -name "*.jpg" -size +10M
找到可疑文件后逐个查看内容。如果是webshell(远程执行代码的脚本),证明站点曾被入侵——此时需要:
- 立即删除所有webshell文件。
- 检查admin表是否有异常账号(可疑用户名、邮箱)。
- 检查dede_admin表的logintime字段是否有可疑登录记录。
- 修改所有管理员密码。
- 检查cookies表和session表是否有持久化的攻击者会话。
- 检查数据库内容是否被篡改(首页文章、栏目设置、友情链接)。
- 更换所有API密钥和敏感配置。
如果不确定是否被入侵,请安全公司做专业审计。自行处理容易遗漏后门。
## DedeCMS上传漏洞历史回顾
了解过往漏洞演化能更好理解为什么要叠加多层防御:
- 2010年首次披露:扩展名白名单不严,简单改后缀绕过。官方修补:增加扩展名严格白名单。
- 2012年绕过:用双扩展名evil.php.jpg绕过单一扩展名检查。
- 2015年绕过:利用multipart边界畸形让PHP解析器误判扩展名。
- 2018年披露:上传图片时通过EXIF字段注入PHP代码,配合服务器配置错误执行。
- 2022年披露:利用SVG文件类型绕过PHP默认的图片类型识别(SVG是文本格式可以含脚本)。
每次漏洞披露后官方都打过补丁,但DedeCMS 2018年之后官方停止维护,部分变体漏洞至今没有官方修复。社区版本(v5.7 SP2 community edition等)部分修了,但完整防护仍需要站长自己做加固。本文给出的五层防御覆盖了所有已知变体。
## 长期防御建议
- 升级到DedeBIZ或迁出:DedeBIZ是DedeCMS原班人马另起炉灶的新项目,安全性维护比社区版本好。如果业务允许,迁到WordPress或现代CMS是更彻底的方案。整套服务器加固体系可以参考 Linux下DedeCMS生产级安全加固指南 (https://zhangwenbao.com/dedecms-site-security-settings-in-linux-environment.html) 配置mpm-itk、php-fpm权限分离与fail2ban。
- WAF兜底:在Nginx前面加ModSecurity或者云厂商的WAF(如阿里云、Cloudflare WAF),开启文件上传相关的规则集。规则集要选最新版,老版规则对2022年后的新变体覆盖不全。
- 定期审计:每月跑一次上面的find命令搜可疑文件,是防止后门驻留的基本动作。可以把命令写进cron + 邮件告警,自动化值班。
- 最小权限:uploads目录的PHP文件执行权限设为644(不含+x),目录设为755。即使有恶意文件落地,权限受限难以执行。
- 监控日志:Nginx access.log里/uploads/*.php的访问全部记录到独立日志文件,定期分析。任何这类访问都是潜在攻击。
## 常见问题解答
## 修了uploadsafe.inc.php后正常的图片上传也失败怎么办
大概率是MIME白名单太严格。先查PHP错误日志找具体ShowMsg的报错点。常见误伤场景:客户端是老版本IE上传JPEG时MIME是image/pjpeg而不是image/jpeg;客户端是Mac Safari上传HEIC转JPEG时MIME可能带image/heif;客户端是企业网络的代理服务器重写了MIME。对这些场景把白名单适当放宽(加image/pjpeg、image/heif)。如果是finfo函数未启用(php.ini没开fileinfo扩展),需要先在PHP里开启 extension=fileinfo。
## 可选的内容扫描层经常误伤合法文件怎么办
内容扫描层是可选的,不是必须。对内容多样的站点(接受Office文档、PSD等)误伤率会比较高。建议处理方式:第一是只对图片类型扩展启用该层,不扫描其他文件;第二是把stripos简单匹配换成正则上下文匹配(要求 'eval(' 前后有典型PHP代码特征);第三是放弃该层,仅依赖前四层。前四层已经能拦下绝大多数已知攻击变体。
## 修复后还需要更换管理员密码吗
强烈建议更换。如果漏洞被利用过(即使你不知道),攻击者可能已经拿到管理员凭据或留下后门。修复完上传漏洞之后必须做:第一全量审计PHP文件搜webshell;第二修改所有管理员密码(包括FTP、SSH、数据库、CMS后台);第三检查管理员账号列表是否有可疑新增;第四清除所有session强制所有用户重新登录。这些是事后清理的标准动作。
## 有没有现成的脚本批量检测DedeCMS站是否含webshell
D盾、河马webshell查杀(hm.shellpub.com)、安全狗都有专门针对PHP webshell的扫描工具。免费版能识别90%以上的已知webshell特征。把站点wwwroot目录上传到这些工具扫一遍能初步排查。注意工具有误报率,扫到可疑文件需要人工核实再决定是否删除。商业级安全审计推荐找专业安全公司做静态分析+动态测试。
## DedeCMS还在更新吗,应该继续用吗
DedeCMS官方在2018年宣告停止商业授权销售,目前的5.7 SP2和5.8都是社区维护版本。安全补丁是社区自发提供,不像WordPress有完善的更新机制。如果你的站点是新项目,强烈不建议选DedeCMS——长期安全风险高。已有的老站点建议要么迁到WordPress等活跃项目,要么按本文方式做安全加固后继续维护。
## 修了文件后被DedeCMS升级覆盖怎么办
把修改记录到独立的补丁文件dede_security_patch.diff用git管理。每次官方升级前先git diff看你的修改是否会被覆盖;升级后跑一遍diff应用补丁。如果团队有CI/CD流程,把补丁应用纳入部署管道。手工维护时记得在uploadsafe.inc.php的修改段加注释标记 'CUSTOM_SECURITY_PATCH 起止',下次升级前用grep快速定位。
## uploads目录禁解析后老的图片访问会受影响吗
不会。Nginx的 location ~ \.(php|...) 规则只匹配PHP等动态扩展,对.jpg、.png、.gif这些静态文件不影响。配置后立刻刷新一些图片URL验证。如果发现某些资源被误屏蔽,看location块的具体匹配规则。配置生效需要nginx -s reload。
## 多站点共用一个服务器加固一个站点要不要也加固其他
必须。同服务器上的多个站点如果有一个被入侵,攻击者通常能横向渗透到其他站点(通过共享的PHP进程、共用的MySQL账号、文件系统权限不严等)。安全加固要全服务器同时做。如果实在不能同时改,至少要在Nginx上对uploads目录全局禁解析,并隔离不同站点的PHP-FPM池。
## 实战补充:保哥处理过的六个被入侵案例
过去4年保哥处理过6个被DedeCMS上传类漏洞利用过的客户站。整理一下每个案例的入侵路径和修复过程,看完能更直观理解多层防御的必要性。
入侵路径 | 发现时间 | 影响范围 | 修复方案 |
双扩展名evil.php.jpg绕过 | 入侵后32天发现 | 首页被植入跳转JS | MIME校验+文件名规范化+Nginx禁解析 |
EXIF字段注入PHP代码 | 入侵后17天发现 | 插入垃圾外链 (https://zhangwenbao.com/ai-spam-backlink-detection-guide.html)页面412个 | getimagesize+Nginx禁解析+图片专用内容扫描 |
SVG含script绕过 | 入侵后6天发现 | 管理员账号被盗用一次 | 白名单移除svg+Nginx禁解析 |
multipart边界畸形 | 入侵后88天发现 | 整站镜像到攻击者域名 | 升级PHP 7.4到8.1+全部方案 |
文件名编码绕过 | 入侵后14天发现 | 植入挖矿脚本 | 文件名规范化+服务器全局扫 |
编辑器插件漏洞 | 入侵后4天发现 | SEO黑链插入 | 移除老版编辑器插件+MIME校验 |
这6个案例的共同点:单层防御失败时,多层防御能阻断攻击链。比如双扩展名案例绕过了扩展名检查,但如果有Nginx禁解析配合,文件落地后也无法被执行。编辑器插件案例如果同步部署了 KindEditor编辑器深度优化指南 (https://zhangwenbao.com/kindeditor-image-upload.html) 里的上传目录归档与CSRF加固,攻击面会进一步收窄。
## PHP版本与防御能力的关系
PHP自身版本对文件上传漏洞防御能力影响很大:
- PHP 5.6及以下:finfo在某些版本对畸形文件MIME探测有bug;getimagesize对部分构造的图片返回值不可靠。如果服务器还在用PHP 5.6,强烈建议升级。
- PHP 7.0到7.3:基础校验函数稳定,但random_bytes在某些低版本编译时未启用。
- PHP 7.4到8.x:所有本文用到的函数都稳定可用。如果你的DedeCMS跑在PHP 7.4以下,应该优先升级PHP。
DedeCMS 5.7 SP2官方支持PHP 5.6到7.4,5.8社区版兼容到PHP 8.0。如果可能升级到PHP 8.0或更高版本,本文的防御方案能更稳定运行。升级PHP之前必须备份并在测试环境验证DedeCMS兼容性,部分老插件可能在PHP 8.x下报错。
## 结合宝塔面板的实施步骤
大部分国内DedeCMS站点跑在宝塔面板上。宝塔自带的"网站防火墙"插件能在Nginx层做部分上传防护,但不够细化。建议同时做:
- 宝塔后台 - 网站 - 你的站点 - 设置 - 配置文件,把上面Nginx的location段加进去,保存重启。
- 宝塔后台 - 安全 - 防火墙,开启"文件上传过滤",规则集选最严。
- 宝塔后台 - 文件 - 你的uploads目录 - 权限 - 设为755,所有者www,禁止其他用户写。
- 宝塔后台 - 软件商店 - 安装"网站监控"插件,开启文件变更告警。
- 本文MIME校验、getimagesize验证、文件名规范化三层通过SSH编辑include/uploadsafe.inc.php实施。改前备份原文件到backup目录。
宝塔面板的防火墙规则对DedeCMS特定漏洞的覆盖度不全(毕竟是通用WAF不是DedeCMS专用),仍需要本文的代码层加固兜底。安装宝塔之后定期升级到最新版,老版宝塔本身也存在过几个安全公告。
## 权威参考资料
## 织梦article_add Cookie泄漏SQL注入:五道防线修复
- URL:https://zhangwenbao.com/article_add-php-in-dedecms-has-cookies-leak-causing-sql-vulnerabilities-to-be-repaired.html
- 分类:织梦CMS教程
- 发布:2018-07-08 | 更新:2026-06-02
- 摘要:DedeCMS会员中心的SQL注入根因在fields hash校验机制:md5虽单向,但fields内容能被攻击者拿到,构造合法哈希就绕过了字段类型保护。本文给出五道纵深防线的完整PHP代码——服务端密钥双重md5、PDO参数化、白名单与长度控制、全站定位与Nginx拦截特征字符串。
- 关键词:织梦漏洞,SQL注入,DedeCMS安全,article_add,Cookie安全
> **TLDR**:摘要:DedeCMS会员中心的SQL注入根因在fields hash校验机制——md5虽单向,但fields内容能被攻击者拿到,构造合法哈希就绕过了字段类型保护。本文给出五道纵深防线的完整PHP代码——掺服务端密钥的双重md5、PDO参数化替代拼接、白名单标签与长度控制、横向扫描所有相似入口、服务器层WAF兜底,再附验证、全站入侵审计和迁出建议。
> 摘要:DedeCMS会员中心的SQL注入根因在fields hash校验机制——md5虽单向,但fields内容能被攻击者拿到,构造合法哈希就绕过了字段类型保护。本文给出五道纵深防线的完整PHP代码——掺服务端密钥的双重md5、PDO参数化替代拼接、白名单标签与长度控制、横向扫描所有相似入口、服务器层WAF兜底,再附验证、全站入侵审计和迁出建议。
DedeCMS 的会员中心 member/article_add.php 文件 2016 年首次披露过一个 SQL 注入漏洞:攻击者可以通过构造特殊的 Cookie 绕过后端的 dede_fieldshash 校验机制,向数据库执行任意查询。这个漏洞核心问题是 Cookie 信息泄漏让校验哈希可被预测,配合不严格的参数过滤造成注入。本文不讨论攻击如何构造(攻击细节请阅 CNVD / CVE 数据库的官方披露),只讲修复方案、纵深防御、修复后的入侵审计。保哥过去 4 年帮 9 个被 SQL 注入打过的 DedeCMS 客户做过修复,本文是这些工作的整理。
覆盖版本:DedeCMS 5.7 SP2 / 5.7 UTF8 SP1 / 5.8 社区版。涉及的文件主要是 member/article_add.php、include/dedesql.class.php、include/common.inc.php。修复前必须备份完整源代码和数据库。
## dede_fieldshash 校验机制的设计意图
先理解 DedeCMS 的设计才能知道为什么会出现这个漏洞。member/article_add.php 是会员发布文章的入口。用户在前台填表单提交文章时,DedeCMS 用一个隐藏字段 dede_fields 描述各个字段的类型和约束(如 title=string,4-100; body=html,200-50000);同时用 dede_fieldshash 字段存这个描述的 md5 哈希,防止前端篡改字段类型。
原版逻辑大致是:
- 表单生成时把 dede_fields 写到 hidden input,并计算 dede_fieldshash = md5(dede_fields)。
- 用户提交后,后端取 POST 里的 dede_fields,重新计算 md5,与 POST 的 dede_fieldshash 比较。
- 如果一致,相信 dede_fields 没被篡改,按它描述的字段约束处理表单数据。
这个设计的问题是:md5 是单向哈希,但只要 dede_fields 的内容被攻击者知道,就能自己算 md5 构造合法 hash。攻击者只需要在 Cookie 里或者别的地方拿到一份合法的 dede_fields 样本,就能任意构造新的字段描述,让后端按攻击者的约束处理数据——攻击者可以让 body 字段不做 SQL 转义,直接传入恶意 SQL 片段实现注入。
修复的核心思路是给 md5 计算加一个攻击者无法预测的密钥(HMAC 思路),让攻击者即使知道 dede_fields 也算不出合法 hash。
## 掺入服务端密钥的双重 md5 改造
在 member/article_add.php 大约第 40 行(具体行号视 SP 版本和你之前的修改略有差异,用关键字 fieldshash 定位)找到 hash 校验段。原代码大致是:
$fieldshash = md5($dede_fields);
if ($fieldshash !== $_POST['dede_fieldshash']) {
ShowMsg('字段描述被篡改', '-1');
exit;
}
修改为引入服务端密钥的双重 md5:
// 服务端密钥,部署时在 data/common.inc.php 里配置
// $cfg_security_salt = '你自定义的高熵字符串_至少32字符'
$securitySalt = isset($GLOBALS['cfg_security_salt']) ? $GLOBALS['cfg_security_salt'] : 'anythingelse_长度至少32位_请在部署时替换';
$expectedHash = md5(md5($dede_fields) . $securitySalt);
if ($expectedHash !== $_POST['dede_fieldshash']) {
ShowMsg('字段描述被篡改', '-1');
exit;
}
核心要点:
- 用双重 md5(md5(...) . salt) 而不是简单 md5(... . salt),避免长度扩展攻击。
- 密钥从配置文件读,不要硬编码在 article_add.php 里(否则源码泄漏就废)。
- 密钥长度至少 32 字符,使用高熵随机串(用 bin2hex(random_bytes(16)) 生成一次后写入配置)。
- 每个站点的密钥应该不同,不要复制粘贴常见的示例字符串。
注意:修改这段后,原来的表单生成处也要同步修改 fieldshash 的计算逻辑——否则前端生成的 hash 和后端期望的不一致,所有合法提交都会被拦截。表单生成代码通常在 dede 系统的 include/inc_archives_functions.inc.php 或类似位置,搜 md5($dede_fields) 找到对应代码段同样改为双重 md5 加 salt。
## 参数化查询替代字符串拼接
第一步只是堵了表单字段类型被篡改的入口。但 DedeCMS 整个项目里有大量字符串拼接的 SQL,根本治理是改用参数化查询。从 member/article_add.php 的数据写入语句开始:
原版(脆弱)写法:
$query = "INSERT INTO #@__archives (typeid, title, body, mid)
VALUES ($typeid, '$title', '$body', $mid)";
$dsql->ExecuteNoneQuery($query);
改为参数绑定写法(DedeCMS 自带的 dedesql.class.php 支持参数绑定但很多老代码没用):
$dsql->SetQuery("INSERT INTO #@__archives (typeid, title, body, mid) VALUES (?, ?, ?, ?)");
$dsql->ExecuteWithParams([$typeid, $title, $body, $mid]);
如果 dedesql.class.php 没有 ExecuteWithParams 方法(老版本可能没有),用 PDO 直接写:
$pdo = new PDO("mysql:host=$cfg_dbhost;dbname=$cfg_dbname;charset=utf8mb4", $cfg_dbuser, $cfg_dbpwd);
$stmt = $pdo->prepare("INSERT INTO {$cfg_dbprefix}archives (typeid, title, body, mid) VALUES (?, ?, ?, ?)");
$stmt->execute([$typeid, $title, $body, $mid]);
参数化查询的核心保护是:MySQL 服务端把 SQL 模板和参数分开解析,参数值永远不会被当作 SQL 代码执行。这是 SQL 注入的根本防御。
## 输入过滤与白名单标签
参数化是技术层防御,业务层还需要输入过滤。在 member/article_add.php 接收 POST 数据后立刻做一层过滤:
function sanitizeInt($val) {
return (int)$val; // 强制转 int,非数字字符全部丢弃
}
function sanitizeString($val, $maxLen = 1000) {
$val = trim($val);
$val = strip_tags($val, '
');
if (mb_strlen($val) > $maxLen) {
$val = mb_substr($val, 0, $maxLen);
}
return $val;
}
$typeid = sanitizeInt($_POST['typeid'] ?? 0);
$title = sanitizeString($_POST['title'] ?? '', 100);
$body = sanitizeString($_POST['body'] ?? '', 50000);
$mid = (int)$cfg_ml->M_ID; // 会员 ID 从会话拿,不信任 POST
几个关键细节:
- 整型字段用强制类型转换((int)),是最严的过滤。
- 字符串字段用 strip_tags 加白名单标签,只保留必要的 HTML 标签,其他全清。
- 会员 ID(mid)一定从会话或登录态拿,绝对不信 POST 里的字段——这是常见的越权漏洞起点。
- 用 mb_strlen 而不是 strlen,UTF-8 中文字符串长度计算才准确。
## 横向扫描所有相似入口
仅修 member/article_add.php 不够。DedeCMS 的会员中心有多个文件用相似的 dede_fieldshash 校验机制:
- member/album_add.php(图集发布)
- member/soft_add.php(软件下载发布)
- member/info_add.php(资讯发布)
- member/edit.php(文章编辑)
- member/album_edit.php、soft_edit.php、info_edit.php 等编辑入口
每个文件都需要先加 salt 改造 fieldshash 校验,再改参数化查询,再加输入过滤。漏一个文件就等于留一个口子。
用 grep 全站搜定位需要改的文件:
# 搜所有用 fieldshash 校验的文件
grep -r "fieldshash" /www/wwwroot/yoursite.com/member/
# 搜可能含字符串拼接 SQL 的文件
grep -rn "INSERT INTO.*VALUES.*\\\$" /www/wwwroot/yoursite.com/member/
# 搜直接用 $_POST 拼 SQL 的危险写法
grep -rn "\\\$_POST\\[" /www/wwwroot/yoursite.com/member/ | grep -i "select\\|insert\\|update\\|delete"
这三条命令能列出绝大多数需要审计的文件。逐个看每个文件的 SQL 写法是否安全。
## 服务器层面的 WAF 兜底
代码层防御是主战场,但服务器层面的 WAF 能拦截大量已知 SQL 注入特征。在 Nginx 配置里增加规则:
map $args $sql_injection {
default 0;
"~*union[\s]+select" 1;
"~*select[\s]+.*[\s]+from" 1;
"~*[\'\"].*[\\\\]+x[0-9a-f]{2}" 1;
"~*concat[\s]*\(" 1;
}
server {
if ($sql_injection) {
return 403;
}
# ...其他配置
}
这套规则会拦下 SQL 注入的典型特征(UNION SELECT、CONCAT、十六进制编码等)。注意 WAF 规则是字符匹配,对绕过手法(如 /*!union*/select、空格替换为 %20+%20 等)覆盖不全。WAF 只是兜底,不能替代代码层加固。
商业 WAF(云厂商的 Web 应用防火墙、Cloudflare、阿里云盾)对 SQL 注入的覆盖比上面的简单规则更全面。对预算允许的站点强烈推荐。
## 验证修复是否生效
修完做几组测试:
- 测试 1:用合法会员账号发布一篇正常文章。预期:成功,按预期写入数据库。
- 测试 2:手动篡改前端 dede_fieldshash 字段(用浏览器开发者工具)后提交。预期:服务端拦下,提示"字段描述被篡改"。
- 测试 3:在 title 字段输入 ' or 1=1--。预期:被参数化查询当作字面值处理,title 实际存储这串字符,不触发 SQL 异常。
- 测试 4:在 typeid 字段输入 1; DROP TABLE users。预期:被强制类型转换为整数 1,DROP 部分丢弃。
每个测试都要看后端日志确认行为符合预期。如果有任何一个测试触发了意外异常,需要回溯检查修改是否完整。
## 修复后的全站入侵审计
如果你的站点已经在生产环境运行了一段时间才修复,必须假设漏洞已经被利用过,做一次全面审计:
- 检查 dede_admin 表:看是否有不熟悉的管理员账号、最近一次登录 IP 是否可疑。SELECT id, userid, email, logintime, loginip FROM dede_admin ORDER BY logintime DESC LIMIT 20。
- 检查 dede_member 表:搜近期注册的会员,看是否有批量注册的可疑账号(同 IP、同邮箱域名、同注册时间集中)。
- 检查 dede_archives 表:搜 title 或 body 含 link / a href / iframe 的文章,可能是黑链注入。SELECT id, title, mid, sortrank (https://zhangwenbao.com/method-to-dedecms-article-set-a-dream-day.html) FROM dede_archives WHERE body LIKE '%