再谈如何愉快地使用钉钉

目录

声明:本文包含 AI 辅助创作。

折腾仅因个人兴趣,记录分享仅为研究学习交流,请勿用于违法用途。

2026-06-29 更新:修正了重签名步骤。初版里我图省事用了 --deep,它会把 app 内嵌的 framework 连带重签成 ad-hoc,系统更新后触发签名加载失败、整个客户端双击都打不开了。已改为只重签主可执行文件、补 --options runtime,详见方案 B。

起因

接着 如何愉快地使用钉钉 那篇往下写。上次留了个尾巴:「如何不让别人看自己的已读状态」那节,我偷懒只甩了俩链接没展开。这次把它补上。

直接的触发点是最近刷屏的《置身钉内》。里面有段话:

钉钉的基因,从诞生的第一天起,就是永远站在「发信人」立场……为什么卡片里的消息一定要算已读,为什么系统要主动把事推到用户面前,很多答案,都可以回到这个原点。

发消息的人那边,「已读」是省心;收消息的人这边,它有时候就是一只盯着你的眼睛。改不了产品站在哪边,那就只能从客户端这一侧想办法,给自己留一点「装没看见」的余地。遇到难回答的问题,至少还有不说话的机会。

原理

已读回执的流程很朴素:

你点开消息 → 客户端调用 updateToRead 接口 → 服务器更新状态 → 对方看到「已读」

缺口在第一步和第二步之间,别让 updateToRead 这个请求发出去就行。下面两套方案,一套硬改二进制,一套运行时注入。

前置分析

主二进制是 DingTalk.app/Contents/MacOS/DingTalk,一个 Universal Binary(x86_64 + arm64)。先用 strings 摸一遍:

strings /Applications/DingTalk.app/Contents/MacOS/DingTalk | grep -i "updateToRead"

能定位到这些东西:

类型内容
API 路径/r/IDLMessageStatus/updateToRead
API 路径/r/IDLMessageStatus/updateToReadV2
ObjC 方法-[DTMojoMessageService updateToRead:mids:uuid:completionHandler:]
C++ 函数dtbiz::im::DTMessageServiceImplStd::BatchUpdateToReadImplV2
日志字符串DTMessage::BatchUpdateToReadImplV2, will call updateToRead, messageIds.size =

理出来的调用链:

用户点开消息
  → DTMojoMessageService -updateToRead:mids:uuid:completionHandler:   (ObjC 桥接层)
    → DTMessageServiceImplStd::BatchUpdateToReadImplV2                (C++ 业务逻辑)
      → HTTP POST /r/IDLMessageStatus/updateToReadV2                  (网络请求)
        → 服务器标记消息为已读

从上到下任意一刀切断都能达成目的,区别只是哪一刀更省事、更不容易被察觉。

再看一眼 entitlements:

codesign -d --entitlements :- /Applications/DingTalk.app 2>&1

跟本文相关的两条:

Entitlement含义
allow-dyld-environment-variablestrue允许 DYLD 环境变量注入(方案 B 的前提)
disable-library-validationfalse库校验没关,要重签名才能塞第三方 dylib

方案 B 会用到,先记下。顺手把原始 entitlements 导出存成文件,后面两个方案重签都要用:

codesign -d --entitlements :- /Applications/DingTalk.app > entitlements.plist 2>/dev/null

这份文件别手敲。钉钉的 entitlements 有二十多项,漏一项都可能让某些功能哑掉。直接从官方签名导出来用,改动越少越稳。方案 A 重签原样用这份;方案 B 只需把 com.apple.security.cs.disable-library-validation 一项翻成 true,其余不动:

plutil -replace com.apple.security.cs.disable-library-validation -bool true entitlements.plist

方案 A:Patch 二进制

原理

最暴力的思路。直接改二进制里的 /r/IDLMessageStatus/updateToRead 字符串,把 Read 里某个字母改掉(比如 ReadXead),路径就变成一个不存在的地址。服务器路由匹配不上,已读状态纹丝不动。

优缺点

优点是简单粗暴,只动几个字节,不用额外文件。缺点:路径改坏之后服务器会返回错误响应,客户端收到异常可能误判成断线,连累正常消息收发。

步骤

1. 备份原始二进制

cp /Applications/DingTalk.app/Contents/MacOS/DingTalk ./DingTalk.backup

这步千万别省,patch 错了还能救回来。

2. 定位字符串偏移

data = open('/Applications/DingTalk.app/Contents/MacOS/DingTalk', 'rb').read()
for pattern in [b'/r/IDLMessageStatus/updateToRead\x00',
                b'/r/IDLMessageStatus/updateToReadV2\x00']:
    start = 0
    while True:
        idx = data.find(pattern, start)
        if idx == -1: break
        print(f'Found at offset 0x{idx:08x}: {pattern[:-1].decode()}')
        start = idx + 1

因为是 Universal Binary,每个字符串都会出现两组,分别属于 x86_64 和 arm64:

Found at offset 0x0980350e: /r/IDLMessageStatus/updateToRead
Found at offset 0x09803573: /r/IDLMessageStatus/updateToReadV2
Found at offset 0x1a6bc0d8: /r/IDLMessageStatus/updateToRead
Found at offset 0x1a6bc13d: /r/IDLMessageStatus/updateToReadV2

3. Patch 字符串

把每处 Read 里的 R0x52)改成 X0x58),长度不变,不会破坏字符串表对齐:

#!/usr/bin/env python3
"""patch_dingtalk.py - 修改钉钉已读回执 API 路径"""
import sys

target = '/Applications/DingTalk.app/Contents/MacOS/DingTalk'
needle = b'/r/IDLMessageStatus/updateToRead'

with open(target, 'rb') as f:
    data = bytearray(f.read())

count = 0
start = 0
while True:
    idx = data.find(needle, start)
    if idx == -1:
        break
    # 'R' in 'Read' is at offset 28 from string start
    r_pos = idx + 28
    if data[r_pos] == 0x52:  # 'R'
        data[r_pos] = 0x58   # 'X'
        count += 1
        tag = data[idx:idx+35].split(b'\x00')[0].decode()
        print(f'Patched offset 0x{r_pos:08x}: {tag}')
    start = idx + 1

if count == 0:
    print('No matching strings found. Already patched or wrong binary?')
    sys.exit(1)

with open(target, 'wb') as f:
    f.write(data)

print(f'\nDone. Patched {count} occurrence(s).')
print('Now run: codesign --force --sign - --options runtime --entitlements entitlements.plist /Applications/DingTalk.app')

4. 重签名

改完二进制原签名就废了,重签一下。方案 A 不注入 dylib,不用动 disable-library-validation,直接用前置分析里导出的那份原始 entitlements.plist

codesign --force --sign - --options runtime \
    --entitlements entitlements.plist \
    /Applications/DingTalk.app

同样别用 --deep,它会连带把内嵌 framework 改成 ad-hoc,某次系统更新后随时触发加载失败、整个客户端打不开。这条坑的细节见方案 B 的警告框。--options runtime 则是补回 hardened runtime 标志。

5. 还原

cp ./DingTalk.backup /Applications/DingTalk.app/Contents/MacOS/DingTalk
codesign --force --sign - --options runtime \
    --entitlements entitlements.plist \
    /Applications/DingTalk.app

方案 B:DYLD 注入 Hook

这套比 A 干净,推荐。

原理

macOS 的 DYLD_INSERT_LIBRARIES 能在进程启动时塞一个自定义 dylib 进去。这个 dylib 在 constructor 里用 ObjC Runtime 的 method_setImplementation,把 DTMojoMessageServiceupdateToRead:mids:uuid:completionHandler: 换成自己的实现。新实现啥也不干,调一下 success callback 就 return,请求根本不发出去。

比方案 A 好在三处:不 patch 二进制字节,改的是运行时内存(唯一动静是为放下 dylib,把主二进制重签一下——但只签这一层、不碰内嵌 framework,远没 A 的逐字节破坏那么重),不跑注入脚本就回到原生已读行为;请求没出门,服务器不会回错误;还能用 NSLog 把每次拦截打出来,心里有数。

调用链拦截点:

用户点开消息
  → DTMojoMessageService -updateToRead:mids:uuid:completionHandler:
    ↑ 在这里拦住,直接 return,后面全部跳过
    ✗ DTMessageServiceImplStd::BatchUpdateToReadImplV2  (不会被调用)
      ✗ HTTP POST /r/IDLMessageStatus/updateToReadV2    (不会发出)

完整代码:noread_hook.m

// noread_hook.m
// DYLD 注入 hook:拦截钉钉已读回执,实现「偷看消息不已读」
// 编译:clang -dynamiclib -arch arm64 -framework Foundation -o noread_hook.dylib noread_hook.m
// 如果是 Intel Mac,将 arm64 改为 x86_64;也可以用 -arch arm64 -arch x86_64 编译通用版本
#import <Foundation/Foundation.h>
#import <objc/runtime.h>

static IMP sOriginalUpdateToRead = NULL;

// 替换后的方法实现:直接调用 success callback,跳过网络请求
static void HookedUpdateToRead(id self, SEL _cmd, id cid, id mids, id uuid,
                                id completionHandler) {
    NSLog(@"[NoRead] Blocked updateToRead — cid: %@, message count: %lu",
          cid, (unsigned long)[mids count]);

    // completionHandler 是 success block (void(^)(void))
    // 直接调用它,让调用方认为已读回执已成功发送
    if (completionHandler) {
        void (^successBlock)(void) = completionHandler;
        successBlock();
    }
}

__attribute__((constructor))
static void NoReadHookInit(void) {
    NSLog(@"[NoRead] Initializing hook...");

    // 1. 查找目标类
    Class cls = NSClassFromString(@"DTMojoMessageService");
    if (!cls) {
        // 子进程(如 prelaunch、Tblive)中没有这个类,属正常现象
        NSLog(@"[NoRead] DTMojoMessageService not found in this process, skipping.");
        return;
    }

    // 2. 查找目标方法
    SEL sel = NSSelectorFromString(@"updateToRead:mids:uuid:completionHandler:");
    Method method = class_getInstanceMethod(cls, sel);
    if (!method) {
        NSLog(@"[NoRead] Target method not found, skipping.");
        return;
    }

    // 3. 替换实现
    sOriginalUpdateToRead = method_setImplementation(method, (IMP)HookedUpdateToRead);
    NSLog(@"[NoRead] Hook installed. Original IMP: %p", sOriginalUpdateToRead);
}

那段「子进程里没有这个类属正常现象」的注释:钉钉会拉起一堆 prelaunchTblive 之类的辅助进程,它们压根没加载 DTMojoMessageService,hook 在它们里 return 掉就行,别让 constructor 抛异常。

编译

# Apple Silicon
clang -dynamiclib -arch arm64 -framework Foundation -o noread_hook.dylib noread_hook.m

# Intel Mac
clang -dynamiclib -arch x86_64 -framework Foundation -o noread_hook.dylib noread_hook.m

# 通用版本(两种架构都支持)
clang -dynamiclib -arch arm64 -arch x86_64 -framework Foundation -o noread_hook.dylib noread_hook.m

签名与注入准备

前面记下的 disable-library-validation=false 在这儿,钉钉默认不加载第三方签名的 dylib。得先把 App 重签,把这一项改成 true

1. 准备 entitlements.plist

就用前置分析里导出的那份 entitlements.plist,把 disable-library-validation 一项翻成 true,其余一律不动:

plutil -replace com.apple.security.cs.disable-library-validation -bool true entitlements.plist

还没导出的话,导出加翻转一步到位:

codesign -d --entitlements :- /Applications/DingTalk.app 2>/dev/null > entitlements.plist
plutil -replace com.apple.security.cs.disable-library-validation -bool true entitlements.plist

还是那句,别手敲。二十多项 entitlement,漏一项就可能让功能哑掉。从官方签名导出再改,最稳。

2. 重签名 App

先给结论——别用 --deep,这条命令才是对的:

codesign --force --sign - --options runtime \
    --entitlements entitlements.plist \
    /Applications/DingTalk.app

⚠️ 关于 --deep,我踩过一个很痛的坑。 文章初版我图省事写了 codesign --force --deep --sign ---deep 会把 app 里二十多个内嵌 framework 连带 ad-hoc 重签,顺手抹掉它们原本的 Team ID 和公证信息。重签当天一切正常,十来天后系统更新收紧了对 ad-hoc loadable code 的 mmap 校验,Masonry.frameworkQtNetwork.framework 一个接一个加载失败,dyldcode signing blocked mmap()——整个钉钉双击都打不开了,最后只能重装。

教训:注入第三方 dylib 只需要重签主可执行文件这一层、翻一个 entitlement 就够。内嵌 framework 的官方签名千万别碰,所以不用 --deep--options runtime 是把 hardened runtime 标志补回来——原签名带这个标志,重签不补会丢。

重签完顺手验证一下:主二进制应是 adhoc,runtime,framework 仍是原厂的 runtime + Team ID(凡是 framework 被改成 ad-hoc,就是签错位置了):

# 主二进制:adhoc + runtime
codesign -dvvv /Applications/DingTalk.app/Contents/MacOS/DingTalk 2>&1 | grep flags
# 期望:flags=0x10002(adhoc,runtime)

# framework:仍是官方签名,没被动过
codesign -dvvv /Applications/DingTalk.app/Contents/Frameworks/Masonry.framework 2>&1 | grep -E "flags|TeamIdentifier"
# 期望:flags=0x10000(runtime),TeamIdentifier 仍有值

3. 签名 dylib

codesign --force --sign - noread_hook.dylib

使用

启动脚本 launch_noread.sh

#!/bin/bash
DYLIB_PATH="$(cd "$(dirname "$0")" && pwd)/noread_hook.dylib"

if [ ! -f "$DYLIB_PATH" ]; then
    echo "Error: dylib not found at $DYLIB_PATH"
    exit 1
fi

echo "Launching DingTalk with NoRead hook..."
DYLD_INSERT_LIBRARIES="$DYLIB_PATH" /Applications/DingTalk.app/Contents/MacOS/DingTalk
chmod +x launch_noread.sh
./launch_noread.sh

预期日志输出

启动时:

[NoRead] Initializing hook...
[NoRead] Hook installed. Original IMP: 0x1058e3b08

查看消息时:

[NoRead] Blocked updateToRead — cid: cid1234567, message count: 3

子进程(正常,可忽略):

[NoRead] DTMojoMessageService not found in this process, skipping.

不想从终端启动也行,用 macOS 的 log 命令实时过滤:

log stream --predicate 'message contains "NoRead"'

还原

要分两层:

  • 只关掉 hook:不用终端注入脚本,正常双击启动即可,dylib 不会被加载,已读回执恢复原状。注入本身就是运行时行为,不跑就不生效。
  • 恢复官方签名:方案 B 为了放下 dylib 已经把主二进制 ad-hoc 重签、改了 entitlements。没有钉钉的开发者证书,没法手工签回官方签名——要么等钉钉自动更新覆盖(新版会带官方签名盖回去),要么干脆重装。在此之前 app 能正常用,只是签名栏变成了「自己签的」。

hook 这层随时能开关,底子干不干净取决于你介意不介意签名那栏。

套话

本文基于个人设备、个人账号、官方已安装的 macOS 钉钉客户端。不涉及破解软件分发、绕过账号鉴权,也没有攻击服务端或动别人数据。

逆向、调试、Hook 都只该在你自己拥有、收发双方都是你自己的设备上做。拦掉已读回执,改的是你自己这台机器上客户端的行为,别给组织或上级隐瞒本该知悉的工作信息,也别用来规避正常的工作协同。

折腾仅因个人兴趣,记录分享只为研究学习交流,请勿用于违法用途。

-END-

评论区加载中…