记一次Path Finder macOS版破解

0x00 起因

Path Finder 是一款强大的 macOS 文件管理器,功能远超原生 Finder。但从某个版本开始转为付费订阅制,未激活版本仅有30天试用。

在 glm 的辅助下进行了逆向分析,并做了一个 PoC 脚本。

注:本文仅学习交流技术原理,请购买正版许可证支持正版

0x01 初步分析

先看看 Path Finder.app 的结构:

1
2
3
4
5
6
7
8
9
10
Path Finder.app/
├── Contents/
│ ├── MacOS/
│ │ └── Path Finder ← Mach-O universal binary (x86_64 + arm64)
│ ├── Frameworks/
│ │ └── PathFinderLicense.framework/
│ │ └── Versions/A/
│ │ └── PathFinderLicense ← 许可证框架
│ └── Resources/
│ └── ...

这是一个原生 Cocoa 应用,许可证验证逻辑被封装在独立的 PathFinderLicense.framework 中。

file 确认二进制类型:

1
2
$ file PathFinderLicense
Mach-O universal binary with 2 architectures: [x86_64] [arm64]

代码签名是 adhoc,说明 patch 后重签名很方便:

1
2
$ codesign -d --verbose "Path Finder.app"
Signature=adhoc

0x02 寻找突破口

strings 搜索关键信息:

1
2
3
4
5
6
$ strings PathFinderLicense | grep -i "license\|keychain"
com.cocoatech.PathFinder.Registration
application-password-v1
PFLicenseManager
isActive
loadLicenseInfoFromKeychain

发现了关键类 PFLicenseManager 和方法 isActiveloadLicenseInfoFromKeychain

nm 查看符号表:

1
2
3
$ nm PathFinderLicense | grep -i "isActive\|loadLicense"
0000d3cc T -[PFLicenseManager isActive]
00015960 T -[PFLicenseManager loadLicenseInfoFromKeychain]

找到了,isActive 方法在 0xd3cc 偏移处(arm64 架构的地址)。

0x03 逆向激活流程

静态分析

radare2 反汇编 isActive 方法:

1
2
3
$ r2 -A PathFinderLicense
[0x00000000]> s 0x0000d3cc
[0x0000d3cc]> pdf

发现方法逻辑很简单:

1
2
3
1. 保存栈帧
2. 从 self 读取某个状态字段
3. 返回 bool 值

再看 loadLicenseInfoFromKeychain

1
2
3
4
5
6
7
1. 构造 Keychain 查询参数
- service: "com.cocoatech.PathFinder.Registration"
- account: "application-password-v1"
- class: kSecClassGenericPassword
2. 调用 SecItemCopyMatching
3. 解析返回的数据
4. 更新 licenseStatus

关键发现

通过进一步分析应用启动流程,发现:

  1. AppDelegateapplicationDidFinishLaunching: 中初始化 PFLicenseManager
  2. PFLicenseManager.init 会创建网络队列、Keychain 队列,并初始化许可证状态字段
  3. 调用 loadLicenseInfoFromKeychain 从 Keychain 读取激活信息
  4. 在需要验证时调用 isActive 方法检查激活状态

还发现了一个关键的状态检查点:在 licenseInfoUpdated: 方法中有一个条件跳转,用于检查 isActive 的返回值。如果返回 false,会触发弹窗提示激活。

0x04 破解思路

基于上述分析,确定了两个补丁点:

1. 强制 isActive 返回 true

arm64 架构:

原始指令(函数序言):

1
2
3
f6 57 bd a9    stp x22, x21, [sp, #-0x30]!
f4 4f 01 a9 stp x20, x19, [sp, #0x10]
...

替换为:

1
2
20 00 80 52    mov w0, #1        ; 返回 true
c0 03 5f d6 ret ; 立即返回

x86_64 架构:

原始指令(函数序言):

1
2
3
55             push rbp
48 89 e5 mov rbp, rsp
...

替换为:

1
2
3
31 c0          xor eax, eax      ; eax = 0
ff c0 inc eax ; eax = 1
c3 ret ; 返回

2. NOP 掉状态检查跳转

licenseInfoUpdated: 中找到调用 isActive 后的条件跳转:

arm64 架构:

1
2
9c 48 00 94    bl isActive
60 01 00 34 cbz w0, <skip> ; 如果 w0=0 跳过激活逻辑

cbz 替换为 nop

1
1f 20 03 d5    nop

x86_64 架构:

1
2
3
41 ff d5       call r13           ; 调用 isActive
84 c0 test al, al
74 xx je <skip> ; 如果 al=0 跳过

je 替换为 nop

1
90 90          nop nop

0x05 动态查找方案

由于不同版本的 Path Finder 方法地址可能不同,硬编码偏移不够通用。遂采用动态查找的方式:

  1. radare2ic 命令查找 Objective-C 方法地址
  2. /x 命令搜索指令特征模式
  3. 计算 fat binary 中每个架构的基址偏移
  4. 组合得到最终的补丁地址

关键技术点

查找方法地址:

1
2
$ r2 -q -c 'ic~isActive' PathFinderLicense
0x0000d3cc objc method 1 isActive

搜索指令模式(arm64):

1
2
$ r2 -q -c '/x 9c48009460010034' PathFinderLicense.arm64
0x00004e30 hit0_0 9c48009460010034

解析 fat binary 架构偏移:

1
2
3
4
5
$ lipo -detailed_info PathFinderLicense
architecture arm64
offset 16384
architecture x86_64
offset 98304

最终补丁地址 = 架构基址 + 方法偏移。

0x06 实现

完整的破解流程:

1. 提取架构切片

1
2
run('lipo', ['-thin', 'arm64', frameworkPath, '-output', arm64Path]);
run('lipo', ['-thin', 'x86_64', frameworkPath, '-output', x86Path]);

2. 动态查找补丁点

1
2
const arm64IsActive = r2FindMethod(arm64Path, 'isActive');
const arm64StatusBase = r2FindPattern(arm64Path, '9c 48 00 94 60 01 00 34');

3. 应用补丁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const patches = [
{
arch: 'arm64',
desc: 'isActive -> true',
offset: archOffset.arm64 + arm64IsActive,
original: Buffer.from([0xF6, 0x57, 0xBD, 0xA9, 0xF4, 0x4F, 0x01, 0xA9]),
patched: Buffer.from([0x20, 0x00, 0x80, 0x52, 0xC0, 0x03, 0x5F, 0xD6])
},
// ... x86_64 补丁
];

for (const patch of patches) {
const current = binary.slice(patch.offset, patch.offset + patch.original.length);
if (!current.equals(patch.original)) {
fail(`字节不匹配 @ 0x${patch.offset.toString(16)}`);
}
patch.patched.copy(binary, patch.offset);
}

4. 备份 + 重签名

1
2
3
fs.copyFileSync(frameworkPath, `${frameworkPath}.bak`);
fs.writeFileSync(frameworkPath, binary);
run('codesign', ['--force', '--deep', '--sign', '-', appPath]);

0x07 总结

最终写成了一个脚本,支持一键激活和一键回滚。

需要前置依赖radare2,安装:

1
brew install radare2
完整脚本 (crack.js)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
#!/usr/bin/env node
/**
* Path Finder 激活脚本
* 原理:动态查找补丁点并修改许可证框架
* 依赖:radare2
*
* 用法:
* node crack.js # patch
* node crack.js --rollback
*/

const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');

const DEFAULT_APP_PATH = '/Applications/Path Finder.app';
const CONFIG_FILE = path.join(os.homedir(), '.pathfinder_crack_config.json');

function run(bin, args, opts = {}) {
return spawnSync(bin, args, { encoding: 'utf8', ...opts });
}

function fail(msg) {
console.error(`❌ ${msg}`);
process.exit(1);
}

function getFrameworkPath(appPath) {
return path.join(appPath, 'Contents', 'Frameworks', 'PathFinderLicense.framework', 'Versions', 'A', 'PathFinderLicense');
}

function loadConfig() {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch {
return {};
}
}

function saveConfig(data) {
try {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
} catch {}
}

function resolveAppPath() {
const config = loadConfig();
if (config.path && fs.existsSync(getFrameworkPath(config.path))) return config.path;
if (fs.existsSync(getFrameworkPath(DEFAULT_APP_PATH))) {
saveConfig({ ...config, path: DEFAULT_APP_PATH });
return DEFAULT_APP_PATH;
}
fail('未找到 Path Finder.app');
}

function requireR2() {
if (run('which', ['r2']).status !== 0) {
fail('需要先安装 radare2:brew install radare2');
}
}

function parseArchOffsets(frameworkPath) {
const out = run('lipo', ['-detailed_info', frameworkPath]).stdout;
const arm64 = out.match(/architecture arm64[\s\S]*?offset (\d+)/);
const x86_64 = out.match(/architecture x86_64[\s\S]*?offset (\d+)/);
if (!arm64 || !x86_64) fail('无法解析 fat binary 架构偏移');
return {
arm64: Number(arm64[1]),
x86_64: Number(x86_64[1])
};
}

function r2FindMethod(binaryPath, methodName) {
const out = run('r2', ['-q', '-c', `ic~${methodName}`, binaryPath], { timeout: 30000 }).stdout;
const match = out.match(/0x([0-9a-f]+)/i);
return match ? Number.parseInt(match[1], 16) : null;
}

function r2FindPattern(binaryPath, hexBytes) {
const out = run('r2', ['-q', '-c', `/x ${hexBytes}`, binaryPath], { timeout: 30000 }).stdout;
const match = out.match(/0x([0-9a-f]+)/i);
return match ? Number.parseInt(match[1], 16) : null;
}

function extractSlices(frameworkPath) {
const dir = path.join(os.tmpdir(), 'opencode', 'pf_crack');
fs.mkdirSync(dir, { recursive: true });

const arm64 = path.join(dir, 'PathFinderLicense.arm64');
const x86_64 = path.join(dir, 'PathFinderLicense.x86_64');

run('lipo', ['-thin', 'arm64', frameworkPath, '-output', arm64]);
run('lipo', ['-thin', 'x86_64', frameworkPath, '-output', x86_64]);

if (!fs.existsSync(arm64) || !fs.existsSync(x86_64)) {
fail('无法提取架构切片');
}

return { arm64, x86_64 };
}

function buildPatches(frameworkPath) {
const offsets = parseArchOffsets(frameworkPath);
const slices = extractSlices(frameworkPath);

const arm64IsActive = r2FindMethod(slices.arm64, 'isActive');
const arm64StatusBase = r2FindPattern(slices.arm64, '9c 48 00 94 60 01 00 34');
const x86IsActive = r2FindMethod(slices.x86_64, 'isActive');
const x86StatusBase = r2FindPattern(slices.x86_64, '41 ff d5 84 c0 74');

if (arm64IsActive == null || arm64StatusBase == null || x86IsActive == null || x86StatusBase == null) {
fail('未找到完整补丁点,可能版本不兼容');
}

return [
{
arch: 'arm64',
desc: 'isActive -> true',
offset: offsets.arm64 + arm64IsActive,
original: Buffer.from([0xF6, 0x57, 0xBD, 0xA9, 0xF4, 0x4F, 0x01, 0xA9]),
patched: Buffer.from([0x20, 0x00, 0x80, 0x52, 0xC0, 0x03, 0x5F, 0xD6])
},
{
arch: 'arm64',
desc: 'status check -> nop',
offset: offsets.arm64 + arm64StatusBase + 4,
original: Buffer.from([0x60, 0x01, 0x00, 0x34]),
patched: Buffer.from([0x1F, 0x20, 0x03, 0xD5])
},
{
arch: 'x86_64',
desc: 'isActive -> true',
offset: offsets.x86_64 + x86IsActive,
original: Buffer.from([0x55, 0x48, 0x89, 0xE5]),
patched: Buffer.from([0x31, 0xC0, 0xFF, 0xC0, 0xC3])
},
{
arch: 'x86_64',
desc: 'status check -> nop',
offset: offsets.x86_64 + x86StatusBase + 5,
original: Buffer.from([0x74]),
patched: Buffer.from([0x90, 0x90])
}
];
}

function patch(appPath) {
const frameworkPath = getFrameworkPath(appPath);
const backupPath = `${frameworkPath}.bak`;

if (fs.existsSync(backupPath)) {
console.log('⚠️ 检测到已有备份,先恢复原始文件后重新 patch');
fs.copyFileSync(backupPath, frameworkPath);
fs.rmSync(backupPath, { force: true });
}

console.log('🔍 查找补丁点...');
const patches = buildPatches(frameworkPath);
let binary = fs.readFileSync(frameworkPath);

for (const patch of patches) {
const current = binary.slice(patch.offset, patch.offset + patch.original.length);
if (!current.equals(patch.original)) {
fail(`${patch.arch} ${patch.desc} 字节不匹配 @ 0x${patch.offset.toString(16)}`);
}
patch.patched.copy(binary, patch.offset);
console.log(`✅ ${patch.arch} ${patch.desc} @ 0x${patch.offset.toString(16)}`);
}

fs.copyFileSync(frameworkPath, backupPath);
fs.writeFileSync(frameworkPath, binary);
run('killall', ['Path Finder'], { stdio: 'ignore' });
run('codesign', ['--force', '--deep', '--sign', '-', appPath], { stdio: 'ignore' });
console.log('🎉 Patch 完成');
}

function rollback(appPath) {
const frameworkPath = getFrameworkPath(appPath);
const backupPath = `${frameworkPath}.bak`;

run('killall', ['Path Finder'], { stdio: 'ignore' });

if (!fs.existsSync(backupPath)) {
console.log('⚠️ 未找到备份文件,无需回滚');
return;
}

fs.copyFileSync(backupPath, frameworkPath);
fs.rmSync(backupPath, { force: true });
run('codesign', ['--force', '--deep', '--sign', '-', appPath], { stdio: 'ignore' });
console.log('🎉 回滚完成');
}

(function main() {
const appPath = resolveAppPath();
requireR2();

if (process.argv.includes('--rollback')) {
rollback(appPath);
return;
}

patch(appPath);
})();

仅做技术交流之用,请支持正版软件


记一次Path Finder macOS版破解
https://www.letr7.com/2026/06/15/cracking-pathfinder-macos/
作者
letr
发布于
2026年6月15日
更新于
2026年7月19日
许可协议