分析某听书APP登录协议

注意

  • 此文仅限于技术交流,请不要做违法的事情。对于那些居心叵测的人根据此文造成违法的事情与本人无关。此文章不得转载!!!
  • 关键图片信息已打码,关键代码信息已用XXOO替代。如果APP方需要删除,请发邮件xxoo@hotmail.com,谢谢。
  • 这并不是基础教程,需要一定iOS逆向经验的人才能看懂。

前序

工具

  • SSL Kill Switch 2 抓取https包
  • Reveal 查看APP视图结构
  • class-dump 导出头文件
  • IDA 反汇编Mach-O文件
  • Charles APP抓包
  • Frida Hook框架

逆向VIP过程

  • 这年头看见VIP就手痒,如图点击带有VIP标识的栏目会弹出如图所示,然后逆向就开始了
  • 使用Reveal查看发现列表控制器是LMBookSectionListVC,点击的Cell是LMSectionlistCell,根据经验就知道当执行didSelectRowAtIndexPath时触发的弹框。
  • 使用IDA反汇编Mach-O文件,然后找到LMBookSectionListVC tableView:didSelectRowAtIndexPath:,Fn + F5一下可以看到伪代码,大体如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    -(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    NSArray *bookSections = [[self ownerVM] bookSections];
    LMSection *section = [bookSections safeObjectAtIndex: indexPath.row];
    if ([self isPlayerPlayingWithSection: section]) { // 如果正在播放,就继续不播放
    return
    }
    // 如果没有播放就判断能不能播放
    if ([section hasBuy] == 1 || [section downloadStatus] == 4 || ([section strategy] & 0x10)) { // 满足其中一个就播放
    [self playWithSection: section];
    }
    }
  • 所以hook代码如下。插件安装完成后没有了上面的弹框,直接跳转到播放页面,但是给了一个播放错误的提示收费章节未购买
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    %hook LMSection

    - (long long)hasBuy {
    return 1;
    }

    - (long long)strategy {
    return 16;
    }

    - (long long)downloadStatus {
    return 4;
    }

    %end
  • 通过Charles抓包。猜测是通过token判断是不是VIP,如果不是,list列表为空,就没有播放链接,这下就歇菜了。
  • VIP没有搞掉不甘心,总想搞事情,就逆向登录过程吧。

分析登录协议

请求参数

  • 到登录界面,输入用户名和密码,点击登录。通过Charles抓包如图
  • 上图可知请求分为三部分:
    • BaseURL:https://XXOO.info/XXOO/ClientLogon.action
    • Query String: imeitoken
    • Form: account,pwd,nwt,q,sc
  • Query String拼接到BaseURL后面,Form就是Body内容

寻找传参函数

  • 到登录界面,使用Reveal发现登录按钮属于LMLoginViewController
  • 终端执行frida-trace -U -m "-[LMLoginViewController *]" 某听书APP命令,点击登录按钮,发现执行clickLoginButton:方法
    1
    2
    3
    4
     /* TID 0x403 */
    ...省略很多
    1146 ms -[LMLoginViewController clickLoginButton:<LMOrangeButton: 0x115d31fe0; baseClass = UIButton; frame = (38 277; 299 44); clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x1c442dac0>>]
    ...省略很多
  • 所以接下来在IDA中查看-[LMLoginViewController clickLoginButton:]的实现,核心代码概括如下:
    1
    2
    3
    -[LMLoginViewController clickLoginButton:] {
    [[LMDataManager shareDataManger] manualLoginWithAccount:pwd:success:failure:]
    }
  • 接着查看-[LMDataManager manualLoginWithAccount:pwd:success:failure:]实现,核心代码概括如下,接下来要分析这三段代码。
    1
    2
    3
    4
    5
    - (void)manualLoginWithAccount:(NSString *)account pwd:(NSString *)pwd success:(id)successBlock failure:(id)failureBlock {
    LMHTTPParam *params = [LMHTTPParam buildManualLoginParamDicWithAccount:account pwd:pwd];
    LMLoginApi *request = [[LMLoginApi alloc] initWithRequestParams: params];
    [request startRequestWithSuccessCompletion:successBlock failureCompletion:failureBlock];
    }
  • IDA分析+[LMHTTPParam buildManualLoginParamDicWithAccount:account pwd:pwd]核心代码概括如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    + [LMHTTPParam buildManualLoginParamDicWithAccount:account pwd:pwd] {
    NSString *md5Pwd = MD5DigestToNSString(pwd);
    NSString *base64Pwd = +[LMGTMBase64 base64WithHexStr: md5Pwd] {
    NSData *data = [%c(LMGTMBase64) encodeData:md5Pwd isChunked: NO];
    NSString *base64Pwd = [[NSString alloc] initWithData:data encoding: NSUTF8StringEncoding];
    return base64Pwd;
    }
    NSDictionary *params = @{@"account": account, @"pwd": base64Pwd};
    return params
    }
  • IDA分析-[[LMLoginApi alloc] initWithRequestParams: params]没有做什么特别操作
  • IDA分析-[request startRequestWithSuccessCompletion:successBlock failureCompletion:failureBlock],核心代码如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    - (void)startRequestWithSuccessCompletion:successBlock failureCompletion:failureBlock {
    - [request startWithCompletionBlockWithSuccess:successBlock failure:failureBlock] {
    - [YTKBaseRequest setCompletionBlockWithSuccess:failure:];
    - [YTKBaseRequest start] {
    - [[YTKNetworkAgent sharedAgent] addRequest: request] {
    -[request buildCustomUrlRequest]
    }
    }
    }
    }
  • IDA分析-[request buildCustomUrlRequest],代码很多很长,有四段代码需要注意。
    1
    2
    3
    4
    v52 = objc_msgSend(v90, "multipartFormRequestWithMethod:URLString:parameters:constructingBodyWithBlock:error:",CFSTR("POST"),v50,v30,v32,&v95);
    v73 = objc_msgSend(v90, "requestWithMethod:URLString:parameters:error:", CFSTR("POST"), v50, v30, &v94);
    v86 = objc_msgSend(v90, "requestWithMethod:URLString:parameters:error:", CFSTR("GET"), v85, v30, &v93);
    v70 = objc_msgSend(v90, "requestWithMethod:URLString:parameters:error:", CFSTR("HEAD"), v68, v30, &v92);
  • 我们进行的是POST请求,所以v73 = objc_msgSend(v90, "requestWithMethod:URLString:parameters:error:", CFSTR("POST"), v50, v30, &v94);我们需要分析
  • 从上可知v50包含我们需要的Query String,v30包含我们需要的Form,也就是body。所以接下来就要对这两个参数进行追踪。

解析Query String

  • 跟踪v50如下,发现v50来源于v46,v46是+[NSURL URLWithString:relativeToURL:],那么我们其实要寻找v39
    1
    2
    3
    4
    5
    6
    7
    v46 = objc_msgSend(&OBJC_CLASS___NSURL, "URLWithString:relativeToURL:", v39, v44);
    v47 = (void *)objc_retainAutoreleasedReturnValue(v46);
    objc_release(v45);
    objc_release(v42);
    v48 = objc_msgSend(v47, "absoluteString");
    v49 = &v2->super;
    v50 = objc_retainAutoreleasedReturnValue(v48);
  • 跟踪v39如下,IDA伪代码如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    v33 = ((id (__cdecl *)(LMRequestAgent_meta *, SEL))objc_msgSend)(
    (LMRequestAgent_meta *)&OBJC_CLASS___LMRequestAgent,
    "shareRequestAgent");
    v34 = (void *)objc_retainAutoreleasedReturnValue(v33);
    v35 = ((id (__cdecl *)(YTKBaseRequest *, SEL))objc_msgSend)(&v2->super, "requestUrl");
    v36 = objc_retainAutoreleasedReturnValue(v35);
    v37 = v36;
    v38 = objc_msgSend(v34, "addParamForPostRequestUrl:", v36);
    v39 = objc_retainAutoreleasedReturnValue(v38);
  • 简化一下v39代码, 并且进入addParamForPostRequestUrl查看实现,可以发现取出token和imei进行拼接,拼接结果就是我们需要的Query String,我们离目标越来越近。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    NSString *requestUrl = -[YTKBaseRequest requestUrl];
    v39 = [[LMRequestAgent shareRequestAgent] addParamForPostRequestUrl: requestUrl] {
    v6 = -[LMRequestAgent requestToken](v3, "requestToken");
    v7 = (void *)objc_retainAutoreleasedReturnValue(v6);
    v8 = -[LMRequestAgent requestImei](v3, "requestImei");
    v9 = (void *)objc_retainAutoreleasedReturnValue(v8);
    v10 = objc_msgSend(&OBJC_CLASS___NSMutableString, "string");
    v11 = (void *)objc_retainAutoreleasedReturnValue(v10);
    if ( v9 && objc_msgSend(v9, "length") )
    objc_msgSend(v11, "appendFormat:", CFSTR("?imei=%@"), v9);
    if ( v7 && objc_msgSend(v7, "length") )
    {
    if ( objc_msgSend(v11, "length") )
    objc_msgSend(v11, "appendFormat:", CFSTR("&token=%@"), v7);
    else
    objc_msgSend(v11, "appendFormat:", CFSTR("token=%@"), v7);
    }
    objc_msgSend(v11, "insertString:atIndex:", v5, 0LL);
    }
  • -[LMRequestAgent requestToken]实现如下:
    1
    2
    3
    -[LMRequestAgent requestToken] {
    return [[NSUserDefaults standardUserDefaults] objectForKey: @"token"];
    }
  • -[LMRequestAgent requestImei]实现如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 先从钥匙串里面取如果取到就直接返回。没有取到就生成一个,把新生成的存入钥匙串,并且返回。
    -[LMRequestAgent requestImei] {
    NSString *udid = +[LMUDIDTools UDID] {
    v2 = +[LMUDIDTools getUDIDFromKeyChain]
    v3 = +[LMUDIDTools _UDID_iOS7]
    if ( !v2
    || (unsigned __int64)objc_msgSend(v2, "isEqualToString:", CFSTR("00000000-0000-0000-0000-000000000000")) & 1
    || !((unsigned __int64)objc_msgSend(v2, "isEqualToString:", v3) & 1) ) {
    v2 = +[LMUDIDTools _UDID_iOS7](&OBJC_CLASS___LMUDIDTools, "_UDID_iOS7");
    +[LMUDIDTools settUDIDToKeyChain:](&OBJC_CLASS___LMUDIDTools, "settUDIDToKeyChain:", v2);
    }
    return v2;
    }
    // 对UDID进行Base64,LMGTMBase64使用了开源库`GTMBase64`
    return [udid BASE64Encode] {
    NSData *uuidData = [uuid dataUsingEncoding: NSUTF8StringEncoding];
    NSData *uuidBase64Data = [LMGTMBase64 encodeData: uuidData];
    NSString *imei = [[NSString alloc] initWithData:uuidBase64Data encoding: NSUTF8StringEncoding];
    return imei
    };
    }
  • 我们可以自己写一个-[LMRequestAgent requestImei]简单的实现
    1
    2
    3
    4
    5
    6
    7
    NSString *requestImei() {
    NSString *uuid = [[NSUUID UUID] UUIDString];
    NSData *uuidData = [uuid dataUsingEncoding: NSUTF8StringEncoding];
    NSData *uuidBase64Data = [GTMBase64 encodeData: uuidData];
    NSString *imei = [[NSString alloc] initWithData:uuidBase64Data encoding: NSUTF8StringEncoding];
    return imei;
    }
  • 到这里Query String的两个参数tokenimei都解析完成

解析Form(Body)里面的参数

  • 还记得最上面的v73 = objc_msgSend(v90, "requestWithMethod:URLString:parameters:error:", CFSTR("POST"), v50, v30, &v94);吧,v30就是body里面的参数。
    我们需要追踪v30的来源。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    v24 = ((id (__cdecl *)(LMRequestAgent_meta *, SEL))objc_msgSend)(
    (LMRequestAgent_meta *)&OBJC_CLASS___LMRequestAgent,
    "shareRequestAgent");
    v25 = (void *)objc_retainAutoreleasedReturnValue(v24);
    v26 = ((id (__cdecl *)(LMBaseRequest *, SEL))objc_msgSend)(v2, "requestArgument");
    v27 = objc_retainAutoreleasedReturnValue(v26);
    v28 = v27;
    v29 = objc_msgSend(v25, "processRequest:withArgument:", v2, v27);
    v30 = objc_retainAutoreleasedReturnValue(v29);
  • 简化一下代码就是
    1
    [[LMRequestAgent shareRequestAgent] processRequest:request withArgument: [request requestArgument]];
  • [LMRequestAgent processRequest:withArgument:]实现代码很长,前面分析过Form里面需要的参数是account,pwd,nwt,q,sc
    • account和pwd我们已经解析出来了,下面要解析nwt,q,sc这三个。
    • 多次点击登录按钮发现nwt的值一直为1
    • q每点击一次就+1,猜测q指的是查询次数。
    • sc每点击一次值都不一样,它应该是一些可变参数的拼接
  • 所以接下来的操作都是解析sc,大致浏览一遍,发现,根据上下文可以v8是NSMutableDictionary
    1
    objc_msgSend(v8, "setObject:forKey:", v60, CFSTR("sc"));
  • 跟踪一下frida-trace -U -m "-[NSMutableDictionary setObject:forKey:]" 某听书APP,点击登录按钮会出现很多的调用,需要我们过滤一下key。
    1
    2
    3
    4
     onEnter: function (log, args, state) {
    if (ObjC.Object(args[3]) != 'sc') return
    log('-[__NSDictionaryM setObject:' + ObjC.Object(args[2]) + ' forKey:' + ObjC.Object(args[3]) + ']');
    },
  • 再次点击登录按钮发现确实执行了,这个值跟Chales里面看到的值是一样的。
    1
    2
    3
    Started tracing 13 functions. Press Ctrl+C to stop.
    /* TID 0x403 */
    1203 ms -[__NSDictionaryM setObject:e7b06599148c4dab5daf45b526e69a4f forKey:sc]
  • 这个时候就需要追踪v60的来源。发现v60是[v57 MD5DigestToNSString],也就是对v57进行md5得到v60。而v57又来自于v56。v56是拼接的字符串,拼接的是v55OoiwGXXOO!5kU#@ku0BWm两个字符串。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    v56 = objc_msgSend(
    &OBJC_CLASS___NSString,
    "stringWithFormat:",
    CFSTR("%@%@"),
    v55,
    CFSTR("OoiwGXXOO!5kU#@ku0BWm"),
    v83);
    v57 = (void *)objc_retainAutoreleasedReturnValue(v56);
    v58 = v57;
    v59 = objc_msgSend(v57, "MD5DigestToNSString");
    v60 = (void *)objc_retainAutoreleasedReturnValue(v59);
  • 接下来看看v55来自于v54,v54也是字符串的拼接。
    1
    2
    v54 = objc_msgSend(&OBJC_CLASS___NSString, "createURLStringWithPath:andParams:", v52, v8);
    v55 = (void *)objc_retainAutoreleasedReturnValue(v54);
  • 跟踪一下frida-trace -U -m "+[NSString createURLStringWithPath:andParams:]" 某听书APP,执行后找到对应的js文件,更改如下:
    1
    2
    3
    4
    5
    6
    onEnter: function (log, args, state) {
    log('+[NSString createURLStringWithPath:' + ObjC.Object(args[2])+ ' andParams:' + ObjC.Object(args[3]) + ']');
    },
    onLeave: function (log, retval, state) {
    log('函数返回值' + ObjC.Object(retval));
    }
  • 终端再次执行上面的跟踪命令,点击登录按钮输出如下。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    +[NSString createURLStringWithPath:/XXOO/ClientLogon.action andParams:{
    account = Tyyy;
    imei = M0Q1MDZGMzAtRDREOS00OUMyLUE2MUUtRUM1RUYyMjJENDc1;
    nwt = 1;
    pwd = "0QkGw9rBFy1PYL1B8iSudQ==";
    q = 411;
    token = "wQYGmjeAR1zP1SZKF6GjFg**_u6nbPC95d-IUR7IALtZAUA**";
    }]
    1538 ms 函数返回值/XXOO/ClientLogon.action?account=Tyyy&imei=M0Q1MDZGMzAtRDREOS00OUMyLUE2MUUtRUM1RUYyMjJENDc1&nwt=1&pwd=0QkGw9rBFy1PYL1B8iSudQ==&q=411& token=wQYGmjeAR1zP1SZKF6GjFg**_u6nbPC95d-IUR7IALtZAUA**
  • 这下就跟明确了,所需要的值都已经求出了,只要拼接就可以得到相应的字符串

说在最后

  • 最终代码如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    NSString *getImei() {
    NSString *uuid = [[NSUUID UUID] UUIDString];
    NSData *uuidData = [uuid dataUsingEncoding: NSUTF8StringEncoding];
    NSData *uuidBase64Data = [GTMBase64 encodeData: uuidData];
    NSString *imei = [[NSString alloc] initWithData:uuidBase64Data encoding: NSUTF8StringEncoding];
    return imei;
    }

    NSString *MD5DigestToNSString(NSString *str) {
    unsigned char v10[16];
    const char *v3 = [str UTF8String];
    size_t v5 = strlen(v3);
    CC_MD5(v3, v5, v10);
    NSMutableString *v7 = ([[NSMutableString alloc] initWithCapacity: 32LL]);
    long long v8 = 0LL;
    do [v7 appendFormat:@"%02x", (unsigned char)v10[v8++]];
    while ( v8 != 16 );
    return v7;
    }

    NSString *baseURL = @"https://XXOO.info";
    NSString *relativeURL = @"/XXOO/ClientLogon.action?";
    // 获取Imei
    NSString *imei = getImei();
    // 获取token
    NSString *token = [[NSUserDefaults standardUserDefaults] objectForKey: @"token"];
    // 查询字符串
    NSString *queryStr = [NSString stringWithFormat:@"imei=%@&token=%@",imei, token];
    NSString *account = @"YourUserName";
    NSString *pwdText = @"YourPassword";
    NSString *pwdMd5 = MD5DigestToNSString(pwdText);
    NSData *pwdData = [%c(LMGTMBase64) encodeData: pwdMd5 isChunked: NO];
    NSString *pwdBase64 = [[NSString alloc] initWithData:pwdData encoding: NSUTF8StringEncoding];
    // 查询次数
    NSString *q = @"1";
    NSString *nwt = @"1";
    // 可以看成是加盐字符串
    NSString *salt = @"OoiwGXXOO!5kU#@ku0BWm";
    // 生成sc
    NSString *scStr = [NSString stringWithFormat:@"%@account=%@&imei=%@&nwt=%@&pwd=%@&q=%@&token=%@%@",
    relativeURL, account,imei,nwt,pwdBase64,q,token,salt];
    NSString *scMd5 = MD5DigestToNSString(scStr);
    // 执行请求
    NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
    request.URL = [NSURL URLWithString: [NSString stringWithFormat:@"%@%@%@",baseURL,relativeURL,queryStr]];
    request.HTTPMethod = @"POST";
    NSString *params = [NSString stringWithFormat:@"account=%@&nwt=%@&pwd=%@&q=%@&sc=%@",
    account,nwt,pwdBase64,q,scMd5];
    NSData *data = [params dataUsingEncoding: NSUTF8StringEncoding];
    request.HTTPBody = data;
    NSURLSessionDataTask *dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    id obj =[NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
    NSLog(@"iOSRE:数据为%@",obj);
    }];
    [dataTask resume];
  • 以上代码有两个地方还没有解析。
  • [%c(LMGTMBase64) encodeData: pwdMd5 isChunked: NO];这个不是开源库GTMBase64自带的方法,应该是自己封装的,伪代码显示还挺复杂的,以后有空在搞
  • [[NSUserDefaults standardUserDefaults] objectForKey: @"token"],这个应该是从服务端拉取存入本地,有空在搞