iOS的越狱检测和反越狱检测原理剖析
为什么要检测越狱?因为越狱后会大幅降低安全性。对于一些金融类的APP或者游戏类的,因为监管原因、资金安全问题,甚至防止使用越狱分析等,需要进行检测。不过其实越狱与反越狱就像矛与盾一样,都没有完美的方案。用一些反越狱插件可以防99%的越狱检测方式,本质上因为越狱后可以hook已知的所有检测越狱的方法,包括我下面的几种常用的。对于具体的反越狱插件可以用一些特定的方案来辅助检测。
具体代码清参考我的github
一、 越狱检测方案
1. 检测动态库
1.1 判断动态库stat是否是系统的库,并利用stat 来检测一些特定的文件权限
stat 命令时OS系统中用来判断文件信息的,但是对于私有的路径调用命令返回的是-1,如果越狱后,因为权限变化,可以通过stat返回私有目录下的文件信息。具体命令可以参考官方文档
代码实现:
BOOL isStatNotSystemLib() {
if(TARGET_IPHONE_SIMULATOR)return NO;
int ret ;
Dl_info dylib_info;
int (*func_stat)(const char *, struct stat *) = stat;
if ((ret = dladdr(func_stat, &dylib_info))) {
NSString *fName = [NSString stringWithUTF8String: dylib_info.dli_fname];
if(![fName isEqualToString:@"/usr/lib/system/libsystem_kernel.dylib"]){
return YES;
}
}
char *JbPaths[] = {"/Applications/Cydia.app",
"/usr/sbin/sshd",
"/bin/bash",
"/etc/apt",
"/Library/MobileSubstrate",
"/User/Applications/"};
for (int i = 0;i < sizeof(JbPaths) / sizeof(char *);i++) {
struct stat stat_info;
if (0 == stat(JbPaths[i], &stat_info)) {
return YES;
}
}
return NO;
}
1.2 判断是否注入了动态库
利用_dyld_get_image_name来获取动态库的名字,并查看是否有相关的动态库,这个相对来说最为准确,因为这个系统库运行的更早,且很多越狱的也需要依赖这个库的正常运行,所以更难被绕过
BOOL isInjectedWithDynamicLibrary()
{
int i=0;
char *substrate = "/Library/MobileSubstrate/MobileSubstrate.dylib";
while(true){
// hook _dyld_get_image_name方法可以绕过
const char *name = _dyld_get_image_name(i++);
if(name==NULL){
break;
}
if (name != NULL) {
if (strcmp(name,substrate)==0) {
return YES;
}
}
}
return NO;
}
2. 判断是否有越狱相关文件或权限
2.1 判断是否能打开越狱软件
利用URL Scheme来查看是否能够代开比如cydia这些越狱软件
//Check cydia URL hook canOpenURL 来绕过
if([[UIApplication sharedApplication] canOpenURL:[NSURL URLWithString:@"cydia://package/com.avl.com"]])
{
return YES;
}
if([[UIApplication sharedApplication] canOpenURL:[NSURL URLWithString:@"cydia://package/com.example.package"]])
{
return YES;
}
2.2 判断是否可以访问一些越狱的文件
越狱后会产生额外的文件,通过判断是否存在这些文件来判断是否越狱了,可以用fopen和FileManager两个不同的方法去获取
BOOL fileExist(NSString* path)
{
NSFileManager *fileManager = [NSFileManager defaultManager];
BOOL isDirectory = NO;
if([fileManager fileExistsAtPath:path isDirectory:&isDirectory]){
return YES;
}
return NO;
}
BOOL directoryExist(NSString* path)
{
NSFileManager *fileManager = [NSFileManager defaultManager];
BOOL isDirectory = YES;
if([fileManager fileExistsAtPath:path isDirectory:&isDirectory]){
return YES;
}
return NO;
}
BOOL canOpen(NSString* path)
{
FILE *file = fopen([path UTF8String], "r");
if(file==nil){
return fileExist(path) || directoryExist(path);
}
fclose(file);
return YES;
}
NSArray* checks = [[NSArray alloc] initWithObjects:@"/Application/Cydia.app",
@"/Library/MobileSubstrate/MobileSubstrate.dylib",
@"/bin/bash",
@"/usr/sbin/sshd",
@"/etc/apt",
@"/usr/bin/ssh",
@"/private/var/lib/apt",
@"/private/var/lib/cydia",
@"/private/var/tmp/cydia.log",
@"/Applications/WinterBoard.app",
@"/var/lib/cydia",
@"/private/etc/dpkg/origins/debian",
@"/bin.sh",
@"/private/etc/apt",
@"/etc/ssh/sshd_config",
@"/private/etc/ssh/sshd_config",
@"/Applications/SBSetttings.app",
@"/private/var/mobileLibrary/SBSettingsThemes/",
@"/private/var/stash",
@"/usr/libexec/sftp-server",
@"/usr/libexec/cydia/",
@"/usr/sbin/frida-server",
@"/usr/bin/cycript",
@"/usr/local/bin/cycript",
@"/usr/lib/libcycript.dylib",
@"/System/Library/LaunchDaemons/com.saurik.Cydia.Startup.plist",
@"/System/Library/LaunchDaemons/com.ikey.bbot.plist",
@"/Applications/FakeCarrier.app",
@"/Library/MobileSubstrate/DynamicLibraries/Veency.plist",
@"/Library/MobileSubstrate/DynamicLibraries/LiveClock.plist",
@"/usr/libexec/ssh-keysign",
@"/usr/libexec/sftp-server",
@"/Applications/blackra1n.app",
@"/Applications/IntelliScreen.app",
@"/Applications/Snoop-itConfig.app"
@"/var/lib/dpkg/info", nil];
//Check installed app
for(NSString* check in checks)
{
if(canOpen(check))
{
return YES;
}
}
2.3 查看是否有权限写入私有目录
通过检测是否可以写入私有目录来判断,是否越狱了
NSString *path = @"/private/avl.txt";
NSFileManager *fileManager = [NSFileManager defaultManager];
@try {
NSError* error;
NSString *test = @"AVL was here";
[test writeToFile:path atomically:NO encoding:NSStringEncodingConversionAllowLossy error:&error];
[fileManager removeItemAtPath:path error:nil];
if(error==nil)
{
return YES;
}
return NO;
} @catch (NSException *exception) {
return NO;
}
3. 利用系统命令来判断
3.1 通过lstat命令来判断系统的一些目录是否存在还是变成了链接
因为越狱后会变动一些文件,这些文件目录会迁移到其他区域,但是原来的文件位置必须有效,所以会创建符号链接,链接到原来的路径,我们可以检测这些符号链接是否存在,存在说明就越狱了
//symlink verification
struct stat sym;
// hook lstat可以绕过
if(lstat("/Applications", &sym) || lstat("/var/stash/Library/Ringtones", &sym) ||
lstat("/var/stash/Library/Wallpaper", &sym) ||
lstat("/var/stash/usr/include", &sym) ||
lstat("/var/stash/usr/libexec", &sym) ||
lstat("/var/stash/usr/share", &sym) ||
lstat("/var/stash/usr/arm-apple-darwin9", &sym))
{
if(sym.st_mode & S_IFLNK)
{
return YES;
}
}
3.2 是否能够fork一个子进程
未越狱的设备是无法fork子进程的,可以通过这个检测,还有其他类似的命令:方法posix_spawn,kill,popen等
//Check process forking
// hook fork
int pid = fork();
if(!pid)
{
exit(1);
}
if(pid >= 0)
{
return YES;
}
4. 查看是否有异常类和异常的动态库
4.1 检测是否有异常类
// 查看是否有注入异常的类,比如HBPreferences 是越狱常用的类,这里无法绕过,只要多找一些特征类就可以,注意,很多反越狱插件会混淆,所以可能要通过查关键方法来识别
NSArray *checksClass = [[NSArray alloc] initWithObjects:@"HBPreferences",nil];
for(NSString *className in checksClass)
{
if (NSClassFromString(className) != NULL) {
return YES;
}
}
4.2 检测是否有异常的动态库
这个和1.2章检测注入动态库的区别是,一般反越狱插件会hook_dyld_get_image_name
这个方法,把越狱使用的一些动态库给影藏掉(比如返回其他动态库名称,或者返回正常的),导致匹配不到,可以利用image加载时的回调来从MachO Header中去动态库信息,需要注意的是使用dladdr
检测库信息的时候,也可能被强制返回错误,需要进一步做一下判断,具体看下面代码。
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_dyld_register_func_for_add_image(_check_image);
});
}
// 监听image加载,从这里判断动态库是否加载,因为其他的检测动态库的方案会被hook
static void _check_image(const struct mach_header *header,
intptr_t slide) {
// hook Image load
if (SCHECK_USER) {
// 检测后就不在检测
return;
}
// 检测的lib
NSSet *dylibSet = [NSSet setWithObjects:
@"/usr/lib/CepheiUI.framework/CepheiUI",
@"/usr/lib/libsubstitute.dylib"
@"/usr/lib/substitute-inserter.dylib",
@"/usr/lib/substitute-loader.dylib",
nil];
Dl_info info;
// 0表示加载失败了,这里大概率是被hook导致的
if (dladdr(header, &info) == 0) {
char *dlerro = dlerror();
// 获取失败了 但是返回了dli_fname, 说明被人hook了,目前看的方案都是直接返回0来绕过的
if(dlerro == NULL && info.dli_fname != NULL) {
NSString *libName = [NSString stringWithUTF8String:info.dli_fname];
// 判断有没有在动态列表里面
if ([dylibSet containsObject:libName]) {
SCHECK_USER = YES;
}
}
return;
}
}
5. 检测是否在调试
5.1 查看是否有环境变量DYLD_INSERT_LIBRARIES
#pragma mark 通过环境变量DYLD_INSERT_LIBRARIES检测是否越狱
BOOL dyldEnvironmentVariables ()
{
if(TARGET_IPHONE_SIMULATOR)return NO;
return !(NULL == getenv("DYLD_INSERT_LIBRARIES"));
}
5.2 判断当前进程是否为调试模式
使用sysctl方法来获取当前进程的相关信息,从而确实是否在进行pTraced调试,具体参考sysctl,方法来自于官方https://developer.apple.com/library/archive/qa/qa1361/_index.html, 这里插一句,之前可以通过sysctl获取所有运行的程序,后来被苹果禁止了
BOOL isDebugged()
{
int junk;
int mib[4];
struct kinfo_proc info;
size_t size;
info.kp_proc.p_flag = 0;
mib[0] = CTL_KERN;
mib[1] = KERN_PROC;
mib[2] = KERN_PROC_PID;
mib[3] = getpid();
size = sizeof(info);
junk = sysctl(mib, sizeof(mib) / sizeof(*mib), &info, &size, NULL, 0);
assert(junk == 0);
return ( (info.kp_proc.p_flag & P_TRACED) != 0 );
}
6. 阻止DYLD_INSERT_LIBRARIES
生效 (iOS 10以下才有效)
反越狱插件基本上都是通过DYLD_INSERT_LIBRARIES
来做注入的,这个是官方提供的一套修改动态库的方案,看一下相关说明:
DYLD_INSERT_LIBRARIES
This is a colon separated list of dynamic libraries to load before the ones specified in the
program. This lets you test new modules of existing dynamic shared libraries that are used in
flat-namespace images by loading a temporary dynamic shared library with just the new modules.
Note that this has no effect on images built a two-level namespace images using a dynamic
shared library unless DYLD_FORCE_FLAT_NAMESPACE is also used.
查看下dylib的源码,pruneEnvironmentVariables
方法里,会移出DYLD开头的环境变量。
//
// For security, setuid programs ignore DYLD_* environment variables.
// Additionally, the DYLD_* enviroment variables are removed
// from the environment, so that any child processes don't see them.
//
static void pruneEnvironmentVariables(const char* envp[], const char*** applep)
{
// delete all DYLD_* and LD_LIBRARY_PATH environment variables
***
if ( removedCount != 0 ) {
dyld::log("dyld: DYLD_ environment variables being ignored because ");
switch (sRestrictedReason) {
case restrictedNot:
break;
case restrictedBySetGUid:
dyld::log("main executable (%s) is setuid or setgid\n", sExecPath);
break;
case restrictedBySegment:
dyld::log("main executable (%s) has __RESTRICT/__restrict section\n", sExecPath);
break;
case restrictedByEntitlements:
dyld::log("main executable (%s) is code signed with entitlements\n", sExecPath);
break;
}
}
***
}
检测是否需要拒绝的代码在这里processRestricted,其中geteuid是禁止使用的,所以可以用hasRestrictedSegment方法可以处理。
static bool processRestricted(const macho_header* mainExecutableMH)
{
// all processes with setuid or setgid bit set are restricted
if ( issetugid() ) {
sRestrictedReason = restrictedBySetGUid;
return true;
}
const uid_t euid = geteuid();
if ( (euid != 0) && hasRestrictedSegment(mainExecutableMH) ) {
// existence of __RESTRICT/__restrict section make process restricted
sRestrictedReason = restrictedBySegment;
return true;
}
#if __MAC_OS_X_VERSION_MIN_REQUIRED
// ask kernel if code signature of program makes it restricted
uint32_t flags;
if ( syscall(SYS_csops /* 169 */,
0 /* asking about myself */,
CS_OPS_STATUS,
&flags,
sizeof(flags)) != -1) {
if (flags & CS_RESTRICT) {
sRestrictedReason = restrictedByEntitlements;
return true;
}
}
#endif
return false;
}
检索hasRestrictedSegment,可以指定如果段里面有 __RESTRICT
,并且节里面有 __restrict
,就返回true
static bool hasRestrictedSegment(const macho_header* mh)
{
const uint32_t cmd_count = mh->ncmds;
const struct load_command* const cmds = (struct load_command*)(((char*)mh)+sizeof(macho_header));
const struct load_command* cmd = cmds;
for (uint32_t i = 0; i < cmd_count; ++i) {
switch (cmd->cmd) {
case LC_SEGMENT_COMMAND:
{
const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
//dyld::log("seg name: %s\n", seg->segname);
if (strcmp(seg->segname, "__RESTRICT") == 0) {
const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
const struct macho_section* const sectionsEnd = §ionsStart[seg->nsects];
for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
if (strcmp(sect->sectname, "__restrict") == 0)
return true;
}
}
}
break;
}
cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
}
return false;
}
所以只需要在xcode中Other Linker Flags
加入 -Wl,-sectcreate,__RESTRICT,__restrict,/dev/null
。创建一个空的section来解决(也可以放一个文件,利用getsectdata API获取),不过注意的是,千万不要在非发布环境的地方开启这个,因为会影响到比如Instruments
等依赖动态注入来debug的工具,而且苹果在iOS10以后放弃了检测。具体可以参考:Blocking Code Injection on iOS and OS X。
注意: 新的dylib实现中,这个检测逻辑只有MAC可用,iPhone已经不可用了。
if __MAC_OS_X_VERSION_MIN_REQUIRED
二、防越狱检测的方案和对策
越狱注入的基本原理是:MobileSubstrate
会将 SpringBoard 的 [FBApplicationInfo environmentVariables] 函式做 hook ,将环境变量DYLD_INSERT_LIBRARIES
设定新增需要载入的动态库,但是应用的二进制包无需做任何变化,dyld会在载入应用的时候因为DYLD_INSERT_LIBRARIES
去插入具体的库。 具体可以参考:https://itw01.com/84SJREC.html
2.1 shadow的防检测方法
有一些越狱的插件可以做到防越狱检测,这里以shadow为例,来解释下原理,知己知彼。shadow现在是开源的,可以在这里找到: https://github.com/jjolano/shadow/tree/old(用old分支)。
首先需要利用Tweak软件,大家可以参考这里iOS 越狱的Tweak开发,最主要的是使用cydia Substrate 软件来做动态库注入。具体使用可以参考这里cydiasubstrate,如果有时间会分析下具体的实现,这是个跨平台的开发方案,也支持Android平台。相关源码可以参考substrate.
简单来说就是设置环境变量,用DYLD_INSERT_LIBRARIES
来加载subsrate的注入代码:
https://github.com/jevinskie/substrate/blob/97fa4bae349b867ae789bb756f6c45c311d16e7d/Environment.hpp#L25-L26 。并利用__attribute__((constructor))
,关键字hook的相关代码在mian函数执行前执行。
从这里分析可以知道,本质是通过DYLD_INSERT_LIBRARIES
,详细的原理可以学习下这篇博文:macOS/OSX中的DYLDINSERTLIBRARIES DYLIB注入技术详解。所以可以利用上面提到的6. 阻止
DYLD_INSERT_LIBRARIES生效
来防止。
然后看下源码,shadow主要逻辑为:
- shadow会维护一个列表,检索哪些文件是越狱需要保护的文件
- hook相关的类,如果要检索这些文件,就影藏,返回修改后的结果。
最主要hook以下的方法
-
hook c的类,主要是各种判断文件权限和执行命令的方法,比如:
- access
- getenv
- fopen
- freopen
- stat
- dlopen
hook_NSFileManager | NSFileHandle | NSDirectoryEnumerator | hook_NSFileVersion | NSBundle
hook_NSURL
hook_UIApplication
hook_NSBundle
hook_CoreFoundation
hook UIImage
hook NSMutableArray | NSArray | NSMutableDictionary | NSDictionary | NSString
hook 第三方库检测方法
-
hook hook_debugging
- sysctl 主要用来检测是否当前进程挂载了P_TRACED
- getppid 返回当前的pid
- _ptrace
-
hook_dyld_image 。hook image动态加载的方法
- _dyld_image_count 获取image的数量
- _dyld_get_image_name 获取动态库的名字
hook_dyld_dlsym。 hook 用来检测是否可以加载动态库。功能和dlopen一样
hook系统一些私有方法: vfork | fork | hook_popen(打开管道)
-
hook runtime
-
objc_copyImageNames hook 获取所有加载的Objective-C框架和动态库的名称,shadow并不能所有都hook,他会把应用app的image加载后就停止了,防止检测的执行后注入的动态库被查找到:
-
%hookf(const char * _Nonnull *, objc_copyImageNames, unsigned int *outCount) {
const char * _Nonnull *ret = %orig;if(ret && outCount) { NSLog(@"copyImageNames: %d", *outCount); const char *exec_name = _dyld_get_image_name(0); unsigned int i; for(i = 0; i < *outCount; i++) { if(strcmp(ret[i], exec_name) == 0) { // Stop after app executable. *outCount = (i + 1); break; } } } return ret;
}
-
objc_copyClassNamesForImage 获取动态库里面对应的所有class名称
-
-
hook_dladdr dladdr可以用来获取方法或image对应的信息,比如所属的动态库的名称,这里hook如果是忽略的文件,则返回0,所以如果返回0,要再判断下是否数据真的是空的。
static int (*orig_dladdr)(const void *addr, Dl_info *info); static int hook_dladdr(const void *addr, Dl_info *info) { int ret = orig_dladdr(addr, info); if(!passthrough && ret) { NSString *path = [[NSFileManager defaultManager] stringWithFileSystemRepresentation:info->dli_fname length:strlen(info->dli_fname)]; if([_shadow isImageRestricted:path]) { return 0; } } return ret; }
2.3 如何防止shadow等插件绕过
几个策略:
- 检测这些插件的关键指纹,比如检测只有他们有的类, 参考
4. 查看是否有异常类和异常的动态库
的实现 - 阻止
DYLD_INSERT_LIBRARIES
生效, 参考6. 阻止
DYLD_INSERT_LIBRARIES生效
。(这个可以通过修改macho,重新打包来绕过) - 生产发布前,使用
objc_copyImageNames
方法记录使用的所有动态库,做成白名单,在运行过程中,再运行objc_copyImageNames
去查看当前的动态库是否一致
三、 参考
- Jailbreak Detection Methods
- system 函数被废除的替代方法](https://www.exchen.net/ios-hacker-system-%E5%87%BD%E6%95%B0%E8%A2%AB%E5%BA%9F%E9%99%A4%E7%9A%84%E6%9B%BF%E4%BB%A3%E6%96%B9%E6%B3%95.html)
- https://github.com/theos/theos
- wiki: Cydia_Substrate
- LD_PRELOAD, DYLD_INSERT_LIBRARIES 和 Cydia Substrate
- Blocking Code Injection on iOS and OS X
- 动态库加载源码:dyld
- Simple code injection using DYLD_INSERT_LIBRARIES
- macOS/OSX中的DYLDINSERTLIBRARIES DYLIB注入技术详解。
- A security review of 1,300 AppStore applications
- Tweak原理&防护
- Apple open source