iOS越狱检测app及frida过检测

查看 75|回复 9
作者:andyhah   
简介
近期学习ios逆向,也为了熟悉一下iOS开发正向。用objective-c整了一个越狱检测的crackme,然后用frida过一遍自己写的检测(真是没事找事)。代码粗糙,请见谅。

准备
  • crackme app
  • ios 12.5.5
  • frida 14.0.0


    克隆项目,再用xcode安装到手机上

    使用stat检测敏感路径
    利用stat检查一些越狱后才有的敏感路径,如:/Applications/Cydia.app 和 /usr/sbin/sshd,以此来判断是否越狱。stat判断文件是否存在, 返回0则为获取成功,-1为获取失败。可通过hook stat,过掉检测
    function hook_stat(is_pass){
      var stat = Module.findExportByName('libSystem.B.dylib', 'stat');
      Interceptor.attach(stat, {
        onEnter: function(args) {
          // 这里是方法被调用时的处理逻辑
          // args[0] 是 stat 方法的第一个参数,通常是文件路径
          // args[1] 是 stat 方法的第二个参数,这里可以添加其他参数的处理
          console.log('stat is hooked: ');
        },
        onLeave: function(retval){
          if (is_pass){
            retval.replace(-1);
            console.log(`stat retval: ${Number(retval.toString())} -> -1`);
          }
        }
      });
    }

    检查dylib是否合法
    越狱后会产生一些特殊的链接库,ipa可以通过_dyld_get_image_name来获取所有的链接库,再遍历匹配,判断是否为越狱设备。
    可以通过分析找到ipa检测的dylib,再hook _dyld_get_image_name,将返回替换为合法dylib,过掉检测。
    function hook_dyld_get_image_name(is_pass){
      let cheek_paths = [
        "/Library/MobileSubstrate/MobileSubstrate.dylib",
      ]
      let NSString = ObjC.classes.NSString;
      let true_path = NSString.stringWithString_( "/System/Library/Frameworks/Intents.framework/Intents");
      let _dyld_get_image_name = Module.findExportByName(null, "_dyld_get_image_name");
      Interceptor.attach(_dyld_get_image_name, {
        onEnter: function(args){
          console.log("_dyld_get_image_name is hooked.")
          this.idx = eval(args[0]).toString(10);
        },
        onLeave: function(retval){
          let rtnStr = retval.readCString();
          if(is_pass){
            for (let i=0;i ${true_path}`)
              }
            }
          }
        }
      })
    }

    检测能否启动越狱app
    越狱后会在手机上安装越狱设备,如cydia。可以通过 -[UIApplication canOpenURL:] 来检测是否能启动app。
    可hook -[UIApplication canOpenURL:] 替换返回过掉检测。但canOpenURL方法 返回是个 BOOL,即YES/NO,也就是1和0的宏。但在Interceptor.attach里用 retval.replace()总是会导致app崩溃(不知道原理,望大佬指点)。
    所以使用 Interceptor.replace() + NaviteCallback, 替换掉方法,使其固定返回 0,也就是 NO。但这个解法,也不能算是好方法。。。
    function hook_canopenurl(is_pass){
      let api = new ApiResolver("objc");
      api.enumerateMatches("-[UIApplication canOpenURL:]").forEach((matche) => {
        console.log("canOpenURL is hooked.");
        if (is_pass){
          Interceptor.replace(matche.address, new NativeCallback((url_obj) => {return 0;}, "int", ["pointer"]))
        }
      })
    }

    检测越狱文件和目录
    越狱后会产生特殊的文件和目录,可以通过 fileExistsAtPath 来检测,直接hook过掉
    // -[NSFileManager fileExistsAtPath:isDirectory:]
    function hook_fileExistsAtPath(is_pass){
      let api = new ApiResolver("objc");
      let matches = api.enumerateMatches("-[NSFileManager fileExistsAtPath:isDirectory:]")
      matches.forEach((matche) => {
        console.log("fileExistsAtPath is hooked.");
        if(is_pass){
          Interceptor.replace(matche.address, new NativeCallback((path, is_dir) => {
            console.log(ObjC.Object(path).toString(), is_dir)
            return 0;
          }, "int", ["pointer", "bool"]))
        }
      })
    }

    检测是否可写私有路径权限
    越狱后为root权限,可以在私有路径如 /private/ 下创建文件。如果创建文件无异常则越狱,反之。
    可通过 ObjC.classes.NSError.alloc() 构建一个异常写入ipa检测的异常指针中
    function hook_writeToFile(is_pass){
      let api = new ApiResolver("objc");
      api.enumerateMatches("-[NSString writeToFile:atomically:encoding:error:]").forEach((matche) => {
        Interceptor.attach(matche.address, {
          onEnter: function(args){
            this.error = args[5];
            this.path = ObjC.Object(args[2]).toString();
            console.log("writeToFile is hooked");
          },
          onLeave: function(retval){
            if(is_pass){
              let err = ObjC.classes.NSError.alloc();
              Memory.writePointer(this.error, err);
            }
          }
        })
      })
    }

    检测文件路径和是否是路径链接
    越狱后有些文件会被移动,但这个文件路径又必须存在,所以可能会创一个文件链接。ipa可以检测一些敏感路径是否是链接来判断是否越狱。
    这里仅过掉路径检测(符号链接不会过T.T)
    // oc 检测函数
    + (Boolean)isLstatAtLnk{
        // 检测文件路径是否存在,是否是路径链接
        Boolean result = FALSE;
        NSArray* jbPaths = @[
            @"/Applications",
            @"/var/stash/Library/Ringtones",
            @"/var/stash/Library/Wallpaper",
            @"/var/stash/usr/include",
            @"/var/stash/usr/libexec",
            @"/var/stash/usr/share",
            @"/var/stash/usr/arm-apple-darwin9",
        ];
        struct stat stat_info;
        for(NSString* jbPath in jbPaths){
            char jbPathChar[jbPath.length];
            memcpy(jbPathChar, [jbPath cStringUsingEncoding:NSUTF8StringEncoding], jbPath.length);
            if (lstat(jbPathChar, &stat_info)){
                NSLog(@"stat_info.st_mode: %hu, S_IFLNK: %d, %d", stat_info.st_mode, S_IFLNK, stat_info.st_mode & S_IFLNK);
                if(stat_info.st_mode & S_IFLNK){
                    result = TRUE;
                    NSLog(@"是路径链接>> %@", jbPath);
                }
            }else{
                NSLog(@"路径不存在>> %@", jbPath);
                result = TRUE;
            }
        }
        return result;
    }
    // 过lstat
    function hook_lstat(is_pass){
      var stat = Module.findExportByName('libSystem.B.dylib', 'lstat');
      Interceptor.attach(stat, {
        onEnter: function(args) {
          console.log('lstat is hooked: ');
        },
        onLeave: function(retval){
          if (is_pass){
            retval.replace(1);
            console.log(`lstat retval: ${Number(retval.toString())} -> 1`);
          }
        }
      });
    }
    检测fork
    未越狱的设备是无法fork子进程
    hook fork
    function hook_fork(is_pass){
      let fork = Module.findExportByName(null, "fork");
      if (fork){
        console.log("fork is hooked.");
        Interceptor.attach(fork, {
          onLeave: function(retval){
            console.log(`fork -> pid:${retval}`);
            if(is_pass){
              retval.replace(-1)
            }
          }
        })
      }
    }

    检测越狱常用的类
    查看是否有注入异常的类,比如HBPreferences 是越狱常用的类,再用 NSClassFromString 判断类是否存在
    通过分析找出检测的类名,再去hook NSClassFromString
    function hook_NSClassFromString(is_pass){
      let clses = ["HBPreferences"];
      var foundationModule = Process.getModuleByName('Foundation');
      var nsClassFromStringPtr = Module.findExportByName(foundationModule.name, 'NSClassFromString');
      if (nsClassFromStringPtr){
        Interceptor.attach(nsClassFromStringPtr, {
          onEnter: function(args){
            this.cls = ObjC.Object(args[0])
            console.log("NSClassFromString is hooked");
          },
          onLeave: function(retval){
            if (is_pass){
              clses.forEach((ck_cls) => {
                if (this.cls.toString().indexOf(ck_cls) !== -1){
                  console.log(`nsClassFromStringPtr -> ${this.cls} - ${ck_cls}`)
                  retval.replace(ptr(0x00))
                }
              })
            }
          }
        })
      }
    }

    检测是否有环境变量
    通过getenv函数,查看环境变量DYLD_INSERT_LIBRARIES来检测是否越狱
    hook getenv
    function hook_getenv(is_pass){
      let getenv = Module.findExportByName(null, "getenv");
      Interceptor.attach(getenv, {
        onEnter: function(args){
          console.log("getenv is hook");
          this.env = ObjC.Object(args[0]).toString();
        },
        onLeave: function(retval){
          if (is_pass && this.env == "DYLD_INSERT_LIBRARIES"){
            console.log(`env: ${this.env} - ${retval.readCString()}`)
            retval.replace(ptr(0x0))
          }
        }
      })
    }

    整体代码
    function hook_stat(is_pass){
      var stat = Module.findExportByName('libSystem.B.dylib', 'stat');
      Interceptor.attach(stat, {
        onEnter: function(args) {
          // 这里是方法被调用时的处理逻辑
          // args[0] 是 stat 方法的第一个参数,通常是文件路径
          // args[1] 是 stat 方法的第二个参数,这里可以添加其他参数的处理
          console.log('stat is hooked: ');
        },
        onLeave: function(retval){
          if (is_pass){
            retval.replace(-1);
            console.log(`stat retval: ${Number(retval.toString())} -> -1`);
          }
        }
      });
    }
    function hook_dyld_get_image_name(is_pass){
      let cheek_paths = [
        "/Library/MobileSubstrate/MobileSubstrate.dylib",
      ]
      let NSString = ObjC.classes.NSString;
      let true_path = NSString.stringWithString_( "/System/Library/Frameworks/Intents.framework/Intents");
      let _dyld_get_image_name = Module.findExportByName(null, "_dyld_get_image_name");
      Interceptor.attach(_dyld_get_image_name, {
        onEnter: function(args){
          console.log("_dyld_get_image_name is hooked.")
          this.idx = eval(args[0]).toString(10);
        },
        onLeave: function(retval){
          let rtnStr = retval.readCString();
          if(is_pass){
            for (let i=0;i ${true_path}`)
              }
            }
          }
        }
      })
    }
    function hook_canopenurl(is_pass){
      let api = new ApiResolver("objc");
      api.enumerateMatches("-[UIApplication canOpenURL:]").forEach((matche) => {
        console.log("canOpenURL is hooked.");
        if (is_pass){
          Interceptor.replace(matche.address, new NativeCallback((url_obj) => {return 0;}, "int", ["pointer"]))
        }
      })
    }
    // -[NSFileManager fileExistsAtPath:isDirectory:]
    function hook_fileExistsAtPath(is_pass){
      let api = new ApiResolver("objc");
      let matches = api.enumerateMatches("-[NSFileManager fileExistsAtPath:isDirectory:]")
      matches.forEach((matche) => {
        console.log("fileExistsAtPath is hooked.");
        if(is_pass){
          Interceptor.replace(matche.address, new NativeCallback((path, is_dir) => {
            console.log(ObjC.Object(path).toString(), is_dir)
            return 0;
          }, "int", ["pointer", "bool"]))
        }
      })
    }
    function hook_writeToFile(is_pass){
      let api = new ApiResolver("objc");
      api.enumerateMatches("-[NSString writeToFile:atomically:encoding:error:]").forEach((matche) => {
        Interceptor.attach(matche.address, {
          onEnter: function(args){
            this.error = args[5];
            this.path = ObjC.Object(args[2]).toString();
            console.log("writeToFile is hooked");
          },
          onLeave: function(retval){
            if(is_pass){
              let err = ObjC.classes.NSError.alloc();
              Memory.writePointer(this.error, err);
            }
          }
        })
      })
    }
    function hook_lstat(is_pass){
      var stat = Module.findExportByName('libSystem.B.dylib', 'lstat');
      Interceptor.attach(stat, {
        onEnter: function(args) {
          console.log('lstat is hooked: ');
        },
        onLeave: function(retval){
          if (is_pass){
            retval.replace(1);
            console.log(`lstat retval: ${Number(retval.toString())} -> 1`);
          }
        }
      });
    }
    function hook_fork(is_pass){
      let fork = Module.findExportByName(null, "fork");
      if (fork){
        console.log("fork is hooked.");
        Interceptor.attach(fork, {
          onLeave: function(retval){
            console.log(`fork -> pid:${retval}`);
            if(is_pass){
              retval.replace(-1)
            }
          }
        })
      }
    }
    function hook_NSClassFromString(is_pass){
      let clses = ["HBPreferences"];
      var foundationModule = Process.getModuleByName('Foundation');
      var nsClassFromStringPtr = Module.findExportByName(foundationModule.name, 'NSClassFromString');
      if (nsClassFromStringPtr){
        Interceptor.attach(nsClassFromStringPtr, {
          onEnter: function(args){
            this.cls = ObjC.Object(args[0])
            console.log("NSClassFromString is hooked");
          },
          onLeave: function(retval){
            if (is_pass){
              clses.forEach((ck_cls) => {
                if (this.cls.toString().indexOf(ck_cls) !== -1){
                  console.log(`nsClassFromStringPtr -> ${this.cls} - ${ck_cls}`)
                  retval.replace(ptr(0x00))
                }
              })
            }
          }
        })
      }
    }
    function hook_getenv(is_pass){
      let getenv = Module.findExportByName(null, "getenv");
      Interceptor.attach(getenv, {
        onEnter: function(args){
          console.log("getenv is hook");
          this.env = ObjC.Object(args[0]).toString();
        },
        onLeave: function(retval){
          if (is_pass && this.env == "DYLD_INSERT_LIBRARIES"){
            console.log(`env: ${this.env} - ${retval.readCString()}`)
            retval.replace(ptr(0x0))
          }
        }
      })
    }
    setImmediate(() => {
      hook_stat(true);
      hook_dyld_get_image_name(true)
      hook_canopenurl(true);
      hook_fileExistsAtPath(true);
      hook_writeToFile(true);
      hook_lstat(true);
      hook_fork(true);
      hook_NSClassFromString(true);
      hook_getenv(true)
    })
    小结
    检测的正向代码在项目的 JailBreakCheek 类下。单独过这些检测基本没啥难度,直接hook。但在真实app中还是重点在分析中,如何找到这些具体检测的点。这次分享的frida代码有点粗糙,啊哈哈,要实际使用还得再优化一下。并且可以多看看frida官方的脚本网站https://codeshare.frida.re/
    后面有时间的话,再分享些其他类型的检测如frida检测,混淆代码或加固之类。
    文章参考
  • ios反越狱检测与检测剖析
  • Project: iOS Jailbreak Bypass

    路径, 文件

  • tianruo1987   

    有个ipa,安装在macbook M1的 playcover中,打开提示设备已越狱,直接在macbook上安装,打开也是提示设备已越狱,怎么破?
    pb297110281   

    感谢分享!!!!!!!
    linchurong888   

    支持支持,谢谢分享
    sdieedu   

    很不错的技术
    wzbAwxl   

    感谢感谢
    jmfabc   

    厉害了楼主
    雨落惊鸿,   

    感谢分享
    zuq001   

    以前一直很好奇某些app怎么检测越狱,谢楼主了
    大罗金仙   

    6666大佬太牛了
    您需要登录后才可以回帖 登录 | 立即注册

    返回顶部