TikTok alternative RedNote (Xiaohongshu) fails basic security measures

23/02/25
Reverse engineering RedNote's app to identify security and privacy concerns

Amidst the TikTok ban in early 2025, the Chinese-based app RedNote (Xiaohongshu) began topping charts as an abundance of Americans decided to migrate to a different Chinese app in retaliation over the TikTok ban. It got so much attention that news outlets like CNN and USA TODAY reported on the situation essentially making it known as the de facto TikTok alternative. Newly-acquired foreign user-base aside, the amount of Chinese users this app has garnered is enough to make it a household name. It has even made its way to my circle, of which one of my Chinese friends told me "Girl i'm actually addicted to it".

For those only interested in the tea; there is a TL;DR at the bottom of the page, briefly summarizing the issue with RedNote's security.

Screenshot of Google Trends for the term `rednote', showing a large spike

In this blog post I will criticize some of RedNote's design choices, namely their online network communication and its lack of proper cryptographic practices. In particular I will be highlighting some faults in their security in spite of their claims of "robust security measures".

Despite all my concerns, and by reading this article hopefully also yours, I would like this post not to be taken in the wrong way. This should not deter from the message the community is sending for a baseless ban of an app for simply being in China; a big fat middle finger to the US government. It is also important to mention how wholesome it is that the Chinese users are wholeheartedly welcoming the dubbed "TikTok refugees" into their community.

Encrypted CDN, Plaintext Control

RedNote has interesting approaches to what is encrypted and what stays in plaintext over the wire. As an example, if I were to post a video the upload seems to happen over GQUIC, which we will assume to be encrypted by the GQUIC standard. However, if I follow someone or send a message to someone, that information is fully unencrypted. Consequently, that implies that any adversary listening in can see who you are interested in, who you are talking to, and what you are talking about.

You may argue that this is not a big issue and that most people will not discuss anything super secret over RedNote. However, this still undermines the notion of security you should expect from day-to-day services, especially from one valued around $20 Billion.

Clicking the follow button on a social media app, there is an assumption that that action is restricted to the scope of the app. So for someone to know you followed that person they would have to check your activity within the app. But since the information is unencrypted, anyone listening in to your network traffic can log that action without ever needing to even download RedNote. This is concerning for many reasons.

Whether this is a personal concern to you or not we should all be advocating for the protection of our basic human right to privacy. In case you're still not convinced, I highly recommend reading even the tiniest bit of this page by The Tor Project, which highlights some reasons why keeping your information safe should be a priority even if you don't have anything to hide.

The Story

About two weeks ago I had the idea to take a look at some apps and investigate their security, or lack thereof. I chose RedNote exactly for the reasons highlighted in the preface; it is an app that is growing in popularity due to the (attempted) ban of TikTok in the US, so why not see if it lives up to the security standards we expect from social media nowadays.

My focus will be on the Android app of RedNote, although I have also confirmed that the vulnerabilities described here persist across operating systems, such as iOS. Looking deeper into any possible specificities of the iOS app is left as an exercise to the reader.

To start, I booted up Wireshark and captured the network traffic during the app setup. I scrolled through it for a bit to verify that they use TLS for all their communication, and for the most part they did.

However, I eventually noticed the phone sending packets to port 5333 on the remote server. If you're not familiar with networking, port 5333 is not one you see every day. Looking into the packets to this port I noticed the data was not encrypted. It leaked the type of phone I was using and some other random values which I needed to dig into deeper.

Screenshot of Wireshark with a plaintext-looking portion of a packet highlighted

Digging into the Internals

To find out more about the unencrypted packet over port 5333, I started by statically analysing the APK using jadx. It took me a little while to get my footing since the APK contents are messy, but I eventually realised there was a "SessionAuth" string in the payload, so I could grep the sources and resources of the APK to narrow down my search.

$ grep -rn SessionAuth
grep: split_config.arm64_v8a.apk: binary file matches
grep: split_config.armeabi_v7a.apk: binary file matches
grep: arm64_v8a/resources/lib/arm64-v8a/libxhslonglink.so: binary file matches

This brought me to the shared library libxhslonglink.so, which also fittingly sounds like it handles communication. Opening it up in Ghidra I searched through the strings for "SessionAuth" and was met with quite a rich list of results, but I was mostly drawn to the string

recv kSessionAuthResp: mid:%_, code:%_, msg:%_, token:%_, ts:%_, ciphertype:%_, secretkey:%_

This string was interesting in particular as it mentioned a secret key being formatted into its output.

Screenshot of a Ghidra memory block

One of the cross references (XREFs) with this string is a function called Buf2Resp. Briefly analysing it, it seemed to be responsible for parsing the packet payload in a protobuf serialized format. To verify that this was the case I did some trial deserializations of the payload using protobuf-inspector and at a 12 byte offset I got a hit.

How Protobuf Leaks Type Information

Having now verified that part of the payload was serialized by protobuf, I investigated further to try find out how I could faithfully reconstruct the message structures.

Looking through Ghidra's Symbol Tree I could navigate through the com::xiaohongshu::bifrost::rrmp namespace to recreate an almost complete .proto file of each message used over the wire. The reason for this is that since they used protobuf, the C++ type information for each message structure is encoded within the binary, so looking at each k{name}FieldNumber I could reconstruct a message with fields based on the name within the placeholder, i.e. kSecretKeyFieldNumber = 00000007h becomes secretKey = 7 in the proto message.

The issue here is that we still do not know the types of each field. An option was to attempt to trigger each message type in the app, inspect it with protobuf-inspector and get the types from the given payload. The issue with this is that it would most likely not yield every possible field for a message type.

Though the compiled binary comes to the rescue again as every message structure has a corresponding SerializeWithCachedSizes method. This method contains calls to other serialization functions for each field, depending on the type of that field.

Using all of this information, we find the following definition for the AuthResp message.

message AuthResp {
    optional string mid = 1;
    optional string code = 2;
    optional bytes msg = 3;
    optional string token = 4;
    optional uint64 ts = 5;
    optional int32 cipherType = 6;
    optional string secretKey = 7;
    optional int32 retryInterval = 8;
}

Getting the Packet Header

At this point I had a way to understand payloads, but still no explanation for the 12 byte offset in the packet data. Note that TCP is a stream protocol, so there needs to be a way to tell the server what the size of the data being sent is, which means it had to be somewhere in the first 12 bytes.

Doing another string search for "header" gave me a debug log of a longlink_pack:header with a single XREF. Taking some time with this function allowed me to almost fully understand what the first 12 bytes of a packet were encoding. It also gives a guarantee that the packet header is actually 12 bytes, since there is a call to a AutoBuffer::Write function writing 12 bytes to the beginning of the output buffer.

This function additionally contained a block of code in charge of retrieving the payload length and writing it into 20 bits of bytes 5 to 7 of the header. I also spotted an attempt at data integrity, since the payload is hashed with MD5, where the last 3 bytes of the hash are written to the end of the header.

Some fields still remained a mystery at this point in time and some I still have not decoded to this day. But I realized allocating some time to automating packet parsing instead of reading them manually would be necessary for me to get further in my research.

Simplifying Packet Parsing

I now had an understanding of what a complete packet looks like, where the data comes from and for the most part what it means. But manually parsing each packet I was interested in was a bit tedious. I also could've stopped here and written a post about reverse engineering the protocol, but something still irked me about the unencrypted fields in the AuthResp. I had fields like token and secretKey being transmitted in plaintext over the wire. There had to be some exploit just waiting to be found.

And so, to decrease the amount of time I had to manually sift through bytes to find out what was interesting I created a custom Wireshark dissector for the proprietary protocol, even with the full reconstructed protobuf to decode payloads. The complete fork with all the information discovered even further down in this post is open-sourced at codeberg.org/phoebe/wireshark on the dissector/rednote branch, though please be aware that I am not an active Wireshark developer, so a lot of the practices are most likely not perfect for Wireshark's standards (nor did I respect much of the protobuf standards, since I essentially invented an undeclared option for JSON-encoded strings in protobuf).

Screenshot of Wireshark using a custom dissector

Equipped now with a fancy dissector I could do some more tests and eventually create a proof of concept to demonstrate how concerning the information leaked is.

Payloads Failing to Parse

After a couple rounds of testing, especially when sending messages to another user, I noticed there was a specific case where the protobuf payload seemed to decode to gibberish, essentially failing and making Wireshark complain. I thought for quite a while until I finally remembered the secretKey field discovered earlier! I wasn't sure whether the payload was encrypted or not, my other thought at the time was that it might've been compressed, especially since a common string in an extra field for session authentication was {"acceptEncoding":"gzip"}, but it didn't hurt to try.

My first assumption for symmetric cryptography (since the key is "secret") is always AES and the key field is clearly 16 hex-encoded bytes, making for a perfect AES-128 cipher, so I created a snippet of Go code to attempt decryption of the payload using standard ECB mode.

func decrypt(payload, key []byte) {
    block, _ := aes.NewCipher(key)

    for i := 0; i < len(payload) / 16; i++ {
        block.Decrypt(payload[i * 16:], payload[i * 16:])
    }

    fmt.Println(hex.Dump(payload))
}

Surprisingly, the decrypted output was very similar to the protobuf messages seen in the other packets. What's more is that it is not common to see AES-128 with the ECB mode in practice. But alas, when piping the output into protobuf-inspector a very well parsed result showed up.

root:
    1  = 1
    9  = message:
        1  = "42313260-35e3-454a-9813-32d443e076b0"
        2  = 1739459583164
        3  = "session.1234567891234566796123"
        4  = "674567890000000001339123"
        5  = "67123456000000000121c345"
        6  = "hello"
        7  = 1
        8  = "User"
        12  = 1

Having reconstructed the proto messages before I was able to correlate this to a proto message named ChatOneMessage. Although, I wasn't too sure why or when the messages are occasionally encrypted so I ended up checking the Java side of things. Searching the code for "getSecretKey" I found my way to the com.xingin.xynetcore.XhsLogic class and found that Chat packet types can be encrypted if the cipherType field of the authentication request is set to 1.

This also lead to a realization that packet types are defined by the second and third bytes of the packet header. They form a u16 indicating which proto message the payload should be decoded as.

Proving the Concept

At this point I had collected enough information to create some packets on my own. I decided to create a little Rust project to demonstrate how an adversary could get authenticated as another user and send someone a message on their behalf.

And so I created a Session struct encoding everything needed to create a valid session with the server:

struct Session {
    sock: TcpStream,
    uid: String,
    sid: String, // acts as a token
    device_id: String,
    os: String,
    device_name: String,
    app_version: String,
    os_version: String,
    fingerprint: String,
    nickname: Option<String>,
}

It seems the only things keeping an account secure is the token (sid) and all the device information. Although one might think the device information is not needed to login, I did a test of authenticating with the device information of one user and the user ID/token pair of another, but got an A00005 error in return. Perhaps there is still a way to authenticate without using the same device information, in which case the security would be holding on by a thread (i.e. the token).

No matter how secure it is from a targeted attack standpoint, one could still hijack any account they managed to see the network traffic of, since all of this information is transmitted in plaintext.

Although this proof of concept focuses on the capability an attacker has to impersonate another user and send messages as them, the freedom an attacker has is much larger than that. They could impersonate a user and do any action that this API supports, such as following a user, liking a post, etc.

Serializing a Custom Packet

As discovered in prior sections, we can essentially rebuild the longlink_header packing code in Rust. The proof of concept uses a very simple Packet struct

struct Packet {
    version: u8,
    pkt_type: u16,
    state: u8,
    payload: Option<Vec<u8>>,
}

which gets consumed and serialized into a byte vector using the Packet::serialize function.

impl Packet {
    fn serialize(self) -> Vec<u8> {
        let mut buf = Vec::new();
        let mut header: [u8; 12] = [0; 12];

        header[0] = 6;
        header[1] = ((self.pkt_type >> 8) & 0xff) as u8;
        header[2] = (self.pkt_type & 0xff) as u8;
        header[3] = 0;
        header[4] = (self.version << 2) | 1;
        if let Some(ref payload) = self.payload {
            let digest = md5::compute(&payload);

            header[5] = ((payload.len() >> 0x10) & 0xf) as u8;
            header[6] = ((payload.len() >> 0x8) & 0xff) as u8;
            header[7] = (payload.len() & 0xff) as u8;

            header[9] = digest.0[digest.0.len() - 3];
            header[10] = digest.0[digest.0.len() - 2];
            header[11] = digest.0[digest.0.len() - 1];
        } else {
            header[5] = 1 << 4;
        }

        header[8] = self.state;

        buf.append(&mut header.to_vec());
        if let Some(mut payload) = self.payload {
            buf.append(&mut payload);
        }

        buf
    }
}

Sending a Message on the User's Behalf

Now with observed account information the session can perform an authentication request to any of the RedNote servers, given that the SessionAuth proto message is populated with the same values. If the server responds with code A00000 the authentication succeeded and the adversary is free to impersonate their target.

With an authenticated session the adversary can then simply fill the SendMessage proto struct with the contents of the message and the user ID of the receiving end. Considering the payloads are protobuf and the packet serialization is abstracted by the Packet struct, the process of creating a function like this becomes quite simple and is mostly littered with cloning strings into the proto message.

I did a test on my own devices to see if it works and, as expected, calling the Rust program with all the values observed from pcaps of the phone genuinely reflected in the chat on the phone itself. I sent "hello" to another user, loaded the app, checked the chat, and there it was; a "hello", which was not typed into a phone and purely used information seen over the network.

To test the code I sent a message "hello" from one of my accounts using only information observed from the network.

Screenshot of message send payload to the server

If the server processes the message send -action successfully, it responds with an encrypted payload with a status response "ok", which it did.

Screenshot of message send response succeeding

Loading the phone after this exchange resulted in seeing the new message "hello" in the chat from the sender's point of view, and receiving the message on the receiver's side, proving the validity of the vulnerability.

Disclaimer

The code for this proof of concept has been open-sourced at codeberg.org/phoebe/rednote-poc. I do not condone use of this software for malicious purposes. Only use the code for experimenting with and verifying the vulnerability with accounts you have full permission to use.

What Should Be Done?

As for current RedNote users, the best suggestion I have is to uninstall or disable the app for the time being until a fix is confirmed. Even when it is running in the background the authentication request is sent immediately leaking the information, so simply "not using it" does not keep you safe.

For the developer team, the security concerns highlighted in this post are all formed by one primary issue; the networking requiring most security for a platform is taking place over an insecure connection. There is clearly code in-place to take care of CDNs over TLS, so I would highly suggest migrating this API to that form of networking, even if it breaks the app for some people for a while. There is no harm in putting some standard common TLS protection over your connections.

Even beyond the plaintext issue however, an account should not be secured by device information and/or a token. Additionally, assuming the device information is not necessary for authentication, we can also notice that the sid is formed as a timestamp in nanoseconds of what I presume to be account creation, with 3 extra digits at the end. This is not enough to keep information secure. An attacker should definitely have many more doors to push through.

RedNote also gives the option to setup a password for your account in the app, though for whatever reason the authentication used for this vulnerability does not use this password as far as I can tell. This is honestly an accidental blessing from RedNote's end, since leaking authentication information is one thing, but leaking passwords that are reused across other platforms is another.

As a whole, I would advise the RedNote team to rethink their API authorization structure. If I set a password to my account I'd expect that password to play a role in how my account interacts with the servers.

Full Disclosure

Before making this post publicly available I emailed the RedNote team about the vulnerability at the product feedback email available on their contact page. The email was sent on Saturday the 15th of February 2025 with a note that I will share the vulnerability with the public on the 23rd of February 2025, although letting them know that I am open to a delay on the publication if they wish to have more time. I additionally sent a second email on the 18th of February 2025 requesting that the issue be considered a high priority. The full email is available here.

Unfortunately, they did not respond to either email. Given the severity of the issue, in terms of adversaries having an opportunity to collect account information over a long period of time, I made the decision to publish this article while the vulnerability still exists. My hope is that putting making the public aware of this will push the RedNote team to resolve it immediately.

Issues with Security Through Obscurity

Security through obscurity is somehow still an issue in apps these days and it is just as worrying to see now as it has always been. Inventing your own protocol doesn't give you more security, but instead makes you a connection begging to be reverse engineered. The temporary layer of protection given by inventing your own proprietary protocol is immediately shattered as soon as one person takes a closer look.

It is important to realize that it is okay to take the simple route of just slapping some TLS on the connection and calling it a day. TLS has gone through it's fair share of vulnerabilities, but that is what makes it the preferred protocol of the web (and QUIC of course, but we can just consider that to be under the TLS umbrella as well).

This is not to say that using your own protocol is always a bad idea, as long as it is backed by reputable research. As an example, take WhatsApp, which uses the Noise Protocol Framework encapsulated in (proprietary) protobuf messages. Their implementation makes sense, because it is based on a framework which has gone through its fair share of research.

Impact

The vulnerability presented in this post poses a dangerous issue for the security of users and the integrity of their actions. I have shown a functioning proof of concept demonstrating how an adversary can send a message of their choice to a recipient of their choice.

Previous research in investigating RedNote has introduced concerns with data storage, privacy or the vague security measures mentioned when it came to describing RedNote's approach to security, though somehow each of these investigations blissfully neglected the blatant plaintext networking.

I would like to emphasize how necessary it is to question the security of apps we use daily. Reverse engineering should go more than looking at app resources and assets. Instead we should look deeper and question what is being done. Bringing me to my final concern; the Google Play Store lists the app with "Data is encrypted in transit. Your data is transferred over a secure connection" in the "Security practices" section of the app details. This gives an extremely misleading sense of security to anyone interested in using the app and in my opinion demonstrates severe irresponsibility from Google's side.

Acknowledgements

I would like to thank my close friends for proof-reading this post and giving wonderful constructive feedback to improve my writing. I am extremely appreciative of them and their help.

TL;DR

One of RedNote's APIs authenticates and functions in plaintext, such that an adversary can capture a packet containing authentication information and perform the API actions as the user. This API includes following, blocking, messaging and most likely other things too. This means we have both an account hijacking vulnerability and a general issue of security, since an adversary can also capture and record the actions someone is performing over this API. Additionally, this authentication bypasses the need for the account password which may be optionally setup in the app.

A proof of concept of the vulnerability is available at this repository and a Wireshark dissector at this Wireshark fork.