79% vs 51%: independent neobanks lag on app security. Read the report

Deep Dive

Deep Dive

Seafile SeaDoc RCE via Public Share Link (CVE-2026-129167)

An anonymous SeaDoc share token reaches every converter endpoint, and a path traversal plants a Python package that runs code; fixed in 2.0.11 and 3.0.8.

TL;DR

  • An absolute path in SeaDoc's Confluence import lets an anonymous holder of a public .sdoc share link write a Python package into the converter's library path and run it. Haiwen fixed the server on November 2, 2026.
  • SeaDoc 2.0.4 to 2.0.10 and 3.0.0 to 3.0.7 are affected. The instance must have a public .sdoc share link or a published wiki.
  • The CVE record rates it CVSS v4.0 9.2 Critical. No exploitation is reported, and it is not in the CISA Known Exploited Vulnerabilities (KEV) catalog as of November 14, 2026.
  • Upgrade to SeaDoc 2.0.11 or 3.0.8, or later. See "How do I fix it?" for the converter commit and the workaround.

Am I affected?

You are exposed when all of these hold:

  • The deployment runs SeaDoc 2.0.4 through 2.0.10, or 3.0.0 through 3.0.7.
  • The instance has a public .sdoc share link or a published wiki. The anonymous token that the share page embeds is the credential the attack uses.
  • The SeaDoc converter is reachable through the Seafile front end. The official Docker Compose deployment exposes it under /sdoc-server/converter/.
  • The deployment was made before November 2, 2026, when Haiwen says the closed-source sdoc-server component was fixed.

A deployment with no public .sdoc share link and no published wiki does not expose the anonymous token path described here. A signed-in user who can obtain a SeaDoc token may still reach the converter. The converter accepts any valid SeaDoc token, so a token issued for one document reaches every converter endpoint. Treat the precondition as a condition on the attack path, not as a safe default.

Branch Affected versions Fixed version
SeaDoc 2.0 2.0.4 to 2.0.10 2.0.11
SeaDoc 3.0 3.0.0 to 3.0.7 3.0.8

The current images at the time of writing are 2.0.13 and 3.0.9. Both include the fix.

Check the running image and the public shares:

# Image tag of the running SeaDoc container
docker inspect --format '{{.Config.Image}}' <seadoc-container>

# Images present on the host
docker image ls | grep sdoc-server

# Public links, in the Seafile web interface
# Open each library, then Sharing, and review the links that are public

The image tag is the reliable version check for the official deployment. The compose file may pin a tag such as 2.0-latest or 3.0-latest, which can move without a version change in the running container.

How do I fix it?

Upgrade SeaDoc to 2.0.11 or 3.0.8, or to the current 2.0.13 or 3.0.9, and recreate the containers.

  1. Pull the fixed image and update the tag in the compose file.
  2. Recreate the seadoc and sdoc-server containers. Pulling the image without recreating the container leaves the old process running.
  3. Confirm the new image tag with docker inspect.
  4. Review public .sdoc links and published wikis. Remove the ones that are not needed.
  5. If you cannot upgrade yet, block the converter endpoints at the reverse proxy, or remove every public .sdoc link and unpublish every wiki. The workaround removes the anonymous token path; it does not fix the path traversal.

The open-source seadoc-converter fix is commit cb43162e of October 29, 2026. It rejects a filename that is not a string, does not end in .html.zip, or contains /, \ or a NUL byte. It also rejects an empty space_key, . and ...

The sdoc-server component is closed source. Haiwen told the researcher by email on November 2, 2026 that SeaDoc 2.0 and 3.0 are fixed. No public commit or advisory describes that half of the fix.

What is the root cause?

The converter accepted any SeaDoc JWT after checking only its signature. It then used an attacker-controlled filename as an absolute path for ZIP extraction, and it kept the extracted files while an upload hung.

SeaDoc has three processes: seahub for authentication and page rendering, sdoc-server for collaborative editing, and seadoc-converter for document conversion. All three share the HMAC key JWT_PRIVATE_KEY. When a visitor opens a public .sdoc share link, seahub embeds a JWT in the page so anonymous preview works.

The converter's check_auth_token() verifies the signature with config.SEADOC_PRIVATE_KEY and returns true. It never checks the document the token was issued for or the permission it carries:

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

The anonymous token is read-only, but the converter ignores the permission field. The token therefore reaches /api/v1/confluence-to-wiki/, which takes a filename and builds a path with os.path.join:

    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, Python discards the first argument. A space_key such as /usr/lib/python3.12/encodings/cp273 makes both space_dir and zip_file_path absolute. The converter creates the directory and unpacks the attacker's ZIP there.

The cleanup runs in a finally block only after process_zip_file() returns. That function posts the result to upload_url with no timeout. An upload target that accepts the connection and never answers keeps the request blocked, so the finally block never runs and the planted files stay on disk.

How is it exploited?

  1. The attacker opens a public .sdoc share link and reads the seadocAccessToken value from the page source. The token is a valid anonymous credential for that document.
  2. The attacker runs a server that returns a ZIP with a __init__.py, accepts a connection and never answers, and returns an HTTP 500 with charset=cp273 when asked for an error.
  3. The attacker posts to /sdoc-server/converter/api/v1/confluence-to-wiki/ with an absolute filename such as /usr/lib/python3.12/encodings/cp273aaaaaaaaa, a download_url that serves the ZIP, and an upload_url that hangs. The converter extracts the ZIP into the Python encodings package.
  4. The attacker posts to /sdoc-server/converter/api/v1/file-convert/ with an upload_url that returns 500 and charset=cp273. The converter logs the response body, and Response.text decodes it with the declared charset.
  5. CPython lazily imports encodings.cp273. FileFinder checks the directory package cp273/__init__.py before the module cp273.py, so the planted file runs inside the converter process.

The researcher's public write-up includes the two requests and the attacker server. The first request blocks while the files are on disk; the second triggers the import. The write-up's marker command wrote uid=0 (root) from the converter container.

The path does not need a restart, because the import happens on demand in the running process. The only precondition is a public .sdoc share link or a published wiki that yields an anonymous token.

How does the attack flow?

An anonymous share token passes the signature-only check, an absolute filename writes a Python package into the converter's library path, and a charset error makes CPython import and run it.

flowchart LR A["Public .sdoc share page
embeds an anonymous JWT"] --> B["Converter checks only
the JWT signature"] B --> C["confluence-to-wiki accepts
the token on every endpoint"] C --> D["Absolute filename makes
os.path.join write outside /tmp/wiki"] D --> E["ZIP is extracted into
the Python encodings package"] E --> F["Hanging upload keeps
the finally cleanup from running"] F --> G["charset=cp273 makes CPython
import the planted package"] G --> H["Code runs in the
converter process"]

What can an attacker do?

An unauthenticated attacker who holds a public share token can run code in the SeaDoc converter process, which the official container runs as root.

The impact includes:

  • Reading and modifying the documents the converter can reach, including the shared HMAC key in its configuration.
  • Using that key to forge SeaDoc tokens for other documents and services.
  • Reaching the network and the mounted volumes the converter container can access.
  • Using the converter as a foothold into the Seafile deployment.

The conditions are part of the impact:

  • The instance must expose a public .sdoc share link or a published wiki.
  • The converter must be reachable from the attacker's network.
  • The deployment must be an affected SeaDoc version built before November 2, 2026.
  • The attacker needs no account and no user interaction.

The researcher demonstrated uid=0 (root) in the official Docker Compose lab. The code runs in the converter container, not on the Seafile application server by default. Whether it reaches the host depends on the container's mounts and network.

Why does the fix work?

The converter fix rejects every filename that could escape /tmp/wiki, so os.path.join can no longer produce an absolute extraction path.

The fix commit cb43162e changes the validation at the start of confluence_to_wiki() and adds a check after the space_key is derived:

-    if not filename:
+    if (not isinstance(filename, str)
+            or not filename.endswith('.html.zip')
+            or any(char in filename for char in ('/', '\\', '\x00'))):
         return {'error_msg': 'path invalid.'}, 400
...
     else:
         wiki_name = filename[:-len('.html.zip')]
         space_key = wiki_name
+
+    if space_key in ('', '.', '..'):
+        return {'error_msg': 'path invalid.'}, 400

     try:
         extract_dir = '/tmp/wiki'
         space_dir = os.path.join(extract_dir, space_key)

The check rejects a slash, a backslash and a NUL byte in filename, so every space_key derived from it is a single path component. The space_key check rejects the three values that would still point outside a normal child path. The extraction stays under /tmp/wiki.

This is a partial fix in the open-source converter. The closed-source sdoc-server change that stops an anonymous read-only token from reaching every converter endpoint is not public. Haiwen says the component is fixed; the token-scope change is not visible to review.

How do I check if I was compromised?

No vendor indicators of compromise have been published. The checks look for the two converter requests and for files planted in the Python library path.

  • Search the reverse-proxy and Seafile access logs for POST requests to /sdoc-server/converter/api/v1/confluence-to-wiki/ with a filename that starts with / or contains ...
  • Search for POST requests to /sdoc-server/converter/api/v1/file-convert/ whose upload_url points outside your infrastructure.
  • In the converter container, list the Python encodings directory for packages you did not install, especially a directory named after a codec such as cp273.
  • Check /tmp/wiki for leftover directories and ZIP files.
  • Check the converter container for unexpected files under /root, /tmp and the application directory.

The researcher's proof of concept writes /root/pwned; an attacker can write anything. A request that fails before extraction may leave no trace, and the converter logs the upload response body, not the full request.

Timeline

  • 2026-10-24: The researcher publishes the write-up.
  • 2026-10-29: The seadoc-converter fix commit cb43162e is authored.
  • 2026-11-02: Haiwen tells the researcher that the closed-source sdoc-server component is fixed.
  • 2026-11-13: CVE-2026-129167 is published at 17:21 UTC.

From the write-up to the CVE in 20 days
Figure 1: The write-up was published on October 24, 2026 and the converter fix followed on October 29. The vendor fixed the closed component on November 2, and the CVE was published on November 13. Source: the researcher's write-up, the fix commit and the CVE record, checked November 14, 2026

FAQ

Is CVE-2026-129167 exploited in the wild?

No exploitation has been reported. The CVE is not in CISA KEV as of November 14, 2026, and the researcher's write-up describes a lab reproduction, not a campaign.

Do I need an account to exploit it?

No. The attacker needs a public .sdoc share link or a published wiki, and reads the anonymous token that the share page embeds. No Seafile account is required.

Does the anonymous token limit the attack to one document?

No. The converter checks only the token signature. The token's permission and document fields are ignored, so the anonymous token reaches every converter endpoint.

Is the Seafile server itself vulnerable, or only SeaDoc?

The flaw is in the SeaDoc converter and the token check shared by the SeaDoc services. A Seafile deployment without SeaDoc is not affected by this CVE. The converter container is the process that runs the attacker's code.

What version fixes it?

SeaDoc 2.0.11 and 3.0.8 are the first fixed releases. The current 2.0.13 and 3.0.9 also include the fix. The closed-source sdoc-server component was fixed on November 2, 2026.

Is the fix complete?

The open-source converter fix closes the absolute path traversal. The token-scope change in the closed-source sdoc-server is not public, so its completeness cannot be reviewed from the source.

References

  • CVE-2026-129167 record, VulnCheck CNA, published 2026-11-13, CVSS v4.0 9.2: https://www.cve.org/CVERecord?id=CVE-2026-129167 (checked November 14, 2026)
  • Wu Yulun, "Seafile Vulnerability Report", published 2026-10-24, the root cause, the reproduction and the two requests: https://vul.yulun.ac.cn/20261024/seafile/ (checked November 14, 2026)
  • seadoc-converter fix commit cb43162e, October 29, 2026, the filename and space_key validation: https://github.com/haiwen/seadoc-converter/commit/cb43162e8bc2991a2900c8acf85090358a30e516 (checked November 14, 2026)
  • Vulnerable seadoc-converter source at bb6cf0f, seadoc_converter/server/apis.py, check_auth_token() and confluence_to_wiki(): https://github.com/haiwen/seadoc-converter/blob/bb6cf0fb9ebc6bb05c9161acbfd6289737ec2cd6/seadoc_converter/server/apis.py (checked November 14, 2026)
  • Vulnerable seadoc-converter source at bb6cf0f, seadoc_converter/converter/utils.py, the upload with no timeout: https://github.com/haiwen/seadoc-converter/blob/bb6cf0fb9ebc6bb05c9161acbfd6289737ec2cd6/seadoc_converter/converter/utils.py (checked November 14, 2026)
  • Docker Hub, seafileltd/sdoc-server tags, 2.0.11, 3.0.8, 2.0.13 and 3.0.9: https://hub.docker.com/r/seafileltd/sdoc-server/tags (checked November 14, 2026)
  • Docker Hub, seafileltd/seafile-mc pull count, 20,336,673: https://hub.docker.com/r/seafileltd/seafile-mc (checked November 14, 2026)
  • CISA Known Exploited Vulnerabilities catalog JSON feed, checked for CVE-2026-129167 on November 14, 2026 (not listed): https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (checked November 14, 2026)