猿人学 App 第八题「grpc」离机复现:手搓 protobuf + gRPC 通用 stub

本文最后更新于:2026年9月1日 下午

[猿人学 App 逆向系列]第八题「寻龙分金看缠山【grpc】」。请求走 gRPC(protobuf over HTTP/2)
这题不难但”形态新”:读懂 gRPC 的服务/方法/消息 schema 后,不需要 .proto、不需要 codegen——
手写 protobuf 编解码 + grpc 通用 stub 收发裸字节
即可离线取数。

⚠️ 猿人学闯关 App 是面向逆向练习的 CTF 式靶场;本文仅用于授权范围内的学习研究,请遵守相关服务条款与法律。

本文目录

  • 请求模型:一个 gRPC UNARY 调用
  • 拆 schema:服务 / 方法 / 消息字段
  • 明文还是 TLS?
  • 手搓 protobuf + gRPC 通用 stub

一、请求模型:一个 gRPC UNARY 调用

ChallengeEightFragment 翻页不是 Retrofit,而是一个 gRPC stub 调用:

1
2
3
4
5
6
7
stub.SayHello(
Req.newBuilder()
.setPage(page) // .OooO00o(page)
.setSign(sign(page, currentTimeMillis)) // .OooO0OO(...)
.setTimestamp(currentTimeMillis) // .OooO0Oo(ts)
.build(),
callback);

签名很简单(纯 Java):

1
2
3
4
MessageDigest md = MessageDigest.getInstance("MD5");   // 算法名是加密字符串,解出来是 "MD5"
md.update(String.valueOf(page).getBytes());
md.update(String.valueOf(ms).getBytes());
sign = Base64.encodeToString(md.digest(), 0); // flag 0 = DEFAULT -> 末尾带 "\n"

即 **sign = Base64(MD5(str(page) + str(ms))) + "\n"**。

二、拆 schema:服务 / 方法 / 消息字段

gRPC 的 MethodDescriptor 在反编译里写得明明白白:

1
2
3
4
MethodDescriptor.newBuilder()
.setType(UNARY)
.setFullMethodName(generateFullMethodName("challenge.Challenge", "SayHello"))
...
  • 方法/challenge.Challenge/SayHello(UNARY),即 HTTP/2 的 :path
  • 服务器ManagedChannelBuilder.forAddress(host, 50052)host 是加密字符串,解出来是 **47.95.8.136**(和第四题 UDP 同一台)。

消息字段号从 protobuf 生成类的 writeTo 直接读:

1
2
3
4
5
6
7
// 请求 Req.writeTo
codedOutputStream.writeUInt32(1, page); // field 1 = page
codedOutputStream.writeUInt64(2, ts); // field 2 = timestamp
codedOutputStream.writeString(3, sign); // field 3 = sign

// 响应 Resp.writeTo: repeated Item items = 1;
// 每个 Item.writeTo: string value = 1;

于是请求/响应的 protobuf 结构完全清楚了。

三、明文还是 TLS?

channel 的构造是 forAddress(host, 50052).usePlaintext().build()——明文 h2c,没有 TLS(”so+tls” 是隔壁第六题的事)。这省掉了证书/pinning 的麻烦,直接连就行。

四、手搓 protobuf + gRPC 通用 stub

关键点:不需要 .proto 文件、不需要 protoc 生成代码。Python 的 grpcio 支持”通用调用”——自己序列化/反序列化裸 bytes,grpc 负责 HTTP/2 帧、gRPC 分帧([flag][4字节长度][protobuf])、trailers。

protobuf 只有几种线格式,手写编解码足够:

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
import grpc, hashlib, base64, time

def varint(n):
o=b""
while True:
b=n&0x7f; n>>=7; o+=bytes([b|(0x80 if n else 0)])
if not n: break
return o

def build_request(page, ms):
sign=(base64.b64encode(hashlib.md5(str(page).encode()+str(ms).encode()).digest()).decode()+"\n").encode()
return (b"\x08"+varint(page) # f1 page (tag 0x08 = field1,varint)
+ b"\x10"+varint(ms) # f2 timestamp (tag 0x10)
+ b"\x1a"+varint(len(sign))+sign) # f3 sign (tag 0x1a = field3,len-delim)

def parse_response(data): # f1 repeated Item; Item f1 = string
vals=[]; i=0
while i<len(data):
if data[i]!=0x0a: break
i+=1; ln,i=readvarint(data,i); msg=data[i:i+ln]; i+=ln; j=0
while j<len(msg):
if msg[j]!=0x0a: break
j+=1; l2,j=readvarint(msg,j); vals.append(int(msg[j:j+l2])); j+=l2
return vals

ch=grpc.insecure_channel("47.95.8.136:50052") # 明文 h2c
call=ch.unary_unary("/challenge.Challenge/SayHello",
request_serializer=lambda b:b, response_deserializer=lambda b:b)

ms=int(time.time()*1000)
print(parse_response(call(build_request(1, ms), timeout=15))) # -> 该页 20 个数

跑起来,page 1 返回的 20 个数与真机屏幕上显示的一模一样。逐页取即可离线复现(每页 20 个数)。服务器不校验时间戳新鲜度、也不封页码,sign 只要与 page/ts 自洽即可。

小结

第八题的”难”只在形态——把标准 HTTP 换成了 gRPC。破法是”读 schema 而非抓包”:MethodDescriptor 给出服务/方法名,protobuf 生成类的 writeTo 给出字段号,加密字符串解出服务器地址。拿到这些,用 grpcio 的通用 stub + 手写 protobuf 就能离线收发,连 .proto 都不用。相比第六题(native+TLS+反调试)那种硬骨头,gRPC 这层”协议壳”其实是最薄的一层。


猿人学 App 第八题「grpc」离机复现:手搓 protobuf + gRPC 通用 stub
https://kingjem.github.io/2026/09/01/猿人学App第八题离机复现-grpc手搓protobuf/
作者
Ruhai
发布于
2026年9月1日
许可协议