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 |