前言
随着 iOS 逆向门槛越来越低,国内灰产也越来越多。自动抢红包则成为了 iOS 逆向入门级项目,GitHub 上也出现了技术水平参差不齐的微信插件的 Repo。而这些插件,往往都是基于一个多开微信。
写这篇文章最主要的目的还是提醒部分厂商,加强 App 安全防护意识,减少灰产的可乘之机。
如何多开
在 iOS 设备上安装多个同一个 App 的方式只有一种,修改 Info.plist 中的 CFBundleIdentifier
。
在整个 iOS 生态中,Bundle Identifier 是作为一个 App 的唯一标识符存在。
对于拥有相同的 Bundle Identifier 的 App,无论 Binary 和资源文件都多大的差异,iOS 都会将它们视为同一个 App。
对于拥有不同的 Bundle Identifier 的 App,也无论 Binary 与和资源文件是否一致,iOS 会将它们视为不同 App。
而 Info.plist 文件,是整个 App 的信息、配置、权限的信息整合文件,其在 App 中起到至关重要的作用。
为了防止 Info.plist 被恶意篡改,iOS 提供一种数字签名技术。通过该技术,计算出 Info.plist 文件的 Hash 值,加密后存入到签名文件中。在安装时与安装后,可通过该签名文件存的 Hash 值进行文件签名校验。也因此,App 签名后无法修改 Info.plist 文件;而即使是已安装 App 的 Info.plist 文件,修改后也会导致 App 闪退。
所以可以得出结论,对于已安装的 App 的 Info.plist 文件的 CFBundleIdentifier 值不会被修改
如何检测多开
通过上述结论,由于 Info.plist 文件的不可修改性质,我们可以在 App 运行时来读取 Info.plist 文件中的值来判断该值是否与出产时候相同,从而判断当前进程是否是一个多开 App。
Foundation.framework
提供了几种获取 App 的 Bundle Identifier 方法,基本如下:
NSBundle.mainBundle.bundleIdentifier;
[NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleIdentifier"];
NSBundle.mainBundle.infoDictionary[@"CFBundleIdentifier"];
[NSDictioanry dictionaryWithContentsOfFile:@"Info.plist"][@"CFBundleIdentifier"]
使用上面几种的任意一种,就可以获取到当前 App 的 Bundle Identifier 值,之后通过 -[NSString isEqualToString:]
方法来判断是否分身。
如何反检测多开
在已经知道如何检测多开的时候,就可以知道如何防止 App 检测多开了。
简单的来说,就是干掉上面的几个方法,强制返回一个原来的值即可。
1. 检测不到多开
具体思路是判断返回值是不是真实的 Bundle Identifier,如果是则返回原来的 Bundle Identifier。这样做的目的防止影响到别的对象以及别的 key 对应的值。
由于 NSDictionary
的特殊性质,通过 key 获取 value 的方式有多种,这几种方法也需要被干掉:
info[@"CFBundleIdentifier"]; // -[NSDictionary objectForKeyedSubscript:]
[info objectForKey:@"CFBundleIdentifier"];
[info valueForKey:@"CFBundleIdentifier"];
在此安利一个我自己写的比较轻量级的非越狱平台 hook 工具——MUHook。仿照 Captain Hook 写的宏集合,同时比 Captain Hook 更加方便快捷,也无需 CydiaSubstrate.dylib。(功能还在完善中)
以微信为例,代码如下:
/*
* 假设当前分身的 Bundle Identifier 值为 com.unique.xin
*/
#import <Foundation/Foundation.h>
#define CFBundleIdentifier @"CFBundleIdentifier"
#define ORIG_VALUE @"com.tencent.xin"
#define REAL_VALUE @"com.unique.xin"
/*
* 这里干掉了 -[NSBundle bundleIdentifier] 方法
*/
MUHInstanceImplementation(NSBundle, bundleIdentifier, NSString *) {
NSString *orig = MUHOrig(NSBundle, bundleIdentifier);
if ([orig isEqualToString:REAL_VALUE]) {
orig = ORIG_VALUE;
}
return orig;
}
/*
* 这里干掉了 -[NSBundle objectForInfoDictionaryKey:] 方法
*
* MUHInstanceImplementation 注释:定义一个 Hook 的实现体
* 第一个参数是要 Hook 的类
* 第二个参数是自己取的方法名,会在下面的 MUHMain 和 MUHOrig 用到,与实际方法名可以不一致,但是要保证唯一性
* 第三个参数是返回值类型
* 第四个参数以及之后的参数是 Hook 方法的实际参数表。
*
* 如果你要 Hook 一个 Class Method 比如 +[UIImage imageNamed]
* 请使用 MUHClassImplementation,具体用法同上
*/
MUHInstanceImplementation(NSBundle, objForInfoKey, id, NSString *key) {
/*
* MUHOrig 注释:调用原方法
* 第一个参数是原方法的类
* 第二个参数是自己取的方法名
* 第三个参数以及之后的参数是传入的实际参数
*/
NSString *orig = MUHOrig(NSBundle, objForInfoKey, key);
if ([key isKindOfClass:[NSString class]]) {
if ([key isEqualToString:CFBundleIdentifier]) {
if ([orig isEqualToString:REAL_VALUE]) {
orig = ORIG_VALUE;
}
}
}
return orig;
}
MUHInstanceImplementation(NSBundle, infoDictionary, NSDictionary *) {
NSMutableDictionary *info = [MUHOrig(NSBundle, infoDictionary) mutableCopy];
if (self == NSBundle.mainBundle) {
info[CFBundleIdentifier] = REAL_VALUE;
}
return [info copy];
}
MUHInstanceImplementation(NSDictionary, objectForKey, id, NSString *key) {
id orig = MUHOrig(NSDictionary, objectForKey, key);
if ([key isKindOfClass:[NSString class]]) {
if ([key isEqualToString:CFBundleIdentifier]) {
if ([orig isEqualToString:REAL_VALUE]) {
id = ORIG_VALUE;
}
}
}
return orig
}
MUHInstanceImplementation(NSDictionary, valueForKey, id, NSString *key) {
id orig = MUHOrig(NSDictionary, valueForKey, key);
if ([key isKindOfClass:[NSString class]]) {
if ([key isEqualToString:CFBundleIdentifier]) {
if ([orig isEqualToString:REAL_VALUE]) {
id = ORIG_VALUE;
}
}
}
return orig
}
MUHInstanceImplementation(NSDictionary, objectForKeyedSubscript, id, NSString *key) {
id orig = MUHOrig(NSDictionary, objectForKeyedSubscript, key);
if ([key isKindOfClass:[NSString class]]) {
if ([key isEqualToString:CFBundleIdentifier]) {
if ([orig isEqualToString:REAL_VALUE]) {
id = ORIG_VALUE;
}
}
}
return orig
}
/*
* MUHMain 注释:定义一个拥有 constructor 属性的函数。
* 当 dyld 加载此二进制文件到内存中的时候会自动调用此函数,完成运行前的 hook 工作
*/
void MUHMain() {
/*
* MUHHookInstanceMessage 注释:让上面定义的某个 Instance Hook 实现体生效
* 第一个参数是上面实现体的类
* 第二个参数是上面实现体自己取的方法名
* 第三个参数是对应的 SEL
*
* 如果要让一个 Class Hook 生效,可以使用
* MUHHookClassMessage()
* 使用方式同上
*/
MUHHookInstanceMessage(NSBundle, bundleIdentifier, bundleIdentifier);
MUHHookInstanceMessage(NSBundle, infoDictionary, infoDictionary);
MUHHookInstanceMessage(NSBundle, objectForInfoDictionaryKey, objectForInfoDictionaryKey:);
MUHHookInstanceMessage(NSDictionary, objectForKey, objectForKey:);
MUHHookInstanceMessage(NSDictionary, valueForKey, valueForKey:);
MUHHookInstanceMessage(NSDictionary, objectForKeyedSubscript, objectForKeyedSubscript:);
}
同时,微信也可以通过 IDFA、DeviceName 来判断是否是同一台设备登录不同的微信,所以以下两个方法也要被干掉:
#import <UIKit/UIKit.h>
#import <AdSupport/AdSupport.h>
MUHInstanceImplementation(ASIdentifierManager, advertisingIdentifier, NSUUID *) {
NSString *idfa = [userDefaults stringForKey:@"com.unique.idfa"];
if(!idfa)
{
idfa = [NSUUID UUID].UUIDString;
[userDefaults setObject:idfa forKey:@"com.unique.idfa"];
[userDefaults synchronize];
}
NSUUID *udid = [[NSUUID alloc] initWithUUIDString:idfa];
return udid;
}
/*
* 强制返回一个不容易被怀疑的大众型名字
*/
MUHInstanceImplementation(UIDevice, name, NSString *) {
return @"iPhone";
}
void MUHMain() {
MUHHookInstanceMessage(ASIdentifierManager, advertisingIdentifier, advertisingIdentifier);
MUHHookInstanceMessage(UIDevice, name, name);
}
2. 只让微信检测不到多开
写到这里,基本上对 NSBundle 的调用都被干掉了。但是这里面存在着一个潜在的问题。
在 App 运行时,除微信主二进制文件外,随着被加载到内存中的二进制还有:微信内置 Framework,微信所用到的系统 Framework,插件自身 dylib。
而我们并不能保证系统 Framework 是否会调用、何时会调用 NSBundle 相关方法。如果系统 Framework 调用了相关方法,得到了假的 Bundle ID,则有可能出现无法预计的问题,甚至是出现了也找不到问题的bug。所以我们必须保证如果是系统调用的方法,要返回真实的 Bundle ID。
同时,如果插件自身想要获取 Bundle ID,也应该要返回一个真实的 Bundle ID。
于是提出需求:如果是微信调用的方法,返回假值,否则返回真值。
我们可以通过 dyld 的 dladdr()
函数配合当前调用栈地址来判断调用者来自哪个二进制文件。具体代码如下:
#include <dlfcn.h>
BOOL MUIsCallFromWeChat() {
NSArray *address = [NSThread callStackReturnAddresses];
Dl_info info = {0};
if(dladdr((void *)[address[2] longLongValue], &info) == 0) return NO;
NSString *path = [NSString stringWithUTF8String:info.dli_fname];
if ([path hasPrefix:NSBundle.mainBundle.bundlePath]) {
// 二进制来自 ipa 包内
if ([path.lastPathComponent isEqualToString:@"MyPlugin.dylib"]) {
// 二进制是插件本身
return NO;
} else {
// 二进制是微信
return YES;
}
} else {
// 二进制是系统或者越狱插件
return NO;
}
}
// 以 -[NSBundle bundleIdentifier] 为例,其余自己扩展
MUHInstanceImplementation(NSBundle, bundleIdentifier, NSString *) {
NSString *orig = MUHOrig(NSBundle, bundleIdentifier);
if (MUIsCallFromWeChat() == NO) {
return orig;
}
if ([orig isEqualToString:REAL_VALUE]) {
orig = ORIG_VALUE;
}
return orig;
}
如何反反检测多开
1. 使用 CoreFoundation 检测(不一定靠谱)
与 Foundation
相对应,CoreFoundation
也有一套关于 CFBundleRef
操作的 C 语言 API。大部分的厂商一般都使用 NSBundle
来获取 Bundle ID。所以可以通过这一套 C 语言 API 来获取 CFBundleIdentifier 继而判断是否多开。
但由于这一套毕竟是苹果公开的 API,插件依旧可以使用 fishhook 来完成 C 函数的 hook。所以个人认为这种方法并不是完全靠谱。
2. 使用 Appex、Watch 检测(不一定靠谱)
由于 iOS 规定,所有 PlugIn 和 Watch App 的 Bundle ID 的前缀必须和 mainBundle 的 Bundle ID 一致,所以如果多开的 App 改了 mainBundle 的 Bundle ID,同时也要修改 PlugIns 和 Watch App 的 Bundle ID。
也因此可以通过获取 PlugIn 和 Watch App 的 Bundle ID 来判断 mainBundle 是否被修改过。
但是一般的微信插件都会删除 PlugIns 和 Watch 文件夹,所以这个方法也不是很靠谱。
3. 使用 FILE + 加密 + 混淆 检测(我认为靠谱)
是否多开最终还是判断 Info.plist 文件中的 CFBundleIdentifier
值是否被修改。上述获取该值的方法都是通过大家都知道的苹果提供的 API 来获取。实际上可以自己实现一套获取方法,绕过耳熟能详的 API,再进行一些字符串加密以及代码混淆即可实现更加安全的多开检测方式。
#define EncryptStr(str) str // 加密算法
#define DecryptStr(str) str // 解密算法
static BOOL ZheBuShiYiGeDuoKaiJianCe()
int result = 1;
NSString *filePath = [NSBundle.mainBundle.bundlePath stringByAppendingPathComponent:DecryptStr(@"此处写死加密的Info.plist"];
NSDictionary *dictionary = [NSDictionary dictionaryWithContentsOfFile:filePath];
filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"temp.txt"];
[dictionary writeToFile:filePath atomically:YES];
// 不知道为什么 Demo 中的 Info.plist 不能直接 UTF8 来读取,必须经过字典 read write 后才能读。
// 这里可能会有漏洞,可以直接 hook -[NSDictionary dictionaryWithContentsOfFile:]
// 但是为什么直接读会出来乱码我也不太清楚,有大神知道的话可以指导我一下。
unsigned long long size = [NSFileManager.defaultManager attributesOfItemAtPath:filePath error:nil].fileSize;
FILE *file = fopen(filePath.UTF8String, "r");
if (file != NULL) {
char *content = malloc(size + 1);memset(content, 0, size + 1);
fread(content, size, sizeof(char), file);
char *beginIndex = strstr(content, DecryptStr("此处写死加密的CFBundleIdentifier"));
beginIndex = strstr(beginIndex, DecryptStr("此处写死加密的<string>")) + 8;
if (beginIndex != NULL) {
char *endIndex = strstr(beginIndex, DecryptStr("此处写死加密的</string>"));
if (endIndex != NULL) {
unsigned long bidsize = endIndex - beginIndex;
char *endbid = malloc(bidsize + 1);memset(content, 0, bidsize + 1);
strlcpy(endbid, beginIndex, bidsize + 1);
result = strcmp(EncryptStr(endbid),"此处写死加密的BundleID");
// 此处直接比较加密的 BundleID,因为 strcmp 也可能会被 hook。
free(endbid);
}
}
free(content);
beginIndex = content = NULL;
fclose(file);
}
printf("%d\n", result);
return result == 0;
}