在开始本文的正式内容之前我想先来吐槽下. 大多数的软件开发人员可能都有着这样一个烦恼, 就是由于工作和其他责任, 不得不搁置自己的一些个人项目甚至是最终完全的遗忘和埋没. 而本文的所述的就是一个被我遗忘已久的项目, 而我写这篇文章的目的就是希望能迫使我自己最终完成这个项目. 好了, 介绍就到这了让我们开始吧.
项目
该项目的目标是构建一个 Spotify 客户端, 让它能够学习我的听曲习惯并跳过一些我通常会跳过的歌曲. 不得不承认, 这种需求来自于我的懒惰. 我不想在当我有心情想要听某些音乐时, 创建或查找播放列表. 我希望的是在我的库中选择一首歌, 然后可以随机播放其他歌曲, 并从队列中删除不 "flow(节奏与旋律的流畅)" 的歌曲.
为了实现这一点, 我需要学习某种能够执行此任务的模型(在未来的帖子中可能更多). 但是为了能够训练一个模型, 我首先需要数据来训练它.
数据
我需要完整的听歌历史记录, 包括我跳过的那些歌曲. 获取历史记录很简单. 虽然 Spotify API 仅允许获取最近 50 首播放的歌曲, 但我们可以设置一个 cron job 来重复轮询该端点. 完整代码已经发布在此处:
最困难的部分是跟踪跳过. Spotify web API 并没有为此提供任何的端点. 之前我使用 Spotify AppleScript API 创建了一些控制播放的服务(本文的其余部分将涉及到 MacOS Spotify 客户端). 我可以使用这些服务来跟踪跳过的内容, 但这感觉像是在回避挑战. 我怎么能完成它呢?
Hooking
我最近学习了解了有关 hooking 的技术, 你可以在其中 "拦截" 从目标二进制文件生成的函数调用. 我认为这将是跟踪跳过的最佳方法.
最常见的钩子类型是 interpose hook. 这种类型的钩子会覆盖 PLT 中的重定位, 但这究竟意味着什么呢?
PLT 或过程链接表允许你的代码引用外部函数 (想想 libc) 而不知道该函数在内存中的位置, 你只需引用 PLT 中的一个条目. 链接器在运行时为 PLT 中的每个函数或符号执行 "重定位". 这种方法的一个好处是, 如果外部函数在不同的地址加载, 则只需要更改 PLT 中的重定位, 而不是每次对代码中该函数的引用.
因此, 当我们为 printf 创建一个 interpose hook 时, 每当我们 hooking 的进程调用 printf 时, 我们将调用 printf 的实现而不是 libc(我们的自定义库通常也会调用标准实现).
在对钩子有了一些基本的知识背景后, 下面我们准备尝试在 Spotify 中插入一个钩子. 但首先我们需要弄清楚我们想要 hook 的是什么.
寻找 hook 的位置
如前所述, 只能为外部函数创建一个 interpose hook, 因此我们将在 libc 或 Objective-C runtime 中查找函数.
在研究在哪 hook 时, 我认为一个开始 hooking 的好地方是 Spotify 处理 "media control keys" 或我 MacBook 上的 F7-F9. 假设这些键的处理程序在 spotify 应用程序中单击 Next 按钮被调用时会调用函数. 我最终在: https://github.com/nevyn/spmediakeytap 上找到了 SPMediaKeyTap 库. 我想我可以试一试, 看看 Spotify 是否复制并粘贴了这个库中的代码. 在 SPMediaKeyTap 库中, 有一个方法 startWatchingMediaKeys. 我在 Spotify 二进制文件上运行了 strings 命令, 看看他们是否有这个方法, 果然:
Bingo!! 如果我们将 Spotify 二进制文件加载到 IDA(当然是免费版本)并搜索此字符串, 我们就会找到相应的方法:
如果我们查看这个函数对应的源码, 我们会发现 CGEventTapCreate 函数的有趣参数 tapEventCallback:
如果我们回顾一下反汇编, 我们可以看到 sub_10010C230 子例程作为 tapEventCallback 参数传递. 如果我们查看这个函数的源码或反汇编, 我们看到只调用了一个库函数 CGEventTapEnable:
让我们尝试 hook 这个函数.
我们需要做的第一件事是创建一个库来定义我们的自定义 CGEventTapEnable. 代码如下:
- #include <CoreFoundation/CoreFoundation.h>
- #include <dlfcn.h>
- #include <stdlib.h>
- #include <stdio.h>
- void CGEventTapEnable(CFMachPortRef tap, bool enable)
- {
- typeof(CGEventTapEnable) *old_tap_enable;
- printf("I'm hooked!\n");
- old_tap_enable = dlsym(RTLD_NEXT, "CGEventTapEnable");
- (*old_tap_enable)(tap, enable);
- }
dlsym 函数调用获取实际库 CGEventTapEnable 函数的地址. 然后我们调用旧的实现, 这样我们就不会意外地破坏任何东西. 让我们像这样编译我们的库( ):
- gcc -fno-common -c <filename>.c
- gcc -dynamiclib -o <library name> <filename>.o
现在, 让我们尝试在插入钩子时运行 Spotify:DYLD_FORCE_FLAT_NAMESPACE=1 DYLD_INSERT_LIBRARIES=<library name> /Applications/Spotify.App/Contents/MacOS/Spotify. 点击进入:
Spotify 打开正常, 但 Apple 的系统完整性保护 (SIP) 没有让我们加载未签名库:(.
幸运的是, 我是 Apple 的 reasonably priced developer 项目的成员, 所以我可以对库进行代码签名. 这个问题算是得到了解决. 让我们用 100 美元证书签名我们的库, 运行上一个命令, 然后......
失败. 这一点不奇怪, Apple 不允许你插入使用任何旧标识签名的库, 只允许使用签名原始二进制文件时使用的库. 看起来我们必须要找到另一种方法来 hook Spotify 了.
作为补充说明, 细心的读者可能会注意到我们 hook 的函数 CGEventTapEnable, 只有在 media key event 超时时才会被调用. 因此, 即使我们可以插入钩子, 我们也可能不会看到任何的输出. 本节的主要目的是详细说明我最初的失败(和疏忽), 并作为一个学习经验.
HookCase
经过一番挖掘, 我发现了一个非常棒的库 HookCase: https://github.com/steven-michaud/HookCase .HookCase 让我们实现一种比插入钩子 ( patch hook) 更为强大的钩子类型.
通过修改你希望 hook 的函数触发中断插入 Patch hooks. 然后, 内核可以处理此中断, 然后将执行转移到我们的个人代码中. 对于那些感兴趣的人, 我强烈建议你阅读 HookCase 文档, 因为它更为详细.
Patch hooks 不仅允许我们对外部函数的 hook 调用, 而且允许我们 hook 目标二进制文件内的任何函数(因为它不依赖于 PLT).HookCase 为我们提供了一个框架来插入 patch 和 / 或 interpose hooks, 以及内核扩展来处理 patch hooks 生成的中断, 并运行我们的自定义代码.
寻找 sub_100CC2E20
既然我们已经有办法 hook Spotify 二进制文件中的任何函数了, 那么只剩下最后一个问题...... 就是位置在哪?
让我们重新访问 SPMediaKeyTap 源码, 看看如何处理媒体控制键. 在回调函数中, 我们可以看到如果按下 F7,F8 或 F9(NX_KEYTYPE_PREVIOUS,NX_KEYTYPE_PLAY 等), 我们将执行 handleAndReleaseMediaKeyEvent 选择器:
然后在所述选择器中通知 delegate:
让我们看看 repo 中的这个 delegate 方法:
事实证明它只是为处理 keys 设置了一个模板. 让我们在 IDA 中搜索 receiveMediaKeyEvent 函数, 并查看相应函数的图形视图:
看起来非常相似, 不是吗? 我们可以看到, 对每种类型的键都调用了一个公共函数 sub_10006FE10, 只设置了一个整数参数来区分它们. 让我们 hook 它, 看看我们是否可以记录按下的键.
我们可以从反汇编中看到, sub_10006FE10 获得了两个参数: 1)指向 SPTClientAppDelegate 单例的 playerDelegate 属性的指针, 以及 2)指定发生了什么类型事件的整数(0 表示暂停 / 播放, 3 表示下一个, 4 表示上一个).
看看 sub_10006FE10(我不会在这里包含它, 但我强烈建议你自己检查一下), 我们可以看到它实际上是 sub_10006DE40 的包装器, 其中包含了大部分内容:
哇! 这看起来很复杂. 让我们试着把它分解一下.
从这个图的结构来看, 有一个指向顶部的节点有许多 outgoing edges:
正如 IDA 所建议的那样, 这是 esi(前面描述的第二个整数参数)上的 switch 语句. 看起来 Spotify 的处理的不仅仅是 Previous,Pause/Play 和 Next. 让我们把关注点集中到处理 Next 或 3 block:
不可否认, 为此我花了一些时间, 但我想请你注意底部第四行的 call r12. 如果你查看其他的一些情况, 你会发现一个非常相似的调用寄存器的模式. 这似乎是一个很好的函数, 但我们如何知道它在哪呢?
让我们打开一个新工具: debugger(调试器). 我最初尝试调试 Spotify 时遇到了很多麻烦. 现在可能是因为我对调试器不太熟悉的原因, 但我认为我想出了一个相当聪明的解决方案.
我们首先在 sub_10006DE40 上设置一个 hook, 然后我们在代码中触发一个断点. 我们可以通过执行汇编指令 int 3 来做到这一点(例如像 GDB 和 LLDB 之类的调试).
以下是在 HookCase 框架中 hook 的样子:
将此添加到 HookCase 模板库后, 你还必须将其添加到 user_hooks 数组:
然后我们可以使用 Makefile HookCase 提供的模板来编译它. 然后可以使用以下命令将库插入 Spotify:HC_INSERT_LIBRARY=<full path to hook dylib> /Applications/Spotify.App/Contents/MacOS/Spotify.
然后我们可以运行 LLDB 并将其 attach 到正在运行的 Spotify 进程, 如下所示:
尝试按 F9(如果 Spotify 不是活动窗口, 它可能会打开 iTunes). 钩子中的 int $3 行应该触发了调试器.
现在我们可以进入到 sub_10006DE40 入口点这步. 请注意, PC 将位于与 IDA 中显示的地址相对应的位置(我认为这是由于进程加载到内存的位置所导致的). 在我当前的进程中, push r15 指令位于 0x10718ee44:
在 IDA 中, 该指令的地址为 0x10006DE44, 它给了我们一个偏移量 0*7121000. 在 IDA 中, 调用 r12 指令的地址为 0x10006E234. 然后我们可以将偏移量添加到该地址, 并相应地设置一个断点, b -a 0x10718f234, 然后继续.
当我们点击目标指令时, 我们可以打印出寄存器 r12 的内容:
我们要做的就是从这个地址减去偏移量, 看, 我们获取到了我们名义上的地址: 0x100CC2E20.
Hooking sub_100CC2E20
现在, 让我们来 hook 这个函数:
将其添加到 user_hooks 数组, 编译, 运行, 并观察: 每次按 F9 或单击 Spotify 应用程序中的 next 按钮, 都会记录我们的消息.
现在我们已经 hook 了 skip 功能,
我将发布剩余的代码, 但我不会完成其余部分的逆向工作, 因为这篇文章已经够长的了.
简而言之, 我也 hook 了 previous 功能(如果你照着做的话, 这会是一个很好的练习). 然后, 在这两个钩子中, 我首先检查当前的歌曲是否已经过了一半. 如果是的话, 我什么都不做, 假设我只是对这首歌感到厌倦, 而不是觉得它不合适. 然后在 backs (F7), 我弹出 last skip.
针对如何检查当前歌曲是否已经过了一半的方法我想说几句. 我最初的方法是实际调用 popen, 然后运行相应的 AppleScript 命令, 但感觉这不太对.
我在 Spotify 二进制文件上运行了 class-dump, 发现了两个类: SPAppleScriptObjectModel 和 SPAppleScriptTrack. 这些方法公开了播放位置, 持续时间和曲目 ID 所需的必要属性. 然后, 我为这些属性 hook 了 getter, 并使用 next 和 back hooks 调用它们(我认为 Swizzle 更合理, 但我无法让它正常工作).
我使用一个文件来跟踪 skips, 其中第一行包含跳过次数, 在跳过时我们增加这个计数器, 并将跟踪 ID 和时间戳写入计数器指定行上的文件. 在 back 按钮, 我们只是减少这个计数器. 这样, 当我们按下 back 按钮时, 我们只是将文件设置为对已回溯文件写入 new skips. 无论如何, 这里的代码是:
总结
希望通过本文你可以学习到一些新的知识, 至少在这个过程中我已学到了很多东西. 另外, 如果你有任何更好的想法或建议请将它告诉我! 谢谢!
来源: http://www.tuicool.com/articles/YnEfyyN