October 18, 2026
The Form Said No, the API Said Yes: The Story of My Second CVE
How one PATCH request let any regular user in Kimai set their own hourly rate.

By Udaypali
5 min read
Finding your first CVE is a rush. Finding a second one is how you learn the first wasn't luck.
Not long ago I broke an invoicing app with SQL injection and landed my first CVE. I was hooked, so I went back to hunting. This time the target was Kimai, and the bug wasn't about destroying data โ it was about editing a field I was never supposed to see.
The Setup
Kimai is an open-source time-tracking app โ around 5.1k stars on GitHub. Teams use it to run timesheets, reports, and the invoices that bill clients for that time.
Here's what made it interesting to me: Kimai mixes permissions with money. Some users are allowed to set billing rates. Most aren't. And any time an app says "this field is off-limits for your role," I want to know one thing โ off-limits everywhere, or just off-limits where the developer happened to be looking?
The Field That Wasn't There
I started where I always start: the parts of the app that touch money. Logged in as a plain employee account โ no special roles, the weakest user I could create โ I went looking for the hourly rate field on my own profile. It wasn't there. The form simply refused to show it. Good; that's the app doing its job.
But here's the rule my first CVE taught me: the frontend is a lie. Just because a form won't show you a field doesn't mean the server will refuse it. A form is one door into a field. It's rarely the only one.
Kimai has a REST API, so I went looking there. In the docs I found a preferences endpoint: PATCH /api/users/{id}/preferences. Preferences are stored as a loose list of name/value pairs โ and hourly_rate is one of them under the hood. That kind of flexible, catch-all endpoint is exactly where mass assignment bugs love to hide.
So I had my hypothesis: send the field the form wouldn't let me touch, straight to the API, against my own account. Time to read the code and see if it would bite.
Digging Into the Code
This is a mass assignment bug โ the formal name is Broken Object Property Level Authorization, or CWE-915. In plain terms: the app lets you write to a field you were never supposed to write to.
The root cause is two pieces of code that were supposed to agree โ and didn't.
Kimai does know a standard user shouldn't touch hourly_rate. When it builds your list of preferences, the role logic checks for the hourly-rate permission, and if you don't have it, marks the field disabled. The web form reads that flag and hides the field. That's why the UI looked locked down.
The problem? The API endpoint that saves preferences never checked that flag. Here's the code in src/API/UserController.php:
foreach ($request->request->all() as $preference) {
// ...
if (null === ($meta = $profile->getPreference($name))) {
throw $this->createNotFoundException(...);
}
$meta->setValue($value); // saved regardless of isEnabled()
}foreach ($request->request->all() as $preference) {
// ...
if (null === ($meta = $profile->getPreference($name))) {
throw $this->createNotFoundException(...);
}
$meta->setValue($value); // saved regardless of isEnabled()
}
It checks that the preference exists. It never checks that it's enabled for you. The "disabled" flag gets calculated perfectly โ and then thrown away the moment you write. One half of the app enforces the rule; the other half forgot to ask.
One PATCH Request Later
Time to test it. I fired up Burp Suite, grabbed my standard user's session cookie, and sent one request. The whole exploit is a single PATCH against my own account:
- Log into Kimai as an ordinary employee โ an account with no special roles.
- Grab the session cookie from the browser. In this example my user ID is
2. - Send a PATCH to your own preferences endpoint, setting the rates the UI wouldn't let you touch:
curl -i -X PATCH "http://localhost:8001/api/users/2/preferences" \
-H "Content-Type: application/json" \
-H "cookie: <YOUR_STANDARD_USER_SESSION>" \
-d '[
{ "name": "hourly_rate", "value": "1337" },
{ "name": "internal_rate", "value": "1337" }
]'curl -i -X PATCH "http://localhost:8001/api/users/2/preferences" \
-H "Content-Type: application/json" \
-H "cookie: <YOUR_STANDARD_USER_SESSION>" \
-d '[
{ "name": "hourly_rate", "value": "1337" },
{ "name": "internal_rate", "value": "1337" }
]'- The server answers
HTTP/1.1 200 OK.
The server answered 200 OK. But here's the sneaky part: hourly_rate doesn't come back in the response. The same disabled flag that should have blocked the write does correctly hide the field on read. So at a glance, nothing happened.
Except it did. The value was saved. The next time an admin opens that profile, or the user logs a timesheet, the billing rate in effect is whatever I set. Write succeeds, read hides it โ which is exactly how a bug like this sits unnoticed.
The Result
This isn't a data breach. Nobody's reading secrets, nothing crashes. It's a business-logic flaw โ and in an app that bills for people's time, that's its own kind of serious.
A regular employee can set the billing rate that drives their own numbers. hourly_rate feeds straight into invoices and timesheet exports. So a low-privilege user can quietly change what their time is worth โ and because the field is hidden from the normal UI, an admin has no reason to go looking. The result: invoices and reports that are wrong, but look completely legitimate.
On the CVSS scale it comes out to 4.3 โ Moderate (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N). Real, but bounded:
Network-reachable, low complexity, needs any valid low-privilege account, no user interaction. Nothing leaks and nothing crashes โ the whole impact lands on the integrity of the financial data.
The fix
The patch is almost as short as the exploit. In updateUserPreference, the maintainers added the one check that was missing โ ask the flag before you write:
if (!$meta->isEnabled()) {
throw $this->createAccessDeniedException('User tried to update preference: ' . $name);
}if (!$meta->isEnabled()) {
throw $this->createAccessDeniedException('User tried to update preference: ' . $name);
}Now if you try to set a preference your role has disabled, the API refuses with access denied instead of silently saving it. They also added a $dirty flag so the user is only saved when something really changed, plus two tests: one proving an admin can still set hourly_rate, one proving a standard user gets denied.
If you run Kimai: upgrade to 2.53.0 or later. If you can't upgrade immediately, the exposure is limited to authenticated users, so review who has accounts and audit user billing rates for unexpected values in the meantime.
The Lesson
The first CVE could have been luck. The second one felt like a method.
The lesson this time: a permission is only as strong as the weakest door into the field. Kimai got the hard part right โ it correctly worked out that I shouldn't touch hourly_rate. It just didn't enforce that on every path that could write it. The form asked. The API didn't.
So now, whenever an app tells me a field is off-limits, I ask the same question: off-limits where? The form is one door. The API is another. Bulk import, impersonation, GraphQL โ every one of them is a door, and every one has to enforce the rule on its own.
And if you're just starting out: don't sleep on the boring-sounding business logic bugs. No fancy payload here, no memory corruption โ just one JSON array sent to the right endpoint. Figuring out what an app is supposed to let you do, and then testing the edges of that, is where a surprising number of real bugs live.
On to the next one.
Technical Resources & Official Tracking
If you want the advisory, the patch, or the official tracking for this one, it's all here:
- Proof of Concept repo: CVE-COLLECTION/CVE-2026โ40486 at main ยท udaypali/CVE-COLLECTION
- GitHub Security Advisory: GHSA-qh43-xrjm-4ggp
- CVE / MITRE: CVE-2026โ40486
- NIST National Vulnerability Database: NVD Details
- The fix: commit 870944e ยท release 2.53.0
- Bug class: CWE-915 โ Mass Assignment
My first big bug was hiding in a dropdown. My second was hiding one layer behind a form that said no. The next one is out there too โ you just have to keep asking what happens past the part they let you see.