FastRawViewer 授权系统逆向:从位布局到零补丁激活

结论

授权完全本地离线校验,激活码无签名,只靠固定位模式 + 三段哈希片段 + 24 bit 到期时间。构造一个「永久 + 本机」的激活码不需要访问服务器,也不需要改二进制——只改注册表。

1
2
3
4
ActivationID = base32(15 字节)
expiry = 0x800000 → 永久,跳过到期判断
trial = 0
指纹 = 沿用官方为本机签发的三个字段

安装目录里的 FastRawViewer.exe 逐字节未改。


1. 组件与存储

组件 作用
FastRawViewer.exe 13.6 MB,x64 Qt5,含全部校验与注册对话框
frv-activate.exe 命令行激活工具,Qt5Network
activate.fastrawviewer.com/activate2.cgi 激活服务器,明文 HTTP,返回 STATUS/AK/MESSAGE

注册表布局:

1
2
3
4
5
6
7
HKLM\SOFTWARE\LibRaw LLC\FastRawViewer\ActivationData2
ActivationID REG_SZ 15 字节 base32,每 6 字符一组
ProgramKey REG_SZ 购买密钥(试用时不存在)

HKCU\Software\LibRaw LLC\FastRawViewer\ActivationData2 ← 用户级副本
HKCU\Software\LibRaw LLC\FastRawViewer\LastUsed
useCount / lastReminder / lastReminderType ← 试用提醒节流

frv-activate 激活成功时同时写 HKLM 与 HKCU;主程序启动读 HKLM,读不到才回落 HKCU。删掉这两处键就回到未激活状态。


2. 编码

自定义 Crockford 风格 Base32,字母表 A–Z2–7,别名 0→O、1→L、8→B,- 直接忽略。解码结果必须恰好 15 字节。

ProgramKey 额外做版本判别,但只比较一个异或值:

1
2
3
4
5
if (decoded_len == 15) {
if ((b[1] ^ b[14]) == 8) return 2; // V2
if ((b[1] ^ b[14]) == 6) return 1; // V1,提示升级
}
return 0;

这不验证密钥本身,真伪完全由服务器在激活时判断。


3. 位布局

15 字节 = 120 bit,实际参与校验的只有一部分:

字节 含义
0 (b0 ^ b7) & 0xF0 == 0x20
1 (b1 ^ b8):bit7 = 试用标志,低 4 位参与状态字
2 指纹 5 bit(b2>>3)+ 到期低字节异或项
3 到期中字节 b3 ^ b8
4 b4 ^ b10 == 0x08
5 到期高字节 b5 ^ b10
6 到期低字节异或项
7 指纹 8 bit:b7 == (H >> 24) & 0xFF
8 试用位 / 到期中字节异或项
10 固定位与到期高字节异或项
12 指纹 8 bit:b12 == (H >> 48) & 0xFF
9/11/13/14 填充,不参与校验

到期时间 24 bit,单位 256 秒:

1
2
expiry    = ((b5 ^ b10) << 16) | ((b3 ^ b8) << 8) | (b2 ^ b6);
days_left = (expiry - (unixtime >> 8)) / 337.5; // 256/86400

0x800000 是永久标记——非试用时直接跳过到期分支。


4. 校验算法

主校验函数(VA 0x140155C20)只有四步:

1
2
3
4
5
6
7
8
9
10
11
12
13
b = base32_decode(ActivationID);                  // 必须 15 字节
if (((b[0] ^ b[7]) & 0xF0) != 0x20) reject; // 固定位模式
if ((b[4] ^ b[10]) != 0x08) reject;

// 指纹:三个候选 FNV-1a-64,命中其一即可
ok = (((H >> 48) & 0xFF) ^ b[12])
+ (((H >> 22) & 0x3FC) ^ (b[7] * 4))
+ (((H >> 3) & 0x1F) ^ (b[2] >> 3));
if (ok != 0) reject; // 三项非负,和为 0 ⇔ 三项全 0

expiry = ((b[5] ^ b[10]) << 16) | ((b[3] ^ b[8]) << 8) | (b[2] ^ b[6]);
if (expiry != 0x800000 && expiry < (time(NULL) >> 8)) → 过期流程;
trial = ((b[1] ^ b[8]) & 0x80) != 0;

指纹哈希的输入是几项机器信息的拼接,各自计算 FNV-1a-64(offset 0xCBF29CE484222325,prime 0x100000001B3):

1
2
3
4
5
HKLM\Software\Microsoft\Windows NT\CurrentVersion : DigitalProductId
(失败时回退)同上解出的 Windows 产品密钥字符串
HKLM\Software\Microsoft\Cryptography : MachineGuid
HKLM\HARDWARE\DESCRIPTION\System : SystemBiosDate / SystemBiosVersion
HKLM\HARDWARE\DESCRIPTION\System\BIOS : SystemManufacturer / SystemProductName

字符串本身用 byte ^ index ^ 0x4B 混淆,还原后就是上面这些路径。

通过后把状态字写进对象:

1
[this + 0x594] = ((b4 ^ b10) << 8) | (b1 ^ b8);   // 0 表示未激活

5. 构造永久码

约束三条:expiry = 0x800000、试用位为 0、指纹三段沿用官方为本机签发的值。

指纹字段不需要知道哈希怎么算——试用码本身就是官方按本机硬件签发的,解出来直接复用:

1
2
3
b7   = (H >> 24) & 0xFF      ← 从试用码的 b[7] 取
b12 = (H >> 48) & 0xFF ← 从 b[12] 取
b2>>3 ← 从 b[2] 取

然后按位反解:

1
2
3
4
5
6
7
b2 ^ b6 = 0x00      →  b6 = b2
b3 ^ b8 = 0x00 → b3 = b8
b5 ^ b10 = 0x80 → b5 = b10 ^ 0x80
b1 ^ b8 = 0x0F → b1 = b8 ^ 0x0F // bit7=0 非试用,且必须 ≠ 0
b0 = b7 ^ 0x20
b4 = b10 ^ 0x08
b9/b11/b13/b14 任意填充

编码回 base32,双写 HKLM + HKCU 即可。


6. 两个踩过的坑

① b1 ^ b8 == 0 会被判定为无效。

第一版把 b[1] 和 b[8] 取成相等,异或为 0。顶部状态生成函数在 0x1401839BB 有一句 test al, al; je,把 0 直接判进「未激活 / 激活码无效」分支:

1
2
Registered version. ACTIVATE LICENSE.
Invalid activation key, reactivate it.

表现就是弹窗顶部和底部文案自相矛盾。改成 0x0F 后两个分支指向同一状态。

② 不要写 ProgramKey。

ProgramKey 只做格式判别(15 字节 + b1^b14 == 8 → V2),本身不验证真伪。但状态机在 0x14015BCA4 处先看许可证输入框长度,≥15 就解码并走「有待激活的购买密钥」分支:

1
License key exists, please activate     按钮变成 Activate license

等于在已激活状态上又叠了一个待激活状态。真正的永久授权只需要 ActivationID。


7. 验证方式

不点按钮、不截图猜,直接读窗口的无障碍树:

1
2
3
4
5
6
7
8
9
10
变更前:
[Text] Trial license
[Text] Trial period expired
[Text] TRIAL EXPIRED ← 底栏 TrialPlate
[Button] Purchase / Activate

变更后:
[Text] FastRawViewer (Registered version)
[Text] License activated
主窗口:无激活对话框、底栏 TrialPlate 消失

UI 无障碍树是 Qt 程序最省事的验证面——比截图 OCR 稳,比 hook 轻。


8. 通用化:不要复刻公式,让程序自己算

把码推到别的机器上时,指纹必须由那台机器算。原计划是按公式复刻,卡在了 DigitalProductId 的变换上:164 字节原始值、十六进制串、UTF-16、Base64、去长度头、反转……十几种变换代入 FNV 都对不上运行时真值。

于是换了个思路——授权码只需要匹配三个候选哈希之一,那就让程序自己算:

  1. 复制一份 exe 到临时目录,清掉 PE 的 DYNAMIC_BASE,保证镜像基址固定在 0x140000000;
  2. 用 CreateDesktopW 建一个隐藏桌面启动它(不会出现在用户屏幕上);
  3. 在 0x140157CE0(三个哈希算完、开始比较的那条指令)写一段 30 字节桩:把 rsi / r15 / r14 / rdi 存到 VirtualAllocEx 的缓冲,回放原指令后跳回;
  4. 读回哈希,用第一个候选生成 15 字节激活码 → base32 → 写注册表;
  5. 结束进程,删临时副本。

安装目录里的 exe 全程不动。脚本会先校验签名 44 0F B6 95 2F 09 00 00,不匹配直接报错退出,不会乱改。


9. 这套授权的强度

问题 影响
激活码无签名 可离线伪造任意机器、任意到期时间
指纹只有 8+5+8 = 21 bit 参与比较 暴力 2²¹ 可命中,直接构造更省事
FNV-1a 非加密哈希 可预测、可碰撞,不构成安全边界
到期用本地时钟,无防回拨 改系统时间即可续期
明文 HTTP 激活 可被 MITM 返回任意 AK(但攻击者根本不需要服务器)
HKCU 副本用户可写 普通用户即可伪造本地授权数据
反激活只是删键 服务器状态与本地可脱节

相对可取的地方:机器绑定、支持反激活迁移、V1/V2 密钥识别、试用提醒按 20/10/5/3/1 天节流。都属于正常用户流程设计,不构成抗破解能力。


10. 要点

  1. 先看数据,再看代码。 注册表里那两处 ActivationData2 直接给出了全部输入输出,比从 WinMain 往下追快得多。
  2. 官方签发的码是最好的样本。 试用码本身就是「合法输入」,解出来的指纹字段天然匹配本机,省掉整个哈希复刻。
  3. 状态机比校验函数更容易踩坑。 校验通过不等于界面正确,b1^b8==0 和 ProgramKey 两个坑都在状态机上。
  4. 公式复刻不动就换路线。 与其逆向 DigitalProductId 的变换,不如让程序自己把哈希算出来。
  5. 改动面越小越好。 零二进制补丁意味着哈希不变、签名不失效、升级不用重做。

本文记录静态分析与本地授权数据重构过程,用于学习 PE 结构、自定义编码与授权校验设计。软件请支持正版。