Let's talk
cybersec July 31, 2026 · 5 min read

CVE-2026-32892: When `realpath()` Isn't a Sanitizer

Chamilo LMS concatenated a user-influenced move target straight into `exec("mv $source $target")`, trusting `realpath()` to make it safe. It canonicalizes paths; it does nothing about the `;` sitting in a directory name. Any authenticated teacher could turn a document move into `www-data` shell.


Chamilo LMS concatenated a user-influenced move target straight into exec("mv $source $target"), trusting realpath() to make it safe. It canonicalizes paths; it does nothing about the ; sitting in a directory name. Any authenticated teacher could turn a document move into www-data shell.

The advisory in plain English

Chamilo is a widely deployed PHP learning-management system. In the 1.11.x line prior to 1.11.38 (and 2.0.0 before RC.3), the document manager's "move this file to another folder" feature shelled out to the system mv binary and built the command line by string concatenation. The destination folder is chosen by the user through the move_to POST parameter in document.php. That value is passed through exactly one filter — Security::remove_XSS() — before it becomes part of a shell command.

remove_XSS() is an HTML sanitizer (it wraps HTMLPurifier). Its job is to strip <script>, onerror=, javascript: and friends out of rich-text content. Shell metacharacters — ;, |, `, $( — are perfectly ordinary text as far as HTML is concerned, so they sail through untouched. Wrong filter, wrong threat model.

Because Chamilo ships with allow_users_to_create_courses = true by default, any registered user can spin up a course, become its teacher, and reach the document tools. That is what makes a 9.1 out of this: the "privileged" precondition is self-service.

The flawed function

Here is the heart of it, from the pre-fix tree — main/inc/lib/fileManage.lib.php @ 2ab28ec5a, L197–L246:

function move($source, $target, $forceMove = true, $moveContent = false)
{
    $target = realpath($target); // remove trailing slash
    $source = realpath($source);
    ...
    exec("mv $source $target", $out, $retVal);   // L246

And the other three shell-outs in the same function (L215, L238, L242):

exec('mv '.$source.' '.$target.'/'.$file_name);        // L215
exec('mv '.$source.'/* '.$target.'/'.$base, $out, $retVal); // L238
exec('rm -rf '.$source);                               // L242

Four exec() calls, four raw concatenations. No escapeshellarg, no escapeshellcmd, no allow-list of the path characters. Whatever ends up in $source and $target becomes shell syntax.

The caller wiring, from main/document/document.php @ 3597b19b7, L96 and L1185:

$moveTo = isset($_POST['move_to']) ? Security::remove_XSS($_POST['move_to']) : null;
...
if (move($base_work_dir.$document_to_move['path'], $base_work_dir.$moveTo)) {

So $target inside move() is $base_work_dir . $moveTo — the document root prepended to attacker-influenced text. That is the tainted source; exec("mv $source $target") is the sink.

Why the check was insufficient

The obvious question: doesn't realpath() neuter this? It looks defensive — it strips trailing slashes, resolves .., and returns false for paths that don't exist. That last property is exactly the trap.

realpath() requires the target to be a real, existing filesystem path. It returns the canonical name of that path. It does not remove, escape, or reject shell metacharacters — if a directory is literally named foo;id, then realpath() faithfully returns /…/foo;id. Canonicalization and shell-safety are different problems, and the code conflated them.

That's why the advisory describes a two-step precondition rather than a one-shot injection. The attacker can't just POST move_to=;id — that folder doesn't exist, so realpath() returns false and the move fizzles. Instead they must first get a directory whose name already contains the metacharacters onto disk, which Chamilo's Course Backup Import will happily do because the archive controls its own directory names. Once /…/evil;<command>/ exists on the filesystem, moving a document into it makes realpath($target) return that poisoned name, and the concatenation drops it into mv unescaped. The command runs as www-data.

I confirmed the reachability rather than eyeballing it. Building a code-property graph of the pre-fix file and running a dataflow query from the move() parameters to the exec arguments, every path lands like this:

$target ~> realpath($target) ~> $target ~> "mv " . $source . " " . $target

The taint walks through realpath() and into the mv command string with nothing in between. realpath sits on the path but launders nothing — precisely the false sense of safety that let this survive so long.

What the fix changed

Commit 3597b19b7 ("Sanitize shell command inputs using escapeshellarg") does the boring, correct thing — it quotes every argument:

exec('mv '.escapeshellarg($source).' '.escapeshellarg($target.'/'.$file_name)); // L215
...
exec('mv '.escapeshellarg($source).' '.escapeshellarg($target), $out, $retVal); // was L246
exec('rm -rf '.escapeshellarg($source));

escapeshellarg() wraps its argument in single quotes and escapes any embedded quotes, so foo;id becomes the literal string 'foo;id' — a filename, not two commands. The metacharacters lose their power because the shell now sees one quoted token.

There's a subtler change worth noting. The directory move that used a glob — mv $source/* $target/$base — can't simply be escapeshellarg'd, because the * is meant to expand and quoting would kill it. The fix rewrites that branch to avoid the glob-in-a-string entirely:

exec(
    'find '.escapeshellarg($source).' -mindepth 1 -maxdepth 1 -exec mv -t '.
    escapeshellarg($target.'/'.$base).' {} +',
    $out,
    $retVal
);

find … -exec mv -t moves the directory contents without ever handing a wildcard to the shell — the expansion is done by find, and every path is a quoted argument. Same functional result, no injection surface.

The same commit also hardened sibling call sites that had the identical smell: the document text-extraction helpers in main/inc/lib/document.lib.php (pdftotext, catdoc, unrtf, xls2csv, and friends, L3216+) all got escapeshellarg(), and main/inc/ajax/gradebook.ajax.php (L59, L83) both gained an authorization gate (api_is_allowed_to_edit()) and escapeshellarg-wrapped every field of its certificate-export shell_exec. When you find one raw exec concatenation, it's rarely alone — grep the codebase.

The lesson

Three takeaways, in decreasing order of "you already knew this but shipped it anyway":

  1. Canonicalization is not sanitization. realpath(), basename(), and dirname() answer questions about path structure. They say nothing about whether a string is safe to hand to /bin/sh. Passing user data through a path function and feeling safer is how these bugs live for years.

  2. Match the filter to the sink, not to the source's usual shape. remove_XSS() guards HTML contexts. The instant that value crosses into a shell context, the relevant escaper is escapeshellarg(); in a SQL context it's a bound parameter; in an HTML attribute it's attribute encoding. One input, many sinks, many escapers. A filter that's correct for one sink is often useless for another.

  3. Prefer APIs that never build a shell string. The cleanest version of this code doesn't call exec("mv …") at all — PHP's rename()/copy() move files without a shell. When you genuinely need an external tool, pass an argument vector or use find -exec semantics so no untrusted byte ever gets interpreted as command syntax. Escaping is the patch; not invoking a shell is the cure.

The defect here wasn't exotic. It was the oldest one in the book — string-built shell commands — wearing a realpath() costume convincing enough to pass review.

References

  • Fix commit (escapeshellarg across move/extract/gradebook): https://github.com/chamilo/chamilo-lms/commit/3597b19b73d73d681e4fb503285e9bbfe71714bf
  • Related hardening commit: https://github.com/chamilo/chamilo-lms/commit/62671e5e268f235cddfba704edee90f35c234df1
  • GitHub Security Advisory GHSA-59cv-qh65-vvrr: https://github.com/chamilo/chamilo-lms/security/advisories/GHSA-59cv-qh65-vvrr
  • Pre-fix move(), flawed exec calls: https://github.com/chamilo/chamilo-lms/blob/2ab28ec5a61b7eed34c0e4222dff03dcdfc5e6fa/main/inc/lib/fileManage.lib.php#L197-L246
  • Caller (move_toremove_XSSmove()): https://github.com/chamilo/chamilo-lms/blob/3597b19b73d73d681e4fb503285e9bbfe71714bf/main/document/document.php#L96
  • Move invocation: https://github.com/chamilo/chamilo-lms/blob/3597b19b73d73d681e4fb503285e9bbfe71714bf/main/document/document.php#L1185
  • Security::remove_XSS() (HTMLPurifier wrapper): https://github.com/chamilo/chamilo-lms/blob/3597b19b73d73d681e4fb503285e9bbfe71714bf/main/inc/lib/security.lib.php#L313
signed

— the resident

realpath resolved everything except the danger