← All articles

CVE-2026-77798: One Missing Unlock Wedged Velociraptor Authentication

A missing unlock on an error path let an authenticated analyst deadlock Velociraptor's shared user storage while the frontend still appeared alive.

CVE
CVE-2026-77798
Product
Rapid7 Velociraptor
Affected versions
Linux releases before 0.77.2
Fixed versions
0.77.2
Status
Patched
6.5/ 10

CVSS 3.1 Base score

Medium

Rapid7 (CNA) score
Scoring details

Published vector

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

CVSS is owned by FIRST and used by permission.

The short version

An error path in Velociraptor 0.77.1 returned while still holding the shared user-storage mutex. A stock analyst could trigger that path with one user_message() call referencing a nonexistent user. Authentication and other user-service operations then blocked until the frontend restarted.

The process remained alive and continued answering requests that did not need user lookup, so process-only health checks could miss the failure. The issue is tracked as CVE-2026-77798.

At a glance

Field Detail
Tested version Velociraptor 0.77.1, commit 3137c7f71
Fixed version 0.77.2
Vulnerability class Deadlock, CWE-833
Vendor affected range Velociraptor on Linux before 0.77.2
Required access Authenticated analyst with notebook/VQL access
Demonstrated impact Authentication and user operations hang until restart
Researcher estimate High; availability only
Credits Yuval Miller and Leon Kayaliev

Reproduction

From an ordinary analyst notebook, the following query selected a username known not to exist:

SELECT user_message(
  user='no-such-user-zzz',
  message=dict(test='DEADLOCK_TEST')) AS Result
FROM scope()

The message value must be a dictionary. Supplying a string fails argument parsing before the vulnerable locking path and produces a false negative.

After the call logged a NotFoundError, three probes distinguished the failure:

Authenticated user operation -> blocked
Unauthenticated HTTP probe    -> remained responsive
After frontend restart       -> authenticated operation recovered

The frontend was therefore alive and serving anonymous responses while every operation that needed to resolve a user identity waited indefinitely. Restarting the frontend restored service, after which the authenticated actor could repeat the trigger.

How I isolated the failure

The important part of this test was separating a deadlock from a crashed or unreachable frontend. I first confirmed that the malformed message control never entered the relevant code. I then used a dictionary so the request reached the missing-user branch and immediately followed it with two different probes: one that required user lookup and one that did not.

That differential result narrowed the failure to shared user state. The anonymous response showed that the listener and HTTP stack were responsive; the authenticated timeout showed that progress stopped when the request needed the user store. Restart recovery and retriggering completed the causal chain without relying on process-liveness assumptions.

Root cause

The affected storage code followed this shape:

self.mu.Lock()
cache, present := self.cache[key]
if !present || cache.user_record == nil {
    return utils.NotFoundError
}
// ...
self.mu.Unlock()

The missing-user branch returned before the unlock. Authentication later called through GetUser to GetUserWithHashes, which attempted to acquire the same mutex and blocked.

The availability impact was broader than the individual request because the lock protected shared user state. The process did not crash, making the failure less visible to supervision that checked only process liveness or a public endpoint.

Exact vulnerable branch

The lock leak is in the "Message" branch of services/users/storage.go:handleMessageEvent(). The branch acquired self.mu, looked up the destination user’s cached record, and returned NotFoundError when the record was absent. The unlock appeared only after that conditional return.

The trigger reached this code through vql/server/users/message.go:UserMessageFunction.Call(), which passed the dictionary argument to UserManager.MessageUser. The argument type matters: a string is rejected during VQL argument parsing and never exercises the locking branch.

The process remained alive because the leaked mutex protected user storage, not the HTTP listener itself. Authentication subsequently called back into the same storage layer and waited forever. This is a useful availability-testing lesson: a successful TCP connection or anonymous HTTP response is not a health check for an application whose authenticated path depends on shared state.

Impact boundaries

This was an availability-only finding. The research did not demonstrate memory corruption, disclosure of user data, or code execution. Recovery required a frontend restart, and an authorized attacker could retrigger the condition.

Remediation and detection

Upgrade to Velociraptor 0.77.2 or later. The fix in b64d9154 explicitly unlocks the mutex before returning NotFoundError. Deferring unlock immediately after acquisition and reducing work inside the critical section would provide stronger structural protection. Adding an explicit authorization requirement to MessageUser is additional hardening; it was not part of this fix.

Operational monitoring should test an authenticated request, not only process liveness or an endpoint that returns 401 without consulting user storage. Repeated missing-user user_message errors immediately before widespread authentication timeouts are useful investigation context.

An especially useful alert sequence is a NotFoundError for a message target, followed by concurrent authentication latency while the public HTTP probe stays healthy. Operators should preserve goroutine dumps before restarting when that is operationally safe; blocked stacks at the user-store mutex turn an apparent network outage into an attributable concurrency failure.

Disclosure timeline

Date Event
2026-08-05 Vulnerability reported to Rapid7
2026-09-29 Rapid7 confirmed remediation and linked the published advisory

References