Typecho ≤ 1.3.0 的 Pingback 服务存在未认证服务端请求伪造( SSRF )漏洞,编号 CVE-2026-7025 , CVSS 3.1 评分 7.3 (高危)。
攻击者无需任何凭证,仅凭一个 HTTP 请求,即可让目标服务器:
- 向任意 URL 发起出站请求(第一跳);
- 解析第一跳响应中的
X-Pingback头,再向任意指定地址发起XML-RPC POST(第二跳)——该跳对目标地址不做任何内网校验。
漏洞由两个缺陷叠加构成:一个 PHP 弱类型比较绕过(进入漏洞路径的门票)+ 一处 出站请求缺少协议与地址白名单( SSRF 的实质)。两者缺一不可。
本文覆盖:漏洞定位、根因源码级分析、完整复现过程、真实带外证据、影响评估,以及可直接套用的修复补丁(含自动打补丁脚本)。
漏洞概览
| 项目 | 内容 |
|---|---|
| 漏洞编号 | CVE-2026-7025 |
| 漏洞类型 | 未认证服务端请求伪造(Unauthenticated SSRF) |
| 影响版本 | Typecho ≤ 1.3.0 |
| 危害组件 | var/Widget/Service.php → Service::sendPingHandle() |
| 触发入口 | POST /action/service?do=ping (或 /index.php/action/service?do=ping ) |
| 利用难度 | 低(单次 HTTP 请求,默认配置即可触发) |
| CVSS 3.1 | 7.3 High(AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L) |
| 官方补丁 | 披露时厂商未响应,无可用补丁 |
影响面:
所有使用默认配置的 Typecho ≤ 1.3.0 站点。Typecho 是国内外广泛使用的中文轻量博客系统,存量站点基数大。
背景:Typecho 的 Pingback 服务
要理解这个漏洞,先要理解 pingback 是什么。
Pingback 是一种博客间的引用通知机制(源自 WordPress 规范)。当 A 站的文章引用了 B 站的文章,A 站会向 B 站发一个通知:"嘿,我引用你了。" B 站收到后会去 A 站核实,核实通过则在 A 站文章的评论区留下一条 pingback 记录。
Typecho 实现这套机制时,把服务端能力拆成了两个动作、两个端点:
| 端点 | 职责 | 方向 |
|---|---|---|
/action/xmlrpc ( Service::action() 中的 xmlrpc 分支) | 接收别人的 pingback 通知 | 入站 |
/action/service?do=ping ( Service::sendPingHandle() ) | 发送 pingback 通知 | 出站 |
漏洞出在发送端。而发送端为了完成"核实引用"这一步,需要主动去访问外部 URL——这就天然构成了一条 SSRF 通道。
漏洞原理:
漏洞的详情请参照: Typecho 1.3.0 (CVE-2026-7025)SSRF漏洞分析与修复
修复方案
立即缓解(1 分钟,不改代码)
前提认知:Typecho 后台没有任何开关能关掉这条路径。入口是无条件注册的。因此唯一能立刻生效的是网络层封禁。
Nginx(放进 server {} 块,必须在 PHP location 之前):
location ~ ^/(index\.php/)?action/service {
deny all;
return 403;
}Apache(.htaccess):
RewriteRule ^(index\.php/)?action/service - [F,L]
坑一:位置错了就不生效。 这是正则 location。nginx 在同为正则的 location 之间取最先匹配到的那一条。所以它必须写在 location ~ \.php 前面。放后面等于没写,你会以为修好了其实没有。
坑二:必须同时覆盖两种入口形式。
location ~ ^/(index\.php/)?action/service
这一条正则同时覆盖 /action/service 和 /index.php/action/service 。少拦一种都白搭——实际攻击用的就是带 index.php 的那种,因为它会直接命中 location ~ \.php ,绕过 location / 里的任何判断。
副作用(必须知道):封掉 /action/service 会连带禁用站内正常的 pingback/trackback 发送,以及依赖 do=async 的异步调用。因为站内发送走的是公网域名(经 CDN 回源),源 IP 不是 127.0.0.1 ,无法用白名单放行。
- 不在乎
pingback(现代博客基本没人用)→ 用封禁,立刻止血 - 要保留站内功能 → 用下面的代码补丁,
/action/service可以保留
代码修复(推荐,根治)
核心思路:在 Http\Client::send() 这一收敛点一次性封死所有出站。这样 pingback 第一跳、第二跳、trackback 全都受控。
修复点 1:token 严格比较
文件: var/Typecho/Common.php → timeTokenValidate()
// 修复前
if (sha1($secret . '&' . $i) == $token) {
// 修复后
if (!is_string($token) || '' === $token) {
return false;
}
// ...
if (hash_equals(sha1($secret . '&' . $i), $token)) {要点:== 换成 hash_equals() 严格且恒定时间,顺带防时序攻击);先用 is_string() 挡掉 true 、数组、数字等所有非字符串输入。
这个改动同时修复 asyncHandle() ,因为它用的是同一个校验函数。
修复点 2:出站请求协议 + 地址白名单
文件: var/Typecho/Http/Client.php → send()
改动 A —— URL 解析后,加协议与地址校验:
$params = parse_url($url);
// 新增:仅允许 http/https
$scheme = strtolower($params['scheme'] ?? '');
$host = $params['host'] ?? '';
if (!in_array($scheme, ['http', 'https'], true)) {
throw new Exception('Forbidden scheme: ' . $scheme, 403);
}
// 新增:复用框架自带的内网校验
if (empty($host) || !Common::checkSafeHost($host)) {
throw new Exception('Forbidden host: ' . $host, 403);改动 B —— cURL 初始化时,锁定协议白名单:
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method);
// 新增:协议层封死 gopher/file/dict 等,并禁止协议跳转与重定向跟随
if (defined('CURLOPT_PROTOCOLS_STR')) {
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, 'http,https');
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS_STR, 'http,https');
} else {
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);这里体现了复用 checkSafeHost() 的价值——它已经处理好了 127.0.0.0/8、10/8、172.16/12、192.168/16、169.254/16、0.0.0.0/8 以及 IPv6 回环/私网,不需要自己重写。
修复点 3(可选,纵深防御)
sendPingHandle() 的第二跳再加一道独立校验。修复点 2 已覆盖全部出站,这一步只是双保险:
if (!empty($xmlrpcUrl)) {
$xp = parse_url($xmlrpcUrl);
if (
empty($xp['host'])
|| !in_array(strtolower($xp['scheme'] ?? ''), ['http', 'https'], true)
|| !Common::checkSafeHost($xp['host'])
) {
continue;
}
$xmlrpc = new \IXR\Client($xmlrpcUrl);
// ...
}改完怎么验证
对照实验(三步):
| 请求 | 修复前 | 修复后 |
|---|---|---|
token: true,pingback 指向 http://127.0.0.1:6379/ | 200 | 403 |
| 合法 token(后台发评论时自动生成) | 200 | 200(不回归) |
合法 token, pingback 指向 http://169.254.169.254/latest/meta-data/ | 服务器发起请求 | 被 checkSafeHost 拒绝 |
漏洞复盘
这个漏洞的技术含量并不高,但它的成因非常典型,值得记住三点:
- 第一,弱类型比较是一类长期存在的坑。
PHP 8虽然收紧了部分松散比较的语义,但string == true这类方向依然成立。凡是把用户输入丢进 == 的地方,都要警惕。正确做法是 === 或hash_equals(),并且先做类型检查。 - 第二,"有锁不用"比"没有锁"更普遍。 框架里明明躺着内网地址校验函数,却在关键路径上忘了调用。这类问题的根因不是能力缺失,而是安全能力没有和业务路径对齐。所以做安全审计时,除了问"有没有防护函数",更要问"这条路径上它被调了吗"。
- 第三,收敛点决定了修复成本。 因为所有出站都经过
Http\Client::send(),一个补丁就能封死全部攻击面。反过来看,如果每个调用点各自实现 HTTP 请求,修复就得改十处、还容易漏。设计时把横切关注点(网络出口、鉴权、日志)收敛到单一入口,安全成本会指数级下降。
最后,一个方法论提醒:验证 SSRF 时别迷信时间差。 只要目标在出站前提前 flush 了响应,时间差就完全失效。这时候必须上带外通道(OOB)。本文的实证正是靠这条思路才拿到了无法伪造的铁证。
1 条评论
谢谢分享,我已经修复咯