Contents

一次失败的迅雷 Mac 版破解尝试

新片 Ready Player One 看得我心痒痒,想去海盗湾下一波枪版过过瘾。一本正经的用 uTorrent 下了一会……这尼玛太慢了受不了。迅雷——启动!下载,开……嗯?会员试用?试用!哇。爽歪歪!——会员试用结束!呃,比 uT 还慢的我天。正好心痒痒想实战一下怎么写一个 hook ,就来吧。

绕过校验

丢 IDA 丢 Hopper 里面都是最基本的。不得不说,自从 IDA 7.0 泄漏以后,不仅有了 Mac 版再也不用虚拟机了,而且 IDA 对ObjC 的方法识别更准确了,虽然找交叉引用还是得自己去找函数指针,但是相比原来已经方便了非常非常多,而且可以直接在 F5 里面查看 ObjC 方法的调用了!非常棒!

先查了一些资料,找到了一个比较新的大概半年前的版本1的分析,这里面介绍了一些迅雷针对老版 hook 的侦测和绕过完整性校验的方法。它里面用到了一个工具,是 theos 工具箱里面的 Logos2 。它的用处是作为代码的预处理器,可以让我们写 hook 变得极为方便~这个工具是本篇文章的核心。我准备先用着,下一篇文章再分析它咋实现的。

这里我对他绕过完整性校验的 hook 稍微做了点改进,因为在调试过程中发现,除了迅雷校验自己的 MD5 名称的目录以外还有其他一些插件的目录长度也是32。

默认	10:35:13.556340 +0800	Thunder	===== fileExistsAtPath: /Applications/Thunder.app/Contents/PlugIns/d50c5550bf387fc861039286aac68014
默认	10:35:14.196828 +0800	Thunder	XPC: DownloadServer launched: version = 4.0316.460.2 - 4.0316.2r.
默认	10:35:14.699477 +0800	Thunder	===== fileExistsAtPath: /Applications/Thunder.app/Contents/PlugIns/subtitle.xlplugin/Contents/MacOS
默认	10:35:14.704012 +0800	Thunder	===== fileExistsAtPath: /Applications/Thunder.app/Contents/PlugIns/660ba3a53307f21b0660d784ccc3b05c
默认	10:35:14.717713 +0800	Thunder	===== fileExistsAtPath: /Applications/Thunder.app/Contents/PlugIns/viprenew.xlplugin/Contents/MacOS
默认	10:35:14.722513 +0800	Thunder	===== fileExistsAtPath: /Applications/Thunder.app/Contents/PlugIns/feedback.xlplugin/Contents/MacOS
默认	10:35:14.723403 +0800	Thunder	===== fileExistsAtPath: /Applications/Thunder.app/Contents/PlugIns/afa7edc8c8b23d21a3b33a0be5858b43
默认	10:35:14.726564 +0800	Thunder	===== fileExistsAtPath: /Applications/Thunder.app/Contents/PlugIns/settings.xlplugin/Contents/MacOS
默认	10:35:14.730920 +0800	Thunder	===== fileExistsAtPath: /Applications/Thunder.app/Contents/PlugIns/xlplayer.xlplugin/Contents/MacOS

为了不破坏功能,对这个hook做了点改进,代码都堆在最下面。用我在以前文章里提到过的 unsign 干掉签名并删除所有 _CodeSignature 目录,就可以将我们的刚写的 dylib 注入啦。

DLL注入

这里有两种方法,第一种是我现在在用的。因为我们干掉应用签名已经修改了二进制文件,所以我不介意继续改它。

# 此处你已经将 theos clone 下来了。
THEOS=/your/path/to/theos/repo
HOOKFILE=/save/source/file/below
# 生成 dylib
$THEOS/bin/logos.pl $HOOKFILE > hook.m && clang -shared -undefined dynamic_lookup -o /Applications/Thunder.app/Contents/MacOS/libThunder.dylib hook.m
# 向 Thunder 插入它
optool install -c load -p @executable_path/libThunder.dylib -t /Applications/Thunder.app/Contents/MacOS/Thunder

另一种方法是利用环境变量 DYLD_INSERT_LIBRARIES ,就是将 /Content/MacOS 里面的同名二进制换成一个shell脚本,让它插入新的环境变量并启动程序,这样子比较优雅。这里引用 ChinaPYG 破解的 Hopper 的代码(因为我在用哈哈哈)。它在原程序名后面加了个 _

#!/bin/bash
Hopper_PATH="`dirname "${0}"`"
Hopper_BIN=$Hopper_PATH/"`basename "${0}"`"_

export DYLD_INSERT_LIBRARIES="${Hopper_PATH}/libChinaPYG.dylib"
"$Hopper_BIN"

到这里我们就成功绕过了迅雷和 macOS 的完整性验证并载入了第一个 hook library(撒花),接下来我们为它添加功能。每次修改完后直接运行上面那句包含 logos.pl 和 clang 的命令就可以一键部署我们刚写的 hook 了。

定位函数

我当然第一时间就把主程序丢进去啦,但是后来发现,里面一些名字有 Vip 的方法根本就没有地方在引用,更不用说在 applicationWillFinishLaunching 里面还检测如果 isVip 为 1 (老版本的hook)程序就马上退出。绝了。

然后在日志里发现,如果我点开了试用,会有这条消息。

默认	09:48:58.184348 +0800	Thunder	try tip window init
默认	09:48:58.229485 +0800	Thunder	try tip window deallocated
默认	09:48:59.263859 +0800	Thunder	setUserInfoToPlayer viptaskstate = 3
默认	09:49:00.128474 +0800	Thunder	setUserInfoToPlayer viptaskstate = 3

发现隐藏文件

我在迅雷目录里用 grep -R 定位 try tip ,发现它在 ./PlugIns/viptask.xlplugin/Contents/MacOS/viptask 里面。然而我打开 PlugIns 目录,竟然找不到这个插件!我 ls 一下它又在,行,又给我套路到了。

/zh/2018/crack-thunder-for-mac/00.png

我想这个东西应该跟 macOS 文件的元数据有关,找了找没有 .DS_Store ,推断应该跟 quarantine 状态一样存在文件系统里面了。枚举一下。

$ xattr -l /Applications/Thunder.app/Contents/PlugIns/* | grep -v com.apple.quarantine
/Applications/Thunder.app/Contents/PlugIns/660ba3a53307f21b0660d784ccc3b05c: com.apple.FinderInfo:
00000000  00 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00  |........@.......|
00000010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |................|
00000020
/Applications/Thunder.app/Contents/PlugIns/afa7edc8c8b23d21a3b33a0be5858b43: com.apple.FinderInfo:
00000000  00 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00  |........@.......|
00000010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |................|
00000020
/Applications/Thunder.app/Contents/PlugIns/liveupdate.xlplugin: com.apple.FinderInfo:
00000000  00 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00  |........@.......|
00000010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |................|
00000020
/Applications/Thunder.app/Contents/PlugIns/lixianspace.xlplugin: com.apple.FinderInfo:
00000000  00 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00  |........@.......|
00000010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |................|
00000020
/Applications/Thunder.app/Contents/PlugIns/settings.xlplugin: com.apple.FinderInfo:
00000000  00 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00  |........@.......|
00000010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |................|
00000020
/Applications/Thunder.app/Contents/PlugIns/userlogin.xlplugin: com.apple.FinderInfo:
00000000  00 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00  |........@.......|
00000010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |................|
00000020
/Applications/Thunder.app/Contents/PlugIns/viptask.xlplugin: com.apple.FinderInfo:
00000000  00 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00  |........@.......|
00000010  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |................|
00000020

这个 com.apple.FinderInfo 就非常有趣了。我查了一下,这是 sys/stat.h 里面定义的一个隐藏 flag3 【斜眼】,而且还给出了4去掉 flag 的方法,但他介绍的方法不是很好用。我还是沿用了删除 quarantine 属性的方法。

# 不是很好用,留作存档
chflags nohidden viptask.xlplugin
# 下面这个可以
xattr -dr com.apple.FinderInfo viptask.xlplugin

这个小技巧可以记下来以后忽悠人来用。CTF 没准就出了呢?

分析 viptask

在 viptask 里面,我们也可以在 VipServiceHelper 中找到 isVip 方法。连这个还有 isLogin 方法先一并hook掉,因为存在 self 里了,说明插件里的数据是从主程序里复制过来的。

char __cdecl -[VipServiceHelper isVip](VipServiceHelper *self, SEL a2)
{
  return self->_isVip;
}

但是会员等级这里似乎不同,这里调用了一个HostController。

unsigned __int8 __cdecl -[VipServiceHelper vipRank](VipServiceHelper *self, SEL a2)
{
  __int64 v2; // rax
  VipServiceHelper *v3; // rbx
  XLHostVipTaskProtocol *v4; // rdi
  unsigned __int8 result; // al

  v3 = self;
  v4 = self->_hostController;
  if ( v4 && (unsigned __int8)objc_msgSend((void *)v4, "respondsToSelector:", "vipRank", v2) )
    result = (unsigned __int64)objc_msgSend((void *)v3->_hostController, "vipRank");
  else
    result = 0;
  return result;
}

这样的话我们直接hook它不如去找他的源头。我们在主程序中搜索 vipRank 找到这个 Controller 类是 XLHostBaseController 。但它又用同样的方法调用了 UserController ,(就是老版本hook的类)而这玩意又继续调用了 self->_loginManager->loginSession->userInformation->vipLevel 。我们按图索骥找到 XLUserInformation 中的 vipLevel 。这个类中还有 vasTypeisVipisYear 等一众函数,这些是在登陆后所有组件读取的来源。我们可以在这儿就干掉它,也可以再向下追溯一下这些 getter 数据的来源。

回到主程序

登陆过程中, -[XLLoginSession updateSession:] 于登陆成功后调用 -[XLUserInformation initWithUserInfo:] 初始化一个信息类并填入信息,这个类中的关键部分如下。

// ...
	// v30 = v3.get("vipList")
    v30 = _objc_msgSend(v3, "objectForKey:", CFSTR("vipList"));
    v31 = (void *)objc_retainAutoreleasedReturnValue(v30);
    v32 = _objc_msgSend(&OBJC_CLASS___NSArray, "class");
	// isinstance(v30, NSArray)
    if ( (unsigned __int8)_objc_msgSend(v31, "isKindOfClass:", v32) )
    {
      v34 = _NSConcreteStackBlock;
      v35 = -1040187392;
      v36 = 0;
      // v37: 对 NSArray 中每个元素调用的 block
      v37 = __38__XLUserInformation_initWithUserInfo___block_invoke;
      v38 = &__block_descriptor_tmp_37;
      v39 = _objc_retain(v4, "isKindOfClass:");
      _objc_msgSend(v31, "enumerateObjectsUsingBlock:", &v34);
      _objc_release(v39);
    }
    _objc_release(v31);
// ...

而其中 v37 是对数组每一个元素调用的方法,相当于做一个 map。

__int64 __fastcall __38__XLUserInformation_initWithUserInfo___block_invoke(__int64 a1, __int64 a2, __int64 a3, _BYTE *a4)
{
  _BYTE *v4; // r15
  v4 = a4;
  v5 = a1;
  v6 = (void *)_objc_retain(a2, a2);
  v7 = _objc_msgSend(&OBJC_CLASS___NSDictionary, "class");
  if ( (unsigned __int8)_objc_msgSend(v6, "isKindOfClass:", v7) )
  {
    v8 = _objc_msgSend(v6, "objectForKey:", CFSTR("vasid"));
    v9 = (void *)objc_retainAutoreleasedReturnValue(v8);
    v63 = v4;
    v10 = v9;
    v11 = _objc_msgSend(v9, "copy");
    v12 = *(_QWORD *)(a1 + 32);
    v13 = *(_QWORD *)(v12 + 200);
    *(_QWORD *)(v12 + 200) = v11;
    _objc_release(v13);
    _objc_release(v10);
    if ( (unsigned __int8)_objc_msgSend(*(void **)(*(_QWORD *)(v5 + 32) + 200LL), "isEqualToString:", CFSTR("2")) )
    {
      *v63 = 1;
      v14 = _objc_msgSend(v6, "objectForKey:", CFSTR("payId"));
      v15 = (void *)objc_retainAutoreleasedReturnValue(v14);
      v16 = v15;
      v17 = _objc_msgSend(v15, "copy");
      v18 = *(_QWORD *)(v5 + 32);
      v19 = *(_QWORD *)(v18 + 176);
      *(_QWORD *)(v18 + 176) = v17;
      _objc_release(v19);
      _objc_release(v16);
       v38 = _objc_msgSend(v6, "objectForKey:", CFSTR("vipLevel"));
      // ...
      v50 = _objc_msgSend(v6, "objectForKey:", CFSTR("isVip"));
      // ...
    }
    // ...
  }
  // ...
}

可见,传进这个方法的每一个 NSArray 的元素,都是一个 NSDictionary 。里面的每个元素都会被复制到 *(a1 + 32) 这个对象中的某块空间。而且还有一个前提是 vasid 必须为 2 。所以 -[XLUserInformation vasId] 我们也必须 hook 到。

高速通道

到这里,我们已经可以成功登陆并显示为高端大气上档次的年费 SVIP6 超级会员了。但是此时选择加速还是会加速失败。

下载任务管理、包括高速通道的部分有些由前面提到的 viptask 插件完成,这从 IDA 的字符串列表可以看出来。我发现 IDA 并不能自动识别出某些没有被引用的 UTF-16LE 字符串。可能以后会我做个插件让它自动化一下。

我们照葫芦画瓢先为插件也加上动态库的链接。 尝试了一天以后发现,我们并不能让插件也在载入时动态链接某个库。因为迅雷在处理插件机制时,在系统的 NSBundle5 之上实现了一个 XLBundleManager 。而这个加载器实在是太复杂了……而我又没学过 Objective-C ,连F5都看不明白的我……臣妾做不到哇。按我的理解,这个啥啥 Bundle 就像是一个动态载入的代码块,相比 DLL 更接近于安卓中 DEX loading ,因为加载得到的结果不是一众放在 GOT 表里全局可用的函数,而是需要用某个特殊 getter 去获取的 Class 对象。

退而求其次,既然所有 Objective-C 的方法都是动态的,都是在运行时用 selector (类似于 C++ Class 的 mangled name) 查找方法,那所有方法必然存储在 Obj-C runtime 中的某个地方。我们在插件加载后、运行前的某个点上注入是否就可以了呢?我将这个点选在了 vipTaskPlugin 的 getter 方法 -[XLHostTaskController vipTaskPluginObject] 上。

id __cdecl -[XLHostTaskController vipTaskPluginObject](XLHostTaskController *self, SEL a2)
{
  XLHostTaskController *v2; // rbx
  XLPluginVipTaskProtocol *v3; // rdi
  ...
  v2 = self;
  v3 = self->_vipTaskPluginObject;
  if ( !v3 )
  {
    v14 = 0LL;
    v15 = &v14;
    v16 = 0x32000000;
   	...
    v4 = _objc_msgSend(v2, "plugins");
    v5 = (void *)objc_retainAutoreleasedReturnValue(v4);
    v8 = _NSConcreteStackBlock;
    v9 = -1040187392;
    v10 = 0;
    v11 = __43__XLHostTaskController_vipTaskPluginObject__block_invoke;
    v12 = &__block_descriptor_tmp_42;
    v13 = &v14;
    // 使用 block invoke 枚举、查找 ID 为 com.xunlei.plugin.task.viptask 的插件并加载
    _objc_msgSend(v5, "enumerateObjectsUsingBlock:");
    v6 = &v2->_vipTaskPluginObject;
    _objc_release(v5);
    // ...
  }
  // ...
}

因此我们 hook 这个方法,判断它被调用前 self->_vipTaskPluginObject 是否已加载,在插件成功加载后再应用对插件的 hook 即可。

但是经过另一番操作发现,这个插件和其他的插件一样,只是负责界面状态的更新而已。

// 这里省略了很多东西,XPC 服务这部分挺复杂的,C++/Obj-C 混写,非常烦人。

在那个 XPC 服务里面,迅雷在启动加速时对正式/试用 VIP 会员向不同端点的同一个 API 发送了请求, MITMProxy 抓包发现是一个二进制流,猜测经过了加密。通过域名定位到一个调用 AES 工具类的 xl_aes_decrypt 方法,调试器下断,解析得到如下返回值。是一个 UTF8 的字符串。

(lldb) print (char*)0x00007FFE7EE38200
(lldb) print (char*)0x00007FFE7EE38200
(char *) $1 = 0x00007ffe7ee38200 "{"flux_need":0,"flux_remain":0,"result":104,"message":"[08104] \xffffffe6\xffffffb5\xffffff81\xffffffe9\xffffff87\xffffff8f\xffffffe4\xffffffb8\xffffff8d\xffffffe8\xffffffb6\xffffffb3","client_sequence":0,"speed_duration":1800,"task_infos":[{"not_sec_requery_interval":900,"requery_averagetime":30,"requery_threshold":800,"message":"[08104] \xffffffe6\xffffffb5\xffffff81\xffffffe9\xffffff87\xffffff8f\xffffffe4\xffffffb8\xffffff8d\xffffffe8\xffffffb6\xffffffb3","result":104,"simple_msg":"\xffffffe6\xffffffb5\xffffff81\xffffffe9\xffffff87\xffffff8f\xffffffe4\xffffffb8\xffffff8d\xffffffe8\xffffffb6\xffffffb3","gcid":"FC610043C589F374429402CC9741051943961FEB"}],"is_super_privilege":0,"simple_msg":"\xffffffe6\xffffffb5\xffffff81\xffffffe9\xffffff87\xffffff8f\xffffffe4\xffffffb8\xffffff8d\xffffffe8\xffffffb6\xffffffb3","flux_capacity":0}\a\a\a\a\a\a\a"

>>> bs.decode()
'{"flux_need":0,"flux_remain":0,"result":104,"message":"[08104] 流量不足","client_sequence":0,"speed_duration":1800,"task_infos":[{"not_sec_requery_interval":900,"requery_averagetime":30,"requery_threshold":800,"message":"[08104] 流量不足","result":104,"simple_msg":"流量不足","gcid":"FC610043C589F374429402CC9741051943961FEB"}],"is_super_privilege":0,"simple_msg":"流量不足","flux_capacity":0}\x07\x07\x07\x07\x07\x07\x07'

然后我就放弃了。这个 JSON 会被转换为 service 内部的一个结构体,而数据类型偏移量都是未知的,查找引用很麻烦。我直接用老版的得了吧。

参考资料