【IOS逆向】麦当劳保姆级分析过程

27 阅读4分钟

声明

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

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


目标

body中的tel,部分请求头,不一一列举了

这边由于测试次数过多,无法发送验证码了,目的就是通过协议拿到这个响应


古法逆向

1. tel

        处于body中,先hook json序列化方法看看

var json_ = ObjC.classes.NSJSONSerialization["+ dataWithJSONObject:options:error:"].implementation;
var telKey = ObjC.classes.NSString.stringWithString_("tel");

function showAddr(addr) {
    var m = Process.findModuleByAddress(addr);
    var mod = m ? (m.name + " + 0x" + addr.sub(m.base).toString(16)) : "unknown";
    return addr + "  " + mod + "  " + DebugSymbol.fromAddress(addr);
}

Interceptor.attach(json_, {
    onEnter: function (args) {
        this.hasTel = false;

        var arg2 = ObjC.Object(args[2]);
        var tel = arg2.objectForKey_(telKey);
        if (!tel.isNull()) {
            this.hasTel = true;
            console.log(111)
            console.log('args[2]:' + arg2);
            console.log('args[2] type :' + arg2.$className);
            console.log('tel:' + ObjC.Object(tel));
            console.log('[caller ret]  ' + showAddr(this.returnAddress));
        }
    },
    onLeave: function (retval) {
    }
})

    

        ida进入-[MCDHTTPEngine URLRequestWithModernRequest:]方法,发现这个__NSDictionary对象是方法接受的第一个参数,继续hook

// MCDHTTPEngine URLRequestWithModernRequest:
var json_ = ObjC.classes.MCDHTTPEngine["- URLRequestWithModernRequest:"].implementation;
function showAddr(addr) {
    var m = Process.findModuleByAddress(addr);
    var mod = m ? (m.name + " + 0x" + addr.sub(m.base).toString(16)) : "unknown";
    return addr + "  " + mod + "  " + DebugSymbol.fromAddress(addr);
}

Interceptor.attach(json_, {
    onEnter: function (args) {
        var arg2 = ObjC.Object(args[2]);
        console.log('args[2]:' + arg2);
        console.log('args[2] type :' + arg2.$className);
        console.log('[caller ret]  ' + showAddr(this.returnAddress));
        }
    ,
    onLeave: function (retval) {
    }
})

        进入-[MCDHTTPEngine fetchUrlRequest:] 发现传入-[MCDHTTPEngine URLRequestWithModernRequest:] 方法的同样是接受的第一个参数,继续hook

var func_ = ObjC.classes.MCDHTTPEngine['- fetchUrlRequest:'];

Interceptor.attach(func_.implementation, {
    onEnter: function (args){
        console.log('args[2]:' + ObjC.Object(args[2]));
        console.log('args[2] type :' + ObjC.Object(args[2]).$className);
        // console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n')
        var m = Process.findModuleByAddress(this.returnAddress);
        var offset = this.returnAddress.sub(m.base);
        console.log('offset:' + offset.toString(16));
        console.log('func: ', DebugSymbol.fromAddress(this.returnAddress));
    }
})

        

        继续追上层调用,找到该偏移值所处代码位置

        会发现传入的__NSDictionary对象就是 *(_QWORD *)(a1 + 40),再去看a1来源以及本函数入参

        会发现一个问题,传进来int64类型的a1,给它加上40转为指针再解引用,就变成了一个__NSDictionary对象,我第一眼看的时候也很抽象,实际上,这是ida的原因,a1是一个block

IDA 里显示成: __int64 a1,只是因为反编译器不知道它真实类型,所以按 64 位整数展示了。

在 64 位程序里: 指针 = 8 字节 = 64 bit = __int64 / uint64_t

所以 IDA 常把未知对象指针写成:
    __int64 a1
    void *a1
    _QWORD a1
    它们本质上都可能是地址。
这里为什么能确定它像 block?因为访问结构很典型:
    *(id *)(a1 + 32)
    *(_QWORD *)(a1 + 40)

block 对象在 arm64 下大概是:
    struct Block_literal {
        void *isa;          // a1 + 0x00
        int flags;          // a1 + 0x08
        int reserved;       // a1 + 0x0c
        void *invoke;       // a1 + 0x10
        void *descriptor;   // a1 + 0x18

        // 后面开始是捕获变量
        id capture0;        // a1 + 0x20 / 32
        id capture1;        // a1 + 0x28 / 40
    };

所以:
    *(id *)(a1 + 32) 就是 block 捕获的第一个对象。
    *(_QWORD *)(a1 + 40) 就是 block 捕获的第二个对象。


换成更准确的类型可以这样理解:

    id __fastcall sub_10024DE14(struct Block_literal *block)
    {
        id engine = block->capture0;
        id request = block->capture1;

        id urlReq = [engine fetchUrlRequest:request];
    }

    或者伪结构:

    struct MyBlock {
        void *isa;
        int flags;
        int reserved;
        void *invoke;
        void *descriptor;
        id capturedEngine;   // +0x20
        id capturedRequest;  // +0x28
    };

    对应的代码:

    objc_msgSend(*(id *)(a1 + 32), "fetchUrlRequest:", *(_QWORD *)(a1 + 40));

    就是:

    [block->capturedEngine fetchUrlRequest:block->capturedRequest];
所以:
    __int64 a1 不代表它真的是整数业务参数,只是 IDA 没识别出来。

    你可以在 IDA 里按 Y 改函数签名,比如改成:

        id __fastcall sub_10024DE14(void *block)

    或者更语义化:

        id __fastcall sub_10024DE14(struct MyBlock *block)

    这样看起来会更清楚。

        hook当前函数或者按x查看交叉引用,定位传入block的位置,进而跟踪block的创建位置

// 24DE14

var base = Process.findModuleByName("McdApp").base;
var addr = base.add(0x24DE14);

Interceptor.attach(addr, {
    onEnter: function (args) {
        // sub_10024DE14(__int64 a1)
        // a1 是 block 地址,所以是 args[0],不是 args[2]
        var block = args[0];
        if (block.isNull()) {
            console.log("block is NULL");
            return;
        }

        // block + 0x20 == *(id *)(a1 + 32)
        // block + 0x28 == *(_QWORD *)(a1 + 40)
        var enginePtr = block.add(0x20).readPointer();
        var paramPtr = block.add(0x28).readPointer();

        console.log("block:", block);
        console.log("enginePtr:", enginePtr);
        console.log("paramPtr:", paramPtr);

        if (!enginePtr.isNull()) {
            var engine = ObjC.Object(enginePtr);
            console.log("engine type:", engine.$className);
            console.log("engine:", engine);
        }

        if (!paramPtr.isNull()) {
            var param = ObjC.Object(paramPtr);
            console.log("param type:", param.$className);
            console.log("param:", param);
        }

        var m = Process.findModuleByAddress(this.returnAddress);
        var offset = this.returnAddress.sub(m.base);
        console.log('offset:' + offset.toString(16));
        console.log('func: ', DebugSymbol.fromAddress(this.returnAddress));
    
    }
})

        跟进 -[MCDHTTPEngine createRequestWithOriginRequest:] 方法可以看到

这几行局部变量的栈偏移:
    void **v10;       // [xsp+0h]
    __int64 v11;      // [xsp+8h]
    id (*v12)(...);   // [xsp+10h]
    void *v13;        // [xsp+18h]
    MCDHTTPEngine *v14; // [xsp+20h]
    id v15;           // [xsp+28h]

然后代码:
    v10 = _NSConcreteStackBlock;
    v11 = 3254779904LL;
    v12 = sub_10024DE14;
    v13 = &unk_102866F08;
    v14 = self;
    v3 = objc_retain(objc_retain(a3));
    v15 = v3;
    v4 = objc_retainBlock(&v10);

因为 v10 在 [xsp+0],它就是这个 stack block 的起始地址。
所以:
    v10  = block + 0x00
    v11  = block + 0x08
    v12  = block + 0x10
    v13  = block + 0x18
    v14  = block + 0x20
    v15  = block + 0x28


因此这句:
    v15 = v3;
实际就是:
    *(id *)((char *)&v10 + 0x28) = v3;
而 v3 来自:
    v3 = objc_retain(objc_retain(a3));
所以等价于:
    *(id *)(block + 0x28) = a3;
之后:
    v4 = objc_retainBlock(&v10);
会把这个 stack block copy/retain 成一个真正可执行的 block。后面调用:
    ((__int64 (__fastcall *)(void ***))v4[2])(v4)
也就是:
    block->invoke(block)
进入 sub_10024DE14(a1) 后,a1 就是 block 地址,所以:
    *(_QWORD *)(a1 + 40)
就是创建时的:
    v15
也就是原来的:
    a3


xsp 是 IDA/反编译器里表示的 栈指针 SP。
在 ARM64 里: SP = Stack Pointer
也就是当前函数栈帧的起始/当前栈位置。
IDA 反编译里看到:
    void **v10;          // [xsp+0h] [xbp-50h]
    __int64 v11;         // [xsp+8h] [xbp-48h]
    id (*v12)(...);      // [xsp+10h] [xbp-40h]
    void *v13;           // [xsp+18h] [xbp-38h]
    MCDHTTPEngine *v14;  // [xsp+20h] [xbp-30h]
    id v15;              // [xsp+28h] [xbp-28h]
意思是这些变量都在当前函数的栈上,而且是连续排列的。
可以理解成: 栈上某个地址 = xsp

xsp + 0x00  -> v10
xsp + 0x08  -> v11
xsp + 0x10  -> v12
xsp + 0x18  -> v13
xsp + 0x20  -> v14
xsp + 0x28  -> v15

所以这几个变量其实组成了一个结构体,也就是 block:
struct Block {
    void *isa;         // xsp + 0x00 = v10
    long flags;        // xsp + 0x08 = v11
    void *invoke;      // xsp + 0x10 = v12
    void *descriptor;  // xsp + 0x18 = v13
    id capture0;       // xsp + 0x20 = v14
    id capture1;       // xsp + 0x28 = v15
};

代码里:
v4 = objc_retainBlock(&v10);
&v10 就是:xsp + 0x00
也就是把 v10 所在的栈地址当成 block 起始地址。
所以:
v15 = v3;
由于 v15 在 [xsp+28h],就等价于:
*(id *)(block + 0x28) = v3;
其中:
xsp = 当前栈上这个临时 block 的起始位置
简单说:xsp 就是栈指针,[xsp+28h] 表示这个局部变量在当前栈帧中相对栈指针偏移 0x28 的位置。

        因此,要看params怎么来的,要继续hook -[MCDHTTPEngine createRequestWithOriginRequest:]的入参或者直接查看交叉引用找上层函数

var func_imp = ObjC.classes.MCDHTTPEngine['- createRequestWithOriginRequest:'].implementation;

Interceptor.attach(func_imp, {
    onEnter: function (args){
        console.log('args[2]:' + ObjC.Object(args[2]));
        console.log('args[2] type :' + ObjC.Object(args[2]).$className);
        // console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n')
        var m = Process.findModuleByAddress(this.returnAddress);
        var offset = this.returnAddress.sub(m.base);
        console.log('offset:' + offset.toString(16));
        console.log('func: ', DebugSymbol.fromAddress(this.returnAddress));
    }
})

        跟进该方法

        来自a3,继续hook入参

// MCDHTTPEngine sendHTTPRequest:callback:

var func_ = ObjC.classes.MCDHTTPEngine['- sendHTTPRequest:callback:'];

Interceptor.attach(func_.implementation, {
    onEnter: function (args){
        console.log('args[2]:' + ObjC.Object(args[2]));
        console.log('args[2] type :' + ObjC.Object(args[2]).$className);
        var m = Process.findModuleByAddress(this.returnAddress);
        var offset = this.returnAddress.sub(m.base);
        console.log('offset:' + offset.toString(16));
        console.log('func: ', DebugSymbol.fromAddress(this.returnAddress));
    }
})

        在这个函数内部就找到了tel的加密入口

        以上只是一种定位追踪的方法,同样的一开始可以hook看看tel是什么时候被放进字典的,多尝试不同的方法,最终通过hook +[NSDictionary dictionaryWithObjects:forKeys:count:]也是可以定位到的

        v10就是明文手机号,进入+[MCDEncryption encryWithString:] 分析tel的加密算法

        流程也很明显,手机号转utf8,使用AES128 key固定 iv为空 ECB模式,最后返回NSData再转hex

        结果没有问题,tel至此结束


2. 各请求头

        a. d值 和 token

        hook 写入请求头的位置

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=='d') {
                    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')
                    var m = Process.findModuleByAddress(this.returnAddress);
                    var offset = this.returnAddress.sub(m.base)
                    console.log('offset: ' + offset)
                    console.log('func: ' + DebugSymbol.fromAddress(this.returnAddress))
                }
            },
            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();

                        if (keyLower=='d') {
                            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);
}

        根据输出定位到block

        查看交叉引用,跳转到 -[AFJSONRequestSerializer requestBySerializingRequest:withParameters:error:] 方法,先hook一下看看入参

// -[AFJSONRequestSerializer requestBySerializingRequest:withParameters:error:]
var func_ = ObjC.classes.AFJSONRequestSerializer['- requestBySerializingRequest:withParameters:error:'];

Interceptor.attach(func_.implementation, {
    onEnter: function (args){
        console.log('args[2]:' + ObjC.Object(args[2]));
        console.log('headers: ' + ObjC.Object(args[2]).allHTTPHeaderFields().toString())
        console.log('args[2] type :' + ObjC.Object(args[2]).$className);
     }
})

        可以看到啥也没有,那么所有请求头就都是在方法内部赋值的了

        v11应该就是headers,看看v34这个block做了什么事

        AFHTTPRequestSerializer 中已经存在的header跳过,不存在的直接set,结合刚刚hook到的情况,入参的headers为空,那么只能是在AFHTTPRequestSerializer 别的方法中设置的,先看看v11返回什么

var base = Process.getModuleByName('McdApp').base
var addr = base.add(0xFEC7E4)

Interceptor.attach(addr, {
    onEnter: function(args){
        console.log(111)
        console.log('x22 : ' + ObjC.Object(this.context.x22))
    }
})

        确实有一部分是AFHTTPRequestSerializer 内设置的

// -[AFHTTPRequestSerializer setValue:forHTTPHeaderField:]
var func_ = ObjC.classes.AFHTTPRequestSerializer['- setValue:forHTTPHeaderField:'];

Interceptor.attach(func_.implementation, {
    onEnter: function (args){
        if(ObjC.Object(args[3]).toString()=='d'){
            console.log('key args[3]:' + ObjC.Object(args[3]));
            console.log('value args[2]:' + ObjC.Object(args[2]))
            var m = Process.findModuleByAddress(this.returnAddress)
            var offset = this.returnAddress.sub(m.base)
            console.log('offset: ' + offset)
            console.log('func: '+  DebugSymbol.fromAddress(this.returnAddress))
        }

     },
     onLeaver: function (retval){

     }
})

        追到 -[MCDHTTPConnectionKit sessionManagerWithRequest:]

        token的赋值点就在下方,简要看了一下,d值是数美sdk,token就是设备uuid,后续再单独分析,先固定就行


b. x-mcd-gw-v , x-hmac-digest 和 authorization

        直接搜字符串定位到

        x-mcd-gw-v是定值1,先去看v27是啥

        再去hook一下入参,看看这个HTTPBody是啥

        很明显了,body->utf8,然后传到+[MCDHTTPSignV4 convertDIGEST:error:] 加密,继续跟

        先看密钥的值,多hook几次,发现是定值

核心逻辑:
for (i = 0; i != 41; ++i)
    bytes[i] = byte_1022277EE[i] ^ (i - 29);

v6 = CFStringCreateWithBytes(
        kCFAllocatorDefault,
        bytes,
        40,
        0x8000100u,
        0
);

也就是:
    从 byte_1022277EE 取 41 个字节
    每个字节 XOR (i - 29)
    然后取前 40 字节当 UTF-8 字符串

        继续看加密位置,也很明显的HMAC-SHA256

        

        再回去跟authorization

        hook一下入参

        剩下的慢慢分析,总结就是

AK = v4AKPro();
SK = v4SKPro();

method = request.HTTPMethod;              // POST
path = request.URL.path;                  // /bff/passport/verifyCode/send
query = request.URL.query ?: "";          // 没有 query 就是空字符串
gmtTime = 当前 GMT 时间字符串;             // Sun, 12 Jul 2026 14:42:34 GMT

参与签名的 header 只取这些:
    signHeaders = {
        "ct",
        "language",
        "p",
        "sid",
        "sv",
        "token",
        "v",
        "x-mcd-gw-v"
    };

过滤掉空值后排序:
headers = 当前 request.headers;

validHeaders = [];

for key in signHeaders {
    value = headers[key];

    if value != nil && value != "" {
        validHeaders.append(key);
    }
}

validHeaders.sort();

生成 signedHeaders:
signedHeaders = join(validHeaders, ";");

生成 canonicalHeaders:
canonicalHeaders = "";

for key in validHeaders {
    canonicalHeaders += key + ":" + headers[key] + "\n";
}

然后拼接待签名字符串:
canonicalString =
    method + "\n" +
    path + "\n" +
    query + "\n" +
    AK + "\n" +
    gmtTime + "\n" +
    canonicalHeaders;
注意:canonicalHeaders 最后一行后面也有 \n。

然后计算签名:
signature = Base64(
    HMAC_SHA256(
        key = SK ASCII bytes,
        data = canonicalString UTF8 bytes
    )
);

最后拼接生成:
Authorization

        


c. 其余header

        剩下的header都比较简单,hook设置头部的方法即可


AI逆向

        也是半小时左右,直接杀穿了,并且生成了一份详细的分析文档,内容较多这边就不放了

        可以看到请求是没问题的