Reversing a Desktop IM Client with Frida - Sending Tampering and Listening to Messages

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.

Extending the Windows IM client is well-trodden territory; this article does the same for the macOS client of WeChat, using Frida. We’ll cover two things: sending messages and listening to incoming messages. (There’s also a static-injection approach, but I’ll skip it to keep the Xcode footprint small. This article also assumes you already know your way around Frida.)

WeChat version

Tools

  1. Hopper Disassembler
  2. class-dump
  3. Frida
  4. PyCharm (optional)
  5. VSCode (optional)

Dumping the headers

Start by dumping the client’s headers with class-dump:

class-dump -H /Applications/WeChat.app

That produces a lot of headers:

-rw-r--r--  1 n  staff   927B  2 15 19:19 WXCPbQcwxtalkPackage.h
-rw-r--r--  1 n  staff   975B  2 15 19:19 WXCPbReportItem.h
-rw-r--r--  1 n  staff   1.7K  2 15 19:19 WXCPbSCAddVoiceGroupMemberResp.h
-rw-r--r--  1 n  staff   772B  2 15 19:19 WXCPbSCCancelCreateVoiceGroupResp.h
-rw-r--r--  1 n  staff   7.2K  2 15 19:19 WXCPbSCCreateVoiceGroupResp.h
-rw-r--r--  1 n  staff   6.9K  2 15 19:19 WXCPbSCEnterVoiceRoomResp.h
-rw-r--r--  1 n  staff   1.1K  2 15 19:19 WXCPbSCExitVoiceRoomResp.h
-rw-r--r--  1 n  staff   1.2K  2 15 19:19 WXCPbSCModifyVoiceGroupInfoResp.h
-rw-r--r--  1 n  staff   872B  2 15 19:19 WXCPbSCSubscribeLargeVideoResp.h
-rw-r--r--  1 n  staff   867B  2 15 19:19 WXCPbSCSubscribeVideoResp.h
-rw-r--r--  1 n  staff   2.0K  2 15 19:19 WXCPbSCVoiceClientSceneReportResp.h
-rw-r--r--  1 n  staff   864B  2 15 19:19 WXCPbSCVoiceGetGroupInfoBatchResp.h
-rw-r--r--  1 n  staff   637B  2 15 19:19 WXCPbSCVoiceMemberWhisperResp.h
-rw-r--r--  1 n  staff   5.9K  2 15 19:19 WXCPbSCVoiceRedirectResp.h
-rw-r--r--  1 n  staff   1.1K  2 15 19:19 WXCPbSCVoiceRoomHelloResp.h
-rw-r--r--  1 n  staff   904B  2 15 19:19 WXCPbSKBuiltinBuffer_t.h
-rw-r--r--  1 n  staff   686B  2 15 19:19 WXCPbSubscribeVideoMember.h
-rw-r--r--  1 n  staff   2.7K  2 15 19:19 WXCPbSwitchVideoGroupResp.h
-rw-r--r--  1 n  staff   1.4K  2 15 19:19 WXCPbVideoGroupMember.h
-rw-r--r--  1 n  staff   671B  2 15 19:19 WXCPbVoiceClientScene.h
-rw-r--r--  1 n  staff   1.2K  2 15 19:19 WXCPbVoiceClientSceneExt.h
-rw-r--r--  1 n  staff   2.9K  2 15 19:19 WXCPbVoiceConf.h

Narrowing it down

Reading all of those one by one is hopeless. But developers name things sensibly — the vendor’s developers certainly don’t name objects abc. The client’s core function is sending messages, and “message” is “Message”. Filter on that:

# n @ localhost in ~/vscodewsp/wechat/dump [20:58:22]
$ ll |wc -l
    4922

# n @ localhost in ~/vscodewsp/wechat/dump [20:58:29]
$ ll -l |grep Message|wc -l
     157

4,922 files down to 157. Now apply naming habits — business logic usually lands in service / controller classes:

# n @ localhost in ~/vscodewsp/wechat/dump [20:58:42]
$ ll -l |grep Message|grep Service|wc -l
       9

# n @ localhost in ~/vscodewsp/wechat/dump [21:02:13]
$ ll -l |grep Message|grep Service
-rw-r--r--  1 n  staff   5.1K  2 15 19:19 FTSFileMessageService.h
-rw-r--r--  1 n  staff   382B  2 15 19:19 IMessageServiceAppExt-Protocol.h
-rw-r--r--  1 n  staff   980B  2 15 19:19 IMessageServiceFileExt-Protocol.h
-rw-r--r--  1 n  staff   381B  2 15 19:19 IMessageServiceFileReTransferExt-Protocol.h
-rw-r--r--  1 n  staff   755B  2 15 19:19 IMessageServiceImageExt-Protocol.h
-rw-r--r--  1 n  staff   780B  2 15 19:19 IMessageServiceVideoExt-Protocol.h
-rw-r--r--  1 n  staff   407B  2 15 19:19 IMessageServiceVideoReTransferExt-Protocol.h
-rw-r--r--  1 n  staff   3.1K  2 15 19:19 MMFTSMessageService.h
-rw-r--r--  1 n  staff    20K  2 15 19:19 MessageService.h

Nine files — readable one by one. MessageService.h is the jackpot:

- (id)SendLocationMsgFromUser:(id)arg1 toUser:(id)arg2 withLatitude:(double)arg3 longitude:(double)arg4 poiName:(id)arg5 label:(id)arg6;
- (id)SendNamecardMsgFromUser:(id)arg1 toUser:(id)arg2 containingContact:(id)arg3;
- (id)SendStickerStoreEmoticonMsgFromUsr:(id)arg1 toUsrName:(id)arg2 md5:(id)arg3 productID:(id)arg4;
- (id)SendEmoticonMsgFromUsr:(id)arg1 toUsrName:(id)arg2 md5:(id)arg3 emoticonType:(unsigned int)arg4;
- (id)SendImgMessage:(id)arg1 toUsrName:(id)arg2 thumbImgData:(id)arg3 midImgData:(id)arg4 imgData:(id)arg5 imgInfo:(id)arg6;
- (id)SendTextMessage:(id)arg1 toUsrName:(id)arg2 msgText:(id)arg3 atUserList:(id)arg4;
- (id)SendAppMusicMessageFromUser:(id)arg1 toUsrName:(id)arg2 withTitle:(id)arg3 url:(id)arg4 description:(id)arg5 thumbnailData:(id)arg6;
- (id)SendAppURLMessageFromUser:(id)arg1 toUsrName:(id)arg2 withTitle:(id)arg3 url:(id)arg4 description:(id)arg5 thumbnailData:(id)arg6;
- (id)SendAppURLMessageFromUser:(id)arg1 toUsrName:(id)arg2 withTitle:(id)arg3 url:(id)arg4 description:(id)arg5 thumbUrl:(id)arg6 sourceUserName:(id)arg7 sourceDisplayName:(id)arg8;

Everything prefixed Send is a message-send function. The hard part is just finding the right header; the rest is mechanical.

Verifying with Frida

Confirm the guess with frida-trace:

$ frida-trace -m "-[MessageService Send*]" 微信
Instrumenting...
-[MessageService SendTextMessageWithString:toUser:]: Auto-generated handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/SendTextMessageWithString_toUser_.js"
-[MessageService SendAppURLMessageFromUser:toUsrName:withTitle:url:description:thumbUrl:sourceUserName:sourceDisplayName:]: Auto-generated handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/SendAppURLMessageFromUser_toUsrN_eaefd0af.js"
------------------------------------------------------------------------------
-[MessageService SendNamecardMsgFromUser:toUser:containingContact:]: Auto-generated handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/SendNamecardMsgFromUser_toUser_c_b5899e8d.js"
Started tracing 18 functions. Press Ctrl+C to stop.

frida-trace drops auto-generated handler scripts into __handlers__/. Send a message and the terminal prints:

195323 ms -[MessageService SendTextMessage:0x600000b6fae0 toUsrName:0x6503cfa934d442eb msgText:0x6000002ec860 atUserList:0x600000a73570]

SendTextMessage — exactly the header we saw. Edit that handler to confirm the arguments:

2908 ms -[MessageService SendTextMessage:0x600000b6fae0 toUsrName:0x6503cfa934d442eb msgText:0x6722df8306c2767b atUserList:0x6000009c2760]

The signature is:

- (id)SendTextMessage:(id)arg1 toUsrName:(id)arg2 msgText:(id)arg3 atUserList:(id)arg4;

Four arguments. arg1 unknown, toUsrName is the recipient, msgText is the message body, arg4 unknown. Print all four (note the ObjC self/_cmd shift — args[2] is the first declared parameter):

onEnter(log, args, state) {
    console.log(`-[MessageService SendTextMessage:${args[2]} toUsrName:${args[3]} msgText:${args[4]} atUserList:${args[5]}]`);
    console.log("arg[1] -> " + new ObjC.Object(args[2]))
    console.log("arg[2] -> " + new ObjC.Object(args[3]))
    console.log("arg[3] -> " + new ObjC.Object(args[4]))
    console.log("arg[4] -> " + new ObjC.Object(args[5]))
  },

Run frida-trace -m "-[MessageService Send*]" 微信 and send a message:

handler output

arg[1] -> wxid_[redacted]
arg[2] -> filehelper
arg[3] -> this is a message test
arg[4] ->
           /* TID 0x307 */
 14534 ms  -[MessageService SendTextMessage:0x600000b6fae0 toUsrName:0x6503cfa934d442eb msgText:0x600000adefd0 atUserList:0x600000add470]

So arg1 is my own id, arg2 is the recipient, arg3 is the text. To tamper with the outbound body:

args[4] = ObjC.classes.NSString.stringWithString_("MacOS WeChat analysis")

Now whatever I type, the other side receives “MacOS WeChat analysis”. That pins down the send-text function — but how do we call it ourselves?

Reading the code in Hopper

Sending needs four parameters: (1) my own id, (2) the recipient id, (3) the message, (4) null. Drag WeChat.app/Contents/MacOS/WeChat into Hopper and search SendTextMessage.

MMMessageSendLogic:

/* @class MMMessageSendLogic */
-(unsigned char)sendTextMessageWithString:(void *)arg2 mentionedUsers:(void *)arg3 {
    r14 = self;
    r15 = [arg2 retain];
    r12 = [arg3 retain];
    r13 = [[CUtility filterStringForTextMessage:r15] retain];
    [r15 release];
    if ([r13 length] != 0x0) {
            stack[-64] = r12;
            rax = [r13 lengthOfBytesUsingEncoding:0x4];
            rbx = rax;
            if (rax >= 0x4001) {
                    rax = [[NSString alloc] initWithFormat:@"ERROR: Text too long, length: %lu, utf8 length: %lu", [r13 length], rbx];
                    stack[0] = "-[MMMessageSendLogic sendTextMessageWithString:mentionedUsers:]";
                    [MMLogger logWithMMLogLevel:0x2 module:"ComposeInputView" file:0x103e0e162 line:0x112 func:stack[0] message:rax];
                    [rax release];
                    rax = [NSBundle mainBundle];
                    rax = [rax retain];
                    stack[-72] = rax;
                    r15 = [[rax localizedStringForKey:@"Message.Input.Too.Long.Title" value:@"" table:0x0] retain];
                    rax = [NSBundle mainBundle];
                    rax = [rax retain];
                    r14 = rax;
                    rax = [rax localizedStringForKey:@"Message.Input.Too.Long.Content" value:@"" table:0x0];
                    rax = [rax retain];
                    [NSAlert showAlertSheetWithTitle:r15 message:rax completion:0x0];
                    [rax release];
                    [r14 release];
                    [r15 release];
                    [stack[-72] release];
                    r14 = 0x0;
                    r12 = stack[-64];
            }
            else {
                    rax = [WeChat sharedInstance];
                    rax = [rax retain];
                    r15 = [[rax CurrentUserName] retain];
                    [rax release];
                    rax = [r14 currnetChatContact];
                    rax = [rax retain];
                    r14 = [[rax m_nsUsrName] retain];
                    [rax release];
                    r12 = [[MMServiceCenter defaultCenter] retain];
                    objc_unsafeClaimAutoreleasedReturnValue([[[r12 getService:[MessageService class]] retain] SendTextMessage:r15 toUsrName:r14 msgText:r13 atUserList:stack[-64]]);
                    [rax release];
                    [r12 release];
                    [r14 release];
                    [r15 release];
                    r14 = 0x1;
                    r12 = stack[-64];
                    r13 = r13;
            }
    }
    else {
            rax = [[NSString alloc] initWithFormat:@"ERROR: Text is empty, can't send"];
            stack[0] = "-[MMMessageSendLogic sendTextMessageWithString:mentionedUsers:]";
            [MMLogger logWithMMLogLevel:0x2 module:"ComposeInputView" file:0x103e0e162 line:0x10c func:stack[0] message:rax];
            [rax release];
            r14 = 0x0;
    }
    [r13 release];
    [r12 release];
    rax = r14 & 0xff;
    return rax;
}

The key line:

objc_unsafeClaimAutoreleasedReturnValue([[[r12 getService:[MessageService class]] retain] SendTextMessage:r15 toUsrName:r14 msgText:r13 atUserList:stack[-64]]);

The first argument is r15, traced back to:

r15 = [[rax CurrentUserName] retain];

And CurrentUserName:

-(void *)CurrentUserName {
    if ([self isLoggedIn] != 0x0) {
            rdi = [[CUtility GetCurrentUserName] retain];
    }
    else {
            rdi = 0x0;
    }
    rax = [rdi autorelease];
    return rax;
}

It checks login state, then calls CUtility’s GetCurrentUserName. That’s the first argument; the other three we can construct by hand.

A Frida script to send

console.log("init success");
function SendTextMessage(wxid, msg) {
    var message = ObjC.chooseSync(ObjC.classes.MessageService)[0]
    var username = ObjC.classes.CUtility.GetCurrentUserName();
    console.log(username)
    console.log("Type of arg[0] -> " + message)
    var toUsrName = ObjC.classes.NSString.stringWithString_(wxid);
    var msgText = ObjC.classes.NSString.stringWithString_(msg);
    message["- SendTextMessage:toUsrName:msgText:atUserList:"](username, toUsrName, msgText, null);
}
SendTextMessage("filehelper", "active call to send a message!")

Save it and run:

frida 微信 --debug --runtime=v8 --no-pause -l test.js

The client sends a message on its own:

actively sent message

Listening to incoming messages

We’ve tampered with outbound and sent actively; now the inbound side. When a message arrives, the client shows a notification — and “notification” is “notify”. In MessageService we find - (void)notifyAddMsgOnMainThread:(id)arg1 msgData:(id)arg2;. Verify with frida:

frida-trace -m "-[MessageService notify*]" 微信
$ frida-trace -m "-[MessageService notify*]" 微信
Instrumenting...
-[MessageService notifyModMsgOnMainThread:msgData:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyModMsgOnMainThread_msgData_.js"
-[MessageService notifyAppMsgUploadProgress:msgData:uploadedBytes:totalBytes:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyAppMsgUploadProgress_msgDa_9b03499e.js"
-[MessageService notifyVideoMsgUploadProgress:msgData:uploadedBytes:totalBytes:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyVideoMsgUploadProgress_msg_e1db5f92.js"
-[MessageService notifyNewMsgNotificationOnMainThread:msgData:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyNewMsgNotificationOnMainTh_d56d83b5.js"
-[MessageService notifyChatSyncMsgsOnMainThread:msgList:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyChatSyncMsgsOnMainThread_msgList_.js"
-[MessageService notifyChatSyncMessagesMergedOnMainThread:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyChatSyncMessagesMergedOnMainThread_.js"
-[MessageService notifyRevokePatMsgOnMainThread:n64MsgId:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyRevokePatMsgOnMainThread_n64MsgId_.js"
-[MessageService notifyAddRevokePromptMsgOnMainThread:msgData:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyAddRevokePromptMsgOnMainTh_81637ebf.js"
-[MessageService notifyDelMsgOnMainThread:msgData:isRevoke:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyDelMsgOnMainThread_msgData_5bbc2297.js"
-[MessageService notifyMsgDeletedForSessionOnMainThread:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyMsgDeletedForSessionOnMainThread_.js"
-[MessageService notifyDelAllMsgOnMainThread:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyDelAllMsgOnMainThread_.js"
-[MessageService notifyAddMsgListForSessionOnMainThread:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyAddMsgListForSessionOnMainThread_.js"
-[MessageService notifyUnreadCntChangeOnMainThread:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyUnreadCntChangeOnMainThread_.js"
-[MessageService notifyMsgResendOnMainThread:msgData:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyMsgResendOnMainThread_msgData_.js"
-[MessageService notifyImgMsgUploadProgress:msgData:uploadedBytes:totalBytes:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyImgMsgUploadProgress_msgDa_e4e0cd43.js"
-[MessageService notifyAppMsgDownloadProgress:msgData:downloadedBytes:totalBytes:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyAppMsgDownloadProgress_msg_4e191704.js"
-[MessageService notifyUIAndSessionOnMainThread:withMsg:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyUIAndSessionOnMainThread_withMsg_.js"
-[MessageService notifyAddMsgOnMainThread:msgData:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyAddMsgOnMainThread_msgData_.js"
Started tracing 18 functions. Press Ctrl+C to stop.

Send a message to yourself and watch the output:

           /* TID 0x307 */
157082 ms  -[MessageService notifyAddMsgOnMainThread:0x6503cfa934d442eb msgData:0x7fd903c9fa00]
           /* TID 0x31e17 */
157092 ms  -[MessageService notifyUnreadCntChangeOnMainThread:0x6503cfa934d442eb]
           /* TID 0xb5c27 */
157228 ms  -[MessageService notifyModMsgOnMainThread:0x6503cfa934d442eb msgData:0x7fd903c9fa00]

notifyAddMsgOnMainThread looks like the receive method, with msgData as the payload. Check the parameter types:

frida-trace -m "-[MessageService notifyAddMsgOnMainThread*]" 微信
$ frida-trace -m "-[MessageService notifyAddMsgOnMainThread*]" 微信
Instrumenting...
-[MessageService notifyAddMsgOnMainThread:msgData:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyAddMsgOnMainThread_msgData_.js"
Started tracing 1 function. Press Ctrl+C to stop.
Type of arg[2] -> NSTaggedPointerString
Type of arg[3] -> MessageData
           /* TID 0x307 */
  2170 ms  -[MessageService notifyAddMsgOnMainThread:0x6503cfa934d442eb msgData:0x7fd90401c960]

First is a String, second is MessageData. Find the struct in the headers:

$ ll -l|grep MessageData
-rw-r--r--  1 n  staff   2.5K  2 15 19:19 FTSFileMessageData.h
-rw-r--r--  1 n  staff   2.0K  2 15 19:19 FTSMessageData.h
-rw-r--r--  1 n  staff   794B  2 15 19:19 IMessageDataExt-Protocol.h
-rw-r--r--  1 n  staff   6.2K  2 15 19:19 MMChatMessageDataSource.h
-rw-r--r--  1 n  staff    25K  2 15 19:19 MessageData.h
-rw-r--r--  1 n  staff   550B  2 15 19:19 MessageDataGroup.h
-rw-r--r--  1 n  staff   2.9K  2 15 19:19 MessageDataPackedInfo.h
-rw-r--r--  1 n  staff   262B  2 15 19:19 NSPasteboard-MessageData.h

MessageData.h:

@interface MessageData : NSObject <NSPasteboardItemDataProvider, IAppMsgPathMgr, IMsgExtendOperation, NSCopying, WCTTableCoding, WCTColumnCoding>
{
    unsigned int mesLocalID;
    long long mesSvrID;
    NSString *fromUsrName;
    NSString *toUsrName;
    unsigned int messageType;
    NSString *msgContent;
    NSString *msgVoiceText;
    unsigned int m_uiVoiceToTextStatus;
    unsigned int msgStatus;
    unsigned int msgImgStatus;
    NSString *msgRealChatUsr;
    NSString *msgPushContent;
    unsigned int m_uiTranslateStatus;
    NSString *msgSource;
    unsigned int mesDes;
    unsigned int msgSeq;
    BOOL bForward;
    NSData *m_dtThumbnail;
    unsigned int msgCreateTime;
    unsigned int m_uiSendTime;
    unsigned int m_uiDownloadStatus;
    id <IMsgExtendOperation> m_extendInfoWithMsgType;
    id <IMsgExtendOperation> m_extendInfoWithFromUsr;
    BOOL isAutoIncrement;
    BOOL m_bShouldShowAll;
    BOOL m_bIsMultiForwardMessage;
    BOOL m_shouldReloadOriginal;
    BOOL m_bHasOriginalMessage;
    unsigned int IntRes1;
    unsigned int IntRes2;
    unsigned int m_uiFileUploadStatus;
    unsigned int m_uiOriginalImgHeight;
    unsigned int m_uiOriginalImgWidth;
    unsigned int m_uiSrcCreateTime;
    unsigned int _m_nsMsgCrc32;
    unsigned int _m_uiUploadedBytes;
    unsigned int _m_uiDownloadedBytes;
    unsigned int _m_uiTotalBytes;
    int _m_nCdnServerRetCode;
    unsigned int _m_uiResendMessageCount;
    long long lastInsertedRowID;
    NSString *StrRes1;
    NSString *StrRes2;
    MMTranslateResult *m_nsTranslationResult;
    NSString *m_nsFilePath;
    NSString *m_nsVideoPath;
    NSString *m_nsVideoThumbPath;
    NSString *dataMd5;
    MessageData *m_refMessageData;
    MessageDataPackedInfo *m_packedInfo;
    NSString *m_nsSrcUserName;
    NSString *m_nsSrcNickName;
    NSString *m_nsAtUserList;
    NSString *_m_nsImgFileName;
    NSString *_m_nsBigFileErrMsg;
    SecondMsgNode *_secondMsgNode;
    MessageData *_referHostMsg;
}

The field names line up. Print them:

var MessageData = new ObjC.Object(args[3]).$ivars;
console.log("fromUsrName -> " + MessageData.fromUsrName)
console.log("toUsrName -> " + MessageData.toUsrName)
console.log("msgContent -> " + MessageData.msgContent)
$ frida-trace -m "-[MessageService notifyAddMsgOnMainThread*]" 微信
-[MessageService notifyAddMsgOnMainThread:msgData:]: Loaded handler at "/Users/n/vscodewsp/wechat/__handlers__/MessageService/notifyAddMsgOnMainThread_msgData_.js"
Started tracing 1 function. Press Ctrl+C to stop.
Type of arg[2] -> NSTaggedPointerString
Type of arg[3] -> MessageData
fromUsrName -> wxid_[redacted]
toUsrName -> filehelper
msgContent -> message listening test
           /* TID 0x307 */
 14909 ms  -[MessageService notifyAddMsgOnMainThread:0x6503cfa934d442eb msgData:0x7fd904426980]

Inbound messages captured, sender and content intact.

Wrap-up

This walks the full loop against a desktop IM client: dump headers with class-dump, narrow the field by naming convention, confirm the candidate with frida-trace, trace the parameter sources in Hopper, then call the send path directly and hook the receive path. The reusable moves:

  • class-dump → grep by domain word → grep by layer suffix is a fast way to collapse thousands of headers into the handful that matter.
  • frida-trace -m "-[Class prefix*]" gives you ready-made handler skeletons — edit, don’t write from scratch.
  • Obtain the “self” id by calling the client’s own GetCurrentUserName rather than hardcoding it.
  • The receive side is a notify* method carrying a MessageData struct; dump its $ivars to get sender, recipient, and body.

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