[原创]vofa+ 注册认证逆向分析与破解实战
作者:小白 & 无锁不能
发表于:2026-5-27
前言
某天我在群里吹水,说最近用 vofa+ 挺好用的,就是每次启动都弹个"未授权"对话框很烦。群友说:"你找无锁不能啊,他专门搞这个的。"
于是我找到了无锁不能,想让他帮忙分析一下 vofa+ 的注册机制。
小白:大佬,能帮我看看 vofa+ 的注册认证怎么破吗?每次启动都弹未授权对话框,太烦了。
无锁不能:行,我先用 IDA + MCP 连上分析一下。
第一章 初探:发现 RSA 公钥
无锁不能打开了 IDA,连接上 vofa+.exe,开始分析。
无锁不能:我先搜一下关键字符串。
很快就在二进制中找到了一个非常显眼的字符串——RSA 公钥:
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCfEsswjzV7nBqkf1TQl+WSyunw
vH6mGZBxoj3Dhr4c0DvmzgQmQ1m8r0DgZOD9qagWvule/00EVt42WPu/y43Jyxap
EsUWtv2rM0a+vLlG8tkAHb4QKrNBROgZnZK9hFiYfxXJKIO40HW8XrmzupVCisXL
B2MpAAKxj6czcSjEeQIDAQAB
-----END PUBLIC KEY-----
无锁不能:这软件用 RSA-1024 做注册码验证,公钥就嵌在程序里,说明注册码是 RSA 加密的 JSON 数据。
小白:那有公钥也没用啊,我们又没有私钥。
无锁不能:没错,纯密码学角度没法伪造签名。但我们可以 patch 二进制跳过验证。
第二章 深入:逆向 VerificationClient 核心类
通过 IDA 的交叉引用功能,无锁不能定位到了公钥引用的位置——VerificationClient 类的构造函数和 CheckACode 方法。
构造函数(0x14001F120)
VerificationClient *__fastcall VerificationClient::VerificationClient(VerificationClient *this)
{
LocalInfo::LocalInfo(&this->info);
FileIO::FileIO(&this->fileio_, nullptr);
RandomString = MyTools::GetRandomString(&result, 32);
QString::operator=(&this->open_dyn1_, RandomString);
pub.d = QString::fromAscii_helper(
"-----BEGIN PUBLIC KEY-----"
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCfEsswjzV7nBqkf1TQl+WSyunw"
"vH6mGZBxoj3Dhr4c0DvmzgQmQ1m8r0DgZOD9qagWvule/00EVt42WPu/y43Jyxap"
"EsUWtv2rM0a+vLlG8tkAHb4QKrNBROgZnZK9hFiYfxXJKIO40HW8XrmzupVCisXL"
"B2MpAAKxj6czcSjEeQIDAQAB"
"-----END PUBLIC KEY-----",
271);
}
CheckACode:注册码验证核心(0x14001FE70)
QJsonObject *__fastcall VerificationClient::CheckACode(
VerificationClient *this, QJsonObject *result, QString a_code)
{
QString::toUtf8(a_code.d, &sBase64);
MyRSA::SetPubKey(&my_rsa, "-----BEGIN PUBLIC KEY-----...");
v6.d = QByteArray::fromBase64(...).d;
v7 = MyRSA::PublicDecryptAll(&my_rsa, &resulta, v6);
QJsonDocument::fromJson(...);
if (!contains("MC") || !contains("SD") || !contains("ED")
|| !contains("R") || !contains("KEY") || !contains("IV"))
return empty;
if (machine_code != local_machine_code)
return empty;
return json_obj;
}
小白:所以注册码就是 Base64 编码的 RSA 加密 JSON?
无锁不能:对。JSON 里包含这些字段:
| 字段 |
说明 |
MC |
机器码(硬件指纹) |
SD |
有效期起始 |
ED |
有效期截止 |
R |
动态挑战应答 |
KEY |
AES-256 密钥 |
IV |
AES-256 初始化向量 |
第三章 挖坑:启动流程中的验证陷阱
小白:那我直接 patch 掉 CheckActivated 函数,让它永远返回 true 不就行了?
无锁不能:有道理,先试试看。
无锁不能反编译了 CheckActivated 函数:
CheckActivated(0x1400201F0)
_BOOL8 __fastcall VerificationClient::CheckActivated(VerificationClient *this)
{
secrect = "ewl61)r&a+33%7q*vae%qort+9*mouch6gc@@@aq4l3os#+#1h";
result = operator+(this->open_dyn1_, this->open_dyn2_);
v3 = MyTools::MD5Code(result, secrect);
this->activated_ = operator==(this->open_md5_, v3);
return this->activated_;
}
地址 0x140020271,修改:
原始:0F B6 43 7C movzx eax, byte ptr [rbx+7Ch] ; 返回 activated_
改: B0 01 mov al, 1 ; 直接返回 1
90 nop
小白:我改了,但启动还是弹未授权对话框啊!咋回事?
无锁不能:等等,我看看启动流程…… 发现问题了!
小白:哪里不对?
无锁不能:CheckActivated 是在运行时才调用的,但 activated_ 这个标志是在启动时由 Activate_from_file() 直接设置的。你的 patch 没覆盖到那个路径。
启动验证流程
程序启动 → Feedback::activate_from_file()
→ VerificationClient::Activate_from_file()
→ 读取 codepart1 文件(不存在)
→ 返回 false
→ activated_ = false (写了 Feedback 的成员变量)
→ QML 读取 activated 属性 → false → 弹窗!
小白:原来 activated_ 是 Activate_from_file 写的,不是 CheckActivated 写的!怪不得打补丁没用。
无锁不能:没错。CheckActivated 只是运行时校验完整性,真正的 activated_ 值在启动时就被 Activate_from_file() 设成了 false。
第四章 追根溯源:QML 属性读取路径
无锁不能:让我追一下 QML 是怎么读到 activated 这个属性的。
通过分析 Feedback::qt_static_metacall 中的 ReadProperty case 3,找到了 QML 读取 activated 属性的实现:
Feedback::qt_static_metacall case 3(0x140048A87)
140048A87: 44 38 8E 30 01 00 00 cmp [rsi+130h], r9b ; froce_activated_ == 0?
140048A8E: B0 01 mov al, 1 ; 先假设已激活
140048A90: 75 07 jnz short +7 ; 如果 froce_activated_ !=0 → 跳过
140048A92: 0F B6 86 31 01 00 00 movzx eax, [rsi+131h] ; 否则读 activated_
140048A99: 88 07 mov [rdi], al ; 返回给 QML
无锁不能:看到了吗?这里有两个因素决定返回给 QML 的值:
froce_activated_(offset 0x130)如果非零 → 直接返回 true
- 否则返回
activated_(offset 0x131)→ 启动时被设为 false
小白:所以就算我 patch 了 CheckActivated,QML 也不走那条路?
无锁不能:对!QML 的 activated 属性直接读的是 Feedback 对象的成员变量 activated_(offset 0x131),这个值是由 Feedback::activate_from_file() → VerificationClient::Activate_from_file() 设置的。你根本没堵对地方。
第五章 破局:正确的 Patch 方案
无锁不能:要真正解决问题,需要在 QML 读取属性的路径上动手。
两个方案:
方案 A:改 activate_from_file 强制返回 true
Feedback::activate_from_file 在 0x140023384,把 call 改成直接 mov al, 1:
原始:e8 17 c9 ff ff call VerificationClient::Activate_from_file
改: B0 01 mov al, 1
90 90 90 nop nop nop
方案 B:改 QML 属性读取(推荐)
地址 0x140048A90,将 jnz 改为 jmp,永远跳过读取 activated_:
原始:75 07 jnz short +7 ; 只有在 froce_activated_ !=0 时才跳过
改: EB 07 jmp short +7 ; 永远跳过,直接返回 al=1
无锁不能:方案 B 更干净,只动 2 个字节,不影响其他逻辑。改完以后,QML 拿到的 activated 永远是 true,未授权对话框就不会弹了。
小白:明白了!原来我打补丁打错地方了,应该打在属性读取路径上,而不是打在 CheckActivated 上。
第六章 深度:完整注册机制总览
为了不留坑,无锁不能把整个注册认证体系完整逆向了一遍,以下是总结:
核心类 VerificationClient 方法总表
| 方法 |
地址 |
功能 |
| VerificationClient() |
0x14001F120 |
构造函数,加载 RSA 公钥 |
| Activate(code) |
0x14001F3C0 |
激活入口 |
| Activate_from_file() |
0x14001FCA0 |
启动时从文件恢复激活状态 |
| Activate_from_file() |
0x140020B10 |
实际文件验证逻辑 |
| CheckACode(code) |
0x14001FE70 |
RSA 解密校验注册码 JSON |
| CheckActivated() |
0x1400201F0 |
运行时验证 |
| Disactivate() |
0x140020280 |
清除激活状态 |
| WriteStupidFiles() |
0x140020620 |
加密写入冗余许可证文件 |
| ReadStupidFiles() |
0x1400203C0 |
读取并解密冗余许可证文件 |
| next_open_dyn1() |
0x140021A70 |
生成新动态挑战值 |
| update_open_dyn1() |
0x1400220E0 |
更新动态挑战值 |
| Feedback::check_activated() |
0x1400233F0 |
QML 层激活状态查询入口 |
激活流程
用户输入注册码
→ Activate(code)
→ CheckACode(code)
→ toUtf8 → Base64 解码
→ RSA 公钥解密 → 解析 JSON
→ 验证 MC/SD/ED/R/KEY/IV
→ 验证 MC == 本地机器码
→ 验证 R == MD5(open_dyn1_ + secret)
→ 写入 codepart1(或 codepart2)
→ AES-256-CBC 加密许可证 JSON
→ WriteStupidFiles() 写入 3 个冗余路径
→ activated_ = true
运行时验证
CheckActivated()
→ MD5(open_dyn1_ + open_dyn2_ + secret) == open_md5_ ?
两个 secret 常量:
ewl61)r&a+33%7q*vae%qort+9*mouch6gc@@@aq4l3os#+#1h(50 字符)
ewl61)r&ax33%7q*vea%qort+9*mouch6gc@@@ap4l3os#+#1h(50 字符)
动态挑战更新
next_open_dyn1():
step1 = MD5(open_dyn1_ + secret1)
step2 = MD5(step1 + secret2)
return step2
防篡改检测
在 _Activate_from_file_ 中有三层时间篡改检测:
now < LDT(当前时间小于上次使用时间)
now < 系统上次启动时间
- 文件修改时间在"将来"
任意一个成立,标记 is_time_chaos_ = 1。
许可证机密文件
WriteStupidFiles 将 AES-256-CBC 加密后的 Base64 数据写入 3 个冗余路径(schema/、action_files/、textures/ 目录),读取时三者必须完全一致才认为有效。
第七章 三种破解方案
方案一:二进制补丁(推荐)
按优先顺序:
- 补丁 A:
0x140048A90 75 07 → EB 07(QML 属性读取始终返回已激活)
- 补丁 B:
0x140020271 0F B6 43 7C → B0 01 90(CheckActivated 返回 true)
- 补丁 C:
0x140023384 将 call 改为 B0 01 90 90 90(activate_from_file 强制返回 true)
方案二:替换 RSA 公钥
将 0x14005F900 处 271 字节的 RSA-1024 公钥替换为自己的密钥对公钥,用私钥签发注册码。
方案三:API Hook
编写 DLL hook RSA_public_decrypt 或 PEM_read_bio_RSA_PUBKEY。
后记
小白:原来如此!我一开始以为注册验证就是 CheckActivated 一个函数的事,没想到 activated_ 标志有这么复杂的写入路径。
无锁不能:这就是逆向的乐趣所在。看一个函数 surface 很简单,但顺着数据流追下去,总能发现意想不到的依赖关系。这次的教训是:patch 之前一定要搞清楚数据流的源头在哪里,不要只看目标函数的表面逻辑。
小白:学到了!数据流分析比单点 patch 重要得多。
参考资料
- 目标软件:vofa+(vofa+.exe)
- 分析工具:IDA Pro 8.x + iida-mcp
- 架构:x64,Qt 框架
版权声明:本文仅供逆向技术学习交流使用,请勿用于商业用途。