WeChat 4.0 Anti-Revoke with Reminder: Symbol Recovery and String Decryption

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.


WeChat 4.0 Anti-Revoke with Reminder (Symbol Recovery + String Decryption)

Most “anti-revoke” tools you find online just search for a string and patch a couple of bytes. They never reverse the actual revoke logic, and none of them can produce a reminder that a message was pulled. This post does it properly, in three stages:

  1. Symbol recovery
  2. String decryption
  3. Function-logic reverse engineering

The final result is delivered via DLL hijacking. Full code: https://github.com/EEEEhex/RevokeHook

0. Environment

  • WeChat version: 4.0.3.22
  • IDA Pro: 9.1
  • x64dbg: Mar 15 2025

1. Symbol recovery

A large application inevitably pulls in a lot of open-source libraries, so some symbols can always be recovered. Without that, guessing at the surrounding context is brutal. I prefer to recover as many symbols as possible before diving in.

Browsing the strings shows WeChat uses a library called mars — a quick search turns up https://github.com/Tencent/mars, Tencent’s own official cross-platform terminal base component for WeChat. It has several parts:

  • comm: a standalone common library — sockets, threads, message queues, coroutines, and so on;
  • xlog: a high-reliability, high-performance runtime logging component;
  • SDT: the network diagnostic component;
  • STN: the signal-dispatch network module, and the main part of mars.

A logging module is gold — the code typically funnels function information out through it.

1.1 Building mars

Build it with build_windows.py per the official docs. The script uses VS2019, so WeChat itself is almost certainly compiled with VS2019 too. The script has a few small quirks; tweak them and it builds fine. First set the environment:

$env:MSVC_TOOLS_PATH=""
$env:MSVC_TOOLS_PATH=""

Then:

py .\build_windows.py --mars

That produces the static library mars.lib. You’ll also want VS2019’s static libs: libcmt.lib, libcpmt.lib, libvcruntime.lib.

1.2 Recovering symbols

I originally planned to use BinDiff for symbol recovery, but the .idb was so large that BinDiff kept crashing. So I fell back to IDA’s official FLAIR toolchain instead:

  1. Generate a .pat with pcf.exe
  2. Generate a .sig with sigmake.exe

Then apply the signature in IDA:

Signature applied in IDA

The important logging symbols come back — but the log text is clearly decrypted at runtime, so the strings still need to be decrypted.

2. String decryption

2.1 The runtime decryption patterns

Looking at the decryption sites, there are broadly two patterns:

Two decryption patterns

What they have in common:

  1. Locate a cmp *, 0, possibly with a few hoisted instructions scattered between;
  2. Then jnz addr; xor reg, reg; followed by two lea;
  3. The first pattern ends with cmp reg, value; jnz addr; mov *, 1; where reg is the one from the xor;
  4. The second pattern has the cmp reg, value; jz addr; in the middle, and a final jmp at the end, with the mov *, 1 at the jz target.

2.2 The decryption script

The full script is on GitHub — it’s a bit messy. The approach is pattern matching: grab all the relevant instructions, emulate the original decryption logic, then patch the decrypted string back into the global that held it.

The drawback is that pattern matching doesn’t always land on the right instructions, because compiler optimization can make two or more decryption blocks share instructions. The upside is simplicity — simple to design, simple to write:

The decryption script

To handle the optimization problem, the script also has a “dec select” mode where you hand-pick the instructions involved, then it emulates and patches:

Manual dec-select mode

3. Logic reverse engineering

3.1 Reversing the key function

With some experience you’ll find the key function at sub_182973360. Decrypt its strings and most of the logic becomes legible. The rough flow:

BYTE* CoReplaceOriginMessageByRevoke_182973360(int64 arg1, _BYTE *arg2, __int64 arg3)
{
	CheckIsReuestRevokingMessage_182976AE0(arg1, arg3);
	int64 v6 = _RTDynamicCast( *(arg3 + 472), 0, &off_188151B10,&off_1881E1FB0, 0); //dynamic_cast<>
	if (!v6) {
		XLogger::DoTypeSafeFormat("sys_extinfo is nullptr");
		return;
	}

	if (CheckIsProcessingRevokeNewXml_1829770A0(arg1, v6 + 200, *(v6 + 160)))
		return;

	v19 = sub_18295DC80(_RCX.m128i_i64[0], *(v6 + 160));
	if (v19)
	{
		XLogger::DoTypeSafeFormat("message:%_, pat revoke msg , no need to show");
		return;
	}

	GetMessageBySvrIdOnRecent_181155750(?, &v180, (v6 + 200), *(v6 + 160)); // get message type
	if ( v180.m128i_i64[1] == 10000 )			// system message (revoke, join group, group management, group voice call, etc.)
	{
		XLogger::DoTypeSafeFormat("message:%_, alerdy is system message");
		return;
	}
	if (v180.m128i_i64[1] == 0x3E00000031 )		// "nudge" message
	{
		XLogger::DoTypeSafeFormat("revoke message:%_, is pat message");
		DeleteMessage_18114F590(?, &v180, 1); //True
		return;
	}

	ConstructRevokeMsg_181A09E20(v6, &?); // build the revoke sysmsg xml
	final_srvid = GetFileFinalSvrid_181175CF0(?, *(v6 + 160));// srvid
	if (final_srvid == 0)
	{
		bool add_revoke_flag = false; // whether the message was successfully added to the data
		v188 = GetMessageBySvrId_181141130(v221, (__int64)&_RCX, (v6 + 200), final_srvid, 0);
		if (v188) {
			DeleteMessage_18114F590(args[0], (__int64)&v180, 0); 		// delete the original message
			add_revoke_flag = AddMessageToDBbyWxID_181198500(*&v219[0], &_RCX, &args_); 	// add the revoke message to the DB
			// i.e. first delete the message with this srvid, then insert a revoke message with the same srvid
		}
		else {
			add_revoke_flag = sub_181198460(v221, (__int64)&_RCX, (__int64)&args_);// insert revoke msg into the DB
			XLogger::DoTypeSafeFormat("origin msg not found, just insert placeholder sysmsg, session_name:%_,serverId:%_")
		}
		if (!add_revoke_flag) {
			XLogger::DoTypeSafeFormat("add system message to db failed");
		}
	}
	else
	{
		XLogger::DoTypeSafeFormat("old svrid:%_ can't get msg, will try new svrid:%_");
		return;
	}

}

The key is v188 = GetMessageBySvrId_181141130(...):

  • When it can get the revoked message’s SrvID, it (1) deletes that message, then (2) adds the revoke reminder to the database.
  • When it can’t get the SrvID, it directly inserts a revoke reminder into the database.

So anti-revoke-with-reminder has two possible approaches:

  1. Nop out DeleteMessage entirely, so it only inserts the reminder.
  2. Modify SrvID in memory to force the else branch — the “origin msg not found” path.

But in practice approach 1 fails: when AddMessageToDBbyWxID runs it still uses the original message’s SrvID, which collides and makes the insert fail. So you have to modify SrvID no matter what. Digging further, both approaches are workable:

3.1.1 Approach 1

The simplest version: modify SrvID right at the top of the function so it takes the “origin msg not found” branch and appends the reminder at the end of the message list. The problem: if the other side sent several messages and then revoked one, the reminder lands at the end, not at the position of the revoked one. E.g. they send 1, 2, 3 and revoke 2 — you see 1 2 3 "the other party revoked the above message", appended at the tail, and you can’t tell which message it was.

3.1.2 Approach 2

Nop out the call DeleteMessage site so the revoked message stays, then let it insert the reminder. But in practice, after nopping delmsg, AddMessageToDBbyWxID still fails to insert — and execution doesn’t even reach the failure branch. So I had to reverse AddMessageToDBbyWxID too. Its innermost call is:

__int64 __fastcall AddMessageToDB_1828A4B30(__int64 a1, __int64 a2, __int64 args, stStdString *wxid)
{
  CoAddMessageToDB_1828A2420(a1, a2, args, wxid, 0);
  return a2;
}

That CoAddMessageToDB function is called with a final argument of 1 in the “origin msg not found” branch. A reasonable guess: that last argument is a bool controlling whether a new local_id can be allocated. When false, it can only insert into the original message’s position (i.e. replace the original local_id); when true, it can allocate a new local_id. A quick live patch confirms it. So approach 2 is:

  1. Patch DeleteMsg to a Nop;
  2. Change CoAddMessageToDB’s last argument to True (1);
  3. Modify srvid before the DB insert.

This fixes approach 1’s shortcoming: even if the other side revoked an earlier message, the reminder lands at that message’s position.

3.2 Key memory structures

3.2.1 Approach 1’s key memory

Static + dynamic analysis shows the memory returned by v6 = _RTDynamicCast(*(arg3 + 472), 0, &off_188151B10, &off_1881E1FB0, 0) is the key structure. It holds everything the revoke path needs:

// v6 is the key memory holding the revoke info
// class v6
// {
//	+00 vtable
//	+08 unk1
//	+...
//	+A0 srvid: int64 			//+160
//	+A8 revoke_msg: std::string 		//+168
//	+C8 wxid: std::string 			//+200
// }
// where
// std::string | size:(0x20)
// {
//	+00 data_ptr: const char[16]
//	+10 size: int64
//	+18 capability: int64
// }

So you just need to modify the srvid data before or after the _RTDynamicCast in CoReplaceOriginMessageByRevoke.

3.2.2 Approach 2’s key memory

The key memory here is the third argument (r8) of CoAddMessageToDB’s caller:

// class r8
// {
//	+000 vtable
//	+008 type: int32				        //+008 0x2710 system message
// 	+010 wxid_sender: std::string			//+016 the other side's wxid
//	+030 wxid_receiver: std::string			//+048 your own wxid
//	+...
//	+0C0 srvid: int64 				        //+192 SrvID
//	+...
//	+0DC time: int32				        //+220 CreateTime
//	+...
//	+118 revoke_sysmsg: std::string			//+280
// }

Note the revoke_msg here is different from approach 1’s: in approach 1 it’s the reminder string; here it’s the constructed XML sysmsg string.

4. Hijacking + hooking

Full logic is on GitHub. Using DLL hijacking, I found that ilink2.dll exports very few functions, so forwarding is trivial:

// hijack ilink2.dll -> ilink2Org.dll
#pragma comment(linker, "/EXPORT:CreateIlinkNetwork=ilink2Org.CreateIlinkNetwork,@1")

Then hook once the DLL loads, and run the memory-modification logic.

4.1 Approach 1 hook point

My hook point here:

Approach 1 hook point

i.e. right after CheckIsReuestRevokingMessage returns — at that point [rdi + 1D8] is the memory I need. The next two instructions are 15 bytes total and involve no relocation, so the hook rewrites them into mov rax, HijackLogicWrapper; jmp rax; (12 bytes). After HijackLogic runs, it jumps to a trampoline that executes the two original instructions and then jmp to the next one:

|jmp hijack| -> |hijack_logic + jmp trampoline| -> |org_logic + jmp org_next_insn| -> |...|

One small detail: CoReplaceOriginMessageByRevoke runs twice, so the second modification must use the same SrvID as the first, or you’ll get two reminder inserts.

4.2 Approach 2 hook point

My hook point here — these three instructions are exactly 12 bytes, so they become a clean mov rax, *; jmp rax;.

Closing

After all this there’s one cosmetic quirk: approach 1’s reminder doesn’t appear immediately, and approach 2’s revoked message doesn’t disappear immediately — you have to click another chat and come back to refresh. Harmless enough.


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