August 12, 2026
Vikunja CVE-2026–55064: The Vulnerability Hidden in the Previous Security Fix
How an “out of scope” edge case in Vikunja’s previous privilege-escalation patch became a new authorization vulnerability three months…

By Antariksha Akhilesh Sharma
5 min read
How an "out of scope" edge case in Vikunja's previous privilege-escalation patch became a new authorization vulnerability three months later.
On April 9, Vikunja shipped a fix for a privilege-escalation vulnerability.
The patch closed the higher-impact path. It also contained a comment explaining one case the fix deliberately did not handle.
About three months later, that exact case became CVE-2026-55064.
That sequence is worth studying, because it illustrates a useful lesson for vulnerability research:
When a security patch says something is "out of scope," treat it as a description of live behavior.
Sometimes the next vulnerability is already documented in the previous fix.
The original privilege escalation
The original issue, CVE-2026-35595 (GHSA-2vq4-854f-5c72), involved project hierarchy and inherited permissions.
Suppose an attacker had Write access to somebody else's project.
They could re-parent that project beneath a project they personally owned.
Vikunja used a recursive permission CTE to calculate permissions through the project hierarchy. Once the victim project was moved beneath the attacker-controlled parent, the attacker's Admin permission cascaded down to it.
In other words:
Write access + re-parenting → Admin access
The initial fix addressed this by requiring Admin permission on both sides of a re-parent operation.
That was a sensible fix for the known vulnerability.
But the patch also included this comment:
// Only gate on non-zero ParentProjectID: the generic update handler
// binds a fresh struct, so an omitted parent_project_id is
// indistinguishable from an explicit 0. Detach-to-root is therefore
// out of scope here -- a proper fix needs a pointer field.// Only gate on non-zero ParentProjectID: the generic update handler
// binds a fresh struct, so an omitted parent_project_id is
// indistinguishable from an explicit 0. Detach-to-root is therefore
// out of scope here -- a proper fix needs a pointer field.The problem with ParentProjectID
ParentProjectID was represented as a plain int64.
In Go, the zero value of an int64 is 0.
The update handler also bound incoming requests into a fresh struct.
As a result, the application could not distinguish between these two requests:
{}{}and:
{
"parent_project_id": 0
}{
"parent_project_id": 0
}From the perspective of the resulting struct, both could produce:
ParentProjectID == 0ParentProjectID == 0But semantically, those requests mean very different things.
The first means:
Do not change the project's parent.
The second means:
Explicitly detach this project from its current parent and move it to the root.
The distinction takes on real significance in systems where authorization flows from hierarchy.
The guard only understood re-parenting
The authorization check introduced by the first fix only ran when the supplied parent ID was greater than zero.
Conceptually, the logic treated this as the security-sensitive case:
Project A
|
+--> move beneath Project BProject A
|
+--> move beneath Project BBut it did not treat this as equivalent:
Project A
|
X
detach to rootProject A
|
X
detach to rootSo a request containing:
{
"parent_project_id": 0
}{
"parent_project_id": 0
}did not pass through the new Admin-permission check.
That alone would not necessarily have been a vulnerability.
The important second half was that parent_project_id remained in the update column set unconditionally. So although the authorization guard did not consider the operation to be a parent change, the database update still did.
The write happened.
A user with only Write permission could therefore detach another user's project from its parent without having Admin rights.
Why detaching a project matters
In a hierarchical permission model, moving something to the root is not merely cosmetic. The project may be receiving permissions through its parent. Detaching it can sever that inheritance chain.
That means collaborators who previously reached the project through inherited permissions may suddenly lose access.
The vulnerability therefore was not simply:
A Write user can change a numeric parent field.
It was:
A Write user can perform an Admin-only hierarchy mutation that changes the project's effective authorization relationships.
CVE-2026-55064 CVSS: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N) CWE-862, Missing Authorization Affected: >= 2.3.0. Fixed in 2.4.0.
Its impact was lower than the original privilege escalation, but the underlying mistake is arguably more interesting.
The negative control makes the PoC meaningful
There is also an important lesson in how this kind of authorization bug should be demonstrated.
A successful request alone proves surprisingly little.
If the attacker changes parent_project_id and the API accepts it, you still need to establish that the account was not legitimately authorized to perform an equivalent administrative operation.
That is where a negative control matters.
Using the same account, in the same session:
- Attempt an operation that clearly requires Admin privileges, such as deleting the project.
- Observe the server returning
403 Forbidden. - Perform the vulnerable
parent_project_id: 0update. - Observe that the Admin-equivalent hierarchy change succeeds.
The 403 is what gives the exploit context.
It demonstrates that the account does not possess Admin rights, yet is still able to cross an authorization boundary through the vulnerable path.
In practice that looks like this. First, confirm the account lacks Admin:
curl -s -o /dev/null -w '%{http_code}' -X DELETE \
http://localhost:3456/api/v1/projects/4 \
-H "Authorization: Bearer $TOKEN"
# 403curl -s -o /dev/null -w '%{http_code}' -X DELETE \
http://localhost:3456/api/v1/projects/4 \
-H "Authorization: Bearer $TOKEN"
# 403Then perform the detach:
curl -s -X POST http://localhost:3456/api/v1/projects/4 \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Sensitive Child Project","parent_project_id":0}'curl -s -X POST http://localhost:3456/api/v1/projects/4 \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Sensitive Child Project","parent_project_id":0}'Across three independent runs, the DELETE returned 403 every time while parent_project_id moved from 3 to null. Same account, same session, same minute.
Two guards, one shared assumption
Forget the missing pointer. The real story here is the mental model behind the authorization checks.
Two separate guards were written around the idea of detecting a re-parent operation.
They were looking for something conceptually like:
old parent -> new parentold parent -> new parentBut they did not recognise:
old parent -> no parentold parent -> no parentas the same class of security-sensitive state transition.
That is the real variant-analysis lesson.
Authorization bugs frequently survive because security logic is written around a specific representation of an operation rather than its underlying effect.
"Move to another parent" and "detach from the current parent" look different at the API level.
From an authorization perspective, however, they are both mutations of the same trust boundary.
nil and 0 are not the same thing
Vikunja fixed the root cause in version 2.4.0, along the lines the original patch comment had already suggested.
That changes the model from something like:
ParentProjectID int64ParentProjectID int64to the conceptual equivalent of:
ParentProjectID *int64ParentProjectID *int64Now there are two representable states:
nilnilmeans:
The client did not provide this field.
While:
ptr(0)ptr(0)means:
The client explicitly asked to set the parent to zero.
Once the application can distinguish those states, the authorization layer can reason about them correctly.
This is a common API security problem.
For update endpoints, there is often a critical difference between:
- omitted,
- explicitly empty,
- explicitly zero,
- explicitly
null, - and unchanged.
If the binding layer collapses those states into the same representation, downstream authorization code may be unable to tell whether a sensitive mutation was requested.
Patch comments are attack-surface documentation
The original Vikunja developers understood the limitation and said so in the patch itself. The scope decision was reasonable: security patches are often deliberately narrow, and redesigning an entire update model while responding to a vulnerability introduces its own risk.
But that means security patches deserve to be reviewed differently from ordinary bug fixes. Do not only ask:
Does this patch stop the published exploit?
Also ask:
What definition of the dangerous operation does this patch assume?
Then look for equivalent state transitions outside that definition. If a guard protects A -> B, test:
A -> 0
A -> null
A -> root
A -> deleted parent
A -> archived parent
A -> self
A -> previous valueA -> 0
A -> null
A -> root
A -> deleted parent
A -> archived parent
A -> self
A -> previous valueThe interesting variants live at representation boundaries: zero values, omitted fields, nulls, empty arrays, defaults, sentinel values.
And when you are doing variant analysis on CVE-related commits, some of the cheapest greps available are: TODO, for now, out of scope, a proper fix would, follow-up, not handled, currently assumes, temporary, edge case. These mark places where maintainers consciously accepted incomplete behavior. Most will not be exploitable. Some describe the next vulnerability almost verbatim.
Pay particular attention when the patch author has already left you a map. Rather than hiding in some unrelated subsystem, the next vulnerability was sitting inside the previous security fix.
"Detach-to-root is therefore out of scope here."