HackXK 发表于 2026-7-18 11:39

[macOS] iCollections 9.7.7 逆向分析

> **分析工具:IDA Pro**
> **辅助工具:otool、lipo、codesign、plutil、xxd**
> **目标版本:iCollections 9.7.7(Build 97708)**
> **目标架构:x86_64 + arm64**



## 前言

这篇记录的是 iCollections 9.7.7 在 macOS 下的一次完整静态分析过程,主要目的是梳理它的授权判断、激活入口、启动向导和延迟弹窗之间的关系。

这个样本看起来不复杂,但实际上有两个容易绕进去的地方:

1. App 是 Universal Mach-O,x86_64 和 arm64 两套代码都要处理。
2. 启动时看到的窗口不只有 `Wizard.nib`,后面还有一个延迟调用的 `Register.nib`。

我一开始也走了弯路,直接处理 `EnterLicenseDialog` 函数本体后,窗口确实没有了,但程序启动几秒后会自己退出。后面顺着调用链重新梳理,才确定正确的做法是保留函数本体,只跳过对它的调用点。

本文仅记录逆向分析和 Mach-O 调试方法,用于技术交流与研究。

---

## 一、样本信息

| 项目 | 内容 |
|---|---|
| App | iCollections |
| 版本 | 9.7.7 |
| Build | 97708 |
| Bundle ID | `com.naarak.Collections` |
| 主程序 | `iCollections.app/Contents/MacOS/iCollections` |
| Mach-O | Universal/Fat |
| 架构 | `x86_64` + `arm64` |

为了避免后面的命令里全是长路径,先统一定义几个变量:

```bash
ROOT="$HOME/Desktop/iCollections"
APP="$ROOT/iCollections.app"
BIN="$APP/Contents/MacOS/iCollections"
```

先看基本信息:

```bash
file "$BIN"
lipo -detailed_info "$BIN"
plutil -p "$APP/Contents/Info.plist" | head -n 40
codesign -dvvv "$APP" 2>&1
```

样本是两个 slice 合在一起的 Fat Mach-O,本版的 slice 偏移是:

```text
x86_64 slice offset = 0x4000
arm64slice offset = 0x28C000
```

这两个数要记下来。IDA 里看到的是虚拟地址(VA),真正落到 Fat 文件上时,还要加上对应架构的 slice offset。

动手前先备份,同时记录原始文件哈希:

```bash
mkdir -p "$ROOT/backups"
cp "$BIN" "$ROOT/backups/iCollections.original.unmodified"
shasum -a 256 "$BIN" | tee "$ROOT/backups/iCollections.original.sha256"
```

这一步看起来很普通,但后面试错时非常有用。特别是对函数本体下手后出现闪退,没有干净备份的话,很难判断到底是哪一处改坏了。

---

## 二、IDA 加载双架构

Fat Mach-O 在 IDA 里不要只建一个数据库。我这里分别加载了两次:

```text
iCollections_x86_64.i64
iCollections_arm64.i64
```

打开主程序后,IDA 的 Mach-O loader 会让我们选择架构。第一次选 `x86_64`,第二次选 `arm64`,分别等待自动分析完成。

这个 App 的 Objective-C 元数据保留得比较完整,所以在 IDA 中可以直接看到不少类名和方法名。

可以先搜这些关键词,快速建立整体印象:

```text
NRegister
IsRegistered
LicenseType
DecodeKey
NWizard
Activate:
OpenWizard:
InitWithDelegate:
EnterLicenseDialog
Register.nib
Wizard.nib
```

我的习惯是先从方法名入手,再跟调用引用,不一上来就搜弹窗文字。弹窗文字只能告诉我们“这里会显示什么”,方法和 Xref 才能说明“它为什么会走到这里”。

---

## 三、地址和文件偏移怎么换算

这个样本两个 slice 的 `__TEXT` 基址都是:

```text
0x100000000
```

因此文件偏移的换算公式是:

```text
file offset = slice offset + (VA - 0x100000000)
```

以 x86_64 的 `0x1000F8421` 为例:

```text
slice offset = 0x4000
VA         = 0x1000F8421

file offset = 0x4000 + (0x1000F8421 - 0x100000000)
            = 0xFC421
```

这里有个很容易犯的错:把 IDA 里的 VA 当成整个 Fat 文件的偏移。这样写进去的位置肯定不对,严重时连另一个 slice 或 load command 都可能被破坏。

每改一组字节,都可以在终端再校验一次:

```bash
xxd -g 1 -s 0xFC421 -l 32 "$BIN"
```

---

## 四、授权状态的入口

先看 `NRegister` 类。在 IDA 的 Functions 或 Names 窗口搜索:

```objc
+
+
+
+
```

`IsRegistered` 返回当前是否处于已注册状态,`LicenseType` 返回授权类型。从后续的使用点看,类型值 `7` 可以走完整功能路径。这里只按样本的实际分支记录,不对这个枚举值强行起名。

### 4.1 x86_64:`IsRegistered`

函数从 `0x1000F841D` 开始,前四个字节是常规栈帧:

```asm
push    rbp
mov   rbp, rsp
```

原始逻辑会读取一个全局状态,再经过 `test` / `cmp` / `setne` 组合得到布尔值。这里保留栈帧序言,从 `0x1000F8421` 开始改为固定返回 `1`:

```asm
mov   eax, 1
pop   rbp
ret
```

Patch 信息:

```text
VA      : 0x1000F8421
fileoff : 0xFC421
bytes   : B8 01 00 00 00 5D C3 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
```

之所以有一串 NOP,是为了覆盖后面的旧逻辑,避免旧指令残留成为误导。

### 4.2 x86_64:`LicenseType`

方法从 `0x1000F843C` 开始,同样保留栈帧序言,从 `0x1000F8440` 处改:

```asm
mov   eax, 7
pop   rbp
ret
```

```text
VA      : 0x1000F8440
fileoff : 0xFC440
bytes   : B8 07 00 00 00 5D C3 90
```

### 4.3 arm64:`IsRegistered` 和 `LicenseType`

arm64 没有 x86 那种可变长指令,每条指令固定 4 字节,处理起来反而更直观。

`IsRegistered` 改为:

```asm
mov   w0, #1
ret
```

```text
VA      : 0x1000ADCA8
fileoff : 0x339CA8
bytes   : 20 00 80 52 C0 03 5F D6 1F 20 03 D5 1F 20 03 D5 1F 20 03 D5
```

`LicenseType` 改为:

```asm
mov   w0, #7
ret
```

```text
VA      : 0x1000ADCC0
fileoff : 0x339CC0
bytes   : E0 00 80 52 C0 03 5F D6 1F 20 03 D5
```

到这里只是让程序内部的状态查询回答“已注册”。如果从启动阶段进入,向导和延迟许可证窗口仍然有自己的调用路径,所以还要继续往下跟。

---

## 五、跟进 `-`

在 IDA 里定位:

```objc
-
```

这个方法包含了激活按钮的主要处理逻辑。结合伪代码和汇编分支,可以看到几个明显的失败出口:

```text
输入为空            -> return
长度大于 40         -> fail
trim 后为空         -> fail
DecodeKey 返回 0/8      -> invalid alert
DecodeKey 返回 2      -> return
```

这里我没有整段删掉 `DecodeKey`,而是只处理它后面的失败分支。原因很简单:后续成功流程还会使用前面准备好的对象和状态,如果粗暴地把整个解码过程拿掉,很容易在后面留下空对象或未初始化状态。

### 5.1 x86_64 分支

x86_64 中这几处都是 6 字节的近跳条件分支,使用等长 6 字节 NOP 覆盖:

```text
66 0F 1F 44 00 00
```

| 作用 | VA | file offset | 原始字节 | Patch 后 |
|---|---:|---:|---|---|
| 空输入 return | `0x10001EDCC` | `0x22DCC` | `0F 84 97 03 00 00` | `66 0F 1F 44 00 00` |
| 长度大于 40 | `0x10001EE10` | `0x22E10` | `0F 87 BC 00 00 00` | `66 0F 1F 44 00 00` |
| trim 后为空 | `0x10001EE25` | `0x22E25` | `0F 84 A7 00 00 00` | `66 0F 1F 44 00 00` |
| `DecodeKey` invalid | `0x10001EE49` | `0x22E49` | `0F 84 A1 01 00 00` | `66 0F 1F 44 00 00` |
| `DecodeKey` ret 2 | `0x10001EE59` | `0x22E59` | `0F 84 0A 03 00 00` | `66 0F 1F 44 00 00` |

改完后条件跳转不再发生,流程会继续向下走成功分支。

### 5.2 arm64 分支

arm64 每条分支指令占 4 字节,用标准 NOP 覆盖:

```text
1F 20 03 D5
```

| 作用 | VA | file offset | Patch 后 |
|---|---:|---:|---|
| 空输入/失败分支 | `0x100017578` | `0x2A3578` | `1F 20 03 D5` |
| 失败分支 | `0x1000175AC` | `0x2A35AC` | `1F 20 03 D5` |
| 失败分支 | `0x1000175B8` | `0x2A35B8` | `1F 20 03 D5` |
| 失败分支 | `0x1000175D4` | `0x2A35D4` | `1F 20 03 D5` |
| 失败分支 | `0x1000175DC` | `0x2A35DC` | `1F 20 03 D5` |

arm64 的成功路径后面会到 `0x1000175E0`,成功弹窗回调对应的子程序中又会调用 `RemoveActivation` 和 `Start:`。这也说明不应该只盯着一个判断值,而应该把后面的对象生命周期一起看完。

---

## 六、启动向导不是一个窗口那么简单

激活逻辑处理完以后,普通启动时仍然会遇到向导界面。继续搜索 `NWizard`,可以找到:

```objc
+
-
```

上层的 `WizardOrInitialize` 大致逻辑是:

```text
call +
test return value
返回 0 -> 继续 Initialize
返回非 0 -> 进入 Wizard
```

所以 `OpenWizard:` 应该返回 `0`,让主程序继续初始化,而不是直接把上层启动函数整个截断。

### 6.1 x86_64

`+` 固定返回 `0`:

```asm
xor   eax, eax
ret
```

```text
VA      : 0x10001D170
fileoff : 0x21170
bytes   : 31 C0 C3 90 90 90 90 90 90 90
```

`-` 固定返回 `nil`,防止其它路径再直接创建 `Wizard.nib`:

```asm
xor   eax, eax
ret
```

```text
VA      : 0x10001D1D9
fileoff : 0x211D9
bytes   : 31 C0 C3 90 90 90 90 90 90 90 90 90
```

### 6.2 arm64

`+` 固定返回 `0`:

```asm
mov   w0, #0
ret
```

```text
VA      : 0x1000162BC
fileoff : 0x2A22BC
original: F4 4F BE A9 FD 7B 01 A9 FD 43 00 91
bytes   : 00 00 80 52 C0 03 5F D6 1F 20 03 D5
```

`-` 固定返回 `nil`:

```asm
mov   x0, #0
ret
```

```text
VA      : 0x100016310
fileoff : 0x2A2310
bytes   : 00 00 80 D2 C0 03 5F D6 1F 20 03 D5
```

这一组处理的是 `Wizard.nib`。做完后重新启动,没有立即弹向导窗口,但等几秒又会出现许可证界面。这就是这次分析最容易误判的地方:后面那个窗口其实来自 `Register.nib`,和前面的 Wizard 不是同一条路径。

---

## 七、追踪延迟出现的 `Register.nib`

在 Strings 里搜索:

```text
EnterLicenseDialog
Register.nib
performSelector:withObject:afterDelay:
```

对 `EnterLicenseDialog` 的 selector reference 按 `X` 查看引用,可以看到其中一条路径并不是立即调用,而是通过:

```objc
performSelector:withObject:afterDelay:
```

进行延迟调度。这就解释了为什么 App 的主界面已经出来,许可证窗口还会在后面冒出来。

这里最后采用的原则是:

```text
保留 EnterLicenseDialog 函数本体
保留 + 函数本体
只处理实际触发窗口的 call / jmp / bl 调用点
```

### 7.1 x86_64 的四个调用点

| 位置 | VA | file offset | 原始 | Patch | 说明 |
|---|---:|---:|---|---|---|
| 延迟 `performSelector` | `0x10018D37D` | `0x19137D` | `FF D3` | `66 90` | `call rbx` 改 2-byte NOP |
| 尾调用 | `0x10018F0C1` | `0x1930C1` | `FF E0` | `C3 90` | `jmp rax` 改 `ret; nop` |
| 直接调用 | `0x10018F0DB` | `0x1930DB` | `41 FF D5` | `0F 1F 00` | `call r13` 改 3-byte NOP |
| 直接调用 | `0x10018F480` | `0x193480` | `FF D3` | `66 90` | `call rbx` 改 2-byte NOP |

第一处附近的特征很明显:

```asm
; Objc selector ref: EnterLicenseDialog
; Objc selector ref: performSelector:withObject:afterDelay:
xorpd   xmm0, xmm0
call    rbx
```

尾调用那一处不能只用 NOP 放任执行流继续跑,因为原本就是函数尾部的 `jmp rax`,所以改成 `ret; nop` 直接结束当前路径。

### 7.2 arm64 的延迟调用点

arm64 中定位到对 `performSelector:withObject:afterDelay:` 的 `bl`:

```asm
; Objc selector ref: EnterLicenseDialog
movi.2d v0, #0
mov   x3, #0
bl      performSelector:withObject:afterDelay:
```

将这条 `bl` 改为 NOP:

```text
VA      : 0x100116AAC
fileoff : 0x3A2AAC
original: 75 E7 00 94
bytes   : 1F 20 03 D5
```

### 7.3 这里踩过的坑

之前试过直接让 `EnterLicenseDialog` 函数返回,也试过直接处理 `+`。表面上看弹窗消失了,但程序启动约几秒后会正常退出,终端可看到:

```text
iCollections has been terminated.
```

这不像普通的空指针崩溃,更像某条启动状态链被破坏后走了主动退出。恢复函数本体,只跳过外部调用点后,15 秒存活测试可以正常通过。

这也是这次分析最值得记下来的一点:看到弹窗函数时,不要默认“让它直接返回”就是影响最小的改法。有些函数除了显示界面,还参与了对象创建、状态设置或后续回调。

---

## 八、在 IDA 中写入 Patch

我这里采用的是手工改字节。先在数据库中测试,全部确认后再应用到输入文件。

1. 在 IDA 中按 `G`,跳到对应 VA。
2. 确认当前指令和记录中的原始字节一致。
3. 选择 `Edit -> Patch program -> Change byte...`。
4. 输入等长的 Patch 字节。
5. 通过 `Edit -> Patch program -> Patched bytes...` 检查全部修改。
6. 选择 `Apply patches to input file...` 应用到文件。

x86_64 是可变长指令,原指令几个字节,替换后就必须仍然占几个字节。不足的部分用 NOP 填满,不能让后面的指令整体错位。

本次用到的几个常见模板:

| 架构 | 长度 | 字节 | 说明 |
|---|---:|---|---|
| x86_64 | 2 | `66 90` | 2-byte NOP |
| x86_64 | 3 | `0F 1F 00` | 3-byte NOP |
| x86_64 | 6 | `66 0F 1F 44 00 00` | 6-byte NOP |
| arm64 | 4 | `1F 20 03 D5` | NOP |
| arm64 | 4 | `C0 03 5F D6` | `ret` |

应用后不要只相信 IDA 的提示,再按 file offset 用 `xxd` 抽查几个点:

```bash
xxd -g 1 -s 0xFC421-l 27 "$BIN"
xxd -g 1 -s 0x22DCC-l 6"$BIN"
xxd -g 1 -s 0x339CA8 -l 20 "$BIN"
xxd -g 1 -s 0x3A2AAC -l 4"$BIN"
```

另外,一个架构改完后不要忘了切到另一个 IDB。在 Apple Silicon Mac 上,Finder 中的“使用 Rosetta 打开”、启动方式以及系统环境都可能影响实际跑哪个 slice。只改 arm64,最后却跑到 x86_64,表现就会像“Patch 时好时坏”。

---

## 九、Info.plist 中的架构优先级

为了在 ARM Mac 上优先运行 arm64 slice,样本的 `Info.plist` 中加了:

```xml
<key>LSArchitecturePriority</key>
<array>
    <string>arm64</string>
    <string>x86_64</string>
</array>
```

可以用 `/usr/libexec/PlistBuddy` 写入:

```bash
PLIST="$APP/Contents/Info.plist"

/usr/libexec/PlistBuddy -c 'Delete :LSArchitecturePriority' "$PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c 'Add :LSArchitecturePriority array' "$PLIST"
/usr/libexec/PlistBuddy -c 'Add :LSArchitecturePriority:0 string arm64' "$PLIST"
/usr/libexec/PlistBuddy -c 'Add :LSArchitecturePriority:1 string x86_64' "$PLIST"
plutil -lint "$PLIST"
```

这不是二进制 Patch 必需的一步,主要是让测试环境更稳定。如果 Finder 简介中手动勾选了“使用 Rosetta 打开”,这个设置仍可能让 App 走 x86_64。

---

## 十、重新签名

Mach-O 和 `Info.plist` 发生变化后,原签名必然失效。这个 App 内部还带了 Framework 和 Login Item,不能只签主程序。

先从原始 App 导出 entitlements 作为参考:

```bash
codesign -d --entitlements :- "$APP" > "$ROOT/original-entitlements.plist" 2>/dev/null
```

本次重签使用的文件名是:

```text
adhoc-entitlements-nolv.plist
```

其中保留了这一项:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.cs.disable-library-validation</key>
    <true/>
</dict>
</plist>
```

之前只签主 App 时,遇到过这个错误:

```text
dyld: Library not loaded: ZipZap.framework
Reason: mapped file and process have different Team IDs
```

原因是主 App 和内嵌 Framework 签名后的 Team ID 关系被破坏。最后的处理方式是清理扩展属性,然后按内层到外层重签:

```bash
ROOT="$HOME/Desktop/iCollections"
APP="$ROOT/iCollections.app"
ENT="$ROOT/adhoc-entitlements-nolv.plist"

xattr -cr "$APP"

codesign --force --deep --sign - \
"$APP/Contents/Library/LoginItems/iCollectionsHelperT.app"

for fw in "$APP/Contents/Frameworks"/*.framework; do
[ -e "$fw" ] || continue
codesign --force --deep --sign - "$fw"
done

codesign --force --deep --sign - --entitlements "$ENT" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
```

验证成功时应该看到:

```text
iCollections.app: valid on disk
iCollections.app: satisfies its Designated Requirement
```

如果还是报某个 Framework、Helper 或 dylib 签名不一致,就先用下面的命令找出 App 内所有 Mach-O,检查是否有内层组件漏签:

```bash
find "$APP/Contents" -type f -print0 | while IFS= read -r -d '' f; do
file "$f" | grep -q 'Mach-O' && echo "$f"
done
```

---

## 十一、清理旧状态并测试

macOS 会保存窗口恢复状态,App 自己也可能通过 `NSUserDefaults` 记录向导是否完成。调试时如果不清理,很容易出现同一个二进制这次弹窗、下次又不弹的假象。

测试前先清理:

```bash
pkill -9 -x iCollections 2>/dev/null || true
rm -rf "$HOME/Library/Saved Application State/com.naarak.Collections.savedState"

defaults write com.naarak.Collections WizardDone -bool true
defaults write com.naarak.Collections NSQuitAlwaysKeepsWindows -bool false
defaults write com.naarak.Collections ApplePersistenceIgnoreState -bool true
```

普通启动:

```bash
open -n "$APP"
```

除了看界面,还要确认程序不会延迟退出。可以直接从终端启动主程序,连续观察 15 秒:

```bash
BIN="$APP/Contents/MacOS/iCollections"

pkill -9 -x iCollections 2>/dev/null || true
"$BIN" > /tmp/icollections_run.out 2>/tmp/icollections_run.err &
PID=$!

for i in $(seq 1 15); do
sleep 1
if kill -0 "$PID" 2>/dev/null; then
    echo "alive $i"
else
    echo "exited $i"
    break
fi
done

cat /tmp/icollections_run.err
```

本次最终结果是:

```text
alive 1
...
alive 15
```

除了存活时间,还手工检查了下面几项:

```text
App 能正常启动
主界面可以进入
启动时不出现 Wizard.nib
等待后不出现 Register.nib
程序运行 15 秒不自动退出
codesign 深度验证通过
```

---

## 十二、完整 Patch 清单

下面是本版本最终保留的字节,方便自查。合计 23 个修改点:x86_64 共 13 个,arm64 共 10 个。

### x86_64

```text
+
VA 0x1000F8421 / fileoff 0xFC421
B8 01 00 00 00 5D C3 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90

+
VA 0x1000F8440 / fileoff 0xFC440
B8 07 00 00 00 5D C3 90

- 失败分支
VA 0x10001EDCC / fileoff 0x22DCC / 66 0F 1F 44 00 00
VA 0x10001EE10 / fileoff 0x22E10 / 66 0F 1F 44 00 00
VA 0x10001EE25 / fileoff 0x22E25 / 66 0F 1F 44 00 00
VA 0x10001EE49 / fileoff 0x22E49 / 66 0F 1F 44 00 00
VA 0x10001EE59 / fileoff 0x22E59 / 66 0F 1F 44 00 00

+
VA 0x10001D170 / fileoff 0x21170
31 C0 C3 90 90 90 90 90 90 90

-
VA 0x10001D1D9 / fileoff 0x211D9
31 C0 C3 90 90 90 90 90 90 90 90 90

EnterLicenseDialog 调用点
VA 0x10018D37D / fileoff 0x19137D / 66 90
VA 0x10018F0C1 / fileoff 0x1930C1 / C3 90
VA 0x10018F0DB / fileoff 0x1930DB / 0F 1F 00
VA 0x10018F480 / fileoff 0x193480 / 66 90
```

### arm64

```text
+
VA 0x1000ADCA8 / fileoff 0x339CA8
20 00 80 52 C0 03 5F D6 1F 20 03 D5 1F 20 03 D5 1F 20 03 D5

+
VA 0x1000ADCC0 / fileoff 0x339CC0
E0 00 80 52 C0 03 5F D6 1F 20 03 D5

- 失败分支
VA 0x100017578 / fileoff 0x2A3578 / 1F 20 03 D5
VA 0x1000175AC / fileoff 0x2A35AC / 1F 20 03 D5
VA 0x1000175B8 / fileoff 0x2A35B8 / 1F 20 03 D5
VA 0x1000175D4 / fileoff 0x2A35D4 / 1F 20 03 D5
VA 0x1000175DC / fileoff 0x2A35DC / 1F 20 03 D5

+
VA 0x1000162BC / fileoff 0x2A22BC
00 00 80 52 C0 03 5F D6 1F 20 03 D5

-
VA 0x100016310 / fileoff 0x2A2310
00 00 80 D2 C0 03 5F D6 1F 20 03 D5

EnterLicenseDialog 延迟调用点
VA 0x100116AAC / fileoff 0x3A2AAC / 1F 20 03 D5
```

---

## 十三、这次遇到的坑

### 1. 只改 arm64,实际却跑到 x86_64

Universal App 不能只凭机器芯片判断运行架构。Finder 的 Rosetta 选项或旧启动配置都可能让 App 跑 x86_64。最稳妥的做法是两个 slice 都分析、都修改。

### 2. 混淆 VA 和 Fat 文件偏移

IDA 中的 `0x100...` 是虚拟地址,不是整个文件的写入偏移。必须结合 slice offset 和 `__TEXT` 基址进行换算。

### 3. x86_64 指令长度没对齐

2 字节 `call` 就用 2 字节 NOP,3 字节 `call` 就用 3 字节 NOP,6 字节条件跳转就用 6 字节 NOP。不能为了省事全写成一个 `90`,后面的残留字节会被重新解码。

### 4. 直接处理 `EnterLicenseDialog` 函数本体

这是本次最关键的坑。窗口是不弹了,但会破坏后面的启动状态,导致程序几秒后自己退出。最终只跳过调用点,保留函数本身。

### 5. 把 `About.nib` 当成授权核心

关于页只是展示层,处理 `NAbout` / `About.nib` 既不能代替授权状态,也不能解决启动窗口。最终已经完全恢复这部分。

### 6. 重签只顾主程序

App 包内的 Helper 和 Framework 也在签名链上。只签外层 App 可能通过表面检查,但运行时会被 dyld 因 Team ID 或 Library Validation 拒绝。

### 7. 没清理 Saved Application State

旧窗口状态会干扰判断。测试一个启动流程时,要么固定用一套干净配置,要么每轮都重置相同的 defaults 和 Saved State,否则前后现象不可比。

### 8. 死记地址

这些 VA 和 file offset 只对 9.7.7 Build 97708 有效。只要上游重新编译,方法顺序和代码布局就可能变化。真正有用的是方法名、selector、字符串和调用关系。

---

## 十四、新版本怎么重新定位

新版本不要照抄上面的地址,可以按下面的顺序重新走一遍。

### 第一步:重新确认 slice

```bash
BIN='/path/to/iCollections.app/Contents/MacOS/iCollections'
file "$BIN"
lipo -detailed_info "$BIN"
```

记录新的 slice offset,再根据 Mach-O segment 信息确认 `__TEXT` 基址,不要默认它永远不变。

### 第二步:从 Objective-C 方法开始

优先定位:

```objc
+
+
+
+
-
+
-
```

命令行也可以用 `otool` 先筛一遍:

```bash
otool -arch x86_64 -ov "$BIN" | \
egrep -A30 -B8 'NRegister|IsRegistered|LicenseType|DecodeKey|NWizard|Activate:|OpenWizard:'

otool -arch arm64 -ov "$BIN" | \
egrep -A30 -B8 'NRegister|IsRegistered|LicenseType|DecodeKey|NWizard|Activate:|OpenWizard:'
```

### 第三步:跟失败分支,不要只找常量

进入 `Activate:` 后,顺着 `DecodeKey` 的返回值往后看,识别哪些分支进入 invalid alert,哪些分支直接 return,哪一条才是成功流程。优化级别变化后,条件跳转的排列可能和这一版完全不同。

### 第四步:用 selector 找延迟窗口

```bash
otool -arch x86_64 -tvV "$BIN" | grep -B8 -A14 'EnterLicenseDialog'
otool -arch arm64-tvV "$BIN" | grep -B8 -A14 'EnterLicenseDialog'
```

重点看:

```text
EnterLicenseDialog
performSelector:withObject:afterDelay:
Register.nib
```

先确认调用者和对象生命周期,然后再决定处理调用点还是返回值。上一版能用的 Patch 模式,不代表下一版仍然是同一条调用链。

---

## 结语

这次分析最开始只是想找一个授权判断,后面才发现它实际上是四块逻辑叠在一起:

```text
授权状态查询
    -> 激活按钮的输入和 DecodeKey 分支
    -> Wizard.nib 启动向导
    -> Register.nib 延迟许可证窗口
```

真正花时间的地方不是把几条指令改成 NOP,而是判断哪个点改完后不会破坏后面的流程。直接封掉窗口函数导致延迟退出,就是一个很典型的例子。

最后再强调一遍:本文中所有绝对地址和文件偏移都只对 iCollections 9.7.7 Build 97708 这一个样本有效。版本变化后,请按方法名、selector、字符串和 Xref 重新定位,不要直接套地址。

本文只是用于交流学习用途,请勿非法使用。

再次也祝吾爱论坛越做越好,蒸蒸日上。

老爹 发表于 2026-7-18 13:59

这个是干嘛的

lvxu00000 发表于 2026-7-18 15:04

学习大佬操作,感谢

熊猫咖啡 发表于 2026-7-18 23:11

虽然看不懂,但是非常支持,加油

奈酱 发表于 2026-7-19 08:17

谢谢分享

柒渊网络 发表于 2026-7-19 13:43

这个不错,不知道对mac的版本有没有要求

yukimura 发表于 2026-7-19 22:13

学习了{:1_937:}挺好用的

chronos8963 发表于 2026-7-20 09:29

哇,大佬。学习学习

Yeah_whr 发表于 2026-7-21 13:54

佬有没有空出一个ios插件逆向教程

HackXK 发表于 2026-7-22 18:30

Yeah_whr 发表于 2026-7-21 13:54
佬有没有空出一个ios插件逆向教程

iOS的插件逆向教程除了很多了啊
页: [1] 2
查看完整版本: [macOS] iCollections 9.7.7 逆向分析