gRPC 服务安全加固实战:反射禁用与 mTLS 配置

gRPC 服务安全加固是微服务架构里最常被跳过的一步:接口默认以明文 h2c 暴露、Server Reflection 把全部方法签名公开、metadata 里没有凭证校验、单条流不限长度。攻击者拿到内网一个 IP 和端口,就能用 grpcurl 列出所有服务方法并直接调用。本文给出一套完整的 gRPC 服务安全加固配置,覆盖服务端拦截器、Nginx 网关、mTLS 双向认证与限流。

适用场景

  • 微服务之间使用 gRPC 通信,服务端口在宿主机 0.0.0.0:50051 上监听,内网或公网可达。
  • 后端为 App、小程序、前端 BFF 提供 gRPC / gRPC-Web 接口。
  • 服务使用 protobuf 定义接口,但缺乏接口鉴权、访问控制与调用频次限制。
  • 需要满足等保或安全基线的南北向接口管控要求,为 gRPC 入口补上网关层审计与限流。

前置条件

  • gRPC 服务端(Go / Java / Python 均可),已定义 .proto 文件。
  • Nginx ≥ 1.13.10(grpc_pass 指令自该版本起支持)或 Envoy 作为网关。
  • 可用的域名与证书,私有 CA 亦可(用于 mTLS 的客户端证书签发)。
  • 调试机安装 grpcurl、openssl,用于暴露面自查与验证。

原理说明

gRPC 建立在 HTTP/2 之上,使用长度前缀的二进制帧传输 protobuf 消息。这套设计带来三个默认不安全的行为:

  • 明文传输:Go 的 grpc.NewServer() 默认不启用 TLS,即 h2c 明文模式。内网嗅探即可读取全部请求体,包含业务数据与 token。
  • 接口自描述暴露:服务端若注册了 reflection.Register(s),任何人都可以像使用本地 proto 一样列出服务、方法、字段类型,并直接构造请求调用,攻击门槛极低。
  • 无连接态鉴权:gRPC 没有 Cookie 会话概念,鉴权依赖 metadata(等价于 HTTP 头)。若服务端只做 context 透传而不校验,任何持有内网可达地址的人都能调用。

因此加固思路是分层的:先关掉自描述能力,再强制加密与身份校验,最后在网关上做访问控制与限流。三层中任意一层单独实施都不足以防御内网横向移动。

操作步骤

步骤一:以攻击者视角自查暴露面

先确认当前风险,再动手改配置:

# 探测是否明文可达并列出服务
grpcurl -plaintext 10.0.0.10:50051 list

# 列出方法签名(Reflection 开启时可读)
grpcurl -plaintext 10.0.0.10:50051 list myapp.UserService

# 直接调用,验证是否存在无鉴权访问
grpcurl -plaintext -d '{"user_id":"1"}' 10.0.0.10:50051 myapp.UserService/GetUser

若上述命令在非授权机器上返回服务列表或业务数据,说明服务缺少加密、反射与鉴权三层防护。同时检查监听地址:

ss -lntp | grep -E '50051|50052'
# 期望只看到 127.0.0.1 或内网网卡,不应出现 0.0.0.0

步骤二:服务端强制 TLS 并关闭反射

Go 服务端的核心改动是:注册 TLS 凭证、移除 reflection、挂载鉴权拦截器,并限制单条消息大小:

package main

import (
    "context"
    "crypto/tls"
    "crypto/x509"
    "os"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/metadata"
    "google.golang.org/grpc/status"
    "google.golang.org/grpc/codes"
)

func authInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler) (interface{}, error) {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Error(codes.Unauthenticated, "missing metadata")
    }
    tokens := md.Get("authorization")
    if len(tokens) == 0 || !verifyToken(tokens[0]) { // verifyToken 校验签名与有效期
        return nil, status.Error(codes.Unauthenticated, "invalid token")
    }
    return handler(ctx, req)
}

func main() {
    cert, _ := tls.LoadX509KeyPair("/etc/grpc/server.crt", "/etc/grpc/server.key")
    // 强制校验客户端证书,实现 mTLS
    pool := x509.NewCertPool()
    ca, _ := os.ReadFile("/etc/grpc/ca.crt")
    pool.AppendCertsFromPEM(ca)

    creds := credentials.NewTLS(&tls.Config{
        Certificates: []tls.Certificate{cert},
        ClientCAs:    pool,
        ClientAuth:   tls.RequireAndVerifyClientCert,
        MinVersion:   tls.VersionTLS12,
    })

    s := grpc.NewServer(
        grpc.Creds(creds),
        grpc.UnaryInterceptor(authInterceptor),
        grpc.MaxRecvMsgSize(4 << 20),   // 单条消息上限 4MB
        grpc.MaxConcurrentStreams(128),
    )
    registerServices(s)
    // 生产环境不要注册 reflection:
    // reflection.Register(s)
    lis, _ := net.Listen("tcp", "127.0.0.1:50051")  // 只监听本机,由网关转发
    s.Serve(lis)
}

要点:ClientAuth 必须设为 RequireAndVerifyClientCert 而非 VerifyClientCertIfGiven,后者在客户端不提供证书时仍会放行;监听地址改为 127.0.0.1,让外部流量必须经过网关。

步骤三:Nginx 网关做 TLS 终结、路径白名单与限流

# /etc/nginx/conf.d/grpc-gateway.conf
limit_req_zone  $binary_remote_addr zone=grpc:10m rate=50r/s;
limit_conn_zone $binary_remote_addr zone=grpcconn:10m;

server {
    listen 443 ssl;
    http2 on;
    server_name api.example.com;

    ssl_certificate     /etc/nginx/certs/server.crt;
    ssl_certificate_key /etc/nginx/certs/server.key;
    ssl_client_certificate /etc/nginx/certs/ca.crt;
    ssl_verify_client   on;              # 强制 mTLS
    ssl_protocols       TLSv1.2 TLSv1.3;

    client_max_body_size 4m;

    # 拒绝反射服务:这是攻击者列举接口的入口
    location /grpc.reflection. { deny all; }

    # 健康检查单独放行,不暴露业务方法
    location /grpc.health.v1.Health/ {
        grpc_pass grpcs://127.0.0.1:50051;
    }

    # 业务方法白名单:只允许显式声明的方法通过
    location ~ ^/myapp\.(User|Order)Service/ {
        limit_req  zone=grpc burst=100 nodelay;
        limit_conn grpcconn 20;

        grpc_pass            grpcs://127.0.0.1:50051;
        grpc_set_header      X-Real-IP $remote_addr;
        grpc_read_timeout    30s;
        grpc_send_timeout    30s;
        grpc_buffer_size     8k;
    }

    # 其余 gRPC 路径一律拒绝
    location / {
        return 404;
    }
}

路径白名单比黑名单更安全:只允许业务实际使用的方法前缀,未列出的服务即使被部署进进程也无法从外部调用,等于把暴露面收敛到最小集合。

步骤四:网络层收敛端口与出向访问

# 只允许网关节点访问后端 gRPC 端口(iptables 示例)
iptables -A INPUT -p tcp --dport 50051 -s 10.0.0.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport 50051 -j DROP

# 云服务器同步收紧安全组:50051 不放行公网,仅内网网段

# 服务端自身出向也做限制,降低被入侵后的横向移动能力
iptables -A OUTPUT -p tcp --dport 50051 -d 10.0.0.0/24 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 50051 -j DROP

步骤五:为调用方签发客户端证书

# 自建 CA(若已有企业 CA 可直接签发)
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
  -keyout /etc/grpc/ca.key -out /etc/grpc/ca.crt -subj "/CN=grpc-internal-ca"

# 为每个调用方签发独立客户端证书,CN 用于标识调用方身份
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=order-service"
openssl x509 -req -in client.csr -CA /etc/grpc/ca.crt -CAkey /etc/grpc/ca.key \
  -CAcreateserial -out client.crt -days 365 -sha256

# 服务端可基于 CN 做细粒度授权
openssl x509 -in client.crt -noout -subject

配置验证

  • 明文调用应失败:grpcurl -plaintext api.example.com:443 list 应返回 tls: first record does not look like a TLS handshake 或握手失败。
  • 反射应被禁用:带证书调用 grpcurl -cacert ca.crt -cert client.crt -key client.key api.example.com:443 list,应返回 server does not support the reflection API。
  • 不带客户端证书应握手失败:openssl s_client -connect api.example.com:443 -alpn h2 -servername api.example.com,输出中不应出现 Verify return code: 0 (ok);应看到服务端要求客户端证书。
  • 协议协商正确:上述 openssl 输出应包含 ALPN protocol: h2;若为 http/1.1,说明 http2 on 未生效,gRPC 调用会失败。
  • 限流生效:for i in $(seq 1 200); do grpcurl -d '{}' ... myapp.UserService/GetUser >/dev/null; done,观察出现 Unavailable 或 Nginx 返回 503,且 /var/log/nginx/error.log 中记录 limiting requests。
  • 未授权方法被拒:调用白名单外的方法(如 myapp.AdminService/List),应返回 404 而非 Unimplemented,确认请求在网关层即被丢弃。

常见问题(FAQ)

Q1:服务只在 Kubernetes 内网通信,还需要 mTLS 吗?

需要。集群内网络默认是扁平可达的,任何被入侵的 Pod 都能直接访问其他服务的 50051 端口;容器逃逸或配置错误的 Service 也可能把 gRPC 端口暴露出去。mTLS 的价值不只是加密,更在于把「能连通」转化为「有身份」——没有合法客户端证书的调用在 TLS 握手阶段就被拒绝,无需业务代码参与。若集群已有服务网格,可用其自动注入的 mTLS,但要确认 PeerAuthentication 处于 STRICT 模式而非默认的 PERMISSIVE。

Q2:禁用 Server Reflection 之后,开发和排障怎么做?

三种做法并存:开发环境保留 reflection,生产环境移除,靠配置开关控制;调试时用 grpcurl -proto ./proto/user.proto 直接在本地加载 proto 文件,不依赖服务端反射;排障优先使用网关访问日志与 OpenTelemetry 链路数据,而不是列表接口。凡是能在生产环境列出全部方法签名的能力,对攻击者同样好用,属于必须关闭的信息暴露项。

Q3:gRPC 流量能交给传统 WAF 检查吗?

能力有限。传统七层 WAF 主要解析 HTTP/1.1 与部分 HTTP/2 帧,而 gRPC 的 protobuf 消息体是二进制,字段没有明文键名,规则匹配几乎无效。可行方案是在网关侧做协议转换(gRPC-Web / REST 转码)后再交给 WAF,或把 gRPC 的访问控制、限流、mTLS 与审计放在 Envoy / Nginx 这一层实现,WAF 只负责南北向的 HTTP 入口。

Q4:限流应该按连接还是按请求?

两者都要。limit_conn 限制并发流数量,防止单客户端开大量长连接或服务端流占据连接池;limit_req 限制请求速率,防止单连接高频调用。对于服务端流(server streaming)接口,还需单独设置 grpc_read_timeout 与消息大小上限,避免单条流长时间占用资源。

总结

gRPC 服务安全加固的优先级是:先关闭反射、再开 mTLS、然后加网关白名单与限流、最后收敛网络可达性。反射关闭解决接口枚举,mTLS 解决身份伪造与内网嗅探,路径白名单解决未授权方法调用,端口收敛解决横向移动。四层中任何一层缺失,都会让其余三层形同虚设——例如只开 mTLS 但保留反射,攻击者仍可借助合法客户端证书完成完整接口测绘。把这些配置写进部署模板并纳入安全基线检查,才能避免每次发版后防护回退。