目标:一台 macOS 上同时运行两个相互隔离的飞书(主实例
Lark.app+ 第二实例Lark-org2.app),登录不同账号;数据目录、TCC/通知身份互不影响;第二实例不与主实例抢全局快捷键、不会被官方升级覆盖、菜单栏图标可区分。 本文自包含:不依赖任何现成脚本,按步骤逐条执行即可完成。实测版本:143.0.7499.203(macOS,Apple Silicon)。 这是飞书折腾系列第三篇:撤回机制与加密的逆向原理见《飞书撤回消息逆向分析》,防撤回 asar 补丁见《飞书防撤回补丁复现指南》。
0. 前置条件
| 项 | 要求 |
|---|---|
| 系统 | macOS(Apple Silicon;Intel 需把下文 clang 的 -arch 换成 x86_64 自测) |
| 源应用 | /Applications/Lark.app 已安装(是否打了别的补丁无所谓,副本会原样带上) |
| 工具 | python3 + Pillow(pip3 install pillow)、clang(Xcode CLT)、codesign/install_name_tool/otool(随 CLT) |
| 磁盘 | 预留一份飞书体积(~1.5GB) |
约定:下文 $VERDIR 一律指当前飞书的版本目录,先取好:
VERDIR="$(ls "/Applications/Lark.app/Contents/Frameworks/Lark Framework.framework/Versions" | grep -E '^[0-9]' | sort -V | tail -1)"
FW="/Applications/Lark-org2.app/Contents/Frameworks/Lark Framework.framework/Versions/$VERDIR"
DATA="$HOME/Library/Application Support/LarkShell-org2"
1. 步骤一:复制 .app 并改身份
cp -R /Applications/Lark.app /Applications/Lark-org2.app
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.electron.lark.org2" /Applications/Lark-org2.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleName Feishu2" /Applications/Lark-org2.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName 飞书2" /Applications/Lark-org2.app/Contents/Info.plist 2>/dev/null || true
改 CFBundleIdentifier 后,macOS 视其为独立 app:TCC(屏幕录制/麦克风等)、通知、
Dock 固定各自一套,与主实例互不干扰。
2. 步骤二:核心开关——manifest 的 userDataPath
**这是双开的充要条件。**飞书主进程(原生壳)用 Chromium ProcessSingleton 强制单
实例,锁文件在 <userDataPath>/SingletonLock;数据目录名 LarkShell 硬编码在壳
二进制里,命令行 --user-data-dir 与环境变量 LARK_DIR_USER_DATA 均无效,
唯一有效开关是 Framework Resources 下的 manifest.json(注意不在 webcontent 目录里):
python3 - <<PY
import json
p = "$FW/Resources/manifest.json"
d = json.load(open(p))
d['userDataPath'] = "$DATA"
json.dump(d, open(p, 'w'), ensure_ascii=False, separators=(',', ':'))
print('userDataPath =', json.load(open(p))['userDataPath'])
PY
改完 manifest,SingletonLock 落到新目录,双开即成立。只做第 1、2 步已经能双开, 以下步骤是体验加固(防升级/快捷键/图标)。
3. 步骤三:本地化显示名
菜单栏/Finder 的显示名取自各 .lproj/InfoPlist.strings,不改的话 UI 一半「飞书」
一半「飞书2」:
python3 - <<PY
import os, re
base = "/Applications/Lark-org2.app/Contents/Resources"
bad = re.compile(r'CFBundle(Name|DisplayName)')
for lproj in sorted(os.listdir(base)):
s = os.path.join(base, lproj, 'InfoPlist.strings')
if not lproj.endswith('.lproj') or not os.path.isfile(s):
continue
n = '飞书2' if lproj in ('zh-Hans.lproj', 'zh_CN.lproj') else \
('飛書2' if lproj in ('zh-Hant.lproj', 'zh_TW.lproj', 'zh_HK.lproj') else 'Feishu2')
kept = [l for l in open(s, encoding='utf-8').read().splitlines() if not bad.search(l)]
kept += ['', '"CFBundleName" = "%s";' % n, '"CFBundleDisplayName" = "%s";' % n]
open(s, 'w', encoding='utf-8').write('\n'.join(kept) + '\n')
print('ok')
PY
4. 步骤四:禁用自动升级
org2 必须钉在当前版本(升级会覆盖二进制、丢弃下两步的注入)。把数据目录下的 update 路径占死为 0 字节只读文件:
mkdir -p "$DATA"
rm -rf "$DATA/update" && touch "$DATA/update" && chmod 444 "$DATA/update"
效果:官方更新器写不进 update 目录,org2 永不升级;主实例照常升级互不影响。
5. 步骤五:菜单栏状态图标换文字(代理 dylib)
背景(为什么必须用 dylib):菜单栏纸飞机图标不在主进程,在 Lark Helper
进程里,由 Libraries/libbackend.dylib(Rust)从 frame_*.pak 解码 resource
id 50485 后调 [NSStatusBarButton setImage:];frame_*.pak 任何字节改动都会
触发渲染进程启动期完整性 CHECK 直接崩溃(§9 的 resources.pak 没有该校验,
可安全替换),所以只能拦 setImage。
做法:真身改名,代理顶替原路径,转发全部符号,命中 18x16 模板图时换成自绘文字图。
5.1 渲染文字图(以 “lark” 为例,白色模板,超采样防缩糊):
WORK="$(mktemp -d)"
python3 - "$WORK" <<'PY'
import sys
from PIL import Image, ImageDraw, ImageFont
work = sys.argv[1]
FP, IDX, TEXT, GH, SCALE = "/System/Library/Fonts/Helvetica.ttc", 1, "lark", 11, 10
f160 = ImageFont.truetype(FP, 160, index=IDX)
bb = ImageDraw.Draw(Image.new("RGBA", (8, 8))).textbbox((0, 0), TEXT, font=f160)
tw, th = bb[2] - bb[0], bb[3] - bb[1]
W1 = round(tw * GH / th) + 3
fsize = round(160 * (GH * SCALE) / th)
big = Image.new("RGBA", (W1 * SCALE, 16 * SCALE), (0, 0, 0, 0))
d = ImageDraw.Draw(big)
fb = ImageFont.truetype(FP, fsize, index=IDX)
b2 = d.textbbox((0, 0), TEXT, font=fb)
d.text(((big.width - (b2[2] - b2[0])) // 2 - b2[0], (big.height - (b2[3] - b2[1])) // 2 - b2[1]),
TEXT, font=fb, fill=(255, 255, 255, 255))
img1x = big.resize((W1, 16), Image.LANCZOS)
img2x = big.resize((W1 * 2, 32), Image.LANCZOS)
img1x.save(f"{work}/1x.png"); img2x.save(f"{work}/2x.png")
nz = sum(1 for p in img1x.getchannel("A").getdata() if p > 20)
assert nz > 60, f"文字像素过少({nz}),渲染异常"
print(f"lark 图: 1x={W1}x16, 2x={W1*2}x32")
PY
(字号必须乘 SCALE,否则 LANCZOS 缩完只剩一个点;末尾断言非透明像素数。)
5.2 生成代理源码(把两张 PNG 内嵌成 C 数组):
python3 - "$WORK" > "$WORK/proxy.m" <<'PY'
import struct, sys
work = sys.argv[1]
def carr(path, name):
d = open(path, 'rb').read()
rows = [",".join(str(b) for b in d[i:i + 16]) for i in range(0, len(d), 16)]
return "static const unsigned char %s[] = {\n" % name + ",\n".join(rows) + "\n};\n"
w1 = struct.unpack('>I', open(f"{work}/1x.png", 'rb').read()[16:20])[0]
print('#import <AppKit/AppKit.h>')
print('#import <objc/runtime.h>')
print('static const int FRK_W1 = %d;' % w1)
print(carr(f"{work}/1x.png", "frk_png_1x"), end='')
print(carr(f"{work}/2x.png", "frk_png_2x"), end='')
print('''static IMP frk_orig_setImage = NULL;
static NSImage *frk_replacement = nil;
static NSImage *frk_build(void) {
if (frk_replacement) return frk_replacement;
NSBitmapImageRep *r1 = [[NSBitmapImageRep alloc] initWithData:[NSData dataWithBytes:frk_png_1x length:sizeof(frk_png_1x)]];
NSBitmapImageRep *r2 = [[NSBitmapImageRep alloc] initWithData:[NSData dataWithBytes:frk_png_2x length:sizeof(frk_png_2x)]];
if (!r1 || !r2) return nil;
[r1 setSize:NSMakeSize(FRK_W1, 16)];
[r2 setSize:NSMakeSize(FRK_W1 * 2, 32)];
NSImage *im = [[NSImage alloc] init];
[im addRepresentation:r1];
[im addRepresentation:r2];
[im setTemplate:YES];
frk_replacement = im;
return frk_replacement;
}
static void frk_hook_setImage(id self, SEL _cmd, NSImage *img) {
if (img && (int)img.size.width == 18 && (int)img.size.height == 16 && img.isTemplate) {
NSImage *rep = frk_build();
if (rep) img = rep;
}
((void(*)(id,SEL,id))frk_orig_setImage)(self, _cmd, img);
}
__attribute__((constructor))
static void frk_init(void) {
Class c = NSClassFromString(@"NSStatusBarButton");
Method m = c ? class_getInstanceMethod(c, @selector(setImage:)) : NULL;
if (m) frk_orig_setImage = method_setImplementation(m, (IMP)frk_hook_setImage);
}''')
PY
5.3 真身让位 + 编译代理 + 验证:
LIBDIR="$FW/Libraries"
mv "$LIBDIR/libbackend.dylib" "$LIBDIR/libbackend_real.dylib"
install_name_tool -id "@loader_path/libbackend_real.dylib" "$LIBDIR/libbackend_real.dylib"
codesign -f -s - "$LIBDIR/libbackend_real.dylib"
clang -x objective-c -arch arm64 -dynamiclib -framework AppKit -fobjc-arc \
-install_name "@loader_path/libbackend.dylib" \
-o "$LIBDIR/libbackend.dylib" "$WORK/proxy.m" \
-Wl,-reexport_library,"$LIBDIR/libbackend_real.dylib"
otool -l "$LIBDIR/libbackend.dylib" | grep -q LC_REEXPORT_DYLIB \
&& codesign -f -s - "$LIBDIR/libbackend.dylib" \
&& echo "代理 ok" \
|| { echo "失败,还原真身"; mv "$LIBDIR/libbackend_real.dylib" "$LIBDIR/libbackend.dylib"; }
原理:LC_REEXPORT_DYLIB 把真身全部符号经代理转发,dlsym 透明,Lark Helper
无感知;状态项按钮自适应宽度,文字图自动放宽。
6. 步骤六:屏蔽 org2 全局快捷键
两个实例同时注册 Carbon RegisterEventHotKey 会抢注打架(截图/唤起等失灵)。
做法:org2 专用 interpose dylib 让注册恒失败,再往主程序 Mach-O 头部空隙
注入一条 LC_LOAD_DYLIB 加载它。
6.1 编译 interpose dylib(frk_nk 文件名必须 ≤12 字符,头隙只塞得下 56 字节命令):
cat > /tmp/frk_nokey_src.m <<'MEOF'
#import <Carbon/Carbon.h>
extern OSStatus RegisterEventHotKey(UInt32 keyCode, UInt32 mods, EventHotKeyID id,
EventTargetRef target, OptionBits options,
EventHotKeyRef *outRef);
static OSStatus nk_RegisterEventHotKey(UInt32 keyCode, UInt32 mods, EventHotKeyID id,
EventTargetRef target, OptionBits options,
EventHotKeyRef *outRef) {
if (outRef) *outRef = NULL;
return eventHotKeyExistsErr;
}
#define FRK_INTERPOSE(repl, orig) \
__attribute__((used)) static struct { const void *r; const void *o; } \
_interpose_##orig __attribute__((section("__DATA,__interpose"))) = \
{ (const void *)(unsigned long)&repl, (const void *)(unsigned long)&orig };
FRK_INTERPOSE(nk_RegisterEventHotKey, RegisterEventHotKey)
MEOF
clang -x objective-c -arch arm64 -arch arm64e -dynamiclib -framework Carbon \
-install_name @executable_path/frk_nk.dylib \
-o /Applications/Lark-org2.app/Contents/MacOS/frk_nk.dylib /tmp/frk_nokey_src.m
codesign -f -s - /Applications/Lark-org2.app/Contents/MacOS/frk_nk.dylib
6.2 往主程序头部空隙注入 LC_LOAD_DYLIB(解析 load commands,在尾部与 __text
首地址之间的空隙追加一条加载命令并同步 ncmds/sizeofcmds):
python3 - <<'PY'
import struct
p = "/Applications/Lark-org2.app/Contents/MacOS/Feishu"
d = bytearray(open(p, 'rb').read())
magic, cputype, cpusub, ft, ncmds, sizeofcmds, flags = struct.unpack('<7I', d[:28])
off, seen, text_off = 32, 0, None
while seen < ncmds:
cmd, cmdsize = struct.unpack('<II', d[off:off+8])
if cmd == 0x19: # LC_SEGMENT
name = d[off+8:off+24].rstrip(b'\0').decode()
nsects = struct.unpack('<I', d[off+64:off+68])[0]
so = off + 72
for i in range(nsects):
sect = d[so:so+80]
if name == '__TEXT' and sect[0:16].rstrip(b'\0') == b'__text':
text_off = struct.unpack('<I', sect[48:52])[0]
so += 80
off += cmdsize; seen += 1
name = b"@executable_path/frk_nk.dylib"
cmdsize = (24 + len(name) + 1 + 7) & ~7
end = 32 + sizeofcmds
assert end + cmdsize <= text_off, "header gap 不足"
cmd = struct.pack('<III', 0xC, cmdsize, 24) + struct.pack('<III', 2, 0, 0) + name.ljust(cmdsize-24, b'\0')
d[end:end+cmdsize] = cmd
struct.pack_into('<I', d, 16, ncmds+1)
struct.pack_into('<I', d, 20, sizeofcmds+cmdsize)
open(p, 'wb').write(d)
print('LC_LOAD_DYLIB 注入 ok')
PY
只改了 org2 的二进制,主实例快捷键不受影响。
7. 步骤七:整包重签 + 注册
# 改过二进制必须重签;⚠️ 绝不能带 entitlements——含 keychain-access-groups 的受限
# 权限会被内核拒 spawn(POSIX 162,app 起不来)。ad-hoc 无 runtime flag 本就不受限,
# 实测登录态无损。
codesign -f -s - --identifier com.electron.lark /Applications/Lark-org2.app
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
-f /Applications/Lark-org2.app
8. 首次启动
open /Applications/Lark-org2.app
- 登录第二个账号,登录态/消息落在
LarkShell-org2,与主实例(LarkShell)完全隔离; - org2 首启会重置
com.electron.lark-notifier通知权限;若主实例通知失效, 系统设置 → 通知 → 飞书,重新允许一次; - org2 重启后聊天页常停在空白页(不自动加载 messenger),用深链唤醒:
open -a /Applications/Lark-org2.app "lark://client/chat"(必须带-a定向;双开时裸lark://可能路由到主实例)。
9. 可选:Dock 图标与站内 logo 差异化(纯外观)
-
Dock/Finder 图标:任取一个 icns(如紫色 + 角标 2 的自制图)覆盖:
cp 你的.icns /Applications/Lark-org2.app/Contents/Resources/app.icns && touch /Applications/Lark-org2.app -
站内 UI logo(关于页/加载页):在
resources.pak内 16/24/64/128 四张 PNG 原位字节替换(只动 resources.pak,绝不动 frame_*.pak——后者有完整性 CHECK 必崩)。 先从当前版本 pak 提取原图字节(存成16_orig.png等),做一张同尺寸不大于原图的 替换图(存成16_new.png等),然后:python3 - "$FW/Resources/resources.pak" /path/to/logodir <<'PY' import sys, os pak, logo_dir = sys.argv[1], sys.argv[2] data = open(pak, 'rb').read() patched = 0 for f in sorted(os.listdir(logo_dir)): if not f.endswith('_orig.png'): continue orig = open(os.path.join(logo_dir, f), 'rb').read() new = open(os.path.join(logo_dir, f.replace('_orig.png', '_new.png')), 'rb').read() assert len(new) <= len(orig), f + " 替换图必须不大于原图" i = data.find(orig) if i < 0: print(f'跳过 {f}(字节未命中,版本已变)') continue data = data[:i] + new + b'\x00' * (len(orig) - len(new)) + data[i + len(orig):] patched += 1 open(pak, 'wb').write(data) print(f'替换 {patched} 张 logo') PY提取原图的简易办法:用 PIL 以已知尺寸(16/24/64/128)在 pak 里做 PNG 魔数扫描 (
\x89PNG\r\n\x1a\n)+ IHDR 尺寸匹配定位,截取完整 IDAT 段存盘。字节对不上就 跳过,无害。
10. 验证清单
pgrep -fl Feishu # 双实例:两条不同路径的主进程
ls -la "$DATA/update" # 0 字节只读占位(=永不升级)
| 检查 | 预期 |
|---|---|
| 同时开两个飞书 | 互不顶掉、各自登录不同账号 |
| 关于页/Dock | org2 显示「飞书2」,主实例「飞书」 |
| 菜单栏 | org2 是白色 “lark” 文字图标,主实例纸飞机 |
| 全局快捷键 | 双开下主实例截图/唤起正常(org2 注册恒失败,无抢注) |
| 升级 | 主实例升级后 org2 版本不变 |
11. 升级与运维
| 场景 | 操作 |
|---|---|
| 主实例飞书升级 | 删 /Applications/Lark-org2.app(登录态在 LarkShell-org2,不丢)→ 从第 1 步重做(约 2 分钟);若主实例带防撤回补丁,副本自动带上 |
| 只重启 org2 | pgrep -f "Lark-org2.app/Contents/MacOS/Feishu" | xargs kill -9;禁止 pkill -x Feishu(会杀两个) |
| 只重启主实例 | pgrep -f "/Applications/Lark.app/Contents/MacOS/Feishu" | xargs kill -9(全路径模式不会误杀 org2) |
| 防撤回类 asar 补丁 | org2 与主实例各打各的(asar 在各自 .app 内,互不跟随) |
12. 常见偏差对照
| 现象 | 原因与处理 |
|---|---|
| org2 启动把主实例顶掉(单实例弹窗) | manifest userDataPath 没改到——确认改的是最新版本目录下的 manifest.json(见 §0 的 VERDIR 取法) |
| org2 闪退,报 POSIX 162 | 整包重签带了 entitlements——用 §7 的裸命令重签 |
| org2 一启动就崩(渲染进程 EXC_BREAKPOINT) | 动了 frame_*.pak 字节——菜单栏图标只走 §5 代理 dylib,pak 不可改 |
双开后 lark:// 深链没反应 |
路由到了另一个实例,加 -a <app 路径> 定向 |
| org2 被官方升级覆盖 | update 占位失效——检查 $DATA/update 是否 0 字节且 chmod 444 |
| 菜单栏仍是纸飞机 | §5 步骤被跳过(缺 clang/PIL)或新版本图标尺寸变了(不再 18x16,调整 hook 里的尺寸判断) |
| Dock 图标没变 | 覆盖 icns 后没 touch .app,Launch Services 缓存未刷新 |
京公网安备11010602203188号