CodeAnt AI Security Research
CVE-2026-71508: Dolibarr Salary Mass Assignment Lets Users Change Their Pay

Amartya Jha
CEO, CodeAnt AI
TL;DR
CVE-2026-71508 is a mass-assignment flaw (CWE-915) in Dolibarr ERP CRM's User REST API, scored CVSS 6.5. Users::put() copies every body key onto the user record and rejects only five names, all password and key fields, so the five payroll columns (salary, salaryextra, thm, tjm, weeklyhours) fall straight through. An account holding only user.self.creer, the edit-your-own-profile right, rewrites its own salary in one PUT while being refused the Salaries API with 403. The 200 response hides four of the five values it just wrote, which keeps the change quiet. It is fixed in Dolibarr 24.0.0.
What is CVE-2026-71508?
CVE-2026-71508 is a mass-assignment vulnerability in the User REST API of Dolibarr ERP CRM, the open-source suite that runs HR, payroll, and accounting for small and mid-size businesses. It is filed as CWE-915, improperly controlled modification of object attributes, and maps to OWASP API3:2023, broken object property level authorization.
Users::put() decides which fields a caller may write by excluding a short list of known-bad names rather than including a list of known-good ones. The list names five credential fields and stops there. The compensation columns on the user record are not on it, so a caller writes them by naming them in the body.
Because that writable set is dictated by the request rather than by an allow-list, any account that can reach the user-write route can set pay it has no permission to see. It carries a CVSS 3.1 base score of 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N) and was fixed in Dolibarr 24.0.0.
Where Dolibarr Stores Salary and Payroll Data
Dolibarr's Salaries module governs compensation through a salaries permission family, with its own screens and its own export-gated report. What it does not have is a table of its own for the base figures.
Reference salary, extra pay, hourly cost rate (thm), daily cost rate (tjm), and contractual weekly hours are plain columns on the user record in llx_user. The same row is reachable from the Users screen and from the Salaries screen.
That shared row is the whole problem. A control placed on the Salaries side means nothing if the Users side can write the same columns without it.
How Dolibarr's User API Exposes Salary Fields Through Mass Assignment
The update loop in the User API walks the request body and assigns each key to the user object. Its only gate is a five-name deny-list.
The five payroll columns are not on that deny-list, and they are not declared in the object's field map either, so the sanitizer treats them as generic alphanumeric text and their numeric content arrives intact. User::update() then writes them with no permission consideration anywhere on that path.
Between the end of the authorization block and the assignment there is no salaries or hrm check at all. That is the missing guard, and the deny-list naming credentials while forgetting payroll is why it is missing.
Why Dolibarr Blocks Salary Reads but Allows Salary Writes
The clearest evidence that these fields were meant to be protected sits in the same class, on the read path. When the API returns a user, _cleanObjectDatas() builds a $canreadsalary flag from the salary-read rights and strips four of the five payroll fields from the response unless the caller holds them.
So reading four of the five is guarded. Writing any of the five is not. One direction enforces the permission and the other ignores it, on the same fields, in the same file.
That asymmetry is also what hides the attack. A caller without the salary-read right gets a 200 whose body omits the four monetary values it just wrote, so a test that reads only the HTTP response looks like nothing happened.
How CVE-2026-71508 Works: Reproducing the Salary Write
Every request below ran against a throwaway Dolibarr in local Docker, hardened to a production profile with install locked, production mode on, HTTPS forced, and CSRF at its strictest. None of that blocked the write. The database was read directly before and after, because the response hides most of what changes.
Two principals were tested, each holding zero salaries rights, no group, and admin = 0. One holds user.user.creer (write another user's record), the other holds user.self.creer alone (write only its own).
First the control. The user.user.creer principal calls the Salaries API and is refused.
Then the same key writes another user's payroll through the Users route.
HTTP 200, and the body echoes only weeklyhours. The response cleaner strips the four monetary fields for this caller, so the write is confirmed against the database, where all five columns hold the new values.
Now the sharper variant. A second principal whose entire authority is editing its own profile, refused the Salaries API with 403 seconds earlier, raises its own pay.
Its record moves from 1000 / 100 / 10 / 100 / 35 to 999999 / 888888 / 9999 / 7777 / 1. Reading the corrupted row back through the built-in Salaries export profile, the join finance actually runs, shows the tampered figure carried into a report the same instance refuses this principal permission to see.
Expected vs observed. A PUT carrying any of the five payroll keys from a caller with no salaries or hrm.employee right should have been rejected, or those keys silently discarded, which is exactly what the web interface does and what this same API class already does for four of the five on the read side. Instead the request returned 200 and all five columns were rewritten, moments after the Salaries API refused the same key with 403.
Who Can Exploit CVE-2026-71508, and What They Can Change
user.self.creer is the edit-your-own-profile right, a routine grant. user.user.creer is the user-administration write. Neither implies any authority over compensation, and this bug hands both exactly that.
The self-edit variant is the one that squarely crosses a boundary. That principal has no legitimate route to any salaries right and still sets its own salary, bonus, and cost rates to any value it likes.
The damage does not stay in the user table. The altered salary is emitted by the export-gated Salaries and Payments report, so finance acts on a number written by a principal the same instance denies payroll access. Detection is hard, because the 200 response conceals four of the five written values and Dolibarr's security-event log is off by default, so the change realistically surfaces only by querying the user table or spotting a discrepancy later.
CVE-2026-71508 Fix: Protecting Payroll Fields From Mass Assignment
Dolibarr 24.0.0 closes the gap the way the read side already models. Before the assignment, Users::put() refuses the five payroll keys unless the caller satisfies a write-salary predicate built from the salary-read rights, the same rule the web form already wraps around exactly these five fields.
The deeper correction is to stop filtering by exclusion. A five-name deny-list fails the moment a sensitive column is added that nobody remembers to list. An allow-list inverts the default, so only fields explicitly declared writable get written, kept as two sets, a profile set for self-write that reaches no HR or cost field and an administrative set for user-write.
The rule behind it is simple. When a mass-assignment write is filtered by naming known-bad fields rather than known-good ones, the safe default is off by exactly the fields nobody thought to name, and payroll on a shared user record is precisely the kind of field that gets forgotten.
CVE-2026-71508: When “Edit Your Profile” Means “Edit Your Salary”
CVE-2026-71508 shows how a routine profile-edit permission can become a payroll permission when a REST API trusts mass-assigned fields. A user with only user.self.creer could rewrite salary, bonus and cost-rate fields through the Users API, even though the same account was denied access to the Salaries API.
The problem was not a missing endpoint or a broken authentication check. It was a deny-list that forgot to protect sensitive payroll fields.
CodeAnt AI Security Research found this as one of nine Dolibarr findings where different API surfaces disagreed about authorization. The important test was not whether the PUT returned 200, but whether the fields it accepted could cross a permission boundary and change stored payroll data. If a profile API can write fields it was never explicitly designed to expose, the next sensitive field on the user record could be the vulnerability.
This research is part of an ongoing effort by CodeAnt AI Security Research to audit widely-used open-source packages for security vulnerabilities. We believe the open-source ecosystem deserves better tools, better auditing, and more support for the maintainers who keep it running. More findings will be published as patches ship and coordinated disclosure timelines are met.
If you are a maintainer and have been contacted by our team, thank you for your work. If you believe your package may be affected by a similar pattern, we’d love to help: securityresearch@codeant.ai
Related reading: This finding is one node in the split-brain authorization pattern, the pillar behind all nine findings. It is one of three that share the same blind mass-assignment loop. In CVE-2026-71504 the same shape in the Members API writes a linked user's password and reaches administrator, and in CVE-2026-71509 it lets a filer approve their own expense claim. Here the fields that fall through are payroll, and the result is an employee setting their own pay.
FAQs
Can CVE-2026-71508 change more than an employee's salary?
What is the difference between mass assignment and broken object-level authorization in Dolibarr?
Which Dolibarr versions are affected, and how is CVE-2026-71508 fixed?
What privileges does an attacker need to exploit CVE-2026-71508?
What is a mass-assignment vulnerability, and how does CVE-2026-71508 work?
How can an employee set their own salary without any payroll permission?

Get Pentest Report
