【IOS逆向】KFC(v窝50)之保姆级分析过程

23 阅读19分钟

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!

本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请联系作者立即删除!


目标

手机验证码登录和账密登录

phone,pwd,kbsv等等需要还原


古法逆向

1. phone

        看着像base64, hook一下  - [NSData base64EncodedStringWithOptions:],会发现根本不走这个方法,那就去hook序列化方法,看看转成json时是密文还是明文:

ObjC.classes.NSJSONSerialization["+ dataWithJSONObject:options:error:"]

1. 作用就是把 Foundation 对象转成 JSON 格式的 NSData
2. 常见输入:NSDictionary, NSMutableDictionary, NSArray, NSMutableArray
3. 输出:NSData *, 最终可以作为 HTTP body 的 JSON 字节流

        frida注入,点击发送验证码,输出:

========== KFC phone ==========
		class      = __NSDictionaryM
		phone      = xxxxxxxxxxxxxxxxxx
		encodeList = (
			phone
		)
		mainBrand  = KFC
		secretKey  = kfc
		sendType   = 3
		caller     = 0xxxxxxxxxxxxxxxxx

        发现转成json时已经是密文了,并且传入的是__NSDictionaryM类型,所以继续去hook phone被放进字典的地方

console.log("[*] hook.js loaded");

if (!ObjC.available) {
    console.log("[-] ObjC not available");
} else {
    console.log("[*] ObjC available");
    console.log("[*] will install hook after 3 seconds");

    setTimeout(function () {
        var hook_func = ObjC.classes.__NSDictionaryM['- setObject:forKey:'];
        Interceptor.attach(hook_func.implementation, {
            onEnter: function (args) {
                var key = new ObjC.Object(args[3]).toString();
                if (key == "phone") {
                    var value = new ObjC.Object(args[2]).toString();
                    console.log("[*] setObject:forKey: phone = " + value);
                }
            },
            onLeave: function (retval) {

            }
        })
    }, 3000);
}

        重新注入点击发送,hook不到,说明phone 不是通过 setObject:forKey: 写进去的

        补充:

虽然之前看到 class = __NSDictionaryM,但这只说明最终传给 JSON 的对象是可变字典,不代表它一定是这样生成的:[dict setObject:value forKey:@"phone"];

常见的情况:
    1. 用下标写入
        oc可能是:dict[@"phone"] = value
        它走的是 -[__NSDictionaryM setObject:forKeyedSubscript:]
    2. 字典是一次性构造的
        比如:
            @{
				@"phone": encryptedPhone,
				@"mainBrand": @"KFC"
			}
        走的是 -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]
    3. 先创建不可变字典,再 mutableCopy
        比如:
			NSDictionary *d = @{
				@"phone": encryptedPhone
			};
            NSMutableDictionary *m = [d mutableCopy];
        最终你看到的是: __NSDictionaryM
    4. Swift / 框架内部构造
			如果这部分是 Swift 或网络框架内部生成的,可能走 CoreFoundation / Swift bridge,            不一定走你 hook 的 ObjC 方法
       

        现在应该继续hook __NSDictionaryM["- setObject:forKeyedSubscript:"]和__NSPlaceholderDictionary["- initWithObjects:forKeys:count:"] 判断到底是写入还是一次性构造的

        核心逻辑:
setObject:forKey:                  普通写入
setObject:forKeyedSubscript:       dict[@"phone"] = xxx
initWithObjects:forKeys:count:     @{@"phone": xxx}

console.log("[*] hook.js loaded");

if (!ObjC.available) {
    console.log("[-] ObjC not available");
} else {
    console.log("[*] ObjC available");
    console.log("[*] will install hook after 3 seconds");

    setTimeout(function () {
        var hook_func2 = ObjC.classes.__NSDictionaryM['- setObject:forKeyedSubscript:'];

        Interceptor.attach(hook_func2.implementation, {
            onEnter: function (args) {
                var key = new ObjC.Object(args[3]).toString();
                if (key == "phone") {
                    var value = new ObjC.Object(args[2]).toString();
                    console.log("[*] setObject:forKeyedSubscript: phone = " + value);
                    console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n')

                }
            },
            onLeave: function (retval) {

            }
        })
    }, 3000);
}
输出(已脱敏):
			[Apple iPhone::PID::4927 ]-> [*] setObject:forKeyedSubscript: phone = xxxxxx
			0x1042d3c SUPER_APP!-[YUMCUnitedUserAuthSmsCodeHttpService params]
			0x105d708 SUPER_APP!-[YUMCApiManager buildRequestWithService:]
			0x105d4bc SUPER_APP!-[YUMCApiManager loadDataWithService:callback:]
			0x104672c SUPER_APP!-[YUMCUnitedUserAuthUserSignInService loadCodeSendingRequestWithService:completion:]
			0x104e628 SUPER_APP!-[YUMCUnitedUserAuthUserSignInService codeLoginStepSendCodeWithPhone:codeType:completion:]
			0x104e79c SUPER_APP!-[YUMCUnitedMsgCodeSendBiz initiateCodeSendWithType:phone:]
			0x104ec74 SUPER_APP!-[YUMCUnitedPhoneLoginVC handleCodeSendingWithPhone:]
			0x104ea50 SUPER_APP!-[YUMCUnitedMsgCodeInteractionBiz retrieveCodeAfterValidataion]
			0x1824748 UIKitCore!-[UIApplication sendAction:to:from:forEvent:]
			0x1831070 UIKitCore!-[UIControl sendAction:to:forEvent:]
			0x104c888 SUPER_APP!-[UIButton(VerticalCenter) YUMCKFCElderClickExposure_sendAction:to:forEvent:]
			0x104c830 SUPER_APP!-[UIButton(VerticalCenter) sendAction:to:forEvent:]
			0x182e9ec UIKitCore!-[UIControl _sendActionsForEvents:withEvent:]
			0x182f98c UIKitCore!-[UIButton _sendActionsForEvents:withEvent:]
			0x18314c0 UIKitCore!-[UIControl touchesEnded:withEvent:]
			0x104ce69 SUPER_APP!-[UIButton(VerticalCenter) touchesEnded:withEvent:]

        会发现写入的时候已经是密文了,这里先做个补充:

1. __NSDictionaryM 是 NSMutableDictionary 在运行时的真实类名
    平时代码里写的是:NSMutableDictionary *dict = [NSMutableDictionary dictionary];
	但运行时真正的对象类名通常不是 NSMutableDictionary,而是 Foundation 的私有类: __NSDictionaryM , M = Mutable                     
    				
2. NSDictionary / NSMutableDictionary / __NSDictionaryM 的关系: 
	NSDictionary
		抽象公开类

	NSMutableDictionary
		抽象公开类,继承 NSDictionary

	__NSDictionaryM
		真正干活的私有实现类

3. 所以为什么不去hook NSMutableDictionary?
    可以hook,但是很多时候 hook 不到。原因是 Objective-C 方法派发时,真正执行的是子类实现
    例如对象真实类型是: __NSDictionaryM
		调用:
			[dict setObject:value forKey:key]
		最终跑的可能是:
			-[__NSDictionaryM setObject:forKey:]
		而不是:
			-[NSMutableDictionary setObject:forKey:]
		所以实际逆向时常用:
			-[__NSDictionaryM setObject:forKey:]
frida输出堆栈,方法的地址和ida里实际的地址会不一致,不能直接根据frida输出的某个方法的调用地址直接去ida里跳转。
    因为 App 运行时有 ASLR,模块会被随机加载到一个 base。
        正确换算是: IDA地址 = IDA镜像基址 + (运行时地址 - Frida运行时模块base)

    
    function ida(addr) {
        var m = Process.findModuleByAddress(addr);
        var offset = addr.sub(m.base);
        var idaAddr = ptr("0x100000000").add(offset);

        console.log("runtime = " + addr);
        console.log("base    = " + m.base);
        console.log("offset  = " + offset);
        console.log("IDA     = " + idaAddr);
    }

    var imp = ObjC.classes.YUMCUnitedUserAuthSmsCodeHttpService["- params"].implementation;
    ida(imp);

        接着跟进顶层的param方法

        进入-[YUMCUnitedUserAuthSmsCodeHttpService phone方法发现只是单纯 return self->_phone,所以这边也已经加密完成了。线索丢失了,去看看这个类是否有可疑的方法:

        很明显有个setPhone,所以可以猜测:

phone在oc里的实现可能是: @property NSString *phone;

这样编译器通常会自动生成:
    - phone
    - setPhone:

        继续hook

console.log("[*] hook.js loaded");

if (!ObjC.available) {
    console.log("[-] ObjC not available");
} else {
    console.log("[*] ObjC available");
    console.log("[*] will install hook after 0.3 seconds");

    setTimeout(function () {
        var hook_func = ObjC.classes.YUMCUnitedUserAuthSmsCodeHttpService['- setPhone:'];
        Interceptor.attach(hook_func.implementation, {
            onEnter: function (args) {
                console.log('setphone = ' + new ObjC.Object(args[2]).toString());
                console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n')
                
            },
            onLeave: function (retval) {
                // console.log('phone = ' + new ObjC.Object(retval).toString());
                // console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n')

            }
        })
    }, 300);
}

        输出:

[Apple iPhone::PID::6658 ]-> setphone = xxxxxxxxx
0x1054f0 SUPER_APP!-[YUMCUnitedUserAuthUserSignInService codeLoginStepSendCodeWithPhone:codeType:completion:]
0x10579c SUPER_APP!-[YUMCUnitedMsgCodeSendBiz initiateCodeSendWithType:phone:]
0x105a74 SUPER_APP!-[YUMCUnitedPhoneLoginVC handleCodeSendingWithPhone:]
0x105e50 SUPER_APP!-[YUMCUnitedMsgCodeInteractionBiz retrieveCodeAfterValidataion]
0x182748 UIKitCore!-[UIApplication sendAction:to:from:forEvent:]
0x183170 UIKitCore!-[UIControl sendAction:to:forEvent:]
0x105888 SUPER_APP!-[UIButton(VerticalCenter) YUMCKFCElderClickExposure_sendAction:to:forEvent:]
0x105830 SUPER_APP!-[UIButton(VerticalCenter) sendAction:to:forEvent:]
0x182eec UIKitCore!-[UIControl _sendActionsForEvents:withEvent:]
0x18298c UIKitCore!-[UIButton _sendActionsForEvents:withEvent:]
0x1834c0 UIKitCore!-[UIControl touchesEnded:withEvent:]
0x105910 SUPER_APP!-[UIButton(VerticalCenter) touchesEnded:withEvent:]
0x182190 UIKitCore!_UIGestureEnvironmentUpdate
0x18212c UIKitCore!-[UIGestureEnvironment _updateForEvent:window:]
0x182c30 UIKitCore!-[UIWindow sendEvent:]
0x182a64 UIKitCore!-[UIApplication sendEvent:]

        也已经是密文状态,ida跟进-[YUMCUnitedUserAuthUserSignInService codeLoginStepSendCodeWithPhone:codeType:completion:]方法,会看到

        v13就是frida输出的密文,来源于encryptedPassword方法返回值,传入v10也就是明文手机号,跟进这个方法

        很明显最终调用的是- [YUMCUnitedBasicUserBusiness unitedLoginDidEnctyptString:],跟进去后发现它属于OBJC_UNDEF段,看不到它的函数实现,去Functions框直接搜索YUMCUnitedBasicUserBusiness unitedLoginDidEnctyptString: 也是搜不到的

        在frida注入的时候console.log(ObjC.classes.YUMCUnitedBasicUserBusiness);也是找不到的,可能是在别的文件实现的,这边只做导入调用

        这边可以尝试:

//法一:
var soAddr = Process.getModuleByName('SUPER_APP').base
var inline_B80FE4 = soAddr.add(0xB80FE0 ) // 这边就是要 hook 的地址,需要加上基址

Interceptor.attach(inline_B80FE4, {
    onEnter: function(args) {
        console.log('this.context.x0 = ' + ObjC.Object(this.context.x0).toString());
        console.log('this.context.x0 = ' + ObjC.Object(this.context.x0).$className);

    },
    onLeave: function(retval) {
    }
})

//法二:
console.log("[*] hook.js loaded");

if (!ObjC.available) {
    console.log("[-] ObjC not available");
} else {
    console.log("[*] ObjC available");
    console.log("[*] will install hook after 0.3 seconds");

    setTimeout(function () {
        for (var name in ObjC.classes) {
            try {
                var cls = ObjC.classes[name];
                var methods = cls.$ownMethods;

                methods.forEach(function (m) {
                    if (m.indexOf("unitedLoginDidEnctyptString") !== -1) {
                        console.log(name + " " + m);
                    }
                });
            } catch (e) {}
        }
    }, 300);
}

        发现它是属于UserService类的,这是什么情况?

因为 IDA 的静态分析看到的是“声明/静态类型”,运行时真正派发到的是“实际对象类型”。
你看到:
    IDA: -[YUMCUnitedBasicUserBusiness unitedLoginDidEnctyptString:]
    Frida: UserService - unitedLoginDidEnctyptString:
说明:
    v6 在 IDA 里被推断成 YUMCUnitedBasicUserBusiness 类型
    但运行时 v6 实际对象是 UserService
Objective-C 方法调用是动态派发的。

举个简单的例子:
    YUMCUnitedBasicUserBusiness *v6 = [UserService sharedInstance];
    [v6 unitedLoginDidEnctyptString:phone];

    静态看,变量类型是:YUMCUnitedBasicUserBusiness *
    所以ida显示: -[YUMCUnitedBasicUserBusiness unitedLoginDidEnctyptString:]
    但运行时对象真实类是: UserService,所以真正执行的是 -[UserService unitedLoginDidEnctyptString:]

还有一种情况:继承
    @interface UserService : YUMCUnitedBasicUserBusiness @end
    父类声明了方法:- unitedLoginDidEnctyptString:
    子类 UserService 实现了它。
    那 IDA 静态调用点会显示父类:YUMCUnitedBasicUserBusiness
    但 runtime 查到实现类是:UserService
    这也完全正常。

        所以应该进入UserService unitedLoginDidEnctyptString:方法

        传入了明文和key,继续跟进

        再跟

        这边就是最终加密的方法了,传入明文,0加密/1解密,key,只需要分析0的分支即可

        分析过程省略,伪代码如下

先对 手机号v7 进行utf8编码 得到v10 nsdata
v11 = v10 length
v12 = v10 bytes
v20 = 0
v14 = key 
CCCrypt(a4, 1u, 1u, v14, 8uLL, v14, v12, v11, dataOut, (v11 & 0xFFFFFFFFFFFFFFF8LL) + 8, &v20);
	des cbc加密 key==iv = D963r1wp(phone)
v16 = dataout,v20 传入dataWithBytes:length: 转nsdata
v17 = v16 toBase64
return v17

        使用加解密工具验证,结果一致


2. 请求头

        注:有些是定值,学习为主都去看看

        a. rcsav

headers和前面phone的hook方法就不一样了,headers一般不走NSJSONSerialization,它主要处理的是body里的json,而headers通常是设置到 NSMutableURLRequest里:
    
    例如:
        [request setValue:value forHTTPHeaderField:@"rcsdcid"];
        [request setAllHTTPHeaderFields:headersDict];
    
    hook:
        
    console.log("[*] hook.js loaded");

    if (!ObjC.available) {
        console.log("[-] ObjC not available");
    } else {
        console.log("[*] ObjC available");

    setTimeout(function () {
        var set_header = ObjC.classes.NSMutableURLRequest["- setValue:forHTTPHeaderField:"];
        var set_headers = ObjC.classes.NSMutableURLRequest["- setAllHTTPHeaderFields:"];

        Interceptor.attach(set_header.implementation, {
            onEnter: function (args) {
                var headerField = new ObjC.Object(args[3]);
                var headerKey = headerField.toString();
                var headerKeyLower = headerKey.toLowerCase();

                if (headerKeyLower.indexOf("rcsav") == 0) {
                    var headerValue = new ObjC.Object(args[2]);
                    console.log('header_key: ' + headerKey + ' header_value: ' + headerValue.toString());
                    console.log(Thread.backtrace(this.context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join('\n') + '\n')
                }
            },
            onLeave: function (retval) {}
        })

        Interceptor.attach(set_headers.implementation, {
            onEnter: function (args) {
                var headers = new ObjC.Object(args[2]);

                try {
                    var keys = headers.allKeys();
                    var count = keys.count();

                    for (var i = 0; i < count; i++) {
                        var k = keys.objectAtIndex_(i);
                        var key = k.toString();
                        var keyLower = key.toLowerCase();

                        // 模糊匹配:rcsav 开头的 header
                        if (keyLower.indexOf("rcsav") == 0) {
                            var v = headers.objectForKey_(k);
                            console.log('header_key: ' + key + ' header_value: ' + v.toString());
                            console.log(Thread.backtrace(this.context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join('\n') + '\n')
                        }
                    }
                } catch (e) {
                    console.log('setAllHTTPHeaderFields parse error: ' + e);
                }
            },
            onLeave: function (retval) {}
        })
    }, 30);
}
    

        根据堆栈定位到顶层函数[YUMCUnitedUserAuthSmsCodeHttpService interceptRequest:]

        可以hook看看这个函数的入参和返回值是啥

console.log("[*] hook.js loaded");

if (!ObjC.available) {
    console.log("[-] ObjC not available");
} else {
    console.log("[*] ObjC available");

    setTimeout(function () {
        var inter_req = ObjC.classes.YUMCUnitedUserAuthSmsCodeHttpService["- interceptRequest:"];
        Interceptor.attach(inter_req.implementation, {
            onEnter: function (args) {
                var req = new ObjC.Object(args[2]);

                console.log('\n========== interceptRequest ==========');
                console.log('request: ' + req.toString());

                try {
                    console.log('URL: ' + req.URL().absoluteString().toString());
                } catch (e) {
                    console.log('URL: <error> ' + e);
                }

                try {
                    var method = req.HTTPMethod();
                    console.log('method: ' + (method && !method.handle.isNull() ? method.toString() : 'nil'));
                } catch (e) {
                    console.log('method: <error> ' + e);
                }

                try {
                    var headers = req.allHTTPHeaderFields();
                    console.log('\n[headers]');
                    if (headers && !headers.handle.isNull()) {
                        console.log(headers.toString());
                    } else {
                        console.log('nil');
                    }
                } catch (e) {
                    console.log('[headers error] ' + e);
                }

                try {
                    var body = req.HTTPBody();
                    console.log('\n[body]');
                    if (body && !body.handle.isNull()) {
                        var len = parseInt(body.length().toString());
                        console.log('length: ' + len);

                        var bodyStr = ObjC.classes.NSString.alloc().initWithData_encoding_(body, 4);
                        if (bodyStr && !bodyStr.handle.isNull()) {
                            console.log('utf8: ' + bodyStr.toString());
                        } else {
                            console.log('utf8: nil');
                        }

                        console.log('hex:');
                        console.log(hexdump(body.bytes(), {
                            offset: 0,
                            length: len,
                            header: true,
                            ansi: false
                        }));
                    } else {
                        console.log('nil');
                    }
                } catch (e) {
                    console.log('[body error] ' + e);
                }

                console.log('======================================\n');
            },
            onLeave: function (retval) {
                var res = new ObjC.Object(retval)

                try {
                    var headers = res.allHTTPHeaderFields();
                    console.log('\n[headers]');
                    if (headers && !headers.handle.isNull()) {
                        console.log(headers.toString());
                    } else {
                        console.log('nil');
                    }
                } catch (e) {
                    console.log('[headers error] ' + e);
                }
            }
        })
    }, 30);
}
传入一个NSMutableURLRequest对象,要看里面的内容得调用对应的oc方法:

    var req = ObjC.Object(args[2]);

    1. toString() 得到的就是它的description,一般是地址和url信息
    2. .URL().absoluteString().toString() 得到url字符串
    3. .HTTPMethod().toString() 得到请求方式 get post...
    4. .allHTTPHeaderFields().toString() 得到请求头
    5. body(NSData类型的)
        一种是 hexdump(req.HTTPBody().bytes())
        另一种是 var bodyStr = ObjC.classes.NSString.alloc().initWithData_encoding_(body, 4);
    console.log("body string = " + bodyStr.toString()); 
    6. .allHTTPHeaderFields().toString() 得到headers
    
    附:hook的时候要看实参内容:
        1. Objective-C 对象指针
            优先使用ObjC.Object(ptr).toString() 或者调用它的方法
        2. NSData 的 bytes
            使用hexdump,因为它是原始字节
        3. char * c字符串
            ptr.readUTF8String(), 还有一些别的readxxxx方法
        4. 原始 buffer
            hexdump

        结果比较多我就不放了,入参时请求头只有User-Agent,Content-Type,Accept-Language,其余的除了x-yumc开头的都是这个函数内部添加的

        先去追rcasv,函数内部很明显,和之前的phone属于同一个类,发现还有setRcsav方法

        hook它,看看是哪里赋值的

// YUMCUnitedUserAuthSmsCodeHttpService setRcsav:
console.log("[*] hook.js loaded");

if (!ObjC.available) {
    console.log("[-] ObjC not available");
} else {
    console.log("[*] ObjC available");
    console.log("[*] will install hook after 0.3 seconds");
    setTimeout(function () {
        var hook_func = ObjC.classes.YUMCUnitedUserAuthSmsCodeHttpService['- setRcsav:'];
        Interceptor.attach(hook_func.implementation, {
            onEnter: function (args) {
                console.log('rcsav = ' + ObjC.Object(args[2]).toString());
                console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n')
            },
            onLeave: function (retval) {
            }
        })
    }, 300);
}

        发现好几个参数都是这边调用set方法设置的,rscav就是app的版本号

+[NSBundle mainBundle]
  ↓
-[NSBundle infoDictionary]
  ↓
objectForKeyedSubscript:@"CFBundleShortVersionString"setRcsav:

也就是 Info.plist 里的 CFBundleShortVersionString,位置在ipa解包后的Payload/xxx.app/Info.plist:

    这是 iOS App 包的固定结构,一个 iOS 应用打包成 .ipa 后,本质上是一个 zip 包。解压后结构通常是:
    Payload/
     xxx.app/
      Info.plist
      xxx      # 主可执行文件
      Frameworks/
      PlugIns/
      ...

    Info.plist是是 iOS App 必须有的元信息文件,苹果规定 App 的版本号、Bundle ID、显示名称等元信息都放在 bundle 的 Info.plist 里。常见字段:

    CFBundleIdentifier             Bundle ID
    CFBundleShortVersionString     App 显示版本号
    CFBundleVersion                Build 版本号
    CFBundleDisplayName            App 显示名称

        至此rcsav结束


b. rcsdcid

        和rcsav同一个方法

        

        比较简单,就是标准的UUID

v3 = objc_retainAutoreleasedReturnValue(+[NSUUID UUID](&OBJC_CLASS___NSUUID, "UUID"));

v4 = objc_retainAutoreleasedReturnValue(-[NSUUID UUIDString](v3, "UUIDString"));

v5 = objc_retainAutoreleasedReturnValue(-[NSString lowercaseString](v4, "lowercaseString"));

objc_msgSend(v2, "setCrUUID:", v5);

c. kbsv,kbck,kbcts

        

        inlinehook一下后一行,查看v4的请求头就知道这三个头是在里头复制的,接着先hook YUMCSecret_secretRequestWithPath:clientKey:clientSec:requestType:params: 看看入参

        多测试几次发现key和sec是定值,string表可以直接查到,params就是请求发送的所有参数字典,传进去的requestType = 1,所以只需要看这一块代码即可

        其中有个YUMC_MD5就是标准的md5算法,最后转32位小写

id __cdecl -[NSString YUMC_MD5](NSString *self, SEL a2)
{
  NSData *v2; // x19
  void *v3; // x20
  __int64 i; // x23
  unsigned __int8 md[16]; // [xsp+8h] [xbp-48h] BYREF

  v2 = objc_retainAutorelease(objc_retainAutoreleasedReturnValue(-[NSString dataUsingEncoding:](self, "dataUsingEncoding:", 4LL)));
  CC_MD5(-[NSData bytes](v2, "bytes"), (CC_LONG)-[NSData length](v2, "length"), md);
  v3 = objc_retainAutoreleasedReturnValue(+[NSMutableString string](&OBJC_CLASS___NSMutableString, "string"));
  for ( i = 0LL; i != 16; ++i )
    objc_msgSend(v3, "appendFormat:", CFSTR("%02x"), md[i]);
  objc_release(v2);
  return objc_autoreleaseReturnValue(v3);
}

        整体的流程大概就是


d. x-yumc-client-deviceid和x-yumc-client-citycode

        同样的hook设置请求头的方法定位该请求头位置

header_key: x-yumc-client-deviceid header_value: c0280xxxxxxxxxxxxx30de5xxxxxxxxd
0x18084e8c8 CoreFoundation!__NSDICTIONARY_IS_CALLING_OUT_TO_A_BLOCK__
0x1808a9120 CoreFoundation!-[__NSDictionaryI enumerateKeysAndObjectsWithOptions:usingBlock:]
0x103423180 SUPER_APP!-[YUMCNetworkingTrackDemux addtionRequestHeader:]
0x103423200 SUPER_APP!0xa9f200 (0x100a9f200)
0x103422dac SUPER_APP!-[YUMCApiProxy YUMCNetworking_X23_callApiWithRequest:callBack:]
0x103424098 SUPER_APP!-[YUMCApiProxy callApiWithRequest:callBack:]
0x103d3d598 SUPER_APP!-[YUMCApiManager loadDataWithService:callback:]
0x10350672c SUPER_APP!-[YUMCUnitedUserAuthUserSignInService loadCodeSendingRequestWithService:completion:]
0x103506628 SUPER_APP!-[YUMCUnitedUserAuthUserSignInService codeLoginStepSendCodeWithPhone:codeType:completion:]
0x1034c979c SUPER_APP!-[YUMCUnitedMsgCodeSendBiz initiateCodeSendWithType:phone:]
0x1034c97f4 SUPER_APP!0xb457f4 (0x100b457f4)
0x1034e5a74 SUPER_APP!-[YUMCUnitedPhoneLoginVC handleCodeSendingWithPhone:]
0x1034c7e50 SUPER_APP!-[YUMCUnitedMsgCodeInteractionBiz retrieveCodeAfterValidataion]
0x182fe4748 UIKitCore!-[UIApplication sendAction:to:from:forEvent:]
0x183103870 UIKitCore!-[UIControl sendAction:to:forEvent:]
0x191aaec6c libsystem_malloc.dylib!free

        继续hook -[YUMCNetworkingTrackDemux addtionRequestHeader:] 看入参

console.log("[*] hook.js loaded");

if (!ObjC.available) {
    console.log("[-] ObjC not available");
} else {
    console.log("[*] ObjC available");
    console.log("[*] will install hook after 0.3 seconds");
    setTimeout(function () {
        var hook_func = ObjC.classes.YUMCNetworkingTrackDemux['- addtionRequestHeader:'];
        Interceptor.attach(hook_func.implementation, {
            onEnter: function (args) {
                var arg = new ObjC.Object(args[2]);
                console.log('headers = ' + arg.allHTTPHeaderFields().toString());
                console.log('body = ' + hexdump(arg.HTTPBody().bytes()))
                // console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n')
            },
            onLeave: function (retval) {
            }
        })

    }, 10000);
}

        发现传入的是,打印它的请求头和body发现就是之前设置好的所有头和body,现在就只差x-yumc开头的这几个头,进入addtionRequestHeader方法,很明显剩下几个header是block内设置的

        先hook一下v7看看返回是什么

this.context.x0 = <**NSGlobalBlock**: 0x102751cd8>
this.context.x0 type = NSGlobalBlock

        v7为一个block对象,v8就是是取 block 的 invoke 函数地址调用后的结果,返回的肯定是dictionary对象,最后调用enumerateKeysAndObjectsUsingBlock:v9 设置请求头

Block 的代码位置就是它结构里的 invoke 指针,也就是你看到的:v7[2]

        两种hook方法:

var soAddr = Process.getModuleByName('SUPER_APP').base;
var inline_B7ED00 = soAddr.add(0xA9F118);

function printBlockInvoke(blockPtr) {
    try {
        // 64 位 Block 布局:
        // 0x00 isa
        // 0x08 flags + reserved
        // 0x10 invoke 函数指针
        var invoke = blockPtr.add(0x10).readPointer();

        console.log('block ptr    = ' + blockPtr);
        console.log('block invoke = ' + invoke);
        console.log('block symbol = ' + DebugSymbol.fromAddress(invoke));

        var m = Process.findModuleByAddress(invoke);
        if (m) {
            var offset = invoke.sub(m.base);
            console.log('block module = ' + m.name);
            console.log('block base   = ' + m.base);
            console.log('block offset = ' + offset);
            console.log('block IDA    = ' + ptr('0x100000000').add(offset));
        }
    } catch (e) {
        console.log('printBlockInvoke error = ' + e);
    }
}

console.log('[*] SUPER_APP base = ' + soAddr);
console.log('[*] hook target    = ' + inline_B7ED00);
console.log('[*] target symbol  = ' + DebugSymbol.fromAddress(inline_B7ED00));

Interceptor.attach(inline_B7ED00, {
    onEnter: function(args) {
        try {
            var x0obj = ObjC.Object(this.context.x0);
            console.log('this.context.x0 = ' + x0obj);
            console.log('this.context.x0 type = ' + x0obj.$className);

            if (x0obj.$className.indexOf('Block') !== -1) {
                printBlockInvoke(this.context.x0);
            }
        } catch (e) {
            console.log('x0 parse error = ' + e);
            console.log('x0 raw = ' + this.context.x0);
        }
    },
    onLeave: function(retval) {
        try {
            console.log('this.context.x20 = ' + ObjC.Object(this.context.x20));
            console.log('this.context.x20 type = ' + ObjC.Object(this.context.x20).$className);
        } catch (e) {
            console.log('x20 parse error = ' + e);
            console.log('x20 raw = ' + this.context.x20);
        }
    }
});
var track = ObjC.classes.YUMCNetworkingTrack.shareInstance();
var block = track.additionHeaderBlock();

console.log("block = " + block);

var invoke = block.handle.add(0x10).readPointer();

console.log("invoke = " + invoke);
console.log("symbol = " + DebugSymbol.fromAddress(invoke));

var m = Process.findModuleByAddress(invoke);
console.log("module = " + m.name);
console.log("base   = " + m.base);
console.log("offset = " + invoke.sub(m.base));
console.log("IDA    = " + ptr("0x100000000").add(invoke.sub(m.base)));

        为什么要add(0x10)?

64Block 结构大概是:
    struct Block {
        void *isa;        // offset 0x00
        int flags;        // offset 0x08
        int reserved;     // offset 0x0c
        void *invoke;     // offset 0x10
        void *descriptor; // offset 0x18
    };

        最后根据终端输出的ida地址 0x1000ca530,定位到该block,需要重点看的就是deviceid

        整体流程大概就是:先看缓存,存在则直接返回,不存在继续计算,从 Keychain 取 UUID,如果 Keychain 没有,就新生成 UUID,对 UUID 做 MD5,缓存并返回

Keychain 是 iOS 系统提供的安全存储区,可以理解成:比 NSUserDefaults 更安全、更持久的本地数据库
App 常用它保存:
    token
    账号标识
    设备 UUID
    加密 key
    登录凭证
    证书

它有几个特点:
    1. 比普通文件安全
        普通数据可能存在:NSUserDefaults 沙盒文件 plist SQLite 这些比较容易被看到。Keychain 由系统安全服务管理,权限更严格。
    2. 可以持久保存
        很多 App 把设备 UUID 存到 Keychain,因为卸载 App 后,Keychain 数据有时仍然保留,重新安装还能读回来,所以适合做“稳定设备标识”。
    3.App 权限隔离
        一般情况下,一个 App 只能访问自己的 Keychain item。如果配置了 Keychain Access Group,同一开发者的多个 App 可以共享。

3. pwd和smsCode

        使用账密登录就是多了一个pwd以及获取验证码后的登录多了一个smsCode,它们的流程和phone一样,des-->base64;


4. 成果

        短信

        账密(填错误的pwd就是返回这些)


AI 逆向

        新开一个会话,让ai从0开始分析,全程不干扰,半小时底裤都扒光了


总结

记录一下IOS逆向实战学习过程,一起学习·交流可加:yyxxkkyyxxkk

说明 每个请求参数的意义、来源和生成算法。


一、通用加密算法
----------------

用于加密:
- phone
- pwd

算法:
Base64(DES-CBC-PKCS7(明文))

参数:
- DES key: xxxxxxxx
- DES iv : xxxxxxxx
- 模式   : CBC
- Padding: PKCS7
- 输出   : Base64 字符串

IDA 对应逻辑:
- +[NSString encryptWithText:andKey:]
- +[NSString encrypt:encryptOrDecrypt:key:]
- CCCrypt(op=0, alg=DES, options=PKCS7, key=key, iv=key)
- GTMBase64 stringByEncodingData

Python 对应:
def des_b64(text: str, key: str = "xxxxxxxx") -> str:
    key_bytes = key.encode("utf-8")
    data = pad(text.encode("utf-8"), DES.block_size)
    cipher = DES.new(key_bytes, DES.MODE_CBC, iv=key_bytes)
    return base64.b64encode(cipher.encrypt(data)).decode("utf-8")


二、签名参数
------------

请求头中关键签名字段:
- kbck
- kbcts
- kbsv

1. kbck

意义:
客户端 key。

值:
xxxxxxxx

来源:
IDA 中 +[YUMCKFCNetworkingInitializer initSDK]


2. clientSec

意义:
客户端签名密钥,只参与 kbsv 计算,不直接放进 header。

值:
xxxxxxxx

来源:
IDA 中 +[YUMCKFCNetworkingInitializer initSDK]


3. kbcts

意义:
客户端毫秒时间戳。

生成算法:
kbcts = str(int(time.time() * 1000))

示例:
1752141234567


4. kbsv

意义:
请求签名。

生成算法:
sign_src = (
    kbck + "\t" +
    clientSec + "\t" +
    kbcts + "\t" +
    "/" + path + "\t\t" +
    body_json
)

kbsv = md5(sign_src.encode("utf-8")).hexdigest()

注意:
- 输出是小写 MD5 hex。
- path 不带 /api。
- 实际请求 URL 带 /api。

短信接口:
- 请求 URL : /api/user/sendSmsCode
- 签名 path: user/sendSmsCode
- 签名中使用: /user/sendSmsCode

密码登录接口:
- 请求 URL : /api/user/loginByPassword
- 签名 path: user/loginByPassword
- 签名中使用: /user/loginByPassword


三、JSON 序列化
---------------

IDA 使用:
NSJSONSerialization dataWithJSONObject:options:0

Python 对应:
json.dumps(body, separators=(",", ":"), ensure_ascii=False)

特点:
- 无多余空格
- UTF-8
- body_json 既用于签名,也作为真实请求体发送
- 签名和请求体必须使用同一份 body_json


四、phone.py 参数说明
--------------------

接口:
https://xxxxx

签名 path:
user/sendSmsCode

Body:
{
    "phone": des_b64(phone),
    "mainBrand": "KFC",
    "sendType": "3",
    "encodeList": ["phone"],
    "isFromCustomerClient": True,
    "secretKey": "kfc",
}

1. phone

意义:
手机号,加密后传输。

生成算法:
phone = DES-CBC-PKCS7-Base64(手机号)

Python:
"phone": des_b64(phone)


2. mainBrand

意义:
主品牌。

固定值:
KFC


3. sendType

意义:
验证码发送类型。

当前值:
3

IDA 中来自:
setSendType:


4. encodeList

意义:
告诉服务端哪些字段被加密。

短信接口只加密手机号:
["phone"]


5. isFromCustomerClient

意义:
标记请求来自客户客户端。

固定值:
True


6. secretKey

意义:
加密品牌标识。

固定值:
kfc

来源:
encryptionBrand


五、pwd.py 参数说明
-------------------

接口:
https://xxxxx

签名 path:
user/loginByPassword

Body:
{
    "phone": des_b64(phone),
    "gbCityCode": CITY_CODE,
    "isFromCustomerClient": True,
    "deviceId": device_id,
    "secretKey": "kfc",
    "encodeList": ["phone", "pwd"],
    "mainBrand": "KFC",
    "subBrands": ["PHHS", "LA", "TACO", "NLS", "HJH"],
    "pwd": des_b64(password),
}

1. phone

意义:
手机号,加密后传输。

生成算法:
phone = DES-CBC-PKCS7-Base64(手机号)


2. pwd

意义:
密码,加密后传输。

生成算法:
pwd = DES-CBC-PKCS7-Base64(密码)

说明:
pwd 和 phone 使用同一套 DES 加密算法。


3. gbCityCode

意义:
城市编码。

当前值:
350200

脚本变量:
CITY_CODE = "350200"

同时 header 中也会带:
x-yumc-client-citycode: 350200


4. isFromCustomerClient

意义:
标记请求来自客户客户端。

固定值:
True


5. deviceId

意义:
设备 ID。

当前脚本生成算法:
device_id = str(uuid.uuid4()).upper()

示例:
A1B2C3D4-1111-2222-3333-123456789ABC

说明:
pwd.py 中 body 的 deviceId 和 header 的 x-yumc-client-deviceid 使用同一个值。


6. secretKey

意义:
加密品牌标识。

固定值:
kfc


7. encodeList

意义:
告诉服务端哪些字段被加密。

密码登录接口加密两个字段:
["phone", "pwd"]


8. mainBrand

意义:
主品牌。

固定值:
KFC


9. subBrands

意义:
联合登录的子品牌列表。

当前值:
["PHHS", "LA", "TACO", "NLS", "HJH"]

IDA 中来自:
setSubBrands:


六、请求头参数说明
------------------

4. rcsav

意义:
App 版本号。

当前值:
6.34.1

脚本变量:
APP_VERSION = "6.34.1"


5. rcsdcid

意义:
请求/设备追踪 ID。

当前脚本生成算法:
str(uuid.uuid4()).lower()

示例:
a1b2c3d4-1111-2222-3333-123456789abc


7. x-yumc-client-deviceid

意义:
设备 ID。

当前脚本生成算法:
device_id = str(uuid.uuid4()).upper()

说明:
密码登录时,它和 body 中的 deviceId 保持一致。


8. x-yumc-client-channel

意义:
客户端渠道。

固定值:
app


9. user-agent

意义:
模拟 App UA。

当前格式:
SUPER_APP/6.34.1 (iPhone; iOS 15.8.3; Scale/3.00) iOS KFCSuperAPP v6.34.1

生成方式:
由 APP_VERSION 拼接。


10. x-yumc-client-citycode

意义:
城市编码。

当前值:
350200


11. kbck

意义:
客户端 key。

固定值:
xxxxxxxx


12. kbcts

意义:
毫秒时间戳。

生成算法:
str(int(time.time() * 1000))


13. kbsv

意义:
MD5 请求签名。

生成算法:
md5(
    kbck + "\t" +
    clientSec + "\t" +
    kbcts + "\t" +
    "/" + sign_path + "\t\t" +
    body_json
)

输出:
小写 32 位 hex 字符串。


七、固定常量汇总
----------------

ENC_KEY:
xxxxxxxx

CLIENT_KEY / kbck:
xxxxxxxx

CLIENT_SEC:
xxxxxxxx

APP_VERSION:
6.34.1

CITY_CODE:
350200

phone.py URL:
https://xxxxx

phone.py SIGN_PATH:
user/sendSmsCode

pwd.py URL:
https://xxxxx

pwd.py SIGN_PATH:
user/loginByPassword