Skip to main content0xAdham

Command Palette

Search for a command to run...

A Patch Is Not a Fix: Chaining Krayin CRM's Blind Spots

Written by
Avatar of 0xAdham
0xAdham
Published on
--
A Patch Is Not a Fix: Chaining Krayin CRM's Blind Spots

A white-box review of one open-source Laravel CRM, written up for the people who patch it and the people who just need to know how bad it was. All three fixed in 2.2.7.

  • Author: Adham · @0xAdham1
  • Target: krayin/laravel-crm
  • Reported: 2026-10-05
  • Fixed in: 2.2.7
  • CVEs: 3
CVEClassSeverityOne-liner
CVE-2026-112286IDORHigh 7.1Two Quote read endpoints a prior fix never reached; any restricted agent reads competitors' pricing, addresses and PII with a plain GET.
CVE-2026-112287BOLAHigh 8.3Read path says 401, write path says 200 for the same email ID. Trash, bulk-delete, edit and re-send other users' mail.
CVE-2026-112288SSRF · TOCTOULow 3.1The SSRF filter resolves the host once and throws it away; the request resolves again. Answer public, then loopback, and you're inside.

CVE-2026-112286

Cross-user data disclosure in Krayin's Quote module

HIGH · CVSS 4.0 · 7.1 · CWE-639 · IDOR · incomplete-fix

TL;DR

  • The bug: two Quote endpoints never check who owns the record, so a restricted agent reads any user's quotes just by changing a number.
  • The impact: deal pricing, billing and shipping addresses, totals and linked customer emails, across the whole tenant.
  • Status: reported 2026-10-05, fixed in 2.2.7, published as CVE-2026-112286.

At a glance

ProductKrayin CRM (krayin/laravel-crm)
ComponentQuote/QuoteController.php
ClassIDOR / cross-user read disclosure · CWE-639
SeverityHigh, CVSS 4.0 7.1 · CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
Affected / fixed< 2.2.7, fixed in 2.2.7

Why this matters

Picture a sales team where each rep should only see their own deals. This bug lets any rep pull up everyone's quotes: what competitors are charging, who the customers are, where they're billed. No admin rights, no clever tooling, just a logged-in account and a web request. It's a confidentiality leak across every customer in the system.

In plain English

It's a coat check that hands over any coat to whoever names a number, and the tickets are just 1, 2, 3… so you can ask for every coat in the room. Here the "ticket" is the lead_id in the URL; the attendant (the API) looks it up and hands it back without ever checking the coat is yours.

Attack flow

StepActor → EndpointWhat happens
1Agent → GET /admin/quotes/search?search=A valid low-privilege agent session, owning no records
2App → DatabaseSELECT * FROM quotes, never restricted to the agent's authorized IDs
3Database → AppEvery tenant's quotes come back
4App → Agent200 with subjects, billing/shipping addresses, totals and customer PII

The write path of this controller was locked down months earlier by CVE-2026-61460. The read path was never in that report, so it was never touched. That gap is this CVE: a textbook incomplete fix.

Deep dive: root cause and the two endpoints

Krayin scopes each user to a set of records via users.view_permission (global/group/individual), resolved by bouncer()->getAuthorizedUserIds(). Record actions are meant to check the record's user_id against that set. The Quote write methods do (preventUnauthorizedAccess()); these two reads don't.

B1: leadProducts() → GET /admin/quotes/lead-products/{lead_id}

For any lead_id, the caller gets each product's name, quantity, unit price and line total, the full pricing of another user's deal.

B2: search() → GET /admin/quotes/search

->all() applies only the request's search criteria, never a restriction to the caller's authorized IDs. QuoteResource then serializes subject, description, billing/shipping address, totals, the linked person (customer PII) and related leads, for every user's quotes.

PoC: walkthrough and captured leak

Local instance, Krayin 2.2 branch. Owner (global, ID 1) owns a lead and a quote; Agent (individual) owns nothing but holds the Quote feature permission. Both endpoints are GETs, so no CSRF token is needed.

The agent owns nothing, yet reads Owner's product, quantity and exact pricing. The search endpoint goes further, returning full QuoteResource objects with billing addresses and the customer's email. The published PoC (CVE-2026-112286.py) is read-only and refuses any non-local --base.

Fix: remediation that shipped in 2.2.7

Two additions, both mirroring patterns already in the codebase:

CVE-2026-61460 fixed object-level authorization on the write path of this controller and four siblings. This finding is the same bug class on the two read endpoints that fix never reached, reported so the Quote-module fix could actually be completed.

Timeline

  • 2026-10-04: Found during a white-box review of the 2.2 branch
  • 2026-10-05: Reported to the vendor with advisory + PoC
  • 2026-10-09: Confirmed in a local lab
  • 2026-10-19: CVE-2026-112286 published, fixed in 2.2.7

Takeaways

  • Hunter: when a vendor patches an auth bug, check the read endpoints of the same controller; fixes track reports, not bug classes.
  • Dev: a collection endpoint needs the same scope filter as the DataGrid that lists it. ->all() with only search criteria is a leak.

CVE-2026-112287

Broken Object Level Authorization in the Mail module

HIGH · CVSS 8.3 · CWE-639 · BOLA

TL;DR

  • The bug: the Mail module checks ownership when you read an email but not when you modify or delete one.
  • The impact: a low-privilege agent can trash, bulk-delete, edit and re-send other users' emails, and the edit response leaks the thread's contents too.
  • Status: reported 2026-10-05, fixed in 2.2.7, published as CVE-2026-112287.

At a glance

ProductKrayin CRM (krayin/laravel-crm)
ComponentMail/EmailController.php
ClassBOLA on the write path · CWE-639
SeverityHigh, CVSS 3.1 8.3 · AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:H
Affected / fixed< 2.2.7, fixed in 2.2.7

Why this matters

Email threads in a CRM are the record of a customer relationship. This bug lets one sales agent quietly destroy another agent's correspondence, individually or in bulk, and tamper with records they're not even allowed to open. It can also flip a draft into the outbox and send mail on someone else's thread. That's damage to data integrity and availability, with a side of data leakage.

In plain English

Think of a room with two doors. The front door has a lock that checks your badge: try to read the mail and it says "not yours, 401." The back door, the one for deleting and editing, was never fitted with a lock at all. Same room, same mail; one door guards it and the other waves you straight through.

Attack flow

As the restricted (individual-scope) agent, against an email owned by another user:

StepRequestResponseWhat it proves
1GET /admin/mail/inbox/1401 Unauthorizedread is gated
2DELETE /admin/mail/1 (type=trash)200 "deleted"write is not gated
3GET /admin/mail/trash/1401 Unauthorizedstill can't view the email it just trashed

That contradiction, the same ID returns 401 on read and 200 on delete, is the entire vulnerability, provable in two requests.

Deep dive: root cause and affected endpoints

The read path resolves the record's owner and enforces scope. view():

None of the four mutating actions do this. Three things make it exploitable rather than theoretical: the guard is simply absent (not weakened); the route ACL is only feature-level ("may this user use mail"), which every agent passes; and update() reads request('id') ?? $id, so a body ID overrides the URL, making it a disclosure primitive too.

RouteMissing guard
PUT /admin/mail/edit/{id}preventUnauthorizedAccess
DELETE /admin/mail/{id}preventUnauthorizedAccess
POST /admin/mail/mass-updatefilterAuthorizedRecords
POST /admin/mail/mass-destroyfilterAuthorizedRecords

Every sibling module (Person, Organization, Quote, Activity) routes its bulk operations through filterAuthorizedRecords(). Mail is the one that skipped the ownership filter on the delete and modify path.

PoC: trash, bulk-delete and the disclosure write

The PoC only ever drives reversible paths (type=trash, is_read) and refuses a non-local base. Step 1 trashes Owner's email that step 0 just proved the agent can't read:

Flipping is_read via update() returns the full EmailResource, so the same request that tampers also leaks:

Only ever send type=trash while testing. Any other value hard-deletes.

Fix: remediation and why it's a distinct CVE

Apply the ownership gate the read path already uses to every mutating action:

Route the bulk IDs through filterAuthorizedRecords(), and drop the request('id') ?? $id fallback so the path is authoritative. Shipped in 2.2.7. Krayin has a run of prior BOLA/IDOR CVEs (LeadController, PersonController, the attachment download path, the v2.2.6 ACL mapping fix), none covered the mutating actions of EmailController.

Timeline

  • 2026-10-04: Found during a white-box review of the 2.2 branch
  • 2026-10-05: Reported to the vendor with advisory + PoC
  • 2026-10-09: Confirmed in a local lab
  • 2026-10-19: CVE-2026-112287 published, fixed in 2.2.7

Takeaways

  • Hunter: a read-side 401 next to a write-side 200 on the same ID is the cleanest BOLA proof there is. Always test the mutating verbs separately.
  • Dev: feature-level ACL is not object-level authorization. If reads check ownership, every write on the same object must too.

CVE-2026-112288

Bypassing the webhook SSRF filter with DNS rebinding

LOW · CVSS 3.1 · CWE-918 · CWE-367 · TOCTOU

TL;DR

  • The bug: the webhook SSRF guard resolves the target host once to check it's safe, then the HTTP client resolves it again to connect.
  • The impact: control the DNS and you answer "public, safe" to the check and 127.0.0.1 / cloud-metadata to the request, reaching inside the network.
  • Status: a bypass of the fix for CVE-2026-38527; fixed properly in 2.2.7.

At a glance

ProductKrayin CRM (krayin/laravel-crm)
ComponentWebhookService::isSafeEndpoint()
ClassSSRF via DNS rebinding (resolve-then-request TOCTOU) · CWE-918 / CWE-367
SeverityLow, CVSS 3.1 3.1 · AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N
Affected / fixed>= 2.2.5, < 2.2.7, fixed in 2.2.7

Why this matters

Krayin can fire a webhook from a workflow: the server itself makes an outbound HTTP request. There's a filter whose whole job is to stop that request from reaching the company's own internal systems (like the cloud-metadata service that holds credentials). This finding walks straight past that filter, so an ordinary authenticated user can make the server knock on internal doors it was never meant to touch.

In plain English

Security at the gate asks for the visitor's name and checks their ID: looks fine, public address, let them in. But by the time the visitor actually walks through the door, a different person is wearing that name tag. The "name" is the hostname; the ID check is the DNS lookup the guard does; the walk-in is the second DNS lookup the HTTP client does a moment later. The attacker controls what each lookup returns.

Attack flow

StepPhaserebind.test resolves toOutcome
1isSafeEndpoint() check (T0)93.184.216.34 (public)passes the filter, "safe"
2Guzzle request (T1)127.0.0.1 / 169.254.169.254 (answer #2)connects to the internal target
3——SSRF, the guard is bypassed (TOCTOU)

The IP validated at T0 is simply discarded; Guzzle re-resolves at T1 and connects to whatever it gets. Set a zero TTL so the second lookup isn't cached. You don't strictly need rebinding either; plain round-robin DNS mixing a public and a private answer defeats the guard non-deterministically.

Deep dive: the TOCTOU, and what the guard does get right

Scope it honestly, the guard isn't useless. It re-checks inside the on_redirect handler, so the redirect-based pivot is closed; and decimal/octal IP literals fail validation. The one unpinned path is the initial request's re-resolution. There's also a secondary gap: FILTER_FLAG_NO_RES_RANGE doesn't reliably flag IPv4-mapped IPv6 (::ffff:169.254.169.254), which can pass and still connect on a dual-stack host.

This is blind SSRF. triggerWebhook() discards the response body, so there's no reflected read: exfiltration needs an internal service that acts on the request alone, or an oracle. That's why it's Low (3.1, AC:H, blind), not a straight critical metadata read.

PoC: loopback rebinding harness

The whole chain runs on loopback: a rebinding DNS server on 127.0.0.1:5353 that answers public on query #1 and 127.0.0.1 on every query after (TTL 0), plus a loopback listener standing in for the internal service. Driving the repo's unmodified isSafeEndpoint() with the real client options:

The guard said "public, safe" and the request still hit 127.0.0.1. That's the whole bug in one trace.

Fix: pin the connection to the validated IP

The root cause is the second resolution, so pin the connection to the IP the guard validated:

  • Resolve once, validate every A/AAAA address, then force the client to connect to that exact IP (Guzzle's CURLOPT_RESOLVE) while keeping the Host header. Reject answer-sets that mix ranges.
  • Apply the same pin inside on_redirect: resolve and pin the target, don't just re-validate the hostname.
  • Normalize IPv4-mapped IPv6 before the range check and reject ::ffff:* into private space. Consider an allowlist on top of the denylist.

2.2.7 pins the request to the validated IP. CVE-2026-38527 was the original unguarded SSRF; the vendor fixed it by adding this guard. This finding shows the guard is bypassable, so it gets its own ID: an incomplete-fix of a security control.

Timeline

  • 2026-10-04: Found during a white-box review of the 2.2 branch
  • 2026-10-05: Reported with advisory + local rebinding harness
  • 2026-10-09: Confirmed with the repo's unmodified guard + real client options
  • 2026-10-19: CVE-2026-112288 published, fixed in 2.2.7

Takeaways

  • Hunter: any "resolve, validate, then request" flow is a TOCTOU candidate. If the check and the use resolve DNS separately, rebinding wins.
  • Dev: validating a hostname's IP and then connecting by hostname pins nothing. Connect to the exact IP you validated, not the name.

CTF player and bug bounty hunter on HackerOne / Bugcrowd. All three findings were reported to the vendor before publication and are fixed in Krayin 2.2.7. PoC scripts are read-only or reversible and refuse non-local targets. Test only against systems you own or are authorised to assess.

Edit on GitHub
Last updated: --