Featured image of post How to Capture and Analyze gRPC Packets Using Wireshark

How to Capture and Analyze gRPC Packets Using Wireshark

Step-by-Step Guide to Capturing gRPC Packets

 

Introduce

Wireshark is a popular tool for capturing network packets. It can not only capture packets itself but also parse packet files captured by tcpdump.
gRPC is a high-performance RPC framework developed by Google, based on the HTTP/2 protocol and the protobuf serialization protocol.
This article mainly introduces how to capture gRPC packets using Wireshark and parse the packet content.

This article was first published in the medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

Wireshark version: 4.2.2

Configuration

Since gRPC uses the protobuf serialization protocol, we need to add the protobuf file path.
Click Wireshark -> Preferences… -> Protocols -> Protobuf -> Protobuf search paths -> Edit…
Click + to add the path of your protobuf file. Don’t forget to check the Load all files on the right side.
Pasted image 20240511225144

Specific Operations

First, we write a simple gRPC service,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
syntax = "proto3";  
option go_package = "example.com/hxzhouh/go-example/grpc/helloworld/api";  
package api;  
  
// The greeting service definition.  
service Greeter {  
  // Sends a greeting  
  rpc SayHello (HelloRequest) returns (HelloReply) {}  
}  
  
// The request message containing the user's name.  
message HelloRequest {  
  string name = 1;  
}  
  
// The response message containing the greetings  
message HelloReply {  
  string message = 1;  
}

It has just one function Greeter. After completing the server-side code, run it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type server struct {  
    api.UnimplementedGreeterServer  
}  
  
func (s *server) SayHello(ctx context.Context, in *api.HelloRequest) (*api.HelloReply, error) {  
    log.Printf("Received: %v", in.GetName())  
    return &api.HelloReply{Message: "Hello " + in.GetName()}, nil  
}  
  
func main() {  
    lis, err := net.Listen("tcp", ":50051")  
    if err != nil {  
       log.Fatalf("failed to listen: %v", err)  
    }  
    s := grpc.NewServer()  
    api.RegisterGreeterServer(s, &server{})  
    if err := s.Serve(lis); err != nil {  
       log.Fatalf("failed to serve: %v", err)  
    }  
}

Then open Wireshark, select the local network card, and listen to tcp.port == 50051.

If you are new to Wireshark, I recommend reading this article first: https://www.lifewire.com/wireshark-tutorial-4143298

Pasted image 20240511230443
Pasted image 20240511230512

Unary Function

Now we have a gRPC service running on the local 50051 port. We can use BloomRPC or any other tool you like to initiate an RPC request to the server, or use the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func Test_server_SayHello(t *testing.T) {  
    // Set up a connection to the server.  
    conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure(), grpc.WithBlock())  
    if err != nil {  
       log.Fatalf("did not connect: %v", err)  
    }  
    defer conn.Close()  
    c := api.NewGreeterClient(conn)  
  
    // Contact the server and print out its response.  
    name := "Hello"  
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)  
    defer cancel()  
    r, err := c.SayHello(ctx, &api.HelloRequest{Name: name})  
    if err != nil {  
       log.Fatalf("could not greet: %v", err)  
    }  
    log.Printf("Greeting: %s", r.GetMessage())  
}

At this point, Wireshark should be able to capture the traffic packets.
Pasted image 20240511232027
As mentioned earlier, gRPC = HTTP2 + protobuf, and since we have already loaded the protobuf file, we should now be able to parse the packet.
Use the Wireshark shortcut shift+command+U or click Analyze -> Decode As... and set the packet to be decoded as HTTP2 format.
Pasted image 20240511232316
At this point, we can see the request clearly.

Pasted image 20240511232448
Pasted image 20240511232507
Pasted image 20240511232539

Metadata

We know that gRPC metadata is transmitted through HTTP2 headers. Now let’s verify this by capturing packets.
Slightly modify the client code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func Test_server_SayHello(t *testing.T) {  
    // Set up a connection to the server.  
   ..... 
   // add md 
    md := map[string][]string{"timestamp": {time.Now().Format(time.Stamp)}}  
    md["testmd"] = []string{"testmd"}  
    ctx := metadata.NewOutgoingContext(context.Background(), md)  
    // Contact the server and print out its response.  
    name := "Hello"  
    ctx, cancel := context.WithTimeout(ctx, time.Second)  
   ....
}

Then capture packets again. We can see that md is indeed placed in the header.
Pasted image 20240512154517

We also see grpc-timeout in the header, indicating that the request timeout is also placed in the header. The specific details may be covered in a dedicated article, but today we focus on packet capturing.

TLS

The examples above use plaintext transmission, and we used grpc.WithInsecure() when dialing. However, in a production environment, we generally use TLS for encrypted transmission. Detailed information can be found in my previous article.
https://medium.com/gitconnected/secure-communication-with-grpc-from-ssl-tls-certification-to-san-certification-d9464c3d706f
Let’s modify the server-side code:
https://gist.github.com/hxzhouh/e08546cf0457d28a614d59ec28870b11

 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
func main() {  
  
    certificate, err := tls.LoadX509KeyPair("./keys/server.crt", "./keys/server.key")  
    if err != nil {  
       log.Fatalf("Failed to load key pair: %v", err)  
    }  
    certPool := x509.NewCertPool()  
    ca, err := os.ReadFile("./keys/ca.crt")  
    if err != nil {  
       log.Fatalf("Failed to read ca: %v", err)  
    }  
  
    if ok := certPool.AppendCertsFromPEM(ca); !ok {  
       log.Fatalf("Failed to append ca certificate")  
    }  
  
    opts := []grpc.ServerOption{  
       grpc.Creds( // Enable TLS for all incoming connections  
          credentials.NewTLS(&tls.Config{  
             ClientAuth:   tls.RequireAndVerifyClientCert,  
             Certificates: []tls.Certificate{certificate},  
             ClientCAs:    certPool,  
          },  
          )),  
    }  
  
    listen, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", 50051))  
    if err != nil {  
       log.Fatalf("failed to listen %d port", 50051)  
    }  
    // Create a new gRPC server instance with the provided TLS server credentials
    s := grpc.NewServer(opts...)  
    api.RegisterGreeterServer(s, &server{})  
    log.Printf("server listening at %v", listen.Addr())  
    if err := s.Serve(listen); err != nil {  
       log.Fatalf("Failed to serve: %v", err)  
    }  
}

Now let’s also modify the client code:
https://gist.github.com/hxzhouh/46a7a31e2696b87fe6fb83c8ce7e036c

 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
func Test_server_SayHello(t *testing.T) {  
    certificate, err := tls.LoadX509KeyPair("./keys/client.crt", "./keys/client.key")  
    if err != nil {  
       log.Fatalf("Failed to load client key pair, %v", err)  
    }  
  
    certPool := x509.NewCertPool()  
    ca, err := os.ReadFile("./keys/ca.crt")  
    if err != nil {  
       log.Fatalf("Failed to read %s, error: %v", "./keys/ca.crt", err)  
    }  
  
    if ok := certPool.AppendCertsFromPEM(ca); !ok {  
       log.Fatalf("Failed to append ca certs")  
    }  
  
    opts := []grpc.DialOption{  
       grpc.WithTransportCredentials(credentials.NewTLS(  
          &tls.Config{  
             ServerName:   "localhost",  
             Certificates: []tls.Certificate{certificate},  
             RootCAs:      certPool,  
          })),  
    }  
    // Establish connection to the server
    conn, err := grpc.Dial("localhost:50051", opts...)  
    if err != nil {  
       log.Fatalf("Connect to %s failed", "localhost:50051")  
    }  
    defer conn.Close()  
  
    client := api.NewGreeterClient(conn)  
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)  
    defer cancel()  
    r, err := client.SayHello(ctx, &api.HelloRequest{Name: "Hello"})  
    if err != nil {  
       log.Printf("Failed to greet, error: %v", err)  
    } else {  
       log.Printf("Greeting: %v", r.GetMessage())  
    }  
}

At this point, we can capture packets again and use the same method to decode them. However, we will find that decoding as HTTP2 is no longer possible, but we can decode it as TLS1.3.

Pasted image 20240519210518
Pasted image 20240519211040

Conclusion

This article summarizes the basic process of using Wireshark to capture gRPC packets.
By capturing packets, we learn that gRPC parameter transmission is done through HTTP2 data frames, and CTX and other metadata are transmitted through headers. These concepts might be familiar, but hands-on experiments enhance understanding.
With TLS, we can achieve secure gRPC communication. In the next article, we will attempt to decrypt TLS packets.

References

Built with Hugo
Theme Stack designed by Jimmy