I wasn’t looking for this one.
I was working through a CTF machine (still active, so no details there) and hit a Camaleon CMS instance. I saw the version, must have went ‘meh’ and ignored it, because it should have patched against the vulnerability I used to read files anyway.
It worked.
Sometimes you do the thing that shouldn’t work, and then it works, and you just kind of sit there for a second.
and down the rabbit hole we go…
First problem: I couldn’t reproduce it
The obvious next step was to get the source code from GitHub, start one up on my machine, and verify what I’d found. Straightforward, except nothing ever is.
The initial setup didn’t build. So I fixed the build errors. Got it running. Tried the exploit. Nothing.
Tried Docker. Still nothing.
At this point a reasonable person might conclude they’d imagined the whole thing. I am not always a reasonable person. I also have an inexplicable, unexamined, fully accepted dislike of Ruby that was not helping my mood.
So instead I did it the tedious way. I put the machine setup side by side with my local setup and started adding configuration pieces one at a time until something changed.
The thing that finally made it go brrrr was the Solid trio alongside a local S3 instance. Rails 8 Solid Cache, Solid Queue, and Solid Cable. Which is weird because none of those have anything to do with file handling. I have a theory about why it matters and I need to go back through the code to confirm it. When I do I’ll update this post. For now: it matters, and the specific configuration requirement is part of why this stayed hidden.
What’s actually happening
Once I got this monstrosity setup, going through the source code was genuinely fun. Here’s the short version.
Camaleon CMS has a download_private_file endpoint in the media controller:
def download_private_file
cama_uploader.enable_private_mode!
file = cama_uploader.fetch_file("private/#{params[:file]}")
return render plain: helpers.sanitize(file[:error]) if file.is_a?(Hash) && file[:error].present?
send_file file, disposition: 'inline'
end
User input goes straight into fetch_file(). What happens next depends on which storage backend you’re using.
If you’re using local storage, you’re fine. The local uploader calls valid_folder_path?() before doing anything:
def fetch_file(file_name)
return { error: 'Invalid file path' } unless valid_folder_path?(file_name)
return file_name if file_exists?(file_name)
{ error: 'File not found' }
end
And valid_folder_path?() at least checks for .. and ://:
def valid_folder_path?(path)
return false if path.include?('..') || path.include?('://')
true
end
If you’re using the AWS S3 backend:
def fetch_file(file_name)
return file_name if file_exists?(file_name)
return file_name if bucket.object(file_name).download_file(file_name) && file_exists?(file_name)
{ error: 'File not found' }
end
No validation. Path traversal sequences pass straight through. The fix existed, it just wasn’t applied consistently across both backends.
This has happened before
The interesting funsies here is that this isn’t the first time someone found path traversal in Camaleon CMS. CVE-2024-46987 covered the same class of vulnerability. The local uploader got patched. The AWS uploader didn’t. Whoops.
That’s actually a fairly common pattern. A vulnerability gets reported, the most obvious code path gets fixed, and the parallel implementation that does the same thing differently gets missed. It’s worth checking both when you find something like this.
Proof of concept
Setup requirements:
- Camaleon CMS with AWS S3 storage configured (Minio works locally)
- Rails 8 with Solid Cache, Solid Queue, and Solid Cable
- User registration enabled (Settings > General Site > Configuration)
Once you have a registered low-privilege account:
curl 'http://127.0.0.1:3000/admin/media/download_private_file?file=../../../../../../../etc/passwd' \
-H "Cookie: [your session cookie]"
Note the /admin/ prefix. Despite how fancy and locked down that appears, this endpoint is accessible to any authenticated user, not just administrators.
Output:
root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
From there you can read SSH keys, database credentials, .env files, application secrets. The usual path traversal greatest hits.
Fix
For the Camaleon CMS developers, the fix is one line. Add the same validation to the AWS uploader that already exists in the local uploader:
def fetch_file(file_name)
return { error: 'Invalid file path' } unless valid_folder_path?(file_name)
return file_name if file_exists?(file_name)
return file_name if bucket.object(file_name).download_file(file_name) && file_exists?(file_name)
{ error: 'File not found' }
end
For administrators running Camaleon CMS right now: disable user registration if you don’t need it. That removes the attack surface entirely since authentication is required. If you do need registration, switching to local storage as a temporary measure also mitigates this specific issue.
Timeline
- 01/31/2026: Discovered by accident and then on purpose while trying to reproduce. Reported to Camaleon CMS maintainers.
- 02/09/2026: Followed up with Camaleon CMS maintainers.
- 02/16/2026: Reported to VulnCheck, assigned CVE-2026-1776
- 03/09/2026: Camaleon CMS PR
f54a77emerged with the fix - 03/09/2026: CVE-2026-1776 published
Discovered by @investigato. Coordinated disclosure per the VulnCheck and Camaleon CMS security policy.