The vulnerable boundary sits where Velociraptor carries a GUI identity across its HTTP-to-gRPC boundary. The login itself worked correctly. The problem was what happened afterward: grpc-gateway accepted an identity value from an HTTP header, Velociraptor added its authenticated identity under the same metadata key, and the gRPC service trusted the first value.
That gave a GUI user with at least the stock reader permissions control over
the principal used by the authorization layer. I reproduced it with the stock
reader role and used the
primitive to grant that same reader account the administrator role. The
grant was persistent; subsequent sessions no longer needed the malicious
header.
Rapid7 fixed the issue in 0.77.2. It is tracked as CVE-2026-18972 and credited to Yuval Miller and Leon Kayaliev.
Scope and test conditions
| Item | Value |
|---|---|
| Product | Rapid7 Velociraptor |
| Version tested | 0.77.1, official Linux build, commit 3137c7f714ab344dd37d0df1d5393573e41b30a5 |
| Vendor affected range | Linux releases before 0.77.2 |
| Attacker | Authenticated GUI user with the stock reader role |
| Target operation | SetUserRoles |
| Result | Persistent self-promotion to administrator |
| Fixed version | 0.77.2 |
| Weakness | CWE-290, authentication bypass by spoofing |
This was not an unauthenticated login bypass. The attacker needed a valid authenticated GUI request and still had to satisfy the ordinary CSRF controls. The issue was that the effective authorization identity differed from the identity established by the HTTP authenticator.
The boundary I was reviewing
Velociraptor exposes an HTTP API to the GUI and relays those calls to an
internal gRPC service. The gateway authenticates the browser, serializes a
small VelociraptorUser object, and stores it in the HTTP request context.
In the basic-auth path, the relevant operation is equivalent to:
authenticated := VelociraptorUser{Name: userRecord.Name}
requestContext[GRPC_USER_CONTEXT] = JSON(authenticated)
The actual implementation is in
api/authenticators/basic.go.
At this point the state is correct: the identity came from the authenticated
user record, not from a request parameter.
The gateway then converts the HTTP request into a gRPC call. Velociraptor’s
metadata annotator reads the serialized identity from the request context and
adds it under the gRPC metadata key USER:
trustedMetadata["USER"] = quote(authenticatedUserJSON)
See
api/proxy.go.
The design assumes that only this annotator can create USER. That assumption
was false because the same gateway also processed request headers with
grpc-gateway’s default matcher.
The collision in grpc-gateway
Velociraptor 0.77.1 created runtime.NewServeMux() without an incoming-header
matcher. grpc-gateway therefore installed DefaultHeaderMatcher.
That default has a documented feature: an HTTP header beginning with
Grpc-Metadata- is forwarded as gRPC metadata after the prefix is removed.
Consequently:
HTTP header: Grpc-Metadata-USER: {"name":"admin"}
gRPC key: USER
gRPC value: {"name":"admin"}
This behavior is visible in grpc-gateway’s
DefaultHeaderMatcher
and is intentional framework behavior. The vulnerability came from using the
default matcher while also treating one of those metadata names as a private
authentication channel.
The ordering is important. grpc-gateway’s
annotateContext
first iterates over
the HTTP headers accepted by the matcher. Only afterward does it join metadata
returned by application annotators. In simplified form, version 2.18.0 did
this:
md := metadataFromAcceptedHTTPHeaders(request)
md = metadata.Join(md, metadataFromVelociraptorContext(request))
With a malicious header, the resulting metadata was not an overwritten scalar. It was a multi-value key:
USER[0] = {"name":"admin"} // supplied by the HTTP client
USER[1] = {"name":"READER"} // supplied by Velociraptor after login
Velociraptor refers to the logical key as USER in source. grpc-go normalizes
metadata keys to lowercase internally, so the stored map key is user and
md.Get("USER") works because the lookup is normalized as well.
The trusted value still existed, which can make this bug easy to miss during a casual review. The failure was caused by the consumer’s handling of the collision.
The consumer selected the attacker-controlled value
The gRPC server first verified the connection certificate. If the caller was Velociraptor’s dedicated gateway identity, it allowed the gateway to state which web user had authenticated. That trust relationship is reasonable: the gRPC service needs the HTTP gateway to translate browser authentication into a principal.
The vulnerable selection logic reduced to:
values := incomingMetadata.Get("USER")
if len(values) > 0 {
identity = decode(values[0])
}
The source is
services/users/grpc.go.
Because the HTTP-derived value was first, values[0] decoded to the attacker’s
JSON object. The gateway’s mutual-TLS certificate proved that the request came
through the real gateway. It did not prove that every metadata value had been
created by that gateway.
This distinction is the core of the issue:
Trusted transport != trusted field provenance
The backend authenticated the channel, then assigned application-level trust to data that the channel had copied from an untrusted source.
Why SetUserRoles turned spoofing into persistence
Identity spoofing becomes more serious when it reaches an operation that can
change durable authorization state. SetUserRoles provided a clean negative
control and a result that could be verified through the stored role.
The handler obtains the user from the gRPC context and assigns its name to
principal:
user := GetUserFromContext(ctx)
principal := user.Name
AddUserToOrg(principal, requestedUser, requestedRoles)
See
api/users.go.
AddUserToOrg checks whether that principal has ORG_ADMIN in the root
organization or SERVER_ADMIN in every affected organization. Those checks
were present and functioning. They simply evaluated the spoofed administrator
name rather than the authenticated reader. The permission enforcement can be
seen in
services/users/add_org.go.
This is why describing the bug as a “missing permission check” would be misleading. The sensitive sink did check permissions. The principal reaching that check had already been corrupted.
Reproduction
I created a new user with only the built-in reader role and sent the request
through the authenticated GUI API with valid Basic credentials and CSRF
material.
Negative control
First I sent the role update without custom gRPC metadata:
POST /api/v1/SetUserRoles HTTP/1.1
Host: velociraptor.lab
Authorization: Basic <reader credentials, redacted>
Cookie: _gorilla_csrf=<redacted>
X-CSRF-Token: <redacted>
Content-Type: application/json
{"name":"READER","org":"root","roles":["administrator"],"permissions":[]}
Velociraptor denied the request. No role changed. That result established that the reader could not reach the operation through the intended authorization path.
Identity injection
I repeated the same request with one additional header:
Grpc-Metadata-USER: {"name":"admin"}
On 0.77.1, the request succeeded and stored administrator for READER. The
account retained that role after the spoofed request. The result was therefore
not limited to one response under the forged context; the request changed
durable authorization state.
End-to-end data flow
The complete path is short enough to state precisely:
1. Reader authenticates successfully to the HTTP GUI.
2. Authenticator stores {"name":"READER"} in the request context.
3. Client header Grpc-Metadata-USER is accepted by DefaultHeaderMatcher.
4. grpc-gateway maps that header to the gRPC key USER.
5. Velociraptor's annotator appends its trusted USER value.
6. Incoming metadata now contains [attacker, authenticated].
7. GetGRPCUserInfo decodes index zero as the caller.
8. SetUserRoles checks the spoofed admin principal.
9. The reader account receives a persistent administrator role.
There was no need to guess an administrator password, steal an administrator cookie, forge the gateway certificate, or exploit memory corruption. The demonstrated path required the reader’s own credentials, valid CSRF material, and the name of an administrative user.
Patch analysis
The 0.77.2 fix was merged in Velociraptor PR #4962. It changed gateway ingress and the backend metadata consumer.
1. Replace the permissive header matcher
The gateway now installs an explicit incoming-header matcher. It accepts only
the organization-selection header required by the GUI and rejects every other
candidate, including Grpc-Metadata-USER:
runtime.WithIncomingHeaderMatcher(func(in string) (string, bool) {
if strings.ToLower(in) == "grpc-metadata-orgid" {
return "OrgId", true
}
return "", false
})
See the fixed
api/proxy.go.
This is the primary fix. It prevents an HTTP client from creating a USER
metadata value at all. An allowlist is preferable here because future internal
metadata keys are denied automatically rather than exposed automatically.
2. Reject ambiguous identity metadata
The backend condition changed from accepting one-or-more USER values to
accepting exactly one:
- if len(userinfo) > 0 {
+ if len(userinfo) == 1 {
The same rule was applied to OrgId. See the fixed
services/users/grpc.go.
This is useful defense in depth. Even if another code path creates duplicate identity metadata later, the backend no longer resolves the ambiguity by trusting whichever value happens to be first.
Why the two changes complement each other
Filtering only Grpc-Metadata-USER at a reverse proxy would block the known
input but leave the application’s trust model implicit. Checking only
len(USER) == 1 would reject the demonstrated collision but still allow a
future path to supply a single attacker-controlled value.
The patch establishes two independent invariants:
- The HTTP client cannot write the internal identity key.
- The identity consumer refuses multiple candidates.
That is the right pattern for authentication metadata: restrict who can produce it and fail closed when its provenance is ambiguous.
Audit and detection considerations
The durable indicator is the authorization change, not the malicious header. Reverse proxies and application logs may not retain arbitrary request headers, but the role assigned to the low-privileged account remains after exploitation.
Review user_grant audit events and compare the recorded principal with:
- The HTTP-authenticated identity observed by the external authentication layer.
- The principal’s effective permissions immediately before the grant.
- The target account and roles written by the operation.
- Reverse-proxy records containing
Grpc-Metadata-*headers.
There is an attribution trap in this bug. SetUserRoles logs the principal
derived from the already-spoofed gRPC context. An audit event may therefore
name the impersonated administrator rather than the reader who sent the HTTP
request. Authentication and reverse-proxy logs are needed to reconstruct the
disagreement across the boundary.
For temporary protection on an older deployment, Rapid7 recommends blocking
Grpc-Metadata-USER at the reverse proxy. That mitigates this input but should
not replace upgrading to 0.77.2 or later.
Takeaway
The interesting part of CVE-2026-18972 is not that a header was trusted. It is that two independently reasonable mechanisms composed into an unsafe identity channel:
- grpc-gateway intentionally maps prefixed HTTP headers into gRPC metadata.
- Velociraptor intentionally lets its authenticated gateway assert a GUI user.
- gRPC metadata intentionally supports repeated values.
- The consumer intentionally selected the first value.
None of those facts is enough on its own. Together, and in that exact order, they let an untrusted HTTP value arrive first in a metadata field reserved for the trusted gateway.
When authorization depends on metadata added by a proxy, that metadata needs an exclusive producer. If duplicates are possible, they are an authentication error-not a list from which the application should choose a convenient value.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-08-03 | Report submitted to Rapid7 |
| 2026-08-07 | Fix merged in Velociraptor PR #4962 |
| 2026-08-11 | Rapid7 confirmed the 0.77.2 fix and CVE publication by email |
The public advisory currently displays a publication date of 2026-07-31, which predates the report date in the disclosure correspondence. That inconsistency is preserved here rather than silently reconciling the records.