September 25, 2026
I Audited the Entire CMS, 73 Vulnerabilities Were Waiting | Cotonti
A systematic security audit of Cotonti CMS at commit f43f1fc: 1 Critical, 11 High, 50 Medium, 8 Low, 3 Informational

By Harsh Raj Singhania
12 min read
A systematic security audit of Cotonti CMS at commit f43f1fc: 1 Critical, 11 High, 50 Medium, 8 Low, 3 Informational
Note: This post is the public reference for coordinated CVE assignment of all findings described here. All vulnerabilities below were discovered by me and have not been reported to the vendor at the time of writing. Disclosure is being coordinated through VulnCheck CNA.
I've published three posts about Cotonti so far. The first one found a PHP object injection sink nobody had caught. The second one found six more on a second pass. The third one covered CVE assignments, an incomplete fix that another researcher caught, and the corrected PR.
This time I sat down and actually audited the codebase properly. Not a second pass, not a third pass. A systematic file-by-file review of commit f43f1fc, current master.
When I finished: 75 IDs reviewed, 73 confirmed vulnerabilities, 2 invalid (one false positive, one with no exploitable path). One of them is a CVSS 9.0 Critical that leads to remote code execution.
What Cotonti Is
PHP CMF, been around since 2008, used to build community websites and content-heavy sites. Modular plugin system. Version 1.0.0 released 2024, still current. All findings in this post apply to the 1.0.0 line and earlier (Siena branch), tested at commit f43f1fc.
The framework has its own anti-CSRF mechanism: a function called cot_check_xg() in system/functions.php. It's supposed to be called before any state-changing request to verify the CSRF token. This matters a lot for what follows.
The Numbers
Severity Count IDs Critical (9.0) 1 C45 High (7.0โ8.9) 11 C13, C14, C18, C23, C24, C25, C33, C52, C78, C79, C84 Medium (4.0โ6.9) 50 all remaining confirmed IDs Low (0.1โ3.9) 8 C8, C15, C21, C22, C27, C46, C50, C57 Informational 3 C16, C28, C71
Not valid: C20 (false positive), C58 (no exploitable path found).
The Critical Bug: C45 | CSRF โ .htaccess Write โ RCE
CVSS 3.1: 9.0 | CWE-352 โ CWE-94 | Auth: admin victim
This is the one that matters most, so let's start here.
Cotonti ships a URL Editor plugin that lets admins manage .htaccess directives from the admin panel. The handler that saves them is plugins/urleditor/urleditor.admin.php, and it has no CSRF token. The custom_htaccess field is imported with NOC , Cotonti's raw, unsanitized import mode, and written directly to .htaccess with file_put_contents.
On its own (C33), this is a High: an attacker can forge a POST to an admin's session and write arbitrary Apache directives into the site's .htaccess.
But combine it with Cotonti's Personal File System (PFS), where users can upload files, and you get a full RCE chain:
Step 1: CSRF the URL Editor to write this directive to .htaccess:
AddType application/x-httpd-php .jpg
Step 2: Upload a PHP shell via PFS as shell.jpg (PFS doesn't strip this)
Step 3: Request BASE/path/to/shell.jpg โ Apache executes it as PHPStep 1: CSRF the URL Editor to write this directive to .htaccess:
AddType application/x-httpd-php .jpg
Step 2: Upload a PHP shell via PFS as shell.jpg (PFS doesn't strip this)
Step 3: Request BASE/path/to/shell.jpg โ Apache executes it as PHPThe preconditions are: an admin victim who clicks a malicious link, Apache with AllowOverride not set to None, and .htaccess being writable by PHP. All three are realistic on typical shared hosting or default Apache deployments.
CVSS 3.1: AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H โ 9.0 Critical
Fix: require cot_check_xg() on the URL Editor save; don't write raw, unvalidated .htaccess content; keep .htaccess non-writable by the web process.
The 11 High-Severity Findings
C33 โ URL Editor .htaccess Write Without CSRF
The base of the C45 chain. CSRF + raw .htaccess write with no token. Even without the RCE chain, an attacker can modify routing, add/remove redirects, or break availability via .htaccess corruption.
CVSS: 8.2 | plugins/urleditor/urleditor.admin.php
C13 โ Admin User Management Without CSRF
CVSS: 8.0 | system/admin/admin.users.php
The admin panel for managing user accounts and group assignments has no cot_check_xg() call anywhere in the file. A single forged POST can change any user's group to COT_GROUP_SUPERADMINS (group 5):
POST /admin.php?m=users&a=edit&id=TARGET
rusermaingrp=5POST /admin.php?m=users&a=edit&id=TARGET
rusermaingrp=5No token required. The admin just has to be logged in when their browser sends this request โ via an image tag, auto-submitting form, or any other cross-site trigger. That's a privilege escalation to superadmin with one forged request.
C14 โ Per-Item Rights Update Without CSRF
CVSS: 8.0 | system/admin/admin.rightsbyitem.php
Same class of bug as C13. The per-item rights editor writes permission rows from request parameters with no CSRF token. An attacker can grant arbitrary access rights to any group on any item against an admin session.
C18 โ Structure Add/Update Without CSRF (+ Enables Stored XSS)
CVSS: 8.0 | system/admin/admin.structure.php
The delete and resyncall actions in the structure admin check the CSRF token. add and update don't. A forged structure add can write a title containing an XSS payload, which then executes for every visitor of the category list via C77 (structure titles rendered unescaped in page.list.php).
C23 โ Unauthenticated Installer Update Path
CVSS: 7.5 | install.php + modules/install/inc/install.update.php
If install.php is left in place after installation โ which the install process doesn't enforce removal of โ any unauthenticated visitor can trigger the update path:
curl -s BASE/install.phpcurl -s BASE/install.phpNo login, no admin check. The update path re-runs cot_extension_install() for every module and plugin, and truncates the cache table. Repeatably. Anyone on the internet can do this as many times as they want.
The database migration paths (COT_UPGRADE) and config.php rewrite are gated separately, so this doesn't modify your database schema on a current install โ but it resets extension setup and wipes cache on every call.
C24 โ Extra-Field Admin Without CSRF
CVSS: 8.0 | system/admin/admin.extrafields.php
Extra fields are how Cotonti adds custom data columns to pages, users, and other entities. The admin interface for creating and editing them has no CSRF token. An attacker who forges an extra-field add request against an admin session can create a file-type extra-field with empty variants, which is the setup step for the C52 conditional RCE.
C25 / C78 โ Extension Install/Update/Pause Without CSRF
CVSS: 8.0 | system/admin/admin.extensions.php
The extension uninstall action checks cot_check_xg(false). Install, update, and pause do not. A GET request is enough to force installation or update of any extension:
GET BASE/admin.php?m=extensions&b=update&x=EXTGET BASE/admin.php?m=extensions&b=update&x=EXTForcing a reinstall via CSRF against an admin session can reset extension configuration or trigger re-execution of install scripts.
C52 โ File Extra-Field Accepts Any Extension โ Conditional RCE
CVSS: 8.5 | system/extrafields.php + datas/.htaccess
If an extra-field is created with type file and empty variants, the upload accepts any extension. The datas/ directory has an .htaccess that denies only .dat and .php โ not .phtml, .php7, .php8, .phar, or .shtml. A writer can upload evil.phtml containing PHP code to a web-reachable path and request it. Apache maps .phtml to the PHP handler by default.
Can be reached directly by an admin, or set up via the C24 CSRF against an admin session.
C79 โ Site Config Update Without CSRF
CVSS: 8.0 | system/admin/admin.config.php
The configuration update and reset handler has no CSRF token. An attacker can alter any site configuration setting against an admin session โ disabling security features, changing email settings, modifying upload rules.
C84 โ Global Rights Editor Without CSRF
CVSS: 8.0 | system/admin/admin.rights.php
Same story as C13/C14. The global access rights editor writes permission rows with no token. Privilege escalation against an admin session.
Standout Medium-Severity Findings
C47 โ Unauthenticated Reflected XSS via Search Highlight
CVSS: 6.1 | plugins/search/search.header.php
The highlight parameter is imported with the TXT filter (the < character stripping is commented out in the source), then interpolated directly into a JavaScript new RegExp() call without escaping:
// search.page.first.php:16
cot_import('highlight', 'G', 'TXT');
// search.header.php:36
new RegExp('{$wordsToHighlight}', "gi")// search.page.first.php:16
cot_import('highlight', 'G', 'TXT');
// search.header.php:36
new RegExp('{$wordsToHighlight}', "gi")Payload:
GET BASE/index.php?e=page&id=1&highlight=%27);alert(document.domain);//GET BASE/index.php?e=page&id=1&highlight=%27);alert(document.domain);//No login required. The script executes in the victim's browser. A crafted link sent to an admin can be used to steal their CSRF token and chain into any of the admin CSRF bugs above.
C72 โ Unauthenticated Reflected XSS via message.php lng
CVSS: 6.1 | message.php
For msg=920, if the lng parameter doesn't match a key in the language array, the value itself is used as the message body and rendered unescaped:
// message.php:105
$body = isset($L[$lng]) ? $L[$lng] : $lng;// message.php:105
$body = isset($L[$lng]) ? $L[$lng] : $lng;Payload:
GET BASE/message.php?msg=920&lng=<script>alert(document.domain)</script>GET BASE/message.php?msg=920&lng=<script>alert(document.domain)</script>Unauthenticated, works on any Cotonti install. Fix: only accept lng values that are existing keys in $L; htmlspecialchars() the body.
C44 โ Unauthenticated Open Redirect via message.php Error Pages
CVSS: 6.1 | message.php
For error codes 400/401/403/404/500, the redirect parameter is base64-decoded and placed in a <meta http-equiv="refresh"> tag with no URL validation. Base64 alphabet contains no colon, so the entry filter that strips : doesn't block it:
GET BASE/message.php?msg=404&redirect=aHR0cHM6Ly9ldmlsLmNvbQ==
โ meta-refresh to https://evil.comGET BASE/message.php?msg=404&redirect=aHR0cHM6Ly9ldmlsLmNvbQ==
โ meta-refresh to https://evil.comNo login required. Works as a phishing or token-relay vector.
C49 โ First Registrant Becomes Superadmin on Empty Users Table
CVSS: 6.5 | modules/users/inc/users.functions.php
elseif (Cot::$db->countRows($db_users) == 0) {
$tmp2 = COT_GROUP_SUPERADMINS;
}elseif (Cot::$db->countRows($db_users) == 0) {
$tmp2 = COT_GROUP_SUPERADMINS;
}The count check and the insert are not atomic. On a fresh database or after all users have been removed, any registration creates a superadmin account. Two concurrent registrations in that window can both be elevated. This is a race condition + privilege escalation.
C53 โ Client IP Spoofing via Proxy Headers
CVSS: 5.3 | system/functions.php
The client IP is read from HTTP_CLIENT_IP, then X-Forwarded-For, then REMOTE_ADDR, with no trusted-proxy gate. Any request can present an arbitrary IP address:
X-Forwarded-For: 1.2.3.4X-Forwarded-For: 1.2.3.4This bypasses IP bans, rate limits, IP-based deduplication (including the poll vote dedup in C26), and poisons logs. Fix: only trust forwarded headers from configured, known proxy IPs.
C26 โ Poll Double-Vote Race Condition
CVSS: 4.3 | modules/polls/inc/polls.functions.php
Check-then-insert with no UNIQUE constraint on (pv_pollid, pv_userid). Concurrent vote requests can each pass the existence check before either inserts, resulting in multiple vote rows from the same account. Guest dedup uses IP, which is spoofable via C53.
C77 โ Stored XSS via Structure Title on Public Page List
CVSS: 5.4 | modules/page/inc/page.list.php
Category titles at lines 308 and 439 are assigned to template variables without htmlspecialchars(). A title set to an XSS payload (directly by anyone with structure-write access, or via the C18 CSRF) executes for every visitor who loads the category list.
The CSRF Problem
Cotonti has its own anti-CSRF mechanism: cot_check_xg(). It works. When it's called, requests without a valid token are rejected. The problem is that calling it is opt-in, and it was forgotten in the majority of state-changing handlers across the codebase.
Here's the full list of files where CSRF protection is missing on mutating actions:
ID File What an attacker can forge C11 modules/pfs/inc/pfs.edit.php Edit/rename files/folders C12 plugins/i18n/inc/i18n.page.php, i18n.structure.php Translation mutations C13 system/admin/admin.users.php Privilege escalation to superadmin C14 system/admin/admin.rightsbyitem.php Permission changes C17 plugins/contact/contact.tools.php Contact moderation C18 system/admin/admin.structure.php Structure add/edit (โ XSS) C19 / C68 plugins/comments/controllers/actions/CreateAction.php Post comments C24 system/admin/admin.extrafields.php Create/modify data fields (โ RCE) C25 / C78 system/admin/admin.extensions.php Install/update extensions C29 modules/page/inc/page.edit.php Edit any page C30 / C67 modules/page/inc/page.add.php Create pages C31 plugins/banlist/banlist.admin.php Add/remove bans C32 / C60 plugins/ratings/ratings.ajax.php Forge ratings C33 plugins/urleditor/urleditor.admin.php Write .htaccess (โ RCE) C36 / C62 modules/pm/inc/pm.send.php Send private messages C37 / C61 modules/forums/inc/forums.newtopic.php Create forum topics C38 / C61 modules/forums/inc/forums.posts.php Create forum posts C39 plugins/referers/referers.admin.php Wipe referer data C40 modules/pm/inc/pm.list.php Star/flag messages C41 plugins/autoalias2/autoalias2.admin.php Bulk rewrite page aliases C48 plugins/userimages/userimages.admin.php Modify user-image config C51 plugins/comments/controllers/actions/EditAction.php Edit comments (+ object injection) C59 plugins/i18n/inc/i18n.page.php, i18n.structure.php Translation mutations (duplicate ID from different file) C64 plugins/contact/contact.php Forge contact submissions C65 PFS upload/newfolder handlers Upload files to victim's file store C70 modules/polls/inc/polls.functions.php Forge poll votes C79 system/admin/admin.config.php Change site configuration C84 system/admin/admin.rights.php Modify access rights
The admin-facing ones (C13, C14, C24, C25, C33, C78, C79, C84) are the most dangerous because they enable privilege escalation and site takeover. An attacker who can get any admin to load a malicious page โ through a crafted link, an image in a forum post, a spoofed email โ can chain these.
The Crypto Problem
Cotonti generates validation tokens in three places using the same pattern:
$validationKey = md5(microtime());$validationKey = md5(microtime());microtime() returns the current server time in microseconds. md5() is fast. The combination means an attacker who knows roughly when a request was made can brute-force the token window in seconds. The Date response header hands them the server time.
The affected flows are: password recovery (users.passrecover.php), new account registration (users.functions.php), and email address change (users.profile.php). All three write to the user_lostpass column, which is the only thing standing between an attacker and a successful account takeover via the recovery flow.
There's also cot_unique() in system/functions.php, which generates tokens using sha1(mt_rand()). mt_rand() is a Mersenne Twister, not a CSPRNG. Its state can be recovered with enough output samples, making tokens derived from it predictable.
Fix for all of these: bin2hex(random_bytes(32)).
Other Confirmed Findings
A few more worth naming:
C55 โ WhosOnline exposes real IP addresses to non-admins. The online_ip field is assigned to template variables visible to any user who can view the WhosOnline plugin, not just admins. Only the clickable IP-search link is gated to admins.
C15 โ Session cookie scoped to all subdomains. The session cookie is set with a leading-dot domain ('.' . domain), sharing it across every subdomain. A compromise of any subdomain (e.g., sub.example.com) can access the main site's session cookie.
C43 / C69 โ No session ID regeneration on login. cot_user_authorize() never calls session_regenerate_id(true). If an attacker can pre-set a known session ID in the victim's browser (session fixation), the victim's login authenticates that known ID.
C85 โ Template path influence via tpl parameter. The contact page accepts a tpl GET parameter and passes it to cot_tplfile() without restricting to known template names. Path traversal with ../ can reference other .tpl files outside the expected directory.
C86 โ cot_unique() uses mt_rand(). Any token derived from cot_unique() is predictable if enough mt_rand() output is observable. Used in session and authentication token generation.
Disclosure Status
Vendor notification: Not yet. This post is the public reference for CNA submission and CVE assignment. Coordinated disclosure is being handled through VulnCheck.
Fixed version: None at time of writing. All findings apply to Cotonti at commit f43f1fc (current master).
If you're running Cotonti: The most urgent things to address manually while awaiting patches are the unauthenticated bugs (C23, C44, C47, C72) and the admin-CSRF chains that lead to RCE (C33, C45). For C23: remove install.php from your web root. For C47 and C72: WAF rules blocking the PoC parameters are a stopgap, not a fix.
The Full Finding Reference
ID Summary CWE CVSS Auth Required C8 Password-recovery token always regenerated (case typo) CWE-561/330 3.7 None C9 PFS extension denylist incomplete (phtml/phar/.htaccess) CWE-434 5.0 PFS write C10 pfsnomimepass skips MIME check for unknown extensions CWE-434 4.8 PFS write C11 PFS file edit CSRF CWE-352 5.4 PFS user C12 i18n add/edit/delete CSRF CWE-352 6.5 Translator/admin C13 Admin user management CSRF โ privilege escalation CWE-352 8.0 Admin victim C14 Per-item rights update CSRF CWE-352 8.0 Admin victim C15 Session cookie scoped to all subdomains CWE-1004 3.5 โ C16 Weak password policy (min 4 chars) CWE-521 Info None C17 Contact admin moderation CSRF CWE-352 6.5 Admin victim C18 Structure add/update CSRF + enables stored XSS (C77) CWE-352 8.0 Admin victim C19/C68 Comments create CSRF CWE-352 5.4 Comment writer C21 Email-change token uses md5(microtime()) CWE-330 3.7 Self C22 Comments delete cb unserialize (guarded) CWE-502 3.8 Admin + valid token C23 Unauthenticated installer update path CWE-306 7.5 None C24 Extra-field admin CSRF (โ C52 RCE) CWE-352 8.0 Admin victim C25/C78 Extension install/update/pause CSRF CWE-352 8.0 Admin victim C26 Poll double-vote TOCTOU race CWE-362 4.3 Voter C27 Host reassembly from HTTP_HOST CWE-20 3.7 โ C28 user_auth unserialize on session load CWE-502 Info โ C29 Page edit POST update CSRF CWE-352 6.5 Page owner/admin C30/C67 Page add CSRF CWE-352 6.5 Page writer C31 Banlist add/update CSRF CWE-352 6.5 Admin victim C32/C60 Ratings vote CSRF CWE-352 4.3 Member C33 .htaccess write without CSRF (base of C45) CWE-352 8.2 Admin victim C36/C62 PM send CSRF CWE-352 5.4 PM writer C37/C61 Forums new topic CSRF CWE-352 5.4 Forum writer C38/C61 Forums new post CSRF CWE-352 5.4 Forum writer C39 Referers prune CSRF CWE-352 4.3 Admin victim C40 PM star via GET, no CSRF CWE-352 4.3 PM user C41 AutoAlias2 bulk create CSRF CWE-352 5.4 Admin victim C42 cot_url_check() open-redirect bypass (no end anchor) CWE-601 4.7 None C43/C69 No session ID regeneration on login CWE-384 4.8 โ C44 Unauthenticated open redirect via message.php CWE-601 6.1 None C45 CSRF โ .htaccess write โ RCE CWE-352/CWE-94 9.0 Admin victim C46 Profile theme not allowlist-validated CWE-20 3.1 Self C47 Unauthenticated reflected XSS via search highlight CWE-79 6.1 None C48 UserImages admin CSRF CWE-352 5.4 Admin victim C49 First registrant becomes superadmin (empty users table) CWE-266/362 6.5 None C50 Page view counter manipulable by anyone CWE-639 3.7 None C51 Comment EditAction cb unserialize (CSRF-reachable) CWE-502/352 6.5 Comment writer C52 File extra-field โ arbitrary upload โ conditional RCE CWE-434 8.5 Writer C53 Client IP spoofing via X-Forwarded-For CWE-348/290 5.3 None C54 Password-recovery email enumeration CWE-203 5.3 None C55 WhosOnline exposes IP addresses to non-admins CWE-200 5.3 Viewer C56 Page body redir: open redirect (page writer) CWE-601 4.7 Page writer C57 Admin dashboard renders remote feed content unescaped CWE-79/829 3.0 Admin C59 i18n translation CSRF (duplicate class) CWE-352 6.5 Translator/admin C63 Comment EditAction cbu open redirect, no CSRF CWE-601/352 4.7 Comment writer C64 Contact submit CSRF CWE-352 4.3 Member C65 PFS upload/newfolder CSRF CWE-352 5.4 PFS writer C66 PFS extension denylist incomplete (duplicate of C9) CWE-434 5.0 PFS write C70 Poll vote CSRF CWE-352 4.3 Voter C71 Comments CreateAction ci unserialize CWE-502 Info Comment writer C72 Unauthenticated reflected XSS via message.php lng CWE-79 6.1 None C73 message.php confirm-dialog protocol-relative redirect CWE-601 4.7 None C74 Host-header influence under multihost config CWE-20/601 5.3 None C75 get-users AJAX enumerates account names CWE-200 5.3 Varies C76 Login distinguishes inactive/banned vs wrong-password CWE-203 5.3 None C77 Stored XSS via structure title on page list CWE-79 5.4 Structure writer C82 Trashcan info XSS + unserialize CWE-79/502 5.4 Admin victim C83 Weak md5(microtime()) tokens (consolidated) CWE-330/338 5.3 None C84 Global rights editor CSRF CWE-352 8.0 Admin victim C85 Contact tpl template-path influence CWE-73/22 4.3 None C86 cot_unique() uses mt_rand() (non-CSPRNG) CWE-338 4.8 โ
References
- Cotonti source repository: https://github.com/Cotonti/Cotonti
- Tested commit: https://github.com/Cotonti/Cotonti/commit/f43f1fc
My previous Cotonti posts: