20、Hook框架进阶:Frida Stalker(指令追踪)、Frida脚本编写(拦截、修改参数、返回值)

好,咱们继续往下走。前面几章我们把 Frida 的基础 Hook 玩得挺熟了,拦截函数、改参数、改返回值,这些基本操作你应该已经能上手了。但说实话,这些只是 Frida 的冰山一角。今天我要聊的,是 Frida 里一个真正让我觉得「卧槽,还能这样玩」的功能——Stalker

Stalker 是什么?说白了,它是一个指令级别的追踪器。你可以把它理解成在 CPU 执行指令的路上装了一排摄像头,每一条指令跑过去,它都能给你录下来。我当年第一次用 Stalker 去分析一个混淆过的 native 函数时,那种感觉就像在黑屋子里突然开了灯——一切都清清楚楚。

20.1 Stalker 核心概念

先别急着写代码,咱们得先搞清楚 Stalker 到底在干什么。

Frida 的 Stalker 可以让你实时追踪一个线程的执行流。它能捕获到每一条 CPU 指令,包括跳转、调用、返回。你想想看,这意味着什么?意味着你可以看到程序到底走了哪条分支,调了哪些函数,甚至能知道它有没有在偷偷执行一些你没见过的代码。

我在项目中遇到过一件事:一个 App 的 native 层做了很重的混淆,IDA 里看过去全是花指令,根本没法静态分析。我当时就是用 Stalker 把关键函数的执行流 dump 出来,一条指令一条指令地看,最后找到了它的核心算法。嗯,那感觉,挺爽的。

核心要点:
  • Stalker 工作在指令级别,不是函数级别
  • 它可以追踪单个线程的执行流
  • 支持指令的实时修改和替换
  • 性能开销较大,建议精准使用

20.2 Stalker 基础用法

咱们直接上代码。下面这个例子,演示了如何用 Stalker 追踪一个 native 函数的执行过程。

// 附加到目标进程
var threadId = Process.getCurrentThreadId();

// 开始追踪当前线程
Stalker.follow(threadId, {
  events: {
    call: true,   // 追踪函数调用
    ret: true,    // 追踪函数返回
    exec: false   // 不追踪每条指令(太慢了)
  },
  onReceive: function(events) {
    // 处理接收到的事件
    console.log('收到事件数量: ' + events.length);
    Stalker.parse(events, {
      onCall: function(args) {
        console.log('调用: ' + args[0]);
      },
      onRet: function(args) {
        console.log('返回: ' + args[0]);
      }
    });
  }
});

这段代码看起来简单,但实际跑起来你会发现,输出量非常大。所以我个人习惯是先缩小追踪范围,比如只追踪某个特定函数内部的指令,而不是整个线程。

我的小技巧: 先用 Interceptor 拦截目标函数,在函数入口处调用 Stalker.follow(),在函数出口处调用 Stalker.unfollow()。这样只追踪函数内部的指令,效率高得多。

20.3 Stalker 指令级追踪

如果你需要更细粒度的控制,可以开启指令级别的追踪。但我要提醒你,这玩意开销很大,别随便对整个 App 开。

Stalker.follow(threadId, {
  events: {
    call: false,
    ret: false,
    exec: true  // 追踪每条指令
  },
  onReceive: function(events) {
    Stalker.parse(events, {
      onExec: function(address) {
        var instruction = Instruction.parse(address);
        console.log(instruction.address + ': ' + instruction.mnemonic + ' ' + instruction.opStr);
      }
    });
  }
});

你看,这样就能拿到每一条执行的指令了。我曾经用这个功能去分析一个反调试的代码,发现它在执行过程中会随机插入一些无意义的指令来混淆执行流。嗯,Stalker 一开,这些花招全暴露了。

20.4 Stalker 代码修改

Stalker 不仅能看,还能改。你可以在指令执行前替换它,这比 Interceptor 更底层、更强大。

Stalker.follow(threadId, {
  transform: function(iterator) {
    var instruction = iterator.next();
    // 如果遇到某个特定指令,替换它
    if (instruction.mnemonic === 'bl' && instruction.opStr.indexOf('0x1234') !== -1) {
      // 替换成 nop
      iterator.putNop();
    } else {
      iterator.keep();
    }
  }
});

这个 transform 回调会在每条指令执行前被调用。你可以决定是保留原指令、替换成别的指令、还是直接跳过。说实话,这个功能我平时用得不多,因为太底层了,容易把程序搞崩。但如果你在做一些极致的逆向,比如对抗代码虚拟化,这个就是神器。

警告: Stalker 的 transform 操作非常危险。我曾经有一次不小心把一条关键的跳转指令改成了 nop,结果程序直接跑飞了,连 Frida 都断开了连接。所以,一定要先备份,再动手

20.5 Frida 脚本进阶:拦截与修改

好,Stalker 聊完了,咱们回到 Frida 脚本本身。前面几章我们讲过基础的拦截,今天再深入一点,聊聊如何优雅地修改参数和返回值

20.5.1 修改参数

修改参数是逆向中最常见的需求之一。比如你想绕过某个校验,直接把参数改成期望的值。

Interceptor.attach(Module.findExportByName('libc.so', 'strcmp'), {
  onEnter: function(args) {
    var str1 = args[0].readCString();
    var str2 = args[1].readCString();
    console.log('strcmp(' + str1 + ', ' + str2 + ')');
    
    // 如果比较的是某个特定字符串,直接修改参数
    if (str1.indexOf('password') !== -1) {
      // 把第二个参数改成和第一个一样,让比较结果返回0
      args[1] = args[0];
      console.log('已修改参数,绕过密码校验');
    }
  },
  onLeave: function(retval) {
    console.log('strcmp 返回值: ' + retval);
  }
});

你看,这样就能让 strcmp 永远返回 0,相当于绕过了密码校验。但要注意,修改参数要谨慎,有些函数内部会缓存参数的值,你改了可能没用。

20.5.2 修改返回值

修改返回值更直接。比如你想让一个检查函数永远返回成功。

Interceptor.attach(Module.findExportByName('libnative.so', 'check_license'), {
  onLeave: function(retval) {
    console.log('原始返回值: ' + retval);
    // 把返回值改成 1(成功)
    retval.replace(1);
    console.log('已修改返回值,强制返回成功');
  }
});

这里有个坑:返回值类型不同,修改方式也不同。对于整数类型,直接用 retval.replace() 就行。但对于指针类型,你需要用 retval.replace(ptr(0x1234)) 这种方式。我刚开始学的时候,在这个坑里摔了好几次。

返回值修改对照表:
返回值类型 修改方式 示例
整数 (int) retval.replace(新值) retval.replace(1)
指针 (pointer) retval.replace(ptr(新地址)) retval.replace(ptr(0x1234))
浮点数 (float) retval.replace(新值) retval.replace(3.14)
布尔值 (bool) retval.replace(新值) retval.replace(true)

20.6 实战案例:破解一个简单的 License 校验

咱们来一个完整的例子。假设有一个 App,它的 native 层有一个 check_license 函数,返回 0 表示未授权,返回 1 表示已授权。我们要做的就是让它永远返回 1。

// 1. 找到目标函数
var checkLicense = Module.findExportByName('libnative.so', 'check_license');

if (checkLicense) {
  console.log('找到 check_license 地址: ' + checkLicense);
  
  // 2. 拦截函数
  Interceptor.attach(checkLicense, {
    onEnter: function(args) {
      console.log('check_license 被调用');
      // 可以在这里打印参数,看看传了什么
      console.log('参数: ' + args[0] + ', ' + args[1]);
    },
    onLeave: function(retval) {
      console.log('原始返回值: ' + retval);
      // 3. 修改返回值
      retval.replace(1);
      console.log('已修改返回值,授权通过');
    }
  });
  
  // 4. 用 Stalker 追踪函数内部的执行流
  Stalker.follow(Process.getCurrentThreadId(), {
    events: {
      call: true,
      ret: true,
      exec: false
    },
    onReceive: function(events) {
      Stalker.parse(events, {
        onCall: function(args) {
          console.log('内部调用: ' + args[0]);
        }
      });
    }
  });
} else {
  console.log('未找到 check_license 函数');
}

这个脚本做了两件事:一是用 Interceptor 拦截函数并修改返回值,二是用 Stalker 追踪函数内部的调用链。这样你既能破解授权,又能看到它内部调了哪些函数,一举两得。

20.7 避坑指南

最后,分享几个我踩过的坑,希望能帮你省点时间。

我曾经...
  • 忘记解除 Stalker 追踪:Stalker 一旦开启,会持续追踪直到你调用 Stalker.unfollow()。我有一回忘了解除,结果手机越来越烫,最后直接重启了。所以,记得在函数返回时调用 Stalker.unfollow()
  • 在 onLeave 里修改了错误的返回值类型:前面说过,不同类型要用不同的修改方式。我刚开始时直接用 retval.replace('1'),结果程序直接崩了。后来才发现,字符串和整数在底层是完全不同的表示方式。
  • 对系统函数使用 Stalker:Stalker 对系统函数的追踪要格外小心。系统函数调用频繁,开启 Stalker 后性能开销极大,而且容易触发反调试。我建议只对你自己感兴趣的 native 函数使用 Stalker

20.8 本章小结

Stalker 是 Frida 里一个非常强大的功能,它让你能看到代码执行的每一个细节。配合 Interceptor 的参数修改和返回值修改,你几乎可以对任何 native 函数进行全方位的控制和监控。

但记住,能力越大,责任越大。Stalker 用不好,轻则程序崩溃,重则手机死机。所以,我的建议是:先用小函数练手,等熟练了再上复杂的场景。

嗯,今天就到这里。下一章我们会聊聊 Frida 的另一个高级功能——内存搜索和修改。到时候见。


公众号:蓝海资料掘金营,微信deep3321