Reverse Engineering a Chat App's Protobuf Protocol and Building a Message Client
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
Reverse Engineering a Chat App’s Protobuf Protocol and Building a Message Client
I had some time off, and rather than idling I wanted to mess with something new — Protocol Buffers, which I’d barely touched. I asked around for apps that actually use protobuf in the wild, and a friend pointed me at a messaging app that turned out to be a tractable target. (I’ll call it “the app” throughout.)
Capturing the login traffic
The login response looked like this:

The captured request/response:


Reversing the login crypto
The goal was to work out m_e and m_d. There are several ways in; the fastest was to open the dex in jadx and search for the string m_e:

Familiar territory — AES and RSA. I hooked it with Frida using a script I already had lying around, and the dynamic details fell out:

For crypto specifically I generally go dynamic first — a Frida script usually surfaces everything immediately. Only if a key fails to show up do I drop into the native layer and go static, first locating the relevant strings, then the Java-layer logic, and finally either into the .so or a third-party JS/Lua framework.
In the before_doFinal output you can see the raw request data — even the AES key and IV are exposed. The phone number and password were arbitrary; the pwd field is plainly an unsalted MD5 of the plaintext.
The server response:

Converted with Python:
After analysis, the login scheme is:
m_din the request header is AES-encrypted.m_eis RSA-encrypted. The server holds the RSA private key; decryptingm_eyields a key used to AES-decryptm_d, after which the server readsm_d’s plaintext.- The response works the same way, with the app performing the decryption the server would.
After registering and logging in, the server returns the user profile, followed by two more requests using the same crypto for the group list and friend list. At that point Burp stopped seeing traffic, and Charles hit a connect error.
Capturing the chat stream
For the chat traffic I could have used Wireshark or tcpdump, but they’re too noisy for this. I switched to a small homemade SOCKS5 proxy written in Python, purpose-built for capturing TCP streams.

Everything the proxy captures is emitted as raw bytes, which is convenient for the next step:
b'\x1a\x12\x0fHeartBeatAckMsg\x1a\x07\x12\x053.
Print the bytes as hex, and converting hex back to binary is trivial.
Analyzing the chat protocol
protoc operates on binary, so the hex-to-binary conversion is the bridge. I pasted the hex into hexFiend:

Then parsed with protoc. Of two packets, one parsed and one failed outright. Why?
The app is packed with a commercial packer, so I unpacked it, dropped the dex into jadx, and statically confirmed protobuf usage — then used objection to hook the protobuf classes. Searching for HeartBeatAckMsg pinned the key class directly. That’s enough to reconstruct the .proto file. So why did one packet fail to parse?
Digging further with jadx + objection showed the app also uses Netty. Netty + protobuf is a common pairing for high-concurrency, efficient send/receive. The catch: when protobuf rides over Netty, the sender prepends two bytes that encode the protobuf payload length. Strip those, and the parse succeeds:


Oddly, data returned from the server sometimes needs one byte stripped and sometimes two — the heartbeat packet above being an example.
With parsing working, I moved to the jadx-generated proto classes:

The .proto format can be read straight off the generated code, and the field numbers match protoc’s output exactly. Other message types fall out the same way. One reconstructed .proto as an example:
syntax = "proto3";
message HeartBeatMsg{
string token = 1;
string proto_class_name = 2;
HeartBeatMsgContent body = 3;
}
message HeartBeatMsgContent{
string from_id = 1;
int32 device_type = 2;
int32 device_slave_type = 3;
int64 last_msg_sequence_id = 4;
int64 last_msg_receive_time = 5;
string version = 6;
int64 interface_up_time = 7;
string apiurl = 8;
string imurl = 9;
}
A few gotchas:
- The proto syntax version (
proto2vsproto3). stringfields usually need no extra handling, but there are severalintflavors — you have to resolve the correct one by parsing real data, because jadx can’t tell you.
Convert the .proto to Python:
protoc --python_out=. HeartBeatMsg.proto
This generates HeartBeatMsg_pb2.py, which you import and parse with:
import HeartBeatMsg_pb2 # import the generated class
hbm = HeartBeatMsg_pb2.HeartBeatMsg() # instantiate
hbm.ParseFromString(buffer[2:]) # parse; buffer is the captured bytes, so drop the 2-byte Netty header
print(hbm)
Result:

The other .proto files follow the same pattern — the pitfalls are all covered above.
Sending custom messages
With the protocol recovered, writing a sender is mechanical. The flow: log in over HTTP, grab a group/friend ID, then send/receive over TCP. To keep it short I reused the token from Burp:
import socket,uuid,time,google.protobuf
import AuthMsg_pb2,ChatMsg_pb2
skt = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
address = ("test.host.com",5000)
skt.connect(address)
token = {'isAuth': 1, 'nickName': '测试23242', 'headUrl': 'https://down.host.com/head/userhead.png', 'defaultId': '801adfafcc0d1afa0d', 'accessToken': 'tk:801adfafcc0d1afa0d:adfasdfaec0faf6a9', 'token': '1010e3d11973980c36ec8'} # token captured from traffic
# session auth
def auth():
am = AuthMsg_pb2.AuthMsg()
am.token = token['accessToken']
am.proto_class_name = "AuthMsg"
am.body.from_id = token['defaultId']
am.body.device_id = "861234123412" # device id
am.body.device_type = 1;
bnry = am.SerializeToString() # protobuf serialization
skt.send(addNettyHeader(bnry)); # send over the socket
# chat
def chat(news):
msgstr = ChatMsg_pb2.ChatMsg()
msgstr.token = userinfo['accessToken']
msgstr.proto_class_name = "ChatMsg"
msgstr.body.msg_id = "100-"+str(uuid.uuid1()).replace("-","") # message id
msgstr.body.main_type = 6 # fixed
if(news['type']==2): # image message
msgstr.body.slave_type = 3 # 1 plain text/emoji, 3 image, 2 voice
msgstr.body.content = news['txt']
elif(news['type']==1):
msgstr.body.slave_type = 1
msgstr.body.content = news['txt']
msgstr.body.from_id = userinfo['defaultId']
msgstr.body.from_name = userinfo['nickName']
msgstr.body.from_head_url = userinfo['headUrl']
msgstr.body.dest_id = news['to'] # recipient
msgstr.body.send_time = int(time.time()*1000)
msgstr.body.device_type = 1
msgstr.body.device_slave_type = 11
bnry = msgstr.SerializeToString()
skt.send(addNettyHeader(bnry))
# prepend the Netty header
def addNettyHeader(msg):
value = len(msg)
bits = value & 0x7f
value >>= 7
header_array = []
while value:
header_array.append(0x80 | bits)
bits = value & 0x7f
value >>= 7
header_array.append(bits)
return bytes(header_array)+msg
auth()
msg1 = {"type":1,"txt":"今天天气真好,有约吗?","to":123412}
msg2 = {"type":3,"txt":"https://img.host.com/test.jpg","to":123412}
chat(msg1)
chat(msg2)
It sends:

What the vendor got wrong
This was a fun run-through, but the point of documenting it is the defensive lessons — there are several real problems here:
- Client-side keys. The AES key and IV are recoverable by hooking the client, because the app derives or stores them on-device. A server that treats the client as a secure channel is the root flaw. The fix is white-box crypto (better than nothing) or, properly, a design that doesn’t put the key on the device at all.
- Unsalted MD5 for passwords. The
pwdfield is a plain MD5 of the password — no salt, no key-stretching. The server should use bcrypt/Argon2 with a per-user salt. - No application-layer integrity on the protocol. The protobuf + Netty messages can be constructed and injected by anyone who recovers the format, because there’s no signature or replay protection. The server should bind the token to the device, sign each message, and validate sequence/state.
- Over-privileged object-storage credentials. Media uploads go through a public cloud object-storage service using a short-lived credential — but that credential was scoped far too broadly. With it, the account’s full bucket listing was exposed, and upload/download/listing were all possible against media that should have been private. The fix is least-privilege, bucket-private, finely-scoped temporary credentials.
None of this is exotic — each item is a textbook mistake that a well-run platform shouldn’t ship with. That’s the value of walking the protocol end-to-end: you see exactly where the trust boundary was placed, and where it breaks.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch