Summary
The dashboard-widget feature stores a user-supplied data_source string and later executes it as new $class() followed by $obj->$method(), splitting the value on ::. The field is persisted from the request with no validation, so a low-privileged backend user who can manage widgets controls both the class that is instantiated and the (zero-argument) method that is invoked, and the return value is rendered back to them.
A user whose only relevant grants are the dashboard-widget permissions (and who has no permission on the Users module) can set data_source to \CodeIgniter\Shield\Models\UserIdentityModel::findAll and read every account's login e-mail and bcrypt password hash, including the super-admin. The hashes can be cracked offline to take over any account.
Details
The vulnerable code is resolveDataSource() in WidgetService.php. It splits the stored Data Source on "::", then instantiates the class and calls the method with no allow-list:
The value comes from the widget create/update form. The controller only trims it and never validates it (only slug and title are validated), so the attacker controls both the class and the method.
It is triggered by the read-only endpoint backend/dashboard-widgets/data/{slug}, which returns the result as JSON. As long as the widget slug is not one of the built-in names (total-users, recent-activity, and so on), this endpoint runs the attacker's class and method.
A CodeIgniter model needs no constructor arguments and findAll() takes none, so the value below fits the pattern and returns the whole identities table:
\CodeIgniter\Shield\Models\UserIdentityModel::findAll
Because the endpoint only checks the dashboard-widget read permission, a user with no access to the Users module still reads every hash. Verified on the running app: the test user can read widget data but cannot read the Users module.
PoC
Actor: a non-admin user whose group grants only the dashboard-widget permissions and has no Users-module access.
-
Log in to the backend as the low-privileged widget user.
-
Create a widget with:
- Slug: users-leak
- Title: ALLUSERS
- Type: table
- Data Source: \CodeIgniter\Shield\Models\UserIdentityModel::findAll
Save.
-
Trigger the widget and read the response:
GET http://localhost:8080/backend/dashboard-widgets/data/users-leak
Impact
Any authenticated backend user with the dashboard-widget create/read permissions. No admin/super-admin rights, and crucially no Users-module permission, are required.
Disclosure of every user's login e-mail and bcrypt password hash, including the super-admin. This is a cross-module credential-disclosure issue that enables offline cracking and full account takeover.
Fix
Never derive a class/method to instantiate or call from user input. Resolve data_source through a fixed allow-list of approved providers:
/** Approved custom data providers: data_source value => [Class, method]. */
private const ALLOWED_DATA_SOURCES = [
// 'sales.daily' => [\App\Widgets\SalesProvider::class, 'daily'],
];
protected function resolveDataSource(string $source): array
{
if (!isset(self::ALLOWED_DATA_SOURCES[$source])) {
return ['value' => '—', 'label' => 'Unknown source'];
}
[$class, $method] = self::ALLOWED_DATA_SOURCES[$source];
return (array) (new $class())->{$method}();
}
Summary
The dashboard-widget feature stores a user-supplied
data_sourcestring and later executes it asnew $class()followed by$obj->$method(), splitting the value on::. The field is persisted from the request with no validation, so a low-privileged backend user who can manage widgets controls both the class that is instantiated and the (zero-argument) method that is invoked, and the return value is rendered back to them.A user whose only relevant grants are the dashboard-widget permissions (and who has no permission on the Users module) can set
data_sourceto\CodeIgniter\Shield\Models\UserIdentityModel::findAlland read every account's login e-mail and bcrypt password hash, including the super-admin. The hashes can be cracked offline to take over any account.Details
The vulnerable code is resolveDataSource() in WidgetService.php. It splits the stored Data Source on "::", then instantiates the class and calls the method with no allow-list:
The value comes from the widget create/update form. The controller only trims it and never validates it (only slug and title are validated), so the attacker controls both the class and the method.
It is triggered by the read-only endpoint backend/dashboard-widgets/data/{slug}, which returns the result as JSON. As long as the widget slug is not one of the built-in names (total-users, recent-activity, and so on), this endpoint runs the attacker's class and method.
A CodeIgniter model needs no constructor arguments and findAll() takes none, so the value below fits the pattern and returns the whole identities table:
Because the endpoint only checks the dashboard-widget read permission, a user with no access to the Users module still reads every hash. Verified on the running app: the test user can read widget data but cannot read the Users module.
PoC
Actor: a non-admin user whose group grants only the dashboard-widget permissions and has no Users-module access.
Log in to the backend as the low-privileged widget user.
Create a widget with:
Save.
Trigger the widget and read the response:
Impact
Any authenticated backend user with the dashboard-widget
create/readpermissions. No admin/super-admin rights, and crucially no Users-module permission, are required.Disclosure of every user's login e-mail and bcrypt password hash, including the super-admin. This is a cross-module credential-disclosure issue that enables offline cracking and full account takeover.
Fix
Never derive a class/method to instantiate or call from user input. Resolve
data_sourcethrough a fixed allow-list of approved providers: