DedeCMS pm.php SQL 注入修复完整指南:CVE-2018-9134、参数化查询、密码哈希升级与应急响应

DedeCMS 会员中心 pm.php 的 CVE-2018-9134 注入至今仍是脱库主入口。本文从源码层讲清漏洞成因(intval 缺失),给出最小补丁、PDO 参数化查询、ModSecurity WAF 兜底三层修复,并扩展到全站注入点审计、bcrypt 密码升级、被脱库后的应急响应流程。

张文保 更新 24 分钟阅读 961 阅读

DedeCMS 会员中心 member/pm.php(站内私信功能)在 2018 年被披露存在 SQL 注入漏洞,CVE 编号 CVE-2018-9134。攻击者通过构造特殊的 id 参数能直接读取 dede_member 表里的密码哈希、邮箱、手机号等敏感字段。这个漏洞至今仍是 DedeCMS 站点被脱库的最常见入口之一。本文从源码层面讲清漏洞成因(intval 缺失导致字符串拼接进 SQL)、给出最小修复 patch 与几种替代方案(参数化查询、ORM 包装层)、扩展到全站 dede:sql 注入审计的方法论、被脱库后的应急响应、与现代 PHP 生态(PDO 预编译、Doctrine ORM)的迁移路径。

漏洞成因详解

原始漏洞代码

问题代码在 member/pm.php 的 read 分支:

else if ($dopost == 'read') {
    $sql = "SELECT * FROM `#@__member_friends`
            WHERE mid='{$cfg_ml->M_ID}' AND ftype!='-1'
            ORDER BY addtime DESC LIMIT 20";
    $friends = array();
    $dsql->SetQuery($sql);
    $dsql->Execute();
    while ($row = $dsql->GetArray()) {
        $friends[] = $row;
    }

    $row = $dsql->GetOne(
        "SELECT * FROM `#@__member_pms`
         WHERE id='$id' AND (fromid='{$cfg_ml->M_ID}' OR toid='{$cfg_ml->M_ID}')"
    );
    // ...
}

关键问题在 WHERE id='$id' —— $id 直接来自 $_GET['id'] 或 $_POST['id'],未做任何过滤。SQL 字符串拼接的方式让攻击者可以构造:

?id=1' UNION SELECT pwd FROM dede_member WHERE mid=1 -- 

实际拼出的 SQL:

SELECT * FROM dede_member_pms
WHERE id='1' UNION SELECT pwd FROM dede_member WHERE mid=1 -- '
  AND (fromid='current_user' OR toid='current_user')

UNION 查询返回了 dede_member 表里管理员(mid=1)的密码哈希。

为什么单引号包围还是有漏洞

很多新手会以为「id='$id'」的单引号已经把输入限制成字符串,攻击者注不进 SQL。但这是错觉:单引号本身可以被攻击者闭合。?id=1' 中的 ' 把 SQL 里的引号闭合了,后面的 UNION 就脱离了字符串上下文。

为什么 mid 字段拼接是安全的

仔细看 SQL 里同样有 mid='{$cfg_ml->M_ID}',但 M_ID 来自服务器端会话(已登录用户的 ID),不可被外部控制。这是关键差别:来自用户输入的字段必须过滤,来自服务器内部的可控变量不需要。

修复方案一:intval 强类型转换

最小补丁

原文给的修复方案是在拼 SQL 之前加 intval:

else if ($dopost == 'read') {
    $sql = "SELECT * FROM `#@__member_friends`
            WHERE mid='{$cfg_ml->M_ID}' AND ftype!='-1'
            ORDER BY addtime DESC LIMIT 20";
    $friends = array();
    $dsql->SetQuery($sql);
    $dsql->Execute();
    while ($row = $dsql->GetArray()) {
        $friends[] = $row;
    }

    /* 修复:$id 强转整数 */
    $id = intval($id);
    /* */

    $row = $dsql->GetOne(
        "SELECT * FROM `#@__member_pms`
         WHERE id='$id' AND (fromid='{$cfg_ml->M_ID}' OR toid='{$cfg_ml->M_ID}')"
    );

    if (!is_array($row)) {
        ShowMsg('对不起,你指定的消息不存在或你没权限查看!', '-1');
        exit();
    }

    $dsql->ExecuteNoneQuery(
        "UPDATE `#@__member_pms` SET hasview=1
         WHERE id='$id' AND folder='inbox' AND toid='{$cfg_ml->M_ID}'"
    );
    $dsql->ExecuteNoneQuery(
        "UPDATE `#@__member_pms` SET hasview=1
         WHERE folder='outbox' AND toid='{$cfg_ml->M_ID}'"
    );

    include_once(dirname(__FILE__) . '/templets/pm-read.htm');
    exit();
}

intval 的工作原理

PHP 的 intval() 把任意字符串转成整数。intval("1") 是 1,intval("1' UNION SELECT...") 也是 1,因为 PHP 从字符串开头取数字直到第一个非数字字符为止。所有注入 payload 都会被截断为安全的整数。

这个方案的局限

  • 只对整数 id 字段有效。如果字段是字符串(用户名、邮箱、UUID),intval 会变成 0。
  • 同文件中其它 dopost 分支(write、send、del)可能有相似漏洞,需要逐一审计。
  • 修复后下次 DedeCMS 升级 pm.php 可能覆盖你的改动,要做好补丁记录。

修复方案二:参数化查询(更彻底)

DedeCMS 的 dsql 类支持预编译

DedeCMS 自带的 $dsql 对象其实有 SetQuery 之外的预编译接口,但代码里几乎不用。改成参数化:

$id = intval($id); // 防御性双保险

$sql = "SELECT * FROM `#@__member_pms`
        WHERE id = ?
          AND (fromid = ? OR toid = ?)";

$pdo = new PDO($dsn, $user, $pass);
$stmt = $pdo->prepare(str_replace('#@__', $cfg_dbprefix, $sql));
$stmt->execute([$id, $cfg_ml->M_ID, $cfg_ml->M_ID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

PDO 预编译彻底杜绝 SQL 注入,不依赖 intval 这种类型转换的偶然性。

用 escapestring 兜底

如果不引入 PDO,DedeCMS 自带 addslashes 等转义。但 addslashes 不安全(多字节字符集下能被绕过),改用 mysqli_real_escape_string:

$id = mysqli_real_escape_string($GLOBALS['link'], $id);
// 仍要确保字段类型,避免空字符串触发逻辑错误
if (!preg_match('/^\d+$/', $id)) { exit('Invalid ID'); }

全站 SQL 注入审计方法论

定位所有用户输入入口

DedeCMS 通过 require_once common.inc.php 自动把 $_GET / $_POST 的所有值注册为全局变量。比如 ?id=123 会自动产生 $id=123。这种「自动全局化」让追踪输入入口变得困难——表面上 $id 看起来是局部变量,实际是用户输入。

命令行扫描所有可能涉及用户输入的代码:

# 找所有 SQL 字符串拼接的位置
cd /var/www/dedecms
grep -rn "WHERE.*=.*'\$" --include="*.php" .
grep -rn "INSERT.*\$" --include="*.php" .
grep -rn "UPDATE.*=.*\$" --include="*.php" .

标记可疑文件

历史上 DedeCMS 漏洞集中爆出的目录:

  • plus/ —— 几乎每个文件都曾出过漏洞,其中 recommend.php、carbuyaction.php、search.php、mytag_js.php、advancedsearch.php 是高频名单。
  • member/ —— pm.php、resetpassword.php、edit_baseinfo.php、ajax_membergroup.php。
  • include/ —— common.func.php 里的 GetCookie、PutCookie 函数有签名验证缺失。
  • dede/ —— 后台理论上要鉴权,但部分接口的鉴权用 == 而不是 === 比较,可类型混淆绕过。

批量打补丁

对每个识别出的漏洞点应用「intval + addslashes + 严格类型校验」三件套:

// 整数字段
$id = intval($id);
if ($id <= 0) { ShowMsg('参数错误', '-1'); exit(); }

// 字符串字段
$keyword = addslashes(htmlspecialchars($keyword));
if (strlen($keyword) > 50) { ShowMsg('关键词过长', '-1'); exit(); }

// 枚举字段(性别、状态)
$gender = in_array($gender, ['male', 'female', 'other']) ? $gender : 'other';

WAF 层面的防护

nginx 层 SQL 注入拦截

Application 层修复之外,可以在 nginx 加正则规则拦截 SQL 注入特征请求:

map $args $sql_injection {
    default 0;
    "~*(union[\s\+]+select|select[\s\+]+from|insert[\s\+]+into|update[\s\+]+set|drop[\s\+]+table|delete[\s\+]+from|or[\s\+]+1=1|sleep\(|benchmark\(|substr\(|concat\()" 1;
}

server {
    if ($sql_injection = 1) {
        return 403;
    }
    # ...
}

这种规则有误报风险(合法 URL 中含有 select 关键词会被拦),生产部署前用日志模式(log 而不是 return 403)观察一段时间。

用 ModSecurity 与 OWASP CRS

更专业的做法是上 ModSecurity + OWASP Core Rule Set。OWASP CRS 包含几千条 SQL 注入、XSS、命令注入特征规则,覆盖度比手写正则强得多。安装:

sudo apt-get install libmodsecurity3 nginx-module-modsecurity
# 下载 OWASP CRS
git clone https://github.com/coreruleset/coreruleset /etc/nginx/modsec/
# 在 nginx.conf 启用

云 WAF

阿里云、腾讯云、Cloudflare、AWS WAF 都提供 SQL 注入防护规则集。中小站点上云 WAF 是最省心的方案,每月几十到几百块。

密码哈希加固

即便修了 pm.php 的注入,DedeCMS 的密码哈希(MD5)本身已经不安全。MD5 单次哈希加 ec_salt 在 GPU 字典攻击下几小时就能破解。建议:

升级到 password_hash

PHP 5.5+ 提供 password_hash() 与 password_verify(),默认用 bcrypt。改造 DedeCMS 用户验证:

// 用户注册时
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$dsql->ExecuteNoneQuery("INSERT INTO #@__member (userid, pwd) VALUES ('$userid', '$hash')");

// 用户登录时
$row = $dsql->GetOne("SELECT pwd FROM #@__member WHERE userid='$userid'");
if (password_verify($password, $row['pwd'])) {
    // 登录成功
}

cost 参数 12 让 bcrypt 在现代 GPU 上单次哈希耗时约 250ms,字典攻击成本陡增。

历史数据迁移

DedeCMS 库里已有几千上万个 MD5 密码不能直接转成 bcrypt(不知道明文)。处理:

  • 登录时如果检测到 MD5 格式,校验通过后立刻用 bcrypt 重哈希一次写回。
  • 对长期不登录的账号发邮件强制重置密码。
  • 给所有账号默认开启二步验证(2FA)。

被脱库后的应急响应

检测被脱库的迹象

  • nginx access.log 出现大量带 SQL 关键字的请求。
  • MySQL 慢查询日志记录到长查询(attacker 可能在做盲注探测)。
  • 用户反馈邮箱密码在其它站被试出(数据被打包出售)。
  • HaveIBeenPwned 等数据泄漏数据库出现你站点。

应急动作

  1. 立刻把站点切到维护页,修复漏洞。
  2. 强制所有用户改密码(清空 dede_member.pwd 字段或下发邮件链接)。
  3. review 后台管理员账号的最近活动,重置 root 密码。
  4. review 数据库表是否被增删(dede_admin 表是否多了未授权账号)。
  5. 通知用户已发生泄漏(GDPR 与个保法要求 72 小时内通知)。
  6. 向网信办或公安备案上报(数据规模大的话)。

溯源调查

查 access.log 找首个成功注入的请求时间点:

grep -i "union\|select.*from\|sleep(" /var/log/nginx/access.log | head -50

看 IP 地理位置(whois、ipinfo.io)、User-Agent 特征(sqlmap、Havij 等工具有特征 UA)、之后的访问行为模式。完整的攻击链通常是:探测注入 → 拿数据库结构 → 拿用户表 → 离线破解密码哈希 → 登录后台 → 上传 webshell。

迁移到现代 PHP 生态

用 ORM 替代手写 SQL

Doctrine ORM、Eloquent(Laravel 的 ORM)从设计上就是参数化查询,不可能写出 SQL 注入:

// Eloquent 写法
$pm = MemberPM::where('id', $id)
    ->where(function($q) use ($currentUserId) {
        $q->where('fromid', $currentUserId)
          ->orWhere('toid', $currentUserId);
    })
    ->first();

$id 不管是什么内容,永远作为参数绑定到查询,不会被解释为 SQL。

用 Symfony Form 验证用户输入

Symfony Form 组件提供「类型 + 约束」声明式验证:

$builder
    ->add('id', IntegerType::class, [
        'constraints' => [new Assert\Positive()]
    ]);

Form 验证失败的请求根本到不了 controller,更不会触发 SQL 拼接。

升级到 DedeBIZ 或迁移到 Typecho

DedeBIZ 是 DedeCMS 的商业延续版,在原版基础上做了大量安全修复。Typecho 是国产轻量博客系统,代码质量与现代 PHP 实践都更优。如果你的站点不依赖 DedeCMS 特定功能(自定义模型、采集),迁移过去能彻底消除历史漏洞包袱。

常见故障

故障 1:修复后用户无法访问私信

intval 把非数字 id 转成 0,原本应该抛错的请求被当成「查询 id=0 的私信」返回空结果。改进:先校验是否为合法整数:

if (!is_numeric($id) || intval($id) <= 0) {
    ShowMsg('无效的私信 ID', '-1');
    exit();
}
$id = intval($id);

故障 2:升级后补丁丢失

DedeCMS 升级会覆盖 member/pm.php。建议把所有补丁单独维护一份 patches/ 目录,每次升级后用 patch 命令重新应用:

diff -u original/pm.php patched/pm.php > patches/pm.php.patch
# 升级后
patch /var/www/dedecms/member/pm.php < patches/pm.php.patch

故障 3:WAF 规则误拦合法请求

关键词如 select、union 在合法 URL 中也可能出现(比如 ?action=select&item=union_pay)。WAF 规则要精确到「带 SQL 语义的组合」而不是单关键词。先开 log-only 模式观察 1-2 周再启用拦截。

故障 4:参数化查询性能下降

PDO 预编译有缓存机制,第一次执行慢,后续快。如果你看到性能变差,检查是否每次都新建 PDO 连接(连接池缺失)。改用持久连接:

$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_PERSISTENT => true]);

故障 5:审计扫描出大量误报

grep 正则会把字符串字面量里的 SELECT 也算上。结合 AST 分析工具(PHP-Parser、psalm)能更精确:psalm 静态分析 DedeCMS 代码能找出真正的 SQL 注入点。

常见问题解答

修了 pm.php 是不是 DedeCMS 就安全了?

远远不够。pm.php 只是一个公开的漏洞点,DedeCMS 还有几十个未公开或低危的注入位置。完整安全靠「所有用户输入都过滤 + WAF 兜底 + 密码哈希升级 + 后台目录与权限分离」组合。

intval 修复对 base64 编码的 id 还有效吗?

有效。intval("MQ==" base64 解码前) 取数字部分得 0。但攻击者也可能用其它编码绕过。建议先检查格式(is_numeric 或正则)再 intval。

DedeCMS 官方还在修漏洞吗?

原版 DedeCMS 自 2020 年起官方维护停滞。新发现的漏洞官方不会修,社区有人维护非官方补丁但更新慢。建议主动升级到 DedeBIZ 或迁移到 Typecho/WordPress。

WAF 与代码层修复哪个更重要?

代码层是根本,WAF 是兜底。只用 WAF 不修代码:WAF 规则总有空子(编码绕过、时序攻击)。只修代码不用 WAF:未来出现新漏洞前没有兜底防线。两者都做最稳。

升级 PHP 到 8.x 后 DedeCMS 报错怎么办?

DedeCMS 5.7 在 PHP 8.x 下大量函数废弃错误。需要打 dedeCMS-php8 兼容补丁,或者降回 PHP 7.4。但 PHP 7.4 已经停止安全更新(2022 年起),长期不可持续。

能否直接禁用 pm.php?

能。如果你的会员中心不用站内私信功能,nginx 层直接 deny:

location = /member/pm.php { deny all; }

这是最彻底的「关掉攻击面」做法。

如何检查站点是否已被注入?

看 dede_admin 表是否多了陌生账号;看 dede_member 表是否有 mid=1 的密码哈希被替换;用 wpscan 类工具扫描站点指纹;用 phpcheck 扫 webshell 特征。还可以对比 mysqldump 备份与现状的差异。

DedeCMS 有没有像 WordPress Wordfence 那样的安全插件?

没有官方维护的。社区有几个第三方安全插件但更新滞后。目前最务实的做法是 ModSecurity + 云 WAF + 自己审计代码。

升级补丁后怎么验证修复有效?

用 sqlmap 工具针对该 URL 测一次:sqlmap -u "https://example.com/member/pm.php?dopost=read&id=1" --cookie="PHPSESSID=xxx" --level=5 --risk=3。修复有效的话 sqlmap 报「not injectable」。

除了 pm.php 还有哪些 DedeCMS 已知 SQL 注入漏洞?

CVE 数据库搜「DedeCMS」能查到几十个。最常见的还有 plus/recommend.php、plus/carbuyaction.php、plus/search.php、include/dialog/select_soft.php。打全套补丁参考社区维护的 dedecms-vulnerability-patches 项目。

分享到
标签
版权声明

本文标题:《DedeCMS pm.php SQL 注入修复完整指南:CVE-2018-9134、参数化查询、密码哈希升级与应急响应》

本文链接:https://zhangwenbao.com/dedecms-membership-center-pm-php-injection-vulnerability-repair-method.html

版权声明:本文原创,转载请注明出处和链接。许可协议: CC BY-NC-SA 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交