目录
[为什么不能直接用 Composer 搭建](#为什么不能直接用 Composer 搭建)
[虚拟主机与本地 hosts 配置](#虚拟主机与本地 hosts 配置)
[控制器入口与 payload 结构](#控制器入口与 payload 结构)
[payload 的语法推理而非字符串背诵](#payload 的语法推理而非字符串背诵)
摘要:本文复盘一次 ThinkPHP 5.0.10 缓存写入型远程代码执行案例。文章先说明旧版本在真实项目中的存量背景,再记录无法依赖 Composer 时如何从官方仓库手动拼装框架、配置 Nginx 虚拟主机并用本地 hosts 完成访问。随后沿着控制器、缓存类、驱动选择、缓存文件名计算和序列化写入逐层追踪,解释 payload 中 %0d%0a 与注释符的作用。最后通过断点调试复现缓存文件生成过程,并区分直接访问、文件包含和目录配置带来的限制。
背景与复盘范围
这次复盘的对象是 ThinkPHP 框架 5.0.10 版本中与缓存写入有关的历史 RCE 问题。课程材料首先从框架的存量情况切入:ThinkPHP 曾经在国内 Web 开发中占据很高的使用比例,原因包括国产化背景、中文文档和较快的开发效率。随着海外框架逐渐普及、中文支持改善,ThinkPHP 的新增使用量下降,但中小型互联网项目和一部分存量系统仍然可以见到它。渗透测试过程中反复遇到 ThinkPHP,也说明旧版本并不会因为新版本已经发布就自动退出生产环境。

图 01:官方站点首页展示了 ThinkPHP 的框架定位和开发者生态,这是判断其仍具有存量项目价值的背景信息。
课程中还用 PHP 与 Java 的版本迁移作类比。PHP 已经出现 8.x 版本,但一些项目仍以 7.x 为主;JDK 已经发展到更高版本,市场上仍有大量项目使用 JDK 8。原因不是开发者不知道旧版本存在漏洞,而是长期维护的业务系统往往依赖旧版本的函数和特性,整体升级可能接近重新改造底层代码。对 ThinkPHP 5.x 的复盘也采用同一视角:版本号较新不等于线上存量已经完成迁移,漏洞验证必须针对实际部署版本。

图 02:生态页面体现了框架周边项目和使用场景,帮助确认本次复盘讨论的是 Web 应用框架而非单一脚本。
ThinkPHP 的定位是轻量级 Web 开发框架。它预先提供路由、控制器、缓存等基础结构,开发者可以在此之上构建管理后台、商城、建站系统或点餐系统。框架本身像已经完成结构施工的房屋,业务代码负责后续装修;因此,复盘漏洞时既要看业务入口,也要追到框架提供的公共组件。

图 03:仓库列表展示了框架代码的公开来源,后续手动下载和源码追踪均以此为依据。
为什么不能直接用 Composer 搭建
材料将漏洞影响范围放在 ThinkPHP 5.0 到 5.0.10 附近,并指出课程环境需要固定到 5.0.10。常规文档建议使用 Composer 创建项目,但 Composer 仓库会随着维护状态变化。如果仓库已经不再提供目标旧版本,执行安装命令可能失败,也可能解析到较新的 8.x 版本。后者虽然能得到一个可运行的项目,却已经偏离漏洞验证条件,因此不能把"安装成功"误判为"环境正确"。

图 04:仓库页面用于核对目标版本和维护状态,避免自动安装时被解析到其他版本。
课程选择了手动拼装方式。第一步下载 framework 核心仓库,并在版本列表中选择 5.0.10;第二步下载 ThinkPHP 的外部应用仓库。两者缺一不可:核心仓库包含框架内部类,外部仓库提供可运行的应用目录和入口文件。

图 05:版本选择页面显示了 5.0.10 标签,手动下载从这里开始。

图 06:通过仓库下载菜单取得与漏洞版本一致的核心代码。

图 07:外部仓库列表用于寻找与核心库配套的应用骨架。
在寻找外部仓库时,材料遇到了维护提示和项目位置变化。页面上显示的仓库并不一定仍是当前维护入口,因此需要继续查看相关组织和仓库,而不是看到"已停止维护"就直接结束。最终得到两个数据包:一个是核心框架,一个是外部应用框架。

图 08:项目入口的变化说明旧框架下载不能只依赖单个页面,需要沿仓库关系继续确认。

图 09:页面中的维护提示是一次排查分支,结果并未改变"核心库和外部库都要取得"的判断。

图 10:重新定位到外部仓库后,下载第二个数据包。
把两个数据包放到本地后,先为项目建立一个清晰的目录名,例如 tp5.10。外部仓库自身缺少核心库,因此要在外部仓库根目录创建名为 thinkphp 的文件夹,再把刚才下载的核心代码完整复制进去。这个动作完成后,应用目录结构才具备 ThinkPHP 5 的基本形态。

图 11:目录截图展示外部仓库和待放入的核心目录。

图 12:新建的 thinkphp 目录是核心代码的目标位置。

图 13:核心库放入外部仓库后,项目具备可继续配置 Web 入口的结构。
虚拟主机与本地 hosts 配置
项目文件齐全并不意味着可以直接访问。ThinkPHP 的 Web 入口位于 public,如果把项目根目录直接暴露给 Web 服务器,访问路径和框架预期不一致。课程因此把访问方式切换为虚拟主机,并以 Nginx 为例说明配置位置:phpstudy 的 config 目录下有 vhosts 配置,现有的 0localhost 文件可以作为模板。

图 14:vhosts 目录显示现有虚拟主机配置,是新增站点的起点。
复制 0localhost 配置后,修改两处关键内容。第一处是域名,例如 www.tp5010.com;第二处是站点根路径,必须指向 tp5.10/public。public 是框架入口,路径配置错误时,即使 PHP 文件存在,Web 请求也不会进入预期的应用流程。

图 15:默认虚拟主机配置被复制,后续在副本上修改域名和根目录。

图 16:配置中需要同步调整站点域名和 public 入口路径。
虚拟主机使用的域名并没有公共 DNS 解析权限,因此还要修改本机的 hosts 文件。Windows 路径为 C:\Windows\System32\drivers\etc\hosts。在文件中把测试域名解析到 127.0.0.1,浏览器访问该域名时就会回到本地 Nginx,而不会查询外部 DNS。

图 17:截图定位到系统 hosts 文件,修改需要管理员权限。

图 18:本地解析记录把测试域名指向 127.0.0.1,与虚拟主机的 server_name 配置对应。
【小白通俗注解】浏览器解析域名时会先查看本机 hosts。如果其中已有匹配项,就直接使用该地址;没有匹配项时,才继续询问 DNS 服务器。本地实验利用的正是这个优先级。

图 19:域名解析到本机后,浏览器开始命中本地站点。
修改虚拟主机和 hosts 后要重启 Nginx 或对应 Web 服务,再访问 tp5010.com。排查时还要留意浏览器代理设置,代理可能把本地请求带到外部网络。材料中还遇到 Chrome 自动把 http 升级成 https 的情况,导致本地没有可用的 TLS 服务而访问失败。删除地址中的 s,重新用 http 访问后,站点恢复可达。

图 20:自动升级到 https 时,本地测试站点无法建立对应连接。

图 21:改回 http 后,ThinkPHP 5.0.10 页面能够正常返回。
首次访问时还可能出现数据库连接错误。材料说明这是测试代码中预先写入的数据库连接信息与本机环境不一致造成的,与缓存写入验证本身无关。调整或移除这部分自定义配置后,页面可显示 ThinkPHP 5.0.10 的默认信息。

图 22:错误页面显示数据库配置不匹配,问题位于实验环境初始化而非框架入口。

图 23:调整测试配置后,目标版本页面正常呈现。
到这里,环境搭建可以归纳为四个动作:下载 5.0.10 核心库、下载外部应用库、在外部库根目录放入 thinkphp 核心目录、为 public 入口建立虚拟主机并用 hosts 解析到本机。这个顺序不能颠倒,否则后续访问结果无法说明问题发生在代码还是服务器配置。
缓存写入问题的边界
环境可用后,复盘转向漏洞机理。ThinkPHP 的缓存类会把缓存数据序列化后写入 .php 文件。单纯写缓存并不是缺陷,问题出在三个条件同时成立:缓存文件使用了 PHP 扩展名,文件名和目录结构可预测,且缓存目录存在被 Web 访问或被文件包含的可能。
正常的缓存实现通常会把文件放在外部访问不到的位置,并使用难以猜测的名称。这里的实现却根据缓存键生成固定形式的路径,且序列化内容直接进入 PHP 文件。只要攻击者能控制写入值,就可能把 PHP 代码混入缓存文件。

图 24:漏洞说明明确了缓存序列化、.php 文件和可预测路径三个关键条件。
如果缓存目录可以直接通过 Web 访问,写入的内容有机会被当作 PHP 请求处理;如果目录被入口保护而无法直接访问,则还需要与文件包含漏洞结合。"能够写入代码"与"能够触发代码"是两个阶段,不能把它们混为同一个结论。
控制器入口与 payload 结构
为了让数据进入缓存,课程在 application/index/controller/Index.php 中创建最小控制器。代码引入 think\Cache,在 index() 方法里调用 Cache::set(),缓存名固定为 name,缓存值来自 input('get.username')。这意味着 username 是用户可控参数,但缓存键不是用户可控的。

图 25:目录树定位到控制器文件,后续请求从这里进入缓存流程。

图 26:控制器把 GET 参数 username 作为缓存值写入名为 name 的缓存项。

图 27:代码注释标出参数来源和测试请求格式,确认输入确实经过 input() 获取。
测试请求的核心形态是把任意前缀、%0d%0a、一句话代码和注释符组合起来。这里不把它作为面向未知目标的操作指南,而只讨论授权实验中各片段在 PHP 文件中的语法效果。%0d%0a 是 URL 编码形式,解码后对应回车换行;Windows 文本环境中它会把后续内容移到下一行。

图 28:截图展示 username 参数、换行编码和注释符在实验请求中的相对位置。
为什么必须换行?缓存文件写入时,序列化结果前面会出现 PHP 注释起始符。若一句话代码仍停留在注释行,PHP 解释器会忽略它。插入 %0d%0a 后,代码被推到下一行,前面的注释不再覆盖它。与此同时,序列化字符串末尾会带来引号和分号,因此还需要在实验 payload 末尾处理这两个字符,否则生成的文件会出现语法错误。

图 29:从 Cache::set 出发,后续源码追踪围绕初始化和驱动选择展开。
这三个问题构成了源码分析的入口:Cache::set 到底如何接收参数,%0d%0a 为什么能改变注释范围,以及最终哪个缓存驱动负责落盘。材料没有把它们压缩成"payload 成功",而是逐项验证每个语法和调用关系。
从静态方法追到缓存驱动
首先确认 Cache::set 的定义位置。ThinkPHP 使用 Cache::set(...) 这样的双冒号形式调用静态方法;普通实例方法则通过对象箭头调用。静态与实例调用方式不同,但这里的关键不是语法差异本身,而是要进入正确的 set 实现。

图 30:源码中 set 被声明为 public static function,说明双冒号调用对应当前类的静态方法。

图 31:文件树定位到核心库的 Cache.php,用于继续追踪 set 与初始化逻辑。
实验使用的 VS Code 无法稳定执行"按住 Ctrl 点击函数跳转"。这不是代码错误,而是工具能力不足,因此材料建议使用更适合 PHP 的 PhpStorm 进行代码追踪。若编辑器不能跳转,就按类名、方法名和目录手动检索,不能因为跳转失败而停止分析。

图 32:set 内部调用 self::init(),初始化完成后再转交实际驱动。
set 方法并不直接写文件,它先调用本类的 init()。init() 的职责是准备缓存处理器:如果静态处理器尚未建立,就根据传入选项或全局配置选择驱动;随后把连接结果保存到 self::$handler,并返回这个处理器。因此要理解写入行为,必须继续追 init() 和 connect()。

图 33:初始化逻辑根据传入选项、缓存类型和默认配置选择连接方式。
ThinkPHP 5 的缓存驱动目录中包含多种实现,例如文件、Redis、SQLite、Memcache 等。配置决定实际使用哪个驱动。课程环境没有修改默认缓存配置,配置数组中的 type 仍然是 file,因此本次写入最终落到文件驱动,而不是 Redis 或其他后端。

图 34:驱动目录列出可选缓存后端,说明缓存机制本身支持多种存储方式。

图 35:配置项将缓存类型设为 file,这决定了后续实例化的类。
在 connect() 中,程序从配置数组取出 type,拼接出驱动类名,再通过 new $class 实例化。由于 type=file,最终得到的是 think\cache\driver\File 的实例。随后 Cache::set 调用这个实例的 set 方法,调用链才真正进入文件写入逻辑。

图 36:connect() 读取配置中的 type 并据此确定驱动类。

图 37:代码展示 File 驱动被实例化并返回给缓存处理器。
【小白通俗注解】这里的"驱动"可以理解为同一套缓存接口背后的具体实现。上层只调用 set,底层究竟写文件、写 Redis 还是写其他存储,由配置中的 type 决定。
缓存文件名如何被推导出来
进入 File::set 后,分析重点变成两个变量:$filename 从哪里来,$data 如何被格式化。方法接收缓存名、缓存值和可选过期时间。实验中缓存名是固定的 name,缓存值则来自 GET 参数。
文件名由 getCacheKey($name) 计算。第一步对 name 执行 md5(),得到 32 位十六进制字符串;第二步检查 cache_subdir 配置。该选项开启时,程序取哈希前两位作为子目录,再把剩余部分作为文件名主体。

图 38:函数先对缓存名求 MD5,再按 cache_subdir 拆分目录和文件名。

图 39:substr($name, 0, 2) 截取哈希值前两位,用于形成缓存子目录。
substr 的第三个参数决定截取长度:从位置 0 开始取 2 个字符,就是哈希的前两位;再次调用 substr($name, 2) 则从位置 2 取到末尾。两段内容用目录分隔符拼接,形成类似 b0\\... 的层级。

图 40:配置项显示缓存根路径、前缀和子目录开关,决定最终路径拼接方式。
如果配置了 prefix,程序还会把前缀加到哈希名称前面;本次配置中的前缀为空,因此这一步不会改变结果。随后把 path、哈希目录和 .php 扩展名拼接起来,并在目录不存在时调用 mkdir($dir, 0755, true) 创建目录。

图 41:代码展示缓存文件名、父目录以及目录创建条件。
缓存根目录来自配置中的 cache_path,在本地项目中对应 runtime/cache。因此,给定固定缓存名 name 后,文件位置可以按代码推导:runtime/cache/ 加上哈希前两位子目录,再加上剩余哈希和 .php。路径并非随机生成,攻击者不需要读取目录列表也可以计算目标位置。

图 42:文件管理器显示缓存根目录和按哈希前缀划分的子目录。

图 43:文件名由哈希剩余部分和 .php 后缀组成,与源码推导一致。
序列化写入与换行效果
文件名确定后,File::set 对用户可控的 $data 执行序列化。字符串会被表示为类似 s:30:"..."; 的格式,其中 30 是字符串长度。压缩选项在实验配置中为 false,因此程序跳过压缩分支,直接拼接 PHP 注释前缀和序列化结果,再通过 file_put_contents 写入缓存文件。
第一次验证不传入有效值,只刷新控制器页面。结果是 runtime/cache 下生成了预期的哈希文件,但文件中没有可用的 payload。这一步证明文件名计算和目录创建正确,同时排除了"写入函数没有执行"的猜测。

图 44:目录中出现目标缓存文件,但由于没有传入值,文件内容不包含有效代码。
如果把普通字符串直接写入,序列化内容会跟在注释符后面,整个字符串都处于注释范围,不能执行。实验随后删除旧文件并加入 %0d%0a,使一句话代码换到下一行。换行后,序列化尾部仍然会留下引号和分号,因此再用注释符覆盖这两个字符,避免 PHP 语法错误。

图 45:普通字符串写入后全部落在注释范围内,说明不处理换行就无法进入 PHP 代码区。

图 46:调试截图标出 %0d%0a 让后续内容脱离注释行的效果。
再次刷新后,页面返回 Cache success,runtime/cache 中的文件内容出现了换行后的序列化字符串。这个结果只说明可控数据已经写入 PHP 缓存文件,并不等于当前请求已经完成命令执行。

图 47:控制器返回成功文本,证明缓存写入分支已执行。

图 48:缓存文件出现序列化内容和换行结构,验证 payload 的语法作用。
访问路径带来的限制
写入成功后,课程继续验证能否直接通过浏览器访问缓存文件。ThinkPHP 项目把 public 作为 Web 根目录,runtime 位于项目根目录下,通常不在公开入口之内。因此访问 runtime/cache/...php 返回 404 或禁止访问,并不表示文件没有创建,而是 Web 服务器没有把该路径映射到外部请求。

图 49:重新下断点前确认请求从控制器进入缓存方法。

图 50:调试过程中检查本地服务是否正常运行,为后续访问排除环境因素。

图 51:缓存目录不在公开入口时,直接访问并不能取得文件内容。

图 52:删除旧文件,避免历史结果干扰新一轮断点验证。
材料据此给出两个触发分支。第一种是缓存目录因服务器配置不当而可访问,写入文件后可以直接请求;第二种是缓存目录不可访问,但应用存在任意文件包含,能够把缓存文件纳入执行流程。若两者都不存在,漏洞仍然可能造成任意 PHP 文件写入,但难以形成可观测的直接执行结果。

图 53:目录结构截图说明 public 与 runtime 的层级关系。

图 54:虚拟主机配置展示 Web 根目录指向 public,解释了缓存目录不可直接访问的原因。
断点调试中的配置丢失
为了进一步确认每个变量的流转,课程在 Cache::set 及后续驱动函数中设置断点。切换 PHP 或 ThinkPHP 版本后,本地 phpStudy 有时会清理或覆盖虚拟主机配置,导致原本可访问的域名突然失效。这个失败现象一度看起来像代码问题,实际检查配置文件后发现是站点定义被删除。

图 55:版本切换引起的配置变化,说明调试环境本身也可能改变。
排查方式是重新打开 vhosts 配置,补回 tp5010 站点,确认 server_name 和 root 仍然指向测试项目,再重启服务。修正后重新访问域名,页面恢复正常,缓存文件也能再次生成。这个过程说明:当断点请求突然失效时,应先核对 Web 服务配置、端口和入口路径,再判断业务代码是否改变。

图 56:恢复站点定义,补回域名和 public 根路径。

图 57:服务重启后,虚拟主机配置重新加载。

图 58:页面重新可访问,说明失败原因来自配置丢失而不是缓存代码。
单步追踪一次完整写入
断点命中后,调试器首先进入自动加载和 input() 相关代码。自动加载负责类文件载入,input() 负责接收 GET/POST 参数,这两个分支在本次复盘中已有明确结论,因此可以使用"跳出当前函数"或继续执行,避免在无关代码中消耗时间。随后程序进入缓存类的 set 方法。

图 59:调试器首先经过框架自动加载和 input,再返回缓存调用链。
从 set 进入 init 后,静态处理器 self::$handler 尚未建立,于是执行初始化分支。程序读取当前配置中的 cache 数组,再把其中的 type 传给 connect()。调试窗口显示配置值为 file,因此后续类名解析不会走 Redis 或 Memcache 分支。

图 60:初始化函数读取缓存配置,确认当前处理器尚未创建。

图 61:调试器显示 cache 配置数组被传入连接函数。

图 62:type 值明确为 file,驱动选择结果与人工源码分析一致。
程序根据 type 拼接驱动类路径并实例化 File。返回的对象被写入 handler,随后 Cache::set 转而调用 File::set。单步执行到这里,可以把"缓存使用文件驱动"的推理从静态阅读变为运行时证据。

图 63:驱动类解析结果指向 think/cache/driver/File。

图 64:实例化完成后,返回的处理器对象就是文件驱动实例。
进入 File::set 后,调试器看到两个显式参数:缓存名和用户可控值,第三个过期时间参数使用默认值。随后程序调用 getCacheKey,对缓存名求 MD5,取前两位创建子目录,再拼接后 30 位和 .php。调试器显示的哈希片段与人工计算一致。

图 65:断点处的参数对应控制器传入的缓存名和 GET 值。

图 66:运行时生成的哈希片段与源码推导结果相同。

图 67:最终路径包含缓存根目录、两位子目录、剩余哈希和 .php 后缀。
文件不存在时,程序先创建目录和文件路径,再把 $value 序列化。调试窗口中可以看到字符串被编码为 s:30:"...";,其中长度字段与实际传入内容对应。压缩开关为 false,因此不会改变序列化结果。

图 68:调试器显示字符串类型标记、长度值和原始内容。
最后一步是 file_put_contents。写入前,内容由 PHP 注释前缀、换行后的可控片段、序列化尾部和用于注释引号及分号的字符组成。单步越过写入调用后,文件管理器中出现对应缓存文件,说明落盘动作已经完成。

图 69:写入参数包含注释前缀、换行后的内容和序列化尾部。

图 70:文件内容与调试窗口中的字符串一致,验证落盘结果。
对整体调用链的复盘
从整体流程看,请求先进入控制器,再调用 Cache::set。Cache::set 调用 self::init(),init() 检查静态处理器并进入 connect()。connect() 从全局配置读取 cache.type,得到 file 后拼接驱动类名,实例化 think\cache\driver\File,并把实例返回给 handler。随后 handler->set() 进入文件驱动的 set 方法。
文件驱动先通过 getCacheKey() 计算路径,再把用户可控值序列化,最后用 file_put_contents 写入 .php 缓存文件。换行编码的作用只发生在文件内容层面:它改变注释的覆盖范围;它不会改变文件名计算,也不会自动突破 Web 服务器对 runtime 目录的访问限制。

图 71:最终截图汇总了缓存写入链路和实验站点返回结果,作为本次复盘的收束证据。
访问方式与现实限制
课程最后比较了两种访问部署方式。一种是服务器 IP 加目录路径,例如 IP 后接 tp5010/public;另一种是通过虚拟主机域名访问,例如 www.tp5010.com。在多站点服务器上,运营方通常希望每个站点使用独立域名,而不是把项目目录暴露在 IP 路径下。因此真实环境更常见的是虚拟主机方式。
这也带来漏洞利用上的限制。若只能通过 IP 加目录访问,首先需要知道服务器 IP,还要猜中应用在中间件目录下的文件夹名;文件夹名可能不是 tp5010,而是公司名或其他自定义字符串。只要目录名猜错,请求就无法到达缓存文件。即使目录名正确,还要确认服务器同时允许 IP 路径访问,而不是仅接受特定 Host 头的虚拟主机请求。
因此,本案例至少存在三层前提:第一,目标确实运行受影响的 ThinkPHP 5.0.x 版本;第二,缓存写入入口可达且用户输入能够进入 Cache::set;第三,缓存目录可直接访问,或应用另有文件包含路径。若采用 IP 路径,还要再加上服务器 IP 可达和目录名称可猜测两个条件。漏洞编号严重并不等于任意部署都能直接复现,最终可触发性取决于框架、Web 根目录和中间件配置的组合。
把失败结果拆成可验证的假设
整个实验过程中,多个现象都容易被误判为"漏洞不存在"。Composer 没有安装到 5.0.10,首先说明的是仓库与版本约束不匹配,而不是漏洞已经修复;浏览器跳转到 https,说明请求协议发生了变化,而不是域名解析失败;访问 runtime/cache 得到 404,说明公开入口没有映射到项目根目录,而不是缓存文件没有生成;切换运行版本后站点失联,则可能是 phpStudy 重写了 vhosts 文件。把这些现象分别归类后,每一次排查都可以提出一个更小的假设,并用下一步操作验证。
在版本问题上,验证动作是查看仓库标签、下载实际压缩包并检查目录内容,而不是只看安装命令是否返回成功。若得到的是 8.x 项目,后续即使页面可以打开,也不能作为 5.0.10 的证据。手动拼装的两个数据包分别承担不同责任:外部仓库提供应用入口和 public 目录,framework 仓库提供 think 命名空间下的核心类。少复制一个目录,控制器就可能找不到 Cache 类;目录层级放错,Web 服务就可能把项目根目录当作入口。
虚拟主机配置也应按变量逐项核对。server_name 决定请求由哪个站点接收,root 决定相对 URL 映射到哪个文件系统位置,public 决定框架是否处在预期的入口边界。域名写成 www.tp5010.com 而 hosts 中只写 tp5010.com,两者并不匹配;根目录写到 tp5.10 而不是 tp5.10/public,请求虽然可能命中 Nginx,却不会落到 ThinkPHP 的入口脚本。材料中的截图之所以连续出现配置、服务重启和浏览器访问,正是为了把这三个变量分别固定下来。
数据库报错的处理同样遵循隔离原则。页面能够返回,说明域名、端口、Nginx 和 PHP 已经连通;随后出现的数据库连接异常,来自测试项目中预留的连接参数。先修改这部分配置,再回到缓存控制器,可以避免把数据库依赖误认为缓存漏洞的必要条件。验证最小化的目标,是让每个外部依赖只负责一件事,并让失败结果能够指向明确的层级。
payload 的语法推理而非字符串背诵
username 参数的价值不在于某个固定字符串,而在于它最终会被当作 $value 送进序列化流程。缓存名 name 决定文件名,缓存值 username 决定文件内容;两者在调用链中的用途不同。复盘时先把参数角色分开,再解释编码字符的效果,比把整条请求当作不可拆分的"利用串"更容易发现错误。
%0d%0a 在请求层是两个百分号编码字节,进入 PHP 后表现为回车和换行。它没有改变 md5($name) 的结果,也没有改变 cache_subdir 的目录规则,只改变了写入文本的行边界。由于文件开头存在注释符,换行会让后续序列化文本从注释行移出。这个关系可以用三个中间状态验证:不传值时文件可创建但无有效内容;传普通字符串时内容仍在注释范围内;加入换行后,代码片段出现在下一行,文件结构发生变化。
序列化尾部的引号和分号是第二个语法边界。换行只解决了前置注释覆盖问题,却不会删除序列化格式本身。若尾部字符直接落在一句话代码后面,PHP 解析器可能把它们当作多余语法,结果是文件写入成功但解释失败。因此实验中还要保留注释符,把新增的引号和分号排除在可执行代码之外。写入结果需要同时检查行结构和 PHP 语法,不能只看文件大小发生变化。
材料中先让控制器返回 Cache success,再打开生成的缓存文件,是一个重要的分层验证。页面返回只证明 Cache::set 返回了成功值;文件内容截图才证明 file_put_contents 收到了预期字符串;浏览器能否访问该文件则属于更后一层。三个结果分别对应应用调用、文件系统落盘和 Web 暴露,任何一层失败都不应被下一层的现象掩盖。
源码追踪时的变量地图
源码阅读可以沿着一张简化的变量地图进行。控制器中的 $name 是固定的缓存键,input('get.username') 的返回值进入 $value;Cache::set($name, $value) 把两者传入静态缓存类;init() 负责产生 $handler;connect() 把配置数组中的 type 转成类名;文件驱动再把 $name 转换成 $filename,把 $value 转换成 $data。这张地图中的每一次赋值都能在断点窗口中找到对应的运行时值。
当 self::$handler 为空时,init() 才会进入连接分支。若处理器已经存在,后续调用可以直接复用,因而首次请求和刷新请求的调试路径可能不同。材料在断点时专门观察"当前目录下是否已有 handle"这一条件,确认第一次请求会初始化驱动。理解这个条件有助于解释为什么某些断点只在第一次访问时命中,而刷新页面后表现得像跳过了初始化。
connect() 中的 option 不是普通业务参数,而是全局缓存配置。配置数组包含 type、path、prefix、cache_subdir 等字段。type 决定类实现,path 决定根目录,prefix 决定文件名前缀,cache_subdir 决定是否把哈希前两位拆成目录。源码分析如果只盯着 set() 而忽略这组配置,就无法解释文件为什么位于 runtime/cache,也无法解释目录名为什么是哈希前两位。
在文件驱动中,getCacheKey() 先计算完整 MD5,再根据配置拆分字符串。substr($name, 0, 2) 只取前两位,substr($name, 2) 取剩余部分;两段之间使用 DS 拼接。随后程序把结果和 .php 后缀合成文件名,并用 dirname() 得到父目录。目录不存在时才执行 mkdir(),存在时继续向下。断点调试时逐行观察这些变量,可以确认"目录创建"和"文件写入"是两个不同动作。
断点验证如何排除无关分支
调试器进入自动加载器时,不必在每个框架函数中停留。自动加载只负责把类文件载入内存,input() 只负责读取 GET/POST 参数;两者的职责已经从源码和参数值中得到确认。使用单步跳过或跳出当前函数,把注意力重新放回 Cache::set,能够减少框架公共代码造成的噪声。有效调试不是每一行都停,而是只在能改变推理的分支上停。
初始化阶段要重点记录三个时刻:handler 仍为空、配置数组被取出、File 类完成实例化。第一个时刻说明连接尚未建立,第二个时刻说明缓存配置进入运行时,第三个时刻说明驱动选择已经完成。随后进入 File::set,记录 $name、$value、哈希结果和 $filename,就可以把静态代码中的每一个关键结论与真实执行值对应起来。
断点过程中出现的版本切换问题,也提供了环境调试的示范。当站点突然无法访问,先观察 Nginx 配置文件是否仍包含测试域名,再查看服务是否重新加载;配置缺失时补回站点并重启;页面恢复后再重新下断点。若跳过这一步直接修改 payload,可能得到一个"修正后仍然失败"的假象,因为请求根本没有进入控制器。
文件写入阶段的最后一个观察点是 file_put_contents 的参数。这里同时能看到目标路径和完整字符串,因此可以把"文件名预测正确"和"内容构造正确"放在同一处核对。写入调用返回后,再查看文件管理器或编辑器中的内容,确认磁盘上的结果与内存中的参数一致。若两者不同,应优先检查编码、换行转换和文件是否被旧缓存覆盖。
直接访问与包含触发的区别
public 入口带来的目录边界,是本案例最容易被忽略的限制。请求从 Web 根目录开始解析,项目根目录下的 runtime 不会因为文件真实存在就自动变成公开目录。直接请求 runtime/cache/...php 得到 404,表示 URL 到文件系统的映射没有成立;如果得到禁止访问,则表示服务器找到了目录但拒绝返回。两种结果都不能说明缓存写入失败。
若服务器没有设置虚拟主机,而是把应用目录直接挂在 IP 后面,访问路径可能包含项目文件夹和 public。这种部署更容易暴露绝对路径,但前提是知道服务器 IP、项目目录名和入口位置。多站点服务器通常使用域名区分站点,IP 加目录的访问方式反而不常见。材料因此把两种 URL 并列展示,用来说明漏洞成立条件会随着中间件配置变化。
文件包含是另一条完全不同的路径。缓存文件不能被直接请求时,若应用存在任意文件包含点,缓存文件可能被包含进执行流程;如果没有包含点,也没有开放缓存目录,那么写入结果只能停留在文件系统中。这个区分对风险描述很重要:缓存写入是漏洞本体,代码执行是需要额外触发条件的后果。
目录名猜测还会影响 IP 路径。实验项目使用 tp5.10 只是本地命名,真实项目可能使用公司名、业务名或随机目录。即便服务器允许 IP 访问,目录名未知仍会导致 URL 无法命中目标项目。课程用多个网站共存的例子说明,同一台服务器上不同站点的文件夹名并没有统一约定,因此不能把本地路径直接当成远端事实。
从复盘记录提炼可重复的验证顺序
在授权靶场或本地实验中,可以把这次复盘整理成一份不依赖具体字符串的验证顺序。先固定 ThinkPHP 版本并记录来源;再确认 public 入口和虚拟主机域名;随后用最小控制器写入普通字符串,验证缓存文件和路径;接着对比不换行与换行两种内容的差异;最后在断点中重复同一请求,核对配置、驱动、哈希、序列化和落盘结果。每个阶段都有独立成功标准,任何失败都能回到对应阶段重新检查。
第一轮请求不携带有效值,是为了观察文件名生成和目录创建;第二轮请求使用普通文本,是为了确认注释符确实覆盖序列化内容;第三轮请求加入换行编码,是为了验证行边界改变;第四轮请求结合调试器,是为了核对人工推导。这样的递进顺序比一开始就发送复杂 payload 更容易定位错误,也能避免把多个变化同时引入而失去对照组。
实验记录还应保留浏览器地址、服务配置、文件路径和断点截图。浏览器地址能说明使用的是 http 还是 https;服务配置能说明域名和根目录是否匹配;文件路径能说明哈希推导是否正确;断点截图能说明运行时参数是否与静态分析一致。图片并非装饰,而是各个验证阶段的证据链组成部分。
最后需要把"漏洞编号""可写入文件""可访问文件""可执行代码"四个概念分开。编号来自框架对问题的风险评估;可写入文件来自 file_put_contents 的结果;可访问文件取决于 Web 服务器映射;可执行代码还取决于 PHP 解析路径和触发方式。复盘结论只有在这四个层次都被分别说明时,才不会把条件性风险表述成无条件攻击能力。
按证据检查每一个中间结果
环境搭建阶段的第一个证据是版本。下载完成后,目录名和仓库标签都应指向 5.0.10,不能只凭浏览器页面上的项目名称判断。第二个证据是结构:外部应用目录中存在 public、application、runtime 等目录,核心库位于外部目录下的 thinkphp 文件夹。第三个证据是访问:虚拟主机的根路径指向 public,本地 hosts 将域名指向 127.0.0.1,重启服务后浏览器通过 http 返回 ThinkPHP 页面。
这三个证据对应三个不同层级。版本证据回答"代码是否属于目标范围";结构证据回答"框架是否完整";访问证据回答"请求是否进入目标项目"。任何一个层级缺失,后续对漏洞的判断都可能建立在错误对象上。例如,若代码实际是 8.x,控制器仍能返回页面,但结果不再代表 5.0.10;若 root 没有指向 public,页面可能由其他站点返回,控制器自然也不会命中。
控制器阶段的第一个验证是参数来源。input('get.username') 明确限定从 GET 读取值,测试请求中必须出现 username 参数;如果参数名写成其他名称,缓存值会变成空值或默认值。第二个验证是缓存键和值的分工。代码中的 Cache::set('name', input(...)) 让键固定、值可控,因而文件名在同一实验中保持不变,而文件内容随请求变化。第三个验证是返回结果。Cache success 只能说明方法返回,必须配合缓存目录中的文件和内容截图,才能继续向文件驱动推进。
源码阶段的证据要按调用顺序保存。先在 Cache.php 看到 set() 调用 self::init();再在 init() 看到 handler 的初始化条件;接着在 connect() 看到配置数组的 type;最后在文件驱动看到 getCacheKey()、序列化和 file_put_contents。如果调试器在自动加载器或 input() 停住,应该记录"已确认的公共框架路径",然后跳过到下一处尚未确认的逻辑。这样可以把长调用链压缩为一组可复核的断点,而不是凭印象描述"程序进入了缓存"。
路径阶段的证据也有先后。先观察完整 32 位 MD5,再观察前两位被拆为目录,最后观察后 30 位与 .php 拼接成文件名。目录出现并不代表文件已经写入,文件出现也不代表内容正确;只有打开文件看到序列化文本,才说明 $value 已经经过格式化并传给写入函数。材料中先刷新生成空文件,再加入内容进行第二轮验证,正是为了把"路径生成"和"内容写入"分开。
内容阶段要同时看编码前和编码后的状态。请求中看到 %0d%0a,说明客户端仍在发送 URL 编码;调试器或文件内容中看到实际换行,说明服务器端已经完成解码。若只在地址栏检查字符串,很难判断换行是否真的进入 PHP;若只看文件最终形态,又无法知道换行是由请求编码、框架处理还是编辑器换行造成。将请求、断点参数和落盘文件三处并列,是这次复盘中最有价值的对照方式之一。
复盘中容易出现的误读
第一种误读是把旧版本使用量与漏洞必然存在画等号。课程只是说明 ThinkPHP 5.x 在存量系统中仍可能出现,实际是否受影响仍需核对精确版本和代码路径。第二种误读是把 Composer 安装失败理解为漏洞不可复现。失败原因是仓库不再提供目标版本,解决方案是手动下载固定版本;这与漏洞逻辑本身是两件事。第三种误读是把缓存文件扩展名 .php 当成自动执行条件。文件是否可被 PHP 解析,还要看它能否被 Web 请求到或被其他代码包含。
第四种误读是看到哈希文件名就认为路径不可预测。MD5 只对缓存名做确定性转换,缓存名在控制器中是固定的 name;只要知道算法、前缀和缓存根目录,就能推导出相同结果。第五种误读是看到 Cache success 就认为一句话代码已经执行。该返回值发生在写入阶段,执行阶段尚未发生,尤其在 runtime 不公开时更是如此。第六种误读是把 404 当作文件不存在。404 只能说明当前 URL 没有映射到可服务资源,文件仍可能真实存在于项目根目录下。
第七种误读来自调试工具。VS Code 无法跳转函数,不代表类不存在;PhpStorm 可以更方便地定位,但工具替换不会改变调用链。第八种误读来自环境切换。phpStudy 在切换版本后清理虚拟主机配置,站点失联是配置回退,不是 Cache::set 逻辑改变。第九种误读是把 IP 加目录的访问方式当成通用部署。课程用它说明一种可能的服务器映射,而不是声称所有站点都可以通过绝对路径访问。
用最小实验保持结果可复现
最小实验只需要一个控制器方法、一个固定缓存键和一个可控 GET 参数。先不引入数据库查询、复杂路由或额外业务逻辑,让控制器直接返回缓存方法的结果。这样做的价值在于,若页面无法访问,可以优先检查虚拟主机、hosts 和服务状态;若页面能访问但缓存文件不出现,再检查框架目录和配置;若文件出现但内容异常,再检查序列化和输入编码。每个新增组件都应在前一阶段稳定后再加入。
缓存文件验证也应保留对照组。空值请求用于确认文件名和目录;普通字符串用于确认注释前缀的效果;带换行编码的字符串用于观察行边界变化。三个对照组共享同一个缓存键,因此文件路径保持一致,差异集中在文件内容。若每次都更换缓存键,就会同时改变路径和内容,无法判断观察到的差异来自哪一个因素。
断点验证可以使用同一组对照请求。第一次在 Cache::set 查看 $name 和 $value;第二次在 connect() 查看 type;第三次在 getCacheKey() 查看哈希与文件名;第四次在 file_put_contents 查看最终字符串。每个断点都只回答一个问题,离开后再进入下一层。这样即使调试器中途跳入自动加载或参数处理,也能通过调用栈回到主线,不会把公共框架代码误写成漏洞成因。
实验结束后还要清理缓存文件并恢复服务配置。材料中曾删除旧缓存以避免历史内容干扰,也曾重新写入被清理的虚拟主机配置。清理动作不是附带工作,而是保证下一轮请求从相同状态开始的必要条件。对于需要反复验证的历史漏洞,若不清理旧文件、旧断点和旧站点配置,后续成功或失败都可能只是残留状态造成的假象。
结果记录的最小闭环
一次完整记录至少应包含四类材料。第一类是环境材料,包括目标版本、核心仓库和外部仓库的来源,以及项目目录名。第二类是配置材料,包括 server_name、root、hosts 记录和服务重启结果。第三类是代码材料,包括控制器调用、缓存配置、驱动类和文件名计算函数。第四类是运行材料,包括请求参数、断点变量、缓存文件内容和浏览器访问结果。四类材料互相印证,才能把"代码能运行""文件能生成""路径可访问""代码可触发"区分开。
记录顺序也应与程序顺序保持一致。先写环境,再写入口;先写参数,再写缓存键和值;先写驱动选择,再写路径生成;先写序列化,再写落盘;最后记录目录访问和触发条件。任何提前出现的结论都可能遮蔽后文的失败分支。例如,若先写"已经完成 RCE",读者就容易忽略后面 404、禁止访问和文件包含条件;若先写"缓存目录不可访问",又可能忽略前面已经完成的文件写入事实。
复盘文章还需要区分事实、推理与经验。事实包括页面返回、文件生成、断点变量和配置内容;推理包括从 type=file 推出将实例化文件驱动、从 substr 推出两级路径;经验包括优先使用适合 PHP 的代码追踪工具、遇到访问失败先检查环境和配置。把三类内容分开,既能保持技术准确,也能让读者知道哪些结论来自截图和运行结果,哪些结论来自对源码的解释。
在授权环境之外,不应把本地目录、域名和请求样例直接迁移到未知目标。本文保留这些内容,是为了说明课程实验中的参数流向和文件结构;实际验证必须得到明确授权,并且应在隔离靶场中进行。对于外部发布的复盘,最有价值的不是扩大可操作范围,而是清楚呈现版本边界、失败原因、调试证据和触发条件。
从工程视角看,这个案例还说明了配置默认值的重要性。框架虽然支持 Redis、SQLite、Memcache 等多种缓存后端,但测试项目没有主动修改 cache.type,于是默认的 file 驱动承担了全部写入。漏洞分析不能只阅读某个驱动类的实现,还要确认目标项目实际启用的配置;同一套 ThinkPHP 代码在不同缓存后端下,文件名计算、落盘方式和后续触发面都可能不同。课程选择文件驱动,是因为它与 .php 缓存文件和 runtime/cache 路径直接相关。
配置中的 cache_subdir 同样不是无关细节。开启子目录后,哈希前两位被用作目录名,剩余部分作为文件名;如果关闭该选项,路径形态会变化,但"由缓存名确定性计算路径"的事实仍然需要重新核对。prefix 为空只是本次实验的结果,换成其他值后,文件名推导必须把前缀纳入计算。也就是说,路径预测依赖配置与代码的共同约束,而不是依赖某个固定的示例路径。
序列化长度字段是另一个适合用运行时核对的值。截图中看到的 s:30 来自本次传入字符串的长度,改变输入内容后长度也会随之改变。若手工复制字符串时遗漏一个字符,长度字段与实际内容不一致,文件可能仍然成功写入,但后续解析结果会不同。断点窗口和文件内容同时展示长度值,能够快速发现这类看不见的输入差异。
因此,复盘的最终输出不应只是一段利用描述,还应说明哪些条件是固定的、哪些条件可以变化,以及变化后需要重新验证哪一层。版本、驱动、路径配置、Web 根目录和文件包含能力共同决定结果。把这些变量逐项列出,读者才能把文章当作一次可审计的技术复盘,而不是一条脱离环境的操作口诀。
这种变量化记录也方便后续复测:只替换一个条件,其他条件保持不变,才能判断结果变化的真正来源。
复盘还应保留"未改变的条件"。本次实验没有更换缓存驱动,没有把 cache_subdir 关闭,也没有把 Web 根目录改到 runtime;因此观察到的路径、文件扩展名和访问限制,只能代表当前配置组合。后续若调整任一配置,都应重新执行目录生成、文件写入和访问测试,并把新旧结果放在同一份记录中比较。这样才能确认变化来自配置,而不是来自请求内容或调试残留。
每轮测试还应标明时间点和清理状态,确保截图、日志与文件内容能够互相对应。
这些记录让失败结果也具备复用价值,而不是只留下一个无法解释的错误页面。
它们也为后续版本对比提供了明确的基线。
讲师经验与方法总结
这次漏洞本身关联的函数并不多,源码路径相对清晰;但更复杂的漏洞可能跨越更多类和更多配置分支,单步调试的价值会更加明显。
复盘中反复强调了 Debug 的作用。静态阅读可以推导出调用关系,但函数跳转、参数覆盖和条件分支一多,仅靠肉眼很容易把变量来源记错。断点调试能够逐步回答几个关键问题:当前参数是否仍是用户输入、handler 何时创建、配置中的 type 取到了什么、文件名在什么时候确定、序列化后的长度是否正确,以及 file_put_contents 是否真的执行。
工具选择也会影响效率。VS Code 在本次环境中无法稳定跳转到 PHP 定义,导致需要手动定位;PhpStorm 更适合做跨文件追踪。遇到"访问失败"时,不能立刻归因于 payload:浏览器代理、HTTP 自动升级 HTTPS、虚拟主机被版本切换清空、数据库配置残留,都曾在实验中产生干扰。先恢复一个可重复的基线,再进行代码调试,结论才可靠。
学习这类历史漏洞时,建议把"写入"和"触发"分开记录,把"源码推理"和"运行时证据"并列保存。写入阶段关注缓存键、路径、序列化和文件落盘;触发阶段关注 Web 根目录、目录访问策略以及是否存在文件包含。面试或实战复盘中,能够说明失败尝试、失败原因和下一步验证,比只给出一条看似成功的请求更能体现对漏洞边界的理解。
复盘结论
本次 ThinkPHP 5.0.10 案例的核心不是某一段 payload,而是一条可以被验证的因果链:旧版本缓存类接收可控数据;默认 file 驱动把序列化结果写入 .php 文件;缓存名经过 MD5 后按固定规则生成目录和文件名;%0d%0a 让可控内容脱离前置注释;file_put_contents 完成落盘。随后是否能形成代码执行,还要看缓存目录是否可访问,或是否存在文件包含等额外条件。
在授权实验环境中,最稳妥的复盘顺序是先固定版本并完成本地虚拟主机,再用最小控制器确认缓存文件生成,接着静态追踪 Cache::set、init、connect、File::set 和 getCacheKey,最后通过断点验证每个参数和文件内容。这样既能保留漏洞的技术细节,也能避免把"文件写入成功"夸大成"任意目标都能直接执行"。