把BUUCTF上这道『CISCN2019 华北赛区 Day1 Web1 Dropbox』拉到本地复现的时候,我一开始是有点轻敌的。CTF Web题见多了,看到“网盘”两个字就会下意识觉得:无非是文件上传绕过呗。直到注册登录进去,面对一个五脏俱全的Dropbox管理界面,我才意识到这题考的不是单个漏洞,而是“任意文件读取 + 源码审计 + phar反序列化 + symlink组合”整条链。这篇文章就是我当时完整复现的记录,包括踩坑过程,适合正在刷BUUCTF、想搞明白CISCN这类偏综合Web题的人参考。题目环境一直在,不需要额外搭建,跟着请求走一遍就能把整条利用链串起来。这种综合性题目的好处是:一道题串起好几个考点,每个考点的存在感都很强。坏处是:如果你只盯着“上传能不能绕过”,会浪费大量时间在错误的方向上。我复现时的第一条经验就是——先把功能面摸清楚,再决定从哪里下手,而不是看到上传框就往上撞。1. 从登录框到文件管理:Dropbox的功能面到底铺了多大1.1 先别急着打,把页面上的按钮全部点一遍打开题目页面,第一眼是一个登录/注册框。注册一个账号进去,看到一个很标准的文件管理界面:上传区、文件列表、下载按钮、删除按钮。表面上功能只有这几个,但真正要做的不是急着传一句话木马,而是把每个按钮背后的请求都抓下来,搞清楚服务端到底接收了哪些参数。我当时的做法很简单:先传一个正常的test.txt,内容随便写,然后分别点击下载、删除,把对应的请求记录下来。上传用的是POST,带filename字段和文件内容;下载是GET,关键参数filename;删除也是POST,同样带filename。这里一个很明显的特征是:三个功能都在用filename这个参数,但各自对它的处理逻辑可能完全不同,而这正是漏洞的温床。1.2 上传/下载/删除三条请求的参数特征我用Burp逐个把请求记录下来,整理成了一张表,这样后面审计的时候思路清晰很多:功能请求方式关键参数初步风险判断上传POSTfilename + 文件内容文件类型/内容过滤下载GETfilename路径穿越/任意文件读取删除POSTfilename路径穿越/任意文件删除列表GET无信息泄露实际测试时,删除功能传入../../../../etc/passwd是失败的,说明删除侧对路径有校验或拼接方式不同。上传功能只检查了文件扩展名,但并没有严格限制内容,传一个.php会被拦截,传一个.txt就放行。这个上传过滤看起来比较粗糙,但也没有明显到可以直接利用的程度。综合来看,最值得深入的反而是下载接口——它直接暴露了filename参数,且响应是文件内容,天然适合做路径穿越测试。1.3 用“功能-参数-风险”表代替盲打很多新手打CTF容易陷入“看到一个功能就猛打”的状态,比如看到上传就去测绕过,看到登录就去测注入。但实际上,像Dropbox这种多功能的题,真正的考点往往藏在不同功能对同一个参数的差异化处理里。我的建议是:在纸上把每个接口的参数、方法、可能的漏洞类型列出来,先做一轮快速试探,再决定深入方向。试探不一定要用特别逆天的payload,就是普通的../../../../etc/passwd、../flag、....//....//这类基础变形。这一轮做完,基本上就能判断出哪个接口是“突破口”,哪个接口是“辅助功能”。对我这轮复现来说,突破口就是下载接口的filename参数。2. 下载接口的filename参数:一次目录穿越牵出全部源码2.1 目录穿越的验证:从passwd到源码下载接口的请求形如:GET /download.php?filename=test.txt我直接改成:GET /download.php?filename=../../../../../../etc/passwd响应直接返回了passwd文件内容,说明这里是任意文件读取,而且没有对..做过滤。有了这个能力,第一反应当然是去读flag,但实际环境中flag通常不会直接放在网站根目录,也不一定叫flag这个名字。更聪明的做法是:先读源码,搞清楚站点目录结构、后端逻辑、有哪些文件、哪些类,再决定怎么拿到flag。这里有个经验:任意文件读取漏洞产生后,首要目标永远是源码,而不是直接猜flag路径。因为源码会告诉你真正的攻击面在哪里,也会告诉你怎么才能最高效地拿flag。直接猜路径的效率极低,而且很容易猜错方向。2.2 能读不能读的边界:download和delete为什么不一样为什么download能穿越,delete却不能?原因往往是两个功能对filename的拼接和校验方式不同。download的代码逻辑大概率是把uploads/目录和filename做字符串拼接,然后直接readfile或file_get_contents,没有任何realpath校验,所以..能直接穿出去。delete则很可能用了realpath做校验,或者把文件路径限制在了上传目录下,先判断文件是否存在,再执行删除。这么一来,delete就不具备任意文件删除的能力,至少不能通过简单的目录穿越实现。这一点很重要:同一个参数在不同功能中的处理逻辑可能完全不同,不能因为一个接口存在漏洞,就假设其他接口也存在同样的问题。2.3 从报错信息和include链里反推目录结构有了任意文件读取,但不知道源码放在哪,怎么定位?我用的办法是先读/etc/nginx/nginx.conf或Apache配置,找到站点根目录。实际测试时,这类网盘题目常见的源码路径是/var/www/html/,但为了稳妥,我故意请求了一个不存在的文件,比如:GET /download.php?filename=no_such_file_abc.php如果页面返回“文件不存在”之类的错误,或者包含详细的路径信息,就能直接得到物理路径。我当时拿到的是类似/var/www/html/xxx的路径。知道根目录后,顺着读取/var/www/html/index.php,就能看到首页逻辑;再根据index.php里的include/require语句,把class.php、config.php、upload.php这些文件逐个读出来。整个源码审计的起点,就是从一次普通的路径穿越开始的。3. 源码里的漏洞信号:File类的析构函数为什么让我眼前一亮3.1 还原index.php的请求流转拿到源码后,我先把index.php从头到尾读了一遍。整体逻辑大致是这样的:通过action参数控制路由,分别处理文件列表、上传、下载、删除。每个分支都会做对应的参数校验,但校验强度不一。比如下载分支几乎没校验,删除分支做了简单的目录限制,上传分支限制了扩展名但允许ZIP解压。这里最让我注意的一点是:上传功能不仅保存了文件,还支持ZIP压缩包解压。这意味着我上传一个.zip,服务端会用解压逻辑把它展开到上传目录。这个功能本身就是个很大的攻击面——如果解压逻辑不严格,就可能引入符号链接文件、phar文件等特殊条目。3.2 FileList与File:这两个类各管什么继续往下看,源码中存在两个核心类,一个是文件列表相关的类,另一个是文件操作相关的类。文件列表类主要负责遍历上传目录、展示文件名和大小;文件操作类则封装了文件的创建、读取、删除。简化还原之后,文件操作类的结构大致是这样:class File { public $filename; public function __construct($filename) { $this->filename = $filename; } public function __destruct() { if (file_exists($this->filename)) { unlink($this->filename); } } }看到这个类的时候,我眼睛一亮。__destruct()里直接调用了unlink(),而且$this->filename是对象属性,完全可控。这就是一个标准的反序列化利用点:只要能让这个类被反序列化,并且控制filename属性的值,就能在对象销毁时删除目标机器上的任意文件。3.3 __destruct里的unlink:反序列化的标准信号为什么看到析构函数里有unlink就要往反序列化上想?因为在正常的业务逻辑里,这个类可能只是用来操作上传文件的,filename通常来自用户上传时传入的文件名。但如果我能控制filename属性的值,并让这个对象在某个时刻被销毁,unlink就会执行,而且不受任何路径校验限制。那怎么触发反序列化?这就引出了phar反序列化的经典利用链。PHP中,很多文件操作函数都可以通过phar://协议触发反序列化,比如file_exists、is_file、file_get_contents。而代码里几乎所有文件操作都会先判断文件是否存在,这正好满足了触发条件。3.4 攻击面清单:哪些点可以组合成利用链审计完源码后,我在笔记里列了一张攻击面清单:下载接口存在任意文件读取,可以用来读源码、读配置、读flag。上传功能支持ZIP解压,意味着可以上传包含特殊条目的压缩包,包括符号链接和phar文件。文件操作类的析构函数中存在可控的unlink,可以通过phar反序列化触发任意文件删除。删除接口虽然做了路径限制,但如果结合符号链接,可以绕过目录限制。这四个攻击面单独看都不算特别致命,但组合起来就能构成一条完整的利用链。尤其是任意文件读取和ZIP解压这两个能力叠加,等于给了我在目标服务器上放置文件、随后读取文件的双重能力。4. phar反序列化:把磁盘上的文件变成代码执行入口4.1 phar为什么能被当成反序列化入口phar是PHP的一种归档格式,可以把多个文件打包成一个phar文件,类似Java的jar。phar文件内部有一个metadata区域,这个区域会被PHP序列化存储。当PHP的任何文件函数接触到一个phar文件时,会先尝试解析该文件,并反序列化metadata中的内容。如果metadata中正好是一个对象,那么在反序列化时会触发对象的__wakeup方法,在随后的对象销毁过程中还会触发__destruct方法。这就是phar反序列化的核心原理:不需要代码里存在unserialize()函数,只要代码在某个地方对用户可控的文件路径执行了file_exists、is_file、file_get_contents、stat等操作,而这些操作是支持phar://协议的,那么反序列化就会被触发。我在这道题里看到代码中大量使用file_exists来判断文件是否存在,所以几乎可以确定这条路是通的。4.2 本地生成恶意phar的过程要在本地生成一个合法的phar文件,需要修改PHP配置,把phar.readonly设为Off,否则PHP不允许创建phar文件。生成payload的代码很简单:<?php class File { public $filename; } $phar = new Phar('payload.phar'); $phar->startBuffering(); $phar->setStub('GIF89a<?php __HALT_COMPILER(); ?>'); $object = new File(); $object->filename = '/var/www/html/uploads/delete_me.txt'; $phar->setMetadata($object); $phar->addFromString('test.txt', 'test'); $phar->stopBuffering();这里有几个细节要注意。第一,stub不是随便写的,__HALT_COMPILER();后面必须紧跟协议要求的格式,否则phar文件可能无法被正确解析。第二,我在stub前面加了GIF89a,这是一种常见的伪造文件头技巧,目的就是绕过上传功能对文件内容的简单检查。第三,metadata中保存的是File类的对象,反序列化后会生成一个filename属性指向目标文件的实例,当这个对象被销毁时,unlink就会执行。4.3 把phar送到目标机器:上传zip这个入口的作用生成了payload.phar之后,怎么把它传到目标服务器上?直接上传.phar扩展名很可能被拦截,而且题目也有扩展名过滤。但有一个方法可以绕过:把phar文件伪装成内容合法但扩展名为其他格式的文件,甚至直接把它打包进ZIP文件。正好,题目支持ZIP上传并自动解压。那我就把payload.phar改名为test.jpg,然后打包成ZIP上传。服务端解压后,上传目录里就会多出一个内容其实是phar、但扩展名是jpg的文件。之后只要让某个文件操作函数以phar://协议读取这个文件,反序列化就会触发。4.4 触发与观察:如何确认析构函数真的执行了触发的方式也很直接:下载接口支持任意文件读取,很多时候服务端在处理下载时,会先对文件路径执行file_exists或者realpath。此时如果把filename参数设置为phar://uploads/xxx.jpg,就可能触发反序列化。实际操作中,我在uploads目录下创建了一个测试文件delete_me.txt,然后构造了filename指向delete_me.txt的payload.phar,上传后触发。之后再次访问文件列表,发现delete_me.txt已经不见了,说明__destruct里的unlink确实执行了。这一步验证非常重要,因为只有确认了phar反序列化能触发,后面的综合利用才站得住脚。5. symlink与unlink的联合作战:最终读取flag的完整请求链5.1 制作带符号链接的zip:ln -s和zip -y拿到了反序列化删除任意文件的能力之后,我们离flag还差一步:如何读取到flag文件的内容?任意文件读取虽然能穿越目录,但穿越到根目录之后并不能直接列出所有文件,只能靠猜路径。此时需要另一个文件系统技巧:符号链接。思路是这样的:如果上传的ZIP解压后能生成一个指向/的符号链接目录,那么下载接口在拼接路径后,就会顺着这个符号链接访问根目录下的任意文件。制作这个ZIP只需要在Linux环境下执行:ln -s / rootlink zip -y symlink.zip rootlinkzip -y是关键参数,表示存储符号链接本身,而不是链接指向的目标内容。如果不加-y,压缩包里的rootlink会变成普通文件,内容就是/这个字符串,解压后不会成为符号链接。5.2 symlink从哪一步开始绕过路径校验下载接口的逻辑是UPLOAD_PATH . '/' . $filename,然后直接读取文件内容。由于代码没有对filename做realpath校验,也没有检查解析后的真实路径是否还在上传目录内,所以当路径中包含符号链接时,读取操作会直接跟随链接指向的真实路径。这跟目录穿越绕过是两回事。../../../../etc/passwd绕过是依靠..往上跳目录,而符号链接绕过的核心是:链接文件本身是上传目录内的合法条目,但链接的目标可以指向任意目录。如果代码只检查了字符串层面是否包含..,或者只检查路径是否在某个目录下,符号链接就能绕过这种检查。5.3 完整请求顺序:从上传到读取flag在我复现的环境中,整个利用链的请求顺序是这样的:先通过下载接口的任意文件读取,把网站源码和目录结构摸清楚。本地生成一个带phar payload的ZIP文件,里面包含触发反序列化的恶意phar文件。用上传功能把这个ZIP传上去,服务端自动解压,payload落到上传目录。本地生成包含符号链接的ZIP文件,再次上传并解压,生成指向根目录的rootlink符号链接。通过下载接口请求filename=rootlink/flag,直接读取根目录下的flag文件。我复现时发现,其实到第5步就能直接读取flag了,题目环境把flag放在根目录下的/flag文件中。如果路径不同,可以用同样的思路继续探测,比如rootlink/var/www/html/flag、rootlink/proc/self/environ等。5.4 flag不在根目录时的扩展思路如果flag不在根目录,扩展思路就两条:一条是利用任意文件读取,顺着符号链接去读各类敏感文件,比如/proc/self/environ里的环境变量、/proc/self/cmdline里的启动参数;另一条是结合phar反序列化,删除某些关键文件,触发报错信息泄露更多的目录结构和配置内容。我在测试环境中验证过phar删除任意文件是可靠的。比如我构造了filename指向/var/www/html/index.php的payload,触发后页面直接变成了空白,说明目标文件确实被删了。这种能力在某些情况下可以用来破坏业务逻辑,或者在错误页面中获取更多信息。6. 复现中我踩过的四个坑,以及调试思路6.1 路径猜错的时候,先看错误信息再动手最初我直接猜/flag路径,结果下载接口返回了空内容,也没有报错。这里容易让人误判为“文件不存在”,但实际上可能是因为权限不够,也可能是因为flag根本不在那个路径。后来我学聪明了,先故意访问一个显然不存在的路径,观察错误回显。如果错误回显里带着物理路径,就能顺藤摸瓜。复现时遇到读取结果为空,不要急着换路径,先把报错页面和HTTP状态码看全。6.2 本地能生成phar但上传后不触发第一次生成phar并上传后,怎么触发都没反应。后来发现是因为phar.readonly没有关,导致生成了个残缺的phar文件。检查方法很简单:在命令行执行php -r "var_dump(ini_get('phar.readonly'));",如果是On,就得修改php.ini。另外,生成的phar文件要确认一下文件头内容,直接用xxd payload.phar | head看看stub是否正常。如果是GIF89a<?php __HALT_COMPILER();,说明stub没问题。6.3 zip -y丢符号链接:Windows用户的坑我在Windows上用压缩软件直接打包符号链接,结果解压后符号链接变成了普通文本文件,内容就是链接目标路径。这是因为Windows下常用压缩工具默认不保留符号链接属性。正确做法是在Linux环境或WSL下用zip -y命令打包,确保ZIP条目类型是符号链接。上传后,用下载接口读取一下filename=rootlink/etc/passwd,如果能读到passwd内容,就说明符号链接生效了;如果读不到,基本就是ZIP打包方式不对。6.4 环境差异:PHP版本和防护组件的影响BUUCTF的题目环境可能不止一套,不同环境的PHP版本和防伪随机路径组件可能不同。比如PHP 7.x和PHP 8.x在phar反序列化上的行为就有细微差别,有些函数在PHP 8下不再支持某些写法,导致触发失败。我的调试方法是:先用一个不会产生破坏性后果的payload验证触发点,比如删除一个临时文件,确认之后再上真正的利用链。如果删除成功,就说明phar反序列化链路没有问题,剩下的就是路径修正和参数调整。最后再分享一个小经验:这类“网盘”题目的核心逻辑,本质上就是文件系统操作,所以掌握符号链接、目录穿越、phar反序列化这三个技能点特别有用。我在复现过程中最大的收获不是拿到flag的那一刻,而是明白了“文件读取漏洞应该优先用来读源码,而不是猜flag路径”这个道理。你如果也在刷这道题,建议按这个顺序走一遍:先抓包梳理功能面,再测下载接口的任意文件读取,然后顺着源码找反序列化点,最后用符号链接闭合路径。整个过程走通一遍,比单纯看别人的writeup要扎实得多。
把BUUCTF上这道『CISCN2019 华北赛区 Day1 Web1 Dropbox』拉到本地复现的时候,我一开始是有点轻敌的。CTF Web题见多了,看到“网盘”两个字就会下意识觉得:无非是文件上传绕过呗。直到注册登录进去,面对一个五脏俱全的Dropbox管理界面,我才意识到这题考的不是单个漏洞,而是“任意文件读取 + 源码审计 + phar反序列化 + symlink组合”整条链。这篇文章就是我当时完整复现的记录,包括踩坑过程,适合正在刷BUUCTF、想搞明白CISCN这类偏综合Web题的人参考。题目环境一直在,不需要额外搭建,跟着请求走一遍就能把整条利用链串起来。这种综合性题目的好处是:一道题串起好几个考点,每个考点的存在感都很强。坏处是:如果你只盯着“上传能不能绕过”,会浪费大量时间在错误的方向上。我复现时的第一条经验就是——先把功能面摸清楚,再决定从哪里下手,而不是看到上传框就往上撞。1. 从登录框到文件管理:Dropbox的功能面到底铺了多大1.1 先别急着打,把页面上的按钮全部点一遍打开题目页面,第一眼是一个登录/注册框。注册一个账号进去,看到一个很标准的文件管理界面:上传区、文件列表、下载按钮、删除按钮。表面上功能只有这几个,但真正要做的不是急着传一句话木马,而是把每个按钮背后的请求都抓下来,搞清楚服务端到底接收了哪些参数。我当时的做法很简单:先传一个正常的test.txt,内容随便写,然后分别点击下载、删除,把对应的请求记录下来。上传用的是POST,带filename字段和文件内容;下载是GET,关键参数filename;删除也是POST,同样带filename。这里一个很明显的特征是:三个功能都在用filename这个参数,但各自对它的处理逻辑可能完全不同,而这正是漏洞的温床。1.2 上传/下载/删除三条请求的参数特征我用Burp逐个把请求记录下来,整理成了一张表,这样后面审计的时候思路清晰很多:功能请求方式关键参数初步风险判断上传POSTfilename + 文件内容文件类型/内容过滤下载GETfilename路径穿越/任意文件读取删除POSTfilename路径穿越/任意文件删除列表GET无信息泄露实际测试时,删除功能传入../../../../etc/passwd是失败的,说明删除侧对路径有校验或拼接方式不同。上传功能只检查了文件扩展名,但并没有严格限制内容,传一个.php会被拦截,传一个.txt就放行。这个上传过滤看起来比较粗糙,但也没有明显到可以直接利用的程度。综合来看,最值得深入的反而是下载接口——它直接暴露了filename参数,且响应是文件内容,天然适合做路径穿越测试。1.3 用“功能-参数-风险”表代替盲打很多新手打CTF容易陷入“看到一个功能就猛打”的状态,比如看到上传就去测绕过,看到登录就去测注入。但实际上,像Dropbox这种多功能的题,真正的考点往往藏在不同功能对同一个参数的差异化处理里。我的建议是:在纸上把每个接口的参数、方法、可能的漏洞类型列出来,先做一轮快速试探,再决定深入方向。试探不一定要用特别逆天的payload,就是普通的../../../../etc/passwd、../flag、....//....//这类基础变形。这一轮做完,基本上就能判断出哪个接口是“突破口”,哪个接口是“辅助功能”。对我这轮复现来说,突破口就是下载接口的filename参数。2. 下载接口的filename参数:一次目录穿越牵出全部源码2.1 目录穿越的验证:从passwd到源码下载接口的请求形如:GET /download.php?filename=test.txt我直接改成:GET /download.php?filename=../../../../../../etc/passwd响应直接返回了passwd文件内容,说明这里是任意文件读取,而且没有对..做过滤。有了这个能力,第一反应当然是去读flag,但实际环境中flag通常不会直接放在网站根目录,也不一定叫flag这个名字。更聪明的做法是:先读源码,搞清楚站点目录结构、后端逻辑、有哪些文件、哪些类,再决定怎么拿到flag。这里有个经验:任意文件读取漏洞产生后,首要目标永远是源码,而不是直接猜flag路径。因为源码会告诉你真正的攻击面在哪里,也会告诉你怎么才能最高效地拿flag。直接猜路径的效率极低,而且很容易猜错方向。2.2 能读不能读的边界:download和delete为什么不一样为什么download能穿越,delete却不能?原因往往是两个功能对filename的拼接和校验方式不同。download的代码逻辑大概率是把uploads/目录和filename做字符串拼接,然后直接readfile或file_get_contents,没有任何realpath校验,所以..能直接穿出去。delete则很可能用了realpath做校验,或者把文件路径限制在了上传目录下,先判断文件是否存在,再执行删除。这么一来,delete就不具备任意文件删除的能力,至少不能通过简单的目录穿越实现。这一点很重要:同一个参数在不同功能中的处理逻辑可能完全不同,不能因为一个接口存在漏洞,就假设其他接口也存在同样的问题。2.3 从报错信息和include链里反推目录结构有了任意文件读取,但不知道源码放在哪,怎么定位?我用的办法是先读/etc/nginx/nginx.conf或Apache配置,找到站点根目录。实际测试时,这类网盘题目常见的源码路径是/var/www/html/,但为了稳妥,我故意请求了一个不存在的文件,比如:GET /download.php?filename=no_such_file_abc.php如果页面返回“文件不存在”之类的错误,或者包含详细的路径信息,就能直接得到物理路径。我当时拿到的是类似/var/www/html/xxx的路径。知道根目录后,顺着读取/var/www/html/index.php,就能看到首页逻辑;再根据index.php里的include/require语句,把class.php、config.php、upload.php这些文件逐个读出来。整个源码审计的起点,就是从一次普通的路径穿越开始的。3. 源码里的漏洞信号:File类的析构函数为什么让我眼前一亮3.1 还原index.php的请求流转拿到源码后,我先把index.php从头到尾读了一遍。整体逻辑大致是这样的:通过action参数控制路由,分别处理文件列表、上传、下载、删除。每个分支都会做对应的参数校验,但校验强度不一。比如下载分支几乎没校验,删除分支做了简单的目录限制,上传分支限制了扩展名但允许ZIP解压。这里最让我注意的一点是:上传功能不仅保存了文件,还支持ZIP压缩包解压。这意味着我上传一个.zip,服务端会用解压逻辑把它展开到上传目录。这个功能本身就是个很大的攻击面——如果解压逻辑不严格,就可能引入符号链接文件、phar文件等特殊条目。3.2 FileList与File:这两个类各管什么继续往下看,源码中存在两个核心类,一个是文件列表相关的类,另一个是文件操作相关的类。文件列表类主要负责遍历上传目录、展示文件名和大小;文件操作类则封装了文件的创建、读取、删除。简化还原之后,文件操作类的结构大致是这样:class File { public $filename; public function __construct($filename) { $this->filename = $filename; } public function __destruct() { if (file_exists($this->filename)) { unlink($this->filename); } } }看到这个类的时候,我眼睛一亮。__destruct()里直接调用了unlink(),而且$this->filename是对象属性,完全可控。这就是一个标准的反序列化利用点:只要能让这个类被反序列化,并且控制filename属性的值,就能在对象销毁时删除目标机器上的任意文件。3.3 __destruct里的unlink:反序列化的标准信号为什么看到析构函数里有unlink就要往反序列化上想?因为在正常的业务逻辑里,这个类可能只是用来操作上传文件的,filename通常来自用户上传时传入的文件名。但如果我能控制filename属性的值,并让这个对象在某个时刻被销毁,unlink就会执行,而且不受任何路径校验限制。那怎么触发反序列化?这就引出了phar反序列化的经典利用链。PHP中,很多文件操作函数都可以通过phar://协议触发反序列化,比如file_exists、is_file、file_get_contents。而代码里几乎所有文件操作都会先判断文件是否存在,这正好满足了触发条件。3.4 攻击面清单:哪些点可以组合成利用链审计完源码后,我在笔记里列了一张攻击面清单:下载接口存在任意文件读取,可以用来读源码、读配置、读flag。上传功能支持ZIP解压,意味着可以上传包含特殊条目的压缩包,包括符号链接和phar文件。文件操作类的析构函数中存在可控的unlink,可以通过phar反序列化触发任意文件删除。删除接口虽然做了路径限制,但如果结合符号链接,可以绕过目录限制。这四个攻击面单独看都不算特别致命,但组合起来就能构成一条完整的利用链。尤其是任意文件读取和ZIP解压这两个能力叠加,等于给了我在目标服务器上放置文件、随后读取文件的双重能力。4. phar反序列化:把磁盘上的文件变成代码执行入口4.1 phar为什么能被当成反序列化入口phar是PHP的一种归档格式,可以把多个文件打包成一个phar文件,类似Java的jar。phar文件内部有一个metadata区域,这个区域会被PHP序列化存储。当PHP的任何文件函数接触到一个phar文件时,会先尝试解析该文件,并反序列化metadata中的内容。如果metadata中正好是一个对象,那么在反序列化时会触发对象的__wakeup方法,在随后的对象销毁过程中还会触发__destruct方法。这就是phar反序列化的核心原理:不需要代码里存在unserialize()函数,只要代码在某个地方对用户可控的文件路径执行了file_exists、is_file、file_get_contents、stat等操作,而这些操作是支持phar://协议的,那么反序列化就会被触发。我在这道题里看到代码中大量使用file_exists来判断文件是否存在,所以几乎可以确定这条路是通的。4.2 本地生成恶意phar的过程要在本地生成一个合法的phar文件,需要修改PHP配置,把phar.readonly设为Off,否则PHP不允许创建phar文件。生成payload的代码很简单:<?php class File { public $filename; } $phar = new Phar('payload.phar'); $phar->startBuffering(); $phar->setStub('GIF89a<?php __HALT_COMPILER(); ?>'); $object = new File(); $object->filename = '/var/www/html/uploads/delete_me.txt'; $phar->setMetadata($object); $phar->addFromString('test.txt', 'test'); $phar->stopBuffering();这里有几个细节要注意。第一,stub不是随便写的,__HALT_COMPILER();后面必须紧跟协议要求的格式,否则phar文件可能无法被正确解析。第二,我在stub前面加了GIF89a,这是一种常见的伪造文件头技巧,目的就是绕过上传功能对文件内容的简单检查。第三,metadata中保存的是File类的对象,反序列化后会生成一个filename属性指向目标文件的实例,当这个对象被销毁时,unlink就会执行。4.3 把phar送到目标机器:上传zip这个入口的作用生成了payload.phar之后,怎么把它传到目标服务器上?直接上传.phar扩展名很可能被拦截,而且题目也有扩展名过滤。但有一个方法可以绕过:把phar文件伪装成内容合法但扩展名为其他格式的文件,甚至直接把它打包进ZIP文件。正好,题目支持ZIP上传并自动解压。那我就把payload.phar改名为test.jpg,然后打包成ZIP上传。服务端解压后,上传目录里就会多出一个内容其实是phar、但扩展名是jpg的文件。之后只要让某个文件操作函数以phar://协议读取这个文件,反序列化就会触发。4.4 触发与观察:如何确认析构函数真的执行了触发的方式也很直接:下载接口支持任意文件读取,很多时候服务端在处理下载时,会先对文件路径执行file_exists或者realpath。此时如果把filename参数设置为phar://uploads/xxx.jpg,就可能触发反序列化。实际操作中,我在uploads目录下创建了一个测试文件delete_me.txt,然后构造了filename指向delete_me.txt的payload.phar,上传后触发。之后再次访问文件列表,发现delete_me.txt已经不见了,说明__destruct里的unlink确实执行了。这一步验证非常重要,因为只有确认了phar反序列化能触发,后面的综合利用才站得住脚。5. symlink与unlink的联合作战:最终读取flag的完整请求链5.1 制作带符号链接的zip:ln -s和zip -y拿到了反序列化删除任意文件的能力之后,我们离flag还差一步:如何读取到flag文件的内容?任意文件读取虽然能穿越目录,但穿越到根目录之后并不能直接列出所有文件,只能靠猜路径。此时需要另一个文件系统技巧:符号链接。思路是这样的:如果上传的ZIP解压后能生成一个指向/的符号链接目录,那么下载接口在拼接路径后,就会顺着这个符号链接访问根目录下的任意文件。制作这个ZIP只需要在Linux环境下执行:ln -s / rootlink zip -y symlink.zip rootlinkzip -y是关键参数,表示存储符号链接本身,而不是链接指向的目标内容。如果不加-y,压缩包里的rootlink会变成普通文件,内容就是/这个字符串,解压后不会成为符号链接。5.2 symlink从哪一步开始绕过路径校验下载接口的逻辑是UPLOAD_PATH . '/' . $filename,然后直接读取文件内容。由于代码没有对filename做realpath校验,也没有检查解析后的真实路径是否还在上传目录内,所以当路径中包含符号链接时,读取操作会直接跟随链接指向的真实路径。这跟目录穿越绕过是两回事。../../../../etc/passwd绕过是依靠..往上跳目录,而符号链接绕过的核心是:链接文件本身是上传目录内的合法条目,但链接的目标可以指向任意目录。如果代码只检查了字符串层面是否包含..,或者只检查路径是否在某个目录下,符号链接就能绕过这种检查。5.3 完整请求顺序:从上传到读取flag在我复现的环境中,整个利用链的请求顺序是这样的:先通过下载接口的任意文件读取,把网站源码和目录结构摸清楚。本地生成一个带phar payload的ZIP文件,里面包含触发反序列化的恶意phar文件。用上传功能把这个ZIP传上去,服务端自动解压,payload落到上传目录。本地生成包含符号链接的ZIP文件,再次上传并解压,生成指向根目录的rootlink符号链接。通过下载接口请求filename=rootlink/flag,直接读取根目录下的flag文件。我复现时发现,其实到第5步就能直接读取flag了,题目环境把flag放在根目录下的/flag文件中。如果路径不同,可以用同样的思路继续探测,比如rootlink/var/www/html/flag、rootlink/proc/self/environ等。5.4 flag不在根目录时的扩展思路如果flag不在根目录,扩展思路就两条:一条是利用任意文件读取,顺着符号链接去读各类敏感文件,比如/proc/self/environ里的环境变量、/proc/self/cmdline里的启动参数;另一条是结合phar反序列化,删除某些关键文件,触发报错信息泄露更多的目录结构和配置内容。我在测试环境中验证过phar删除任意文件是可靠的。比如我构造了filename指向/var/www/html/index.php的payload,触发后页面直接变成了空白,说明目标文件确实被删了。这种能力在某些情况下可以用来破坏业务逻辑,或者在错误页面中获取更多信息。6. 复现中我踩过的四个坑,以及调试思路6.1 路径猜错的时候,先看错误信息再动手最初我直接猜/flag路径,结果下载接口返回了空内容,也没有报错。这里容易让人误判为“文件不存在”,但实际上可能是因为权限不够,也可能是因为flag根本不在那个路径。后来我学聪明了,先故意访问一个显然不存在的路径,观察错误回显。如果错误回显里带着物理路径,就能顺藤摸瓜。复现时遇到读取结果为空,不要急着换路径,先把报错页面和HTTP状态码看全。6.2 本地能生成phar但上传后不触发第一次生成phar并上传后,怎么触发都没反应。后来发现是因为phar.readonly没有关,导致生成了个残缺的phar文件。检查方法很简单:在命令行执行php -r "var_dump(ini_get('phar.readonly'));",如果是On,就得修改php.ini。另外,生成的phar文件要确认一下文件头内容,直接用xxd payload.phar | head看看stub是否正常。如果是GIF89a<?php __HALT_COMPILER();,说明stub没问题。6.3 zip -y丢符号链接:Windows用户的坑我在Windows上用压缩软件直接打包符号链接,结果解压后符号链接变成了普通文本文件,内容就是链接目标路径。这是因为Windows下常用压缩工具默认不保留符号链接属性。正确做法是在Linux环境或WSL下用zip -y命令打包,确保ZIP条目类型是符号链接。上传后,用下载接口读取一下filename=rootlink/etc/passwd,如果能读到passwd内容,就说明符号链接生效了;如果读不到,基本就是ZIP打包方式不对。6.4 环境差异:PHP版本和防护组件的影响BUUCTF的题目环境可能不止一套,不同环境的PHP版本和防伪随机路径组件可能不同。比如PHP 7.x和PHP 8.x在phar反序列化上的行为就有细微差别,有些函数在PHP 8下不再支持某些写法,导致触发失败。我的调试方法是:先用一个不会产生破坏性后果的payload验证触发点,比如删除一个临时文件,确认之后再上真正的利用链。如果删除成功,就说明phar反序列化链路没有问题,剩下的就是路径修正和参数调整。最后再分享一个小经验:这类“网盘”题目的核心逻辑,本质上就是文件系统操作,所以掌握符号链接、目录穿越、phar反序列化这三个技能点特别有用。我在复现过程中最大的收获不是拿到flag的那一刻,而是明白了“文件读取漏洞应该优先用来读源码,而不是猜flag路径”这个道理。你如果也在刷这道题,建议按这个顺序走一遍:先抓包梳理功能面,再测下载接口的任意文件读取,然后顺着源码找反序列化点,最后用符号链接闭合路径。整个过程走通一遍,比单纯看别人的writeup要扎实得多。