← All articles

CVE-2026-18972: How an HTTP Header Became a Trusted Velociraptor Identity

A code-level analysis of the metadata collision that let a reader become a Velociraptor administrator.

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

CVSS 3.1 Base score

Critical

Rapid7 (CNA) score
Scoring details

Published vector

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

CVSS is owned by FIRST and used by permission.

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:

  1. The HTTP client cannot write the internal identity key.
  2. 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.

References