CVE-2022-44900 (Python, published 2022-12-06T20:15Z)
Description: value: A directory traversal vulnerability in the SevenZipFile.extractall() function of the python library py7zr v0.20.0 and earlier allows attackers to write arbitrary files via extracting a crafted 7z file.
Severity: nan (CVSS v3 base score: 9.1)
Weakness: CWE-22 \u2014 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
Repository: https://github.com/miurahr/py7zr
Files changed:
MANIFEST.in
py7zr/helpers.py
py7zr/py7zr.py
tests/test_extract.py
tests/test_zipslip.py
Commit message: Fix sanity check for path traversal attack

- Previous versions do not detect the attack in some case
   - fixed it by call resolve()
   - resolve() converts "/hoge/fuga/../../../tmp/evil.sh" to be "/tmp/evil.sh" then
     relative_to() can detect path traversal attack.
- Add path checker in writef() and writestr() methods
  - When pass arcname as evil path such as "../../../../tmp/evil.sh"
    it raises ValueError
- Add test case of bad path detection
- extraction: check symlink and junction is under target folder
- Fix relative_path_marker removal
- Don't put windows file namespace to output file path

Signed-off-by: Hiroshi Miura <miurahr@linux.com>


Vulnerable code:
include .coveragerc
include tox.ini
recursive-include docs *.txt
    Supports Windows NT symbolic links and reparse points.
    Supports Windows NT symbolic links and reparse points.
    remove_relative_path_marker,
from py7zr.win32compat import is_windows_native_python, is_windows_unc_path
            # check f.filename has invalid directory traversals
            if path is None:
                # do following but is_relative_to introduced in py 3.9
                # so I replaced it with relative_to. when condition is not satisfied, raise ValueError
                # if not pathlib.Path(...).joinpath(remove_relative_path_marker(outname)).is_relative_to(...):
                #    raise Bad7zFile
                try:
                    pathlib.Path(os.getcwd()).joinpath(remove_relative_path_marker(outname)).relative_to(os.getcwd())
                except ValueError:
                    raise Bad7zFile
                outfilename = pathlib.Path(remove_relative_path_marker(outname))
                outfilename = path.joinpath(remove_relative_path_marker(outname))
                try:
                    outfilename.relative_to(path)
                except ValueError:
                    raise Bad7zFile
            # When python on Windows and not python on Cygwin,
            # Add win32 file namespace to exceed microsoft windows
            # path length limitation to 260 bytes
            # ref.
            # https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
            # In editions of Windows before Windows 10 version 1607,
            # the maximum length for a path is MAX_PATH, which is defined as
            # 260 characters. In later versions of Windows, changing a registry key
            # or select option when python installation is required to remove the limit.
            if is_windows_native_python() and outfilename.is_absolute() and not is_windows_unc_path(outfilename):
                outfilename = pathlib.WindowsPath("\\\\?\\" + str(outfilename))
            self.writef(io.BytesIO(data.encode("UTF-8")), arcname)
            self.writef(io.BytesIO(data), arcname)
            self.worker.extract(self.fp, parallel=(not self.password_protected), skip_notarget=False)  # TODO: print progress
    def extract(self, fp: BinaryIO, parallel: bool, skip_notarget=True, q=None) -> None:
                    self.extract_single(fp, empty_files, 0, 0, q)
                    self.extract_single(open(filename, "rb"), empty_files, 0, 0, q)
            self.extract_single(fp, empty_files, 0, 0, q)
            self._extract_single(fp, files, src_end, q, skip_notarget)
                            # fileish.unlink(missing_ok=True) > py3.7
                            if fileish.exists():
                                fileish.unlink()
                            if sys.platform == "win32":  # hint for mypy
                                _winapi.CreateJunction(str(fileish), dst)  # noqa
                            sym_target = pathlib.Path(omfp.read().decode("utf-8"))
                            # fileish.unlink(missing_ok=True) > py3.7
                            if fileish.exists():
                                fileish.unlink()
                            fileish.symlink_to(sym_target)
    archive.worker.extract(archive.fp, parallel=True)

Fixed code:
from py7zr import Bad7zFile
from py7zr.win32compat import is_windows_native_python, is_windows_unc_path
    Support Windows NT symbolic links and reparse points.
    Support Windows NT symbolic links and reparse points.


def get_sanitized_output_path(fname: str, path: Optional[pathlib.Path]) -> pathlib.Path:
    """
    check f.filename has invalid directory traversals
    do following but is_relative_to introduced in py 3.9,
    so I replaced it with relative_to. when condition is not satisfied, raise ValueError
    if not pathlib.Path(...).joinpath(remove_relative_path_marker(outname)).is_relative_to(...):
        raise Bad7zFile
    """
    if path is None:
        try:
            pathlib.Path(os.getcwd()).joinpath(fname).resolve().relative_to(os.getcwd())
            outfile = pathlib.Path(remove_relative_path_marker(fname))
        except ValueError:
            raise Bad7zFile(f"Specified path is bad: {fname}")
    else:
        try:
            outfile = path.joinpath(remove_relative_path_marker(fname))
            outfile.resolve().relative_to(path)
        except ValueError:
            raise Bad7zFile(f"Specified path is bad: {fname}")
    return outfile


def check_archive_path(arcname: str) -> bool:
    path = pathlib.Path("/foo/boo/fuga/hoge/a90sufoiasj09/dafj08sajfa/")  # dummy path
    return is_target_path_valid(path, path.joinpath(arcname))


def is_target_path_valid(path: pathlib.Path, target: pathlib.Path) -> bool:
    try:
        if path.is_absolute():
            target.resolve().relative_to(path)
        else:
            target.resolve().relative_to(pathlib.Path(os.getcwd()).joinpath(path))
    except ValueError:
        return False
    return True


def check_win32_file_namespace(pathname: pathlib.Path) -> pathlib.Path:
    # When python on Windows and not python on Cygwin,
    # Add win32 file namespace to exceed Microsoft Windows
    # path length limitation to 260 bytes
    # ref.
    # https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
    # In editions of Windows before Windows 10 version 1607,
    # the maximum length for a path is MAX_PATH, which is defined as
    # 260 characters. In later versions of Windows, changing a registry key
    # or select option when python installation is required to remove the limit.
    if is_windows_native_python() and pathname.is_absolute() and not is_windows_unc_path(pathname):
        pathname = pathlib.WindowsPath("\\\\?\\" + str(pathname))
    return pathname
    check_archive_path,
    get_sanitized_output_path,
    is_target_path_valid,
            if path is None or path.is_absolute():
                outfilename = get_sanitized_output_path(outname, path)
                outfilename = get_sanitized_output_path(outname, pathlib.Path(os.getcwd()).joinpath(path))
                path,
                path,
        if not check_archive_path(arcname):
            raise ValueError(f"Specified path is bad: {arcname}")
        return self._writef(bio, arcname)

    def _writef(self, bio: IO[Any], arcname: str):
        if not check_archive_path(arcname):
            raise ValueError(f"Specified path is bad: {arcname}")
        return self._writestr(data, arcname)

    def _writestr(self, data: Union[str, bytes, bytearray, memoryview], arcname: str):
            self._writef(io.BytesIO(data.encode("UTF-8")), arcname)
            self._writef(io.BytesIO(bytes(data)), arcname)
            self.worker.extract(
                self.fp, None, parallel=(not self.password_protected), skip_notarget=False
            )  # TODO: print progress
    def extract(self, fp: BinaryIO, path: Optional[pathlib.Path], parallel: bool, skip_notarget=True, q=None) -> None:
                    path,
                    self.extract_single(fp, empty_files, path, 0, 0, q)
                            path,
                    self.extract_single(open(filename, "rb"), empty_files, path, 0, 0, q)
                                path,
            self.extract_single(fp, empty_files, path, 0, 0, q)
        path,
            self._extract_single(fp, files, path, src_end, q, skip_notarget)
        path,
                            if is_target_path_valid(path, fileish.parent.joinpath(dst)):
                                # fileish.unlink(missing_ok=True) > py3.7
                                if fileish.exists():
                                    fileish.unlink()
                                if sys.platform == "win32":  # hint for mypy
                                    _winapi.CreateJunction(str(fileish), dst)  # noqa
                            else:
                                raise Bad7zFile("Junction point out of target directory.")
                            dst = omfp.read().decode("utf-8")
                            # check sym_target points inside an archive target?
                            if is_target_path_valid(path, fileish.parent.joinpath(dst)):
                                sym_target = pathlib.Path(dst)
                                # fileish.unlink(missing_ok=True) > py3.7
                                if fileish.exists():
                                    fileish.unlink()
                                fileish.symlink_to(sym_target)
                            else:
                                raise Bad7zFile("Symlink point out of target directory.")
    archive.worker.extract(archive.fp, None, parallel=True)
import os

import pytest

from py7zr import SevenZipFile
from py7zr.exceptions import Bad7zFile
from py7zr.helpers import check_archive_path, get_sanitized_output_path
from py7zr.properties import FILTER_LZMA2, PRESET_DEFAULT

testdata_path = os.path.join(os.path.dirname(__file__), "data")


@pytest.mark.misc
def test_check_archive_path():
    bad_path = "../../.../../../../../../tmp/evil.sh"
    assert not check_archive_path(bad_path)


@pytest.mark.misc
def test_get_sanitized_output_path_1(tmp_path):
    bad_path = "../../.../../../../../../tmp/evil.sh"
    with pytest.raises(Bad7zFile):
        get_sanitized_output_path(bad_path, tmp_path)


@pytest.mark.misc
def test_get_sanitized_output_path_2(tmp_path):
    good_path = "good.sh"
    expected = tmp_path.joinpath(good_path)
    assert expected == get_sanitized_output_path(good_path, tmp_path)


@pytest.mark.misc
def test_extract_path_traversal_attack(tmp_path):
    my_filters = [
        {"id": FILTER_LZMA2, "preset": PRESET_DEFAULT},
    ]
    target = tmp_path.joinpath("target.7z")
    good_data = b"#!/bin/sh\necho good\n"
    good_path = "good.sh"
    bad_data = b"!#/bin/sh\necho bad\n"
    bad_path = "../../.../../../../../../tmp/evil.sh"
    with SevenZipFile(target, "w", filters=my_filters) as archive:
        archive.writestr(good_data, good_path)
        archive._writestr(bad_data, bad_path)  # bypass a path check
    with pytest.raises(Bad7zFile):
        with SevenZipFile(target, "r") as archive:
            archive.extractall(path=tmp_path)