前言

刷到siunam师傅的一篇文章:Python Dirty Arbitrary File Write to RCE via Writing Shared Object Files Or Overwriting Bytecode Files。讲的是关于python web服务器上上传文件导致RCE的方法。yiyi师傅还根据这个出了一道题目。

siunam师傅的文章主要是从PEP等文档角度分析的,找到了一种覆盖pyc文件导致RCE的方法。但我在代码审计的过程中发现了一种更方便的绕过方法。

pyc加载

关于pyc的加载逻辑在PEP-3147中,

情况 0:稳态

当要求 Python 导入模块 foo 时,它会搜索 foo.py 文件(或 foo 包,但这对本次讨论并不重要)的 sys.path。如果找到,Python 会查看是否有匹配的 __pycache__/foo.<magic>.pyc 文件,如果有,则加载该 pyc 文件。

<magic>根据python版本生成,比如python3.9<magic>cpython-39python3.11<magic>cpython-311

情况 1:第一次导入

当 Python 找到 foo.py 时,如果 __pycache__/foo.<magic>.pyc 文件缺失,Python 将创建它,同时创建 __pycache__ 目录(如有必要)。Python 将对 foo.py 文件进行解析和字节编译,并将字节码保存在 __pycache__/foo.<magic>.pyc.

情况 2:第二次导入

当要求 Python 第二次导入模块 foo 时(当然是在不同的过程中),它将再次搜索 foo.py 文件 。 当 Python 找到 foo.py 文件时,它会查找匹配的 __pycache__/foo.<magic>.pyc,找到它后,它会读取字节码并照常继续。

pyc检测

如果找到了源文件对应的pyc文件,会先进行检测,如果通过才会加载pyc中的字节码,否则重新编译源文件。

时间戳检测

这部分在PEP-0552中,其中提到了bit field:

The pyc header currently consists of 3 32-bit words. We will expand it to 4. The first word will continue to be the magic number, versioning the bytecode and pyc format. The second word, conceptually the new word, will be a bit field. The interpretation of the rest of the header and invalidation behavior of the pyc depends on the contents of the bit field.

pyc 标头当前由 3 个 32 位字组成。我们会将其扩展为 4。第一个单词将继续是幻数,对字节码和 pyc 格式进行版本控制。第二个单词,从概念上讲,新单词将是一个 bit field。对 pyc 的 Headers 其余部分的解释和失效行为取决于 bit 字段的内容。

bit field是一个32-bits字,也就是4个字节,小端序。

如果bit field的最低字节为0则使用时间戳检测,pyc文件的第三个和第四个32-bits字将分别是源文件的时间戳和文件大小,如果要覆盖pyc文件,要么读取原本的pyc获得相应的数据,要么爆破。

Hash检测

如果bit field的最低字节不为0,则使用Hash检测,同时bit field的第二低字节为check_source标志,他们的作用在PEP中也有描述,不过有一个特殊的特性:

For hash-based pycs with the check_source unset, Python will simply load the pyc without checking the hash of the source file. The expectation in this case is that some external system (e.g., the local Linux distribution’s package manager) is responsible for keeping pycs up to date, so Python itself doesn’t have to check. Even when validation is disabled, the hash field should be set correctly, so out-of-band consistency checkers can verify the up-to-dateness of the pyc. Note also that the PEP 3147 edict that pycs without corresponding source files not be loaded will still be enforced for hash-based pycs.

对于未设置 check_source 的基于哈希的 pycs,Python 将简单地加载 pyc,而不检查源文件的哈希值。在这种情况下,预期是一些外部系统(例如,本地 Linux 发行版的包管理器)负责使 pycs 保持最新状态,因此 Python 本身不必检查。即使禁用了验证,也应正确设置 hash 字段,以便带外一致性检查器可以验证 pyc 的最新版本。另请注意,PEP 3147 法令中未加载相应源文件的 pycs 仍将强制执行基于哈希的 pycs。

也就是说如果将bit field(第5-8字节)设置为01 00 00 00,则不会进行任何检测,直接加载pyc

代码分析

python中加载module或者package有两种方式:

  1. import/__import__
  2. 动态导入

动态导入最终是通过exec_module加载的:

import importlib.util

def dynamicImportModule(moduleName, modulePath):
    spec = importlib.util.spec_from_file_location(moduleName, modulePath)
    importedModule = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(importedModule)
    # do something with the imported module

dynamicImportModule('utils', 'utils.py')

import关键字本质是__import__函数,而__import__函数最终也会调用exec_module函数:

# https://github.com/python/cpython/blob/af6b3b825f3b653ffdb29fc1dd36de8acfe0a641/Lib/importlib/_bootstrap.py#L914

def _load_unlocked(spec):
    # A helper for direct use by the import system.
    if spec.loader is not None:
        # Not a namespace package.
        if not hasattr(spec.loader, 'exec_module'):
            msg = (f"{_object_name(spec.loader)}.exec_module() not found; "
                    "falling back to load_module()")
            _warnings.warn(msg, ImportWarning)
            return _load_backward_compatible(spec)

    module = module_from_spec(spec)

    # This must be done before putting the module in sys.modules
    # (otherwise an optimization shortcut in import.c becomes
    # wrong).
    spec._initializing = True
    try:
        sys.modules[spec.name] = module
        try:
            if spec.loader is None:
                if spec.submodule_search_locations is None:
                    raise ImportError('missing loader', name=spec.name)
                # A namespace package so do nothing.
            else:
                spec.loader.exec_module(module)
       
        ...........

debug得知spec.loaderSourceFileLoader类型的对象,路径为/$PYTHON_HOME/importlib/_bootstrap_external.pySourceFileLoader类继承了SourceLoader类,这个类又继承了_LoaderBasics_LoaderBasics类定义了exec_module函数。代码如下:

def exec_module(self, module):
    """Execute the module."""
    code = self.get_code(module.__name__)
    if code is None:
        raise ImportError('cannot load module {!r} when get_code() '
                            'returns None'.format(module.__name__))
    _bootstrap._call_with_frames_removed(exec, code, module.__dict__)

逻辑就是,通过get_code获得字节码,然后代用exec函数导入,关键点在于get_code

SourceLoader类定义了get_code函数,代码如下:

def get_code(self, fullname):
    """Concrete implementation of InspectLoader.get_code.

    Reading of bytecode requires path_stats to be implemented. To write
    bytecode, set_data must also be implemented.

    """
    source_path = self.get_filename(fullname)
    source_mtime = None
    source_bytes = None
    source_hash = None
    hash_based = False
    check_source = True
    try:
        bytecode_path = cache_from_source(source_path)
    except NotImplementedError:
        bytecode_path = None
    else:
        try:
            st = self.path_stats(source_path)
        except OSError:
            pass
        else:
            source_mtime = int(st['mtime'])
            try:
                data = self.get_data(bytecode_path)
            except OSError:
                pass
            else:
                exc_details = {
                    'name': fullname,
                    'path': bytecode_path,
                }
                try:
                    flags = _classify_pyc(data, fullname, exc_details)
                    bytes_data = memoryview(data)[16:]
                    hash_based = flags & 0b1 != 0
                    if hash_based:
                        check_source = flags & 0b10 != 0
                        if (_imp.check_hash_based_pycs != 'never' and
                            (check_source or
                                _imp.check_hash_based_pycs == 'always')):
                            source_bytes = self.get_data(source_path)
                            source_hash = _imp.source_hash(
                                _RAW_MAGIC_NUMBER,
                                source_bytes,
                            )
                            _validate_hash_pyc(data, source_hash, fullname,
                                                exc_details)
                    else:
                        _validate_timestamp_pyc(
                            data,
                            source_mtime,
                            st['size'],
                            fullname,
                            exc_details,
                        )
                except (ImportError, EOFError):
                    pass
                else:
                    _bootstrap._verbose_message('{} matches {}', bytecode_path,
                                                source_path)
                    return _compile_bytecode(bytes_data, name=fullname,
                                                bytecode_path=bytecode_path,
                                                source_path=source_path)
    .........

前面说到的的PEP中规定的pyc检测就是在这里实现的。其中flags就是bit field的值。默认情况下,_imp.check_hash_based_pycs的值是default,所以只要保证check_sourceFalsehash_basedTrue,也就是bit field01 00 00 00时,就不会进行hash检测直接使用pyc中的字节码。

关于_imp.check_hash_based_pycsPEP-0552中也有规定:

Runtime configuration of hash-based pyc invalidation will be facilitated by a new –check-hash-based-pycs interpreter option. This is a tristate option, which may take 3 values: default, always, and never. The default value, default, means the check_source flag in hash-based pycs determines invalidation as described above. always causes the interpreter to hash the source file for invalidation regardless of value of check_source bit. never causes the interpreter to always assume hash-based pycs are valid. When –check-hash-based-pycs=never is in effect, unchecked hash-based pycs will be regenerated as unchecked hash-based pycs. Timestamp-based pycs are unaffected by –check-hash-based-pycs.

基于哈希的 pyc 失效的运行时配置将由新的 –check-hash-based-pycs 解释器选项提供便利。这是一个三态选项,它可能采用 3 个值:default、always 和 never。默认值 default 表示基于哈希的 pycs 中的 check_source 标志如上所述确定失效。 始终导致解释器对源文件进行哈希处理以使其失效,而不管 check_source 的值如何 位。never 导致解释器始终假设基于哈希的 pycs 有效。当 –check-hash-based-pycs=never 生效时,未经检查的基于哈希的 pycs 将被重新生成为未经检查的基于哈希的 pycs。基于时间戳的 pycs 不受 –check-hash-based-pycs 的影响。

目前来说,python默认不开启强制hash检测,不过可以通过--check-hash-based-pycs参数开启。

利用

编译当前目录下的python源文件为pyc文件

python -m compileall .

bit field(第5-8字节)修改为01 00 00 00,然后上传覆盖即可。

参考

https://siunam321.github.io/research/python-dirty-arbitrary-file-write-to-rce-via-writing-shared-object-files-or-overwriting-bytecode-files/

https://peps.python.org/pep-3147/

https://peps.python.org/pep-0552/