开头先来,祝大家2026年新年快乐!
最近研究了一个偏专业的小众软件,用来控制舞台灯光的,发现比想的要复杂不少
研究的过程中发现使用了 TGltZUxN 这个软件许可证系统,虽然较小众但功能挺全还跨平台
它支持在线激活和离线激活,本帖就来分析一下它的离线激活流程(基于ARM架构)
该软件下载链接:
aHR0cHM6Ly9ocmVmLmxpLz9odHRwczovL2xpZ2h0a2V5YXBwLmNvbS9tZWRpYS9wYWdlcy9kb3dubG9hZC9MaWdodGtleS01LTctNC83YmYzOTg1NTA3LTE3NjU3OTA2MjYvTGlnaHRrZXlJbnN0YWxsZXIuemlw
用到的代码/工具:
免责声明
本文内容仅用于技术研究与安全分析目的,旨在帮助开发者理解软件授权系统的设计思路及潜在安全风险,以促进更安全的软件工程实践。
文中涉及的分析基于公开可获取的软件行为与环境,不包含任何源代码泄露、私钥披露或未授权访问行为。
本文不鼓励、也不支持任何形式的软件盗版、破解、绕过授权机制或侵犯知识产权的行为。任何个人或组织因使用本文内容所产生的法律责任,均由其自行承担,与本文作者无关。
许可证密钥格式分析
许可证首先有会本地的格式校验
我们打开直接选择 No Internet connection?,会弹出离线激活的保存激活请求文件的弹窗
这里可以看出密钥的格式是用-分隔的7段长度为4的字符串,随便输入一串这个格式的点Save File试试
我们可以从这个字符串入手来找到它校验许可证的地方,在app里搜索一下这个字符串
找到了,可以看到它对应的是ActivationError-InvalidProductKey这个字符串
那是时候打开Hopper了,在主程序里搜索这个字符串然后查一下引用
根据函数名其实不难猜到我们这个弹窗应该是在这个函数里面触发的
-[LXTurbo writeActivationRequestFileWithProductKey:toURL:error:]
用lldb验证一下就可以确定是这个函数处理的,不过弹窗是在上一层函数触发的
我们往下追几层就可以找到一些比较有意思的函数(过程比较无聊就省略了)
往下可以追到sub_100DF2E44,对许可证做了一些变换,比如转换成大写字母,但还没有开始校验
核心的校验逻辑应该在sub_100DE01C4里,成功的时候应该返回0
__int64 __fastcall sub_100DE01C4(__int64 a1, const char *a2, int a3)
{
char v6; // [xsp+Bh] [xbp-25h] BYREF
int v7; // [xsp+Ch] [xbp-24h] BYREF
if ( !(unsigned int)sub_100DE0244(a1, (__int64)a2, a3) ) // a2 还是我们的许可证字符串
return 1;
v6 = 0;
if ( (sub_100DD4E70(a1, &v7, &v6) & 1) != 0 )
return 26;
else
return (unsigned int)sub_100DE33D8(a1, a2) ^ 1;
}
这个函数很简单,经过lldb调试我们发现sub_100DE0244返回了0导致它直接返回了1
sub_100DE0244其实就是核心的许可证校验函数,我们只要让它返回1就好了
然后你就会发现这个函数看起来非常的复杂,但是别急我们慢慢来,先看头部这几个小判断
第一个cmp是在判断许可证的长度是否等于0x22,也就是34,这个和我们之前看出来的格式是一样的
第二个cmp取出了许可证的第21位,与0x54比较,0x54在ASCII表里对应的是T
第三个cmp同理,取出了第22位与A比较是否相等
那么到这里我们可以确定许可证的最后两位应该是TA,且总长度为34
然后我们来看看下面一连串bl调用的第一个函数
_QWORD *__fastcall sub_100DE10F8(_QWORD *a1, __int64 a2, __int64 a3, __int64 a4)
{
_QWORD *v8; // x23
unsigned int v9; // w0
char *v10; // x0
unsigned __int64 v11; // x8
char v13[8]; // [xsp+0h] [xbp-90h] BYREF
__int64 v14; // [xsp+8h] [xbp-88h]
__int64 v15; // [xsp+10h] [xbp-80h]
unsigned __int64 v16; // [xsp+20h] [xbp-70h]
unsigned __int64 v17; // [xsp+28h] [xbp-68h]
char *v18; // [xsp+30h] [xbp-60h]
__int64 (__fastcall **v19)(); // [xsp+38h] [xbp-58h] BYREF
__int64 v20; // [xsp+40h] [xbp-50h]
a1[15] = off_101274B58;
v8 = a1 + 15;
sub_100DF871C(a1 + 16);
a1[20] = 0;
a1[21] = 0;
a1[19] = 0x3FFFFFFFFFFFFFFFLL;
a1[24] = 0;
a1[25] = 0;
a1[23] = -1;
*v8 = off_101272F68;
a1[16] = off_101273038;
*a1 = off_101274B58;
sub_100DF871C(a1 + 1);
a1[5] = 0;
a1[6] = 0;
a1[4] = -1;
a1[9] = 0;
a1[10] = 0;
a1[8] = -1;
a1[13] = 0;
a1[14] = 0;
a1[12] = -1;
*a1 = off_101272D88;
a1[1] = off_101272E70;
a1[2] = v8;
v9 = (*(__int64 (__fastcall **)(_QWORD *))(a1[16] + 48LL))(a1 + 16);
v17 = 0;
v18 = 0;
v16 = -1;
v14 = a4;
v15 = v9;
v13[0] = 0;
sub_100DDB4B4(&v19, "IV", v13, 1);
(*(void (__fastcall **)(_QWORD *, __int64, __int64, __int64 (__fastcall ***)()))(*a1 + 56LL))(a1, a2, a3, &v19);
v19 = off_101275838;
if ( v20 )
(*(void (__fastcall **)(__int64))(*(_QWORD *)v20 + 8LL))(v20);
v10 = v18;
v11 = v16;
if ( v16 >= v17 )
v11 = v17;
if ( v18 )
{
for ( ; v11; --v11 )
v10[v11 - 1] = 0;
free(v10);
}
return a1;
}
诶有没有发现有一个IV赫然出现,是AES吗?我们在IDA里可以看到很多off_开头的地址,点一个进去看看
嘿嘿,这不是我们的老朋友CryptoPP嘛,没想到这里也能碰到你
虽然开发者把符号抹的很干净,但虚表还在,这才认出了CryptoPP
根据字符串,我们可以去CryptoPP的动态库里找找看,还原出一部分的符号,方便我们的分析
这个AES_DEC我暂时没有找到比较合适的,但基本可以确定就是AES并且是CBC
下面还用了BaseN_Decoder,w2为5说明是base32编码,这里的x1也就是0x1010a5bac其实就是静态的自定义编码表
我们可以直接用IDA或者lldb把它弄出来
[-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,24,25,26,27,28,29,30,31,-1,-1,-1,-1,-1,-1,-1,0,1,2,3,4,5,6,7,8,9,10,-1,11,12,-1,13,14,15,16,17,18,19,20,21,22,23,-1,-1,-1,-1,-1,-1,0,1,2,3,4,5,6,7,8,9,10,-1,11,12,-1,13,14,15,16,17,18,19,20,21,22,23,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1]
写一个简单的循环遍历一遍就可以得到正确的编码表 ABCDEFGHIJKMNPQRSTUVWXYZ23456789
那许可证大概率就是用这个base32编码过的了,除去末尾固定的TA两位,还剩下26个有效字符
按base32每个字符对应5个bit的数据,26个也就是130bit,刚好是16byte多一点
16byte....那不正好是一个AES-128的一个块的大小嘛
其实AES_DEC的参数已经可以读出一些形似key和iv的数据,但我们不太确定,可以通过对下面两个函数下断点来确定
// 0x100df89c4 (搜索字符串 ": this object cannot use a null IV")
CryptoPP::SimpleKeyingInterface::ThrowIfInvalidIV(unsigned char const*);
// 0x100df872c (搜索字符串 "is not a valid key length" -> 第一个引用)
CryptoPP::SimpleKeyingInterface::SetKey(unsigned char const*, unsigned long, CryptoPP::NameValuePairs const&)
于是我们就得到了正确的key和iv
key: da 8d 76 a9 ce f0 ce 10 8a 2d ae fd c6 21 06 45
iv : 71 48 90 5f 40 d5 09 90 e2 6f 5c 4f 86 cb b8 55
所以它的许可证大致是这样生成的:
初始值 -> AES加密 -> base32编码
为了方便调试分析,我们可以先随便写一些初始值生成一个许可证,一会再来调整
string key = "\xda\x8d\x76\xa9\xce\xf0\xce\x10\x8a\x2d\xae\xfd\xc6\x21\x06\x45";
string iv = "\x71\x48\x90\x5f\x40\xd5\x09\x90\xe2\x6f\x5c\x4f\x86\xcb\xb8\x55";
char data[15] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14};
string encoded = CBC_AESEncryptStr(key, iv, data, sizeof(data));
string serial = encoded + "TA";
for (int i = serial.length() - 4; i > 0; i -= 4)
serial.insert(i, "-");
cout << "serial: " << serial << endl;
这里我随便生成了一个GYSK-NIRR-3HTQ-RHUC-7HJB-6BGJ-XATA
回到lldb,我们把断点设在许可证解密的后面,输入这个生成的许可证
断下了就说明解密阶段成功了,我们往下分析
动态调试可以方便地读取到寄存器的值以及内存中的字符串,这一段其实就是在判断解密出来的初始值长度是否足够
我们在下一个分支处下断点
这里的w20是第5位的数据,它应该为0,下面还有一个类似的校验
经过几次调试,可以确定这里的w8值是固定的,而w20对应的是6-9位的数据
整个函数剩下的除了长度检查就只有这几个位的校验了,于是我们可以去修改一下对应位的值
加上这几行就行,注意是小段序
data[4] = '\x00';
data[5] = '\x5f';
data[6] = '\xdc';
data[7] = '\x47';
data[8] = '\xea';
得到新的许可证YBJW-UFNI-ASMD-GSSP-ERUU-GQG8-7ATA,再拿去试试
这样我们就通过了本地的许可证格式校验,总结一下就是
含固定位初始值 -> AES加密 -> base32编码
没错到现在我们才刚刚完成了第一步,可能也是最简单的一步
解密激活请求与替换公钥
解密激活请求
我们来看看激活请求文件的格式(这里我用随机数据替换了原始的)
<?xml version="1.0" encoding="utf-8"?>
<ActivationRequest><ablock data="oh+z6ZLfHx6OMWqwsqNcWs4CDa+XNt5cD4U2zfDuW2B68x61gUGuyZv7KyhW+nNQtnsBHVXN7A2YgDRa+/jR1Q1clMHqmYCuNjzLKML8B1gqFBjF+h7z9JFU0txdRRkPzfburSia3+2waCLLD3jSd5nIq9jnkLPebCdp/iAXIZpWjKFWDtESMbD/Rf+cQfKt0fqWn2nksU4nFftxrQFi8HfUrtXkUpksHc0XYBkqwsk6y8qaYe08r12WODM/xq0bVD4BOuCbdDHib0vfDBu/BZmPkFnuR2/tmVo/I5FNBT5e59Su6PkctnckQpxNtaIZmxTf4VGLHmRiIiN3DR7O0g==" id="226"/></ActivationRequest>
一般来说这种离线激活为了安全性都会使用RSA,这里的data取出来解密后长度刚好是256byte,也就是2048bit
那我们就可以猜测它用的就是一个2048bit的公钥加密,然后他们用私钥解密再生成激活响应
我们直接对CryptoPP的公钥加密函数下断点,这个还是很好找的
// 0x100e323ac (搜索字符串 ": this key is too short to encrypt any messages")
CryptoPP::TF_EncryptorBase::Encrypt(CryptoPP::RandomNumberGenerator&, unsigned char const*, unsigned long, unsigned char*, CryptoPP::NameValuePairs const&) const
成功断下,我们就可以从参数里读取出加密前的数据了
数据长度为0x4d,可以看到我们刚刚输入的许可证,以及Mac的序列号(空白挡住的部分)
这样开发者就可以在云端把许可证和Mac的序列行绑定,来防止多台Mac用同一个许可证
01 | 22 00 00 00 | 51 4a 35 57 2d 48 41 38 44 2d 58 ...
这里我们可以来大致分析一下这段数据的格式,这里的0x22不就是我们许可证的长度吗
它这里应该是用了Type-Length-Value的编码格式,开头1个字节的type,然后4个字节的整形length,后面跟上这个长度的Value,并且以0xff作为结束标志,这个编码格式我们后面还会遇到很多
既然找到了这个加密的函数,我们来找找它的公钥吧
寻找公钥位置
试着搜索了一下-----BEGIN PUBLIC KEY-----,甚至还找到Lightkey.app/Contents/Resources/pubkey.pem
但是没这么简单,这些都没有用到,它的公钥应该是经过特殊编码过的,不过没关系,我们可以直接利用inline hook来动态获取
CryptoPP里面初始化一个encryptor比如RSAES_OAEP_SHA_Encryptor要传入公钥作为参数,我们hook这个函数就可以拿到它的公钥了
那还是用 tinyhook 这个框架
void (*orig_p)(void *self, const PublicKey &pkey);
void dumppubkey(void *self, const PublicKey &pkey) {
ByteQueue queue;
pkey.Save(queue);
Base64Encoder encoder;
queue.CopyTo(encoder);
encoder.MessageEnd();
FileSink file("pkey.pem");
encoder.CopyTo(file);
file.MessageEnd();
return orig_p(self, pkey);
}
__attribute__((constructor))
void load(void) {
intptr_t base = _dyld_get_image_vmaddr_slide(0);
// CryptoPP::PK_FinalTemplate<CryptoPP::TF_EncryptorImpl<CryptoPP::TF_CryptoSchemeOptions<CryptoPP::TF_ES<CryptoPP::RSA, CryptoPP::PKCS1v15, int>, CryptoPP::RSA, CryptoPP::PKCS_EncryptionPaddingScheme> > >::PK_FinalTemplate(CryptoPP::CryptoMaterial const&)
tiny_hook((void *)(base + 0x100ddf8f4), (void *)dumppubkey, (void **)&orig_p);
return;
}
编译出来注入进主程序,再运行一下,看看当前目录下有没有多一个pkey.pem文件
MIIBIDANBgkqhkiG9w0BAQEFAAOCAQ0AMIIBCAKCAQEAsoxr0DvFPAdXl/v3Pc1qgnPbWxDQ
U/ss+ZvbG+HYVGXfmEnm7fQNsjCKp+iXa6Y+orfvomOypYYsbGhZaHP3P5SIEjVlZzW9qeYN
CXhhlwXCQOm7Pu8vUQkHYQAsE4NmNuvhL2zA1QoowDJ4by8Auv+/nhfRNz+lca46/Fbyy0Nv
CsZtwTMxEY8LT2+YH5biNQg2/QOhXOaZ7EU1hSxqsL8WCNi2KIP5IJu/gxzh9LnlUzPUhYcc
ZMmVzx3rOIhbjOz4sf/CJegXpTHCRwxjxwWxz+DN3fsss64mm6OrR/mgFDZiYf2xkrrwsE0J
0wRhgir3aVP42IODCICmkSWB3QIBEQ==
我们给它加上BEGIN和END头就可以用openssl来解析看看了
RSA Public-Key: (2048 bit)
Modulus:
00:b2:8c:6b:d0:3b:c5:3c:07:57:97:fb:f7:3d:cd:
6a:82:73:db:5b:10:d0:53:fb:2c:f9:9b:db:1b:e1:
d8:54:65:df:98:49:e6:ed:f4:0d:b2:30:8a:a7:e8:
97:6b:a6:3e:a2:b7:ef:a2:63:b2:a5:86:2c:6c:68:
59:68:73:f7:3f:94:88:12:35:65:67:35:bd:a9:e6:
0d:09:78:61:97:05:c2:40:e9:bb:3e:ef:2f:51:09:
07:61:00:2c:13:83:66:36:eb:e1:2f:6c:c0:d5:0a:
28:c0:32:78:6f:2f:00:ba:ff:bf:9e:17:d1:37:3f:
a5:71:ae:3a:fc:56:f2:cb:43:6f:0a:c6:6d:c1:33:
31:11:8f:0b:4f:6f:98:1f:96:e2:35:08:36:fd:03:
a1:5c:e6:99:ec:45:35:85:2c:6a:b0:bf:16:08:d8:
b6:28:83:f9:20:9b:bf:83:1c:e1:f4:b9:e5:53:33:
d4:85:87:1c:64:c9:95:cf:1d:eb:38:88:5b:8c:ec:
f8:b1:ff:c2:25:e8:17:a5:31:c2:47:0c:63:c7:05:
b1:cf:e0:cd:dd:fb:2c:b3:ae:26:9b:a3:ab:47:f9:
a0:14:36:62:61:fd:b1:92:ba:f0:b0:4d:09:d3:04:
61:82:2a:f7:69:53:f8:d8:83:83:08:80:a6:91:25:
81:dd
Exponent: 17 (0x11)
可以看到确实是2048bit的模数,那我们已经拿到了公钥,接下来看看它是从哪里读取出来的就可以替换了
这里虽然也可以通过hook的方式替换,但由于没有符号,还是不够优雅,也没意思
CryptoPP的公钥其实就是存了几个大整数,通过特征码可以找到Integer这个类的拷贝构造函数
我们可以hook它看看这个模数第一次出现是在什么地方
void *(*orig_init)(void *self, const Integer &intg);
void *dumpint(void *self, const Integer &intg) {
Integer modulus("22539662531888887334029278021245928204023278788354358575996255351559122703413614630765097962567786395096989186084658090456926146095948213053530992771926531859094441606612568917307879457980302330044794677158066896556611893918771948839145595123462010181067533927188249179645521074010095855922125505977973511188342618430458563710349493196156024330954843462184272083346337357140004487927394064798897251976424597079169797912804332377519422070767562904432750193757635847427791113863141160006844195770321406253477296432519370336002873569984297384554751049732532936491707332633917789897120131885026509402744020723846722453981");
if (intg == modulus) {
fprintf(stderr, "Copying modulus!\n");
print_backtrace(); // 输出调用栈
}
return orig_init(self, intg);
}
__attribute__((constructor))
void load(void) {
intptr_t base = _dyld_get_image_vmaddr_slide(0);
// CryptoPP::Integer::Integer(CryptoPP::Integer const&)
tiny_hook((void *)(base + 0x100e06534), (void *)dumpint, (void **)&orig_init);
return;
}
运行一下可以看到有很多输出,我们拿出第一个来(删掉了一些没用的符号信息)
Copying modulus!
Obtained 10 stack frames.
0 liblt.dylib 0x0000000101f2cbe4 _Z7dumpintPvRKN8CryptoPP7IntegerE + 144
1 Lightkey 0x0000000100ddc9b8
2 Lightkey 0x0000000100e3178c
3 Lightkey 0x0000000100dfbb04
4 Lightkey 0x0000000100deaa58
5 Lightkey 0x0000000100dea3dc
6 Lightkey 0x0000000100df174c
7 Lightkey 0x00000001000ae518
8 Lightkey 0x000000010010ac34
9 dyld 0x00000001885bab98 start + 6076
追到第四层也就是0x0000000100deaa58,可以看到很多有意思的字符串
"TAPDFV1"
"Signature invalid."
"Pdets not signed."
"Encrypted data doesn't exists."
"Private decrypt key doesn't exists."
"File header is incorrect."
看起来像是在解析某个文件,还有一个链接 Imh0dHBzOi8vd3lkYXkuY29tL2xpbWVsbS9hcGkvcmVzdC8i ,打开来发现是某软件许可证系统
那先不管,我们还是可以先分析分析看看的
if ( (sub_100DED0AC((int)a2, "TAPDFV1") & 1) == 0 )
{
if ( a3 && !sub_100DD8C80(a2 + 2) )
std::ios_base::clear(
(std::ios_base *)((char *)a2 + *(_QWORD *)(*a2 - 24LL)),
*(_DWORD *)((char *)a2 + *(_QWORD *)(*a2 - 24LL) + 32) | 4);
exception = (std::ios_base::failure *)__cxa_allocate_exception(0x20u);
*(_QWORD *)&__ec.__val_ = 1;
__ec.__cat_ = std::iostream_category();
std::ios_base::failure::failure(exception, "File header is incorrect.", &__ec);
__cxa_throw(
exception,
(struct type_info *)&`typeinfo for'std::ios_base::failure,
(void (__fastcall *)(void *))&std::ios_base::failure::~failure);
}
这里可以看出来该文件的文件头应该为TAPDFV1,在app里面搜索这个字符串
很快就锁定了读取的文件是Lightkey.app/Contents/Resources/TurboActivate.dat,那公钥大概率也是存在里面了
来看看它是怎么解析的吧
v6 = std::istream::get(a2);
if ( v6 == 0xFF )
{
v7 = 0;
v8 = 0;
v9 = 0;
}
else
{
v9 = 0;
v8 = 0;
v7 = 0;
do
{
switch ( v6 )
{
case 3u:
v9 = (void *)sub_100DED430(a2, &v42);
break;
case 2u:
v8 = (char *)sub_100DED430(a2, &v43);
break;
case 1u:
v7 = (void *)sub_100DED430(a2, (char *)&v43 + 4);
break;
default:
sub_100DED62C(a2, v6);
break;
}
v6 = std::istream::get(a2);
}
while ( v6 != 0xFF );
}
有没有想到刚刚说的Type-Length-Value编码,std::istream::get(a2)就是在读取一个字节的type,遇到0xff时结束
我们看看这个sub_100DED430在干什么
void *__fastcall sub_100DED430(__int64 a1, unsigned int *a2)
{
size_t v4; // x20
void *v5; // x21
std::ios_base::failure *exception; // x19
std::error_code v8; // [xsp+0h] [xbp-30h] BYREF
std::istream::read(a1, a2, 4); // 读取Length
v4 = *a2;
if ( (v4 & 0x80000000) != 0 )
{
exception = (std::ios_base::failure *)__cxa_allocate_exception(0x20u);
*(_QWORD *)&v8.__val_ = 1;
v8.__cat_ = std::iostream_category();
std::ios_base::failure::failure(exception, "Negative size rejected.", &v8);
__cxa_throw(
exception,
(struct type_info *)&`typeinfo for'std::ios_base::failure,
(void (__fastcall *)(void *))&std::ios_base::failure::~failure);
}
v5 = operator new[](v4);
std::istream::read(a1, v5, v4); // 读取长度为Length的Value
return v5;
}
到这里就很明显是TLV编码了,首先用std::istream::read读取了4字节数据到一个int的Length然后读取了Value并返回用new创建的指针
先把这个函数命名为read_tlv,回到上面的函数,重点看这里的switch
switch ( v6 )
{
case 3u:
v9 = (void *)read_tlv(a2, &v42);
break;
case 2u:
v8 = (char *)read_tlv(a2, &v43);
break;
case 1u:
v7 = (void *)read_tlv(a2, (char *)&v43 + 4);
break;
default:
sub_100DED62C(a2, v6);
break;
}
根据后面的错误提示信息,我们可以推出这每个tag对应的value是什么
1 -> Private decrypt key
2 -> Encrypted data
3 -> Signature
这里并没有我们想要的公钥,开发者也肯定不会把私钥直接放出来,我们要找的应该在这个Encrypted data里面
到这里有点累了,既然我们已经知道它用的第三方许可证系统了,那为了方便分析,我弄到了原版的库还原了这部分的符号,当然也可以通过字符串一个一个还原,但那样工作量未免太大了一点
解析完外层下面就是解密Encrypted data的部分了
私钥是DER存储的,它每次读取固定长度的数据解密,然后拼接起来
CryptoPP::AutoSeededRandomPool::AutoSeededRandomPool((CryptoPP::AutoSeededRandomPool *)&v52, 0, 0x20u);
*(_QWORD *)&__ec.__val_ = off_10008A170;
CryptoPP::Algorithm::Algorithm((CryptoPP::Algorithm *)&__ec.__cat_, 1);
*(_QWORD *)&__ec.__val_ = off_10008C470;
__ec.__cat_ = (const std::error_category *)off_10008C508;
v49 = off_10008C560;
CryptoPP::InvertibleRSAFunction::InvertibleRSAFunction((CryptoPP::InvertibleRSAFunction *)v50);
*(_QWORD *)&__ec.__val_ = off_10008C618;
__ec.__cat_ = (const std::error_category *)off_10008C6B0;
v49 = off_10008C708;
CryptoPP::StringSource::StringSource(
(CryptoPP::StringSource *)v46,
(const unsigned __int8 *)private_decrypt_key,
v43[2],
1,
0);
(*(void (__fastcall **)(_QWORD *, unsigned __int64 *))(v50[0] + 136LL))(v50, v46);
v37 = a1;
v46[0] = (unsigned __int64)off_10008FEA0;
v46[1] = (unsigned __int64)off_100090048;
if ( v47 )
(*(void (__fastcall **)(__int64))(*(_QWORD *)v47 + 8LL))(v47);
operator delete[](private_decrypt_key);
v10 = CryptoPP::TF_CryptoSystemBase<CryptoPP::PK_Decryptor,CryptoPP::TF_Base<CryptoPP::TrapdoorFunctionInverse,CryptoPP::PK_EncryptionMessageEncodingMethod>>::FixedCiphertextLength(&__ec);
v11 = v43[1];
if ( v43[1] >= 1 )
{
v12 = v10; // 一个加密块的大小
v13 = CryptoPP::g_nullNameValuePairs;
v14 = (const unsigned __int8 *)v8;
do // 循环
{
if ( v12 >= (unsigned int)v11 )
v15 = (unsigned int)v11;
else
v15 = v12;
v16 = (CryptoPP::ProxyFilter *)operator new(0x98u);
v17 = (CryptoPP::Algorithm *)operator new(0x20u);
CryptoPP::Algorithm::Algorithm(v17, 0);
*(_QWORD *)v17 = off_10008E7A8;
*((_QWORD *)v17 + 1) = off_10008E938;
*((_QWORD *)v17 + 3) = &v40;
v18 = (CryptoPP::BufferedTransformation *)(*(__int64 (__fastcall **)(std::error_code *, void (__fastcall __noreturn ***)(CryptoPP::Filter *__hidden), _QWORD, __int64))(*(_QWORD *)&__ec.__val_ + 64LL))(
&__ec,
&v52,
0,
v13);
CryptoPP::ProxyFilter::ProxyFilter(v16, v18, 0, 0, v17);
*(_QWORD *)v16 = off_10008BB50;
*((_QWORD *)v16 + 1) = off_10008BD60;
CryptoPP::StringSource::StringSource((CryptoPP::StringSource *)v44, v14, v15, 1, v16);
v44[0] = off_10008FEA0;
v44[1] = off_100090048;
if ( v45 )
(*(void (__fastcall **)(__int64))(*(_QWORD *)v45 + 8LL))(v45);
v11 -= v12;
v14 += v12; // 读取下一个加密块
}
我们从上面off_跳转到的地址,大概是虚表,可以判断出它用的算法是RSAES_OAEP_SHA1
基于此我们可以还原出cpp的实现
string decrypt_data(const vector<byte>& cipher, const RSA::PrivateKey& privateKey) {
// decrypt data using OAEP SHA1 with private key
string plain;
AutoSeededRandomPool rng;
RSAES_OAEP_SHA_Decryptor decryptor(privateKey);
int blockSize = decryptor.FixedCiphertextLength();
int round = cipher.size() / blockSize;
for (int i = 0; i < round; i++) {
ArraySource(cipher.data() + i * blockSize, blockSize, true,
new PK_DecryptorFilter(rng, decryptor,
new StringSink(plain)));
}
return plain;
}
解密出来的数据有点多,这里只放前面一小段
00000000: 010c 0000 004d 6f6e 6f73 7061 6365 2055 .....Monospace U
00000010: 4702 0800 0000 5465 7374 5072 6f64 0324 G.....TestProd.$
00000020: 0100 0030 8201 2030 0d06 092a 8648 86f7 ...0.. 0...*.H..
00000030: 0d01 0101 0500 0382 010d 0030 8201 0802 ...........0....
00000040: 8201 0100 b28c 6bd0 3bc5 3c07 5797 fbf7 ......k.;.<.W...
00000050: 3dcd 6a82 73db 5b10 d053 fb2c f99b db1b =.j.s.[..S.,....
第一个字节0x01,然后是0x0c (12),接着一个长度为12的字符串,没错,这也是用的TLV编码
我们再往下翻翻就可以找到解析内层数据的switch语句
std::ios_base::clear(v21, v21->__rdstate_);
v22 = std::istream::get(&__ec);
v23 = "Failed to open Key.";
while ( v22 != 0xFF )
{
switch ( v22 )
{
case 1u:
*(_QWORD *)(v37 + 16) = TA_LIB_NS::ReadFiles::ReadUTF8String(&__ec);
break;
case 2u:
*(_QWORD *)v37 = TA_LIB_NS::ReadFiles::ReadUTF8String(&__ec);
break;
case 3u:
v24 = (unsigned __int8 *)TA_LIB_NS::ReadFiles::ReadByteArray(&__ec, v46);
CryptoPP::StringSource::StringSource((CryptoPP::StringSource *)&v52, v24, SLODWORD(v46[0]), 1, 0);
CryptoPP::X509PublicKey::BERDecode( // X509 BER 解码公钥
(CryptoPP::X509PublicKey *)(v37 + 104),
(CryptoPP::BufferedTransformation *)&v52);
v52 = off_10008FEA0;
v53 = off_100090048;
if ( v54 )
(*(void (__fastcall **)(__int64))(*(_QWORD *)v54 + 8LL))(v54);
operator delete[](v24);
break;
case 4u:
if ( (TA_LIB_NS::ReadFiles::ReadByteArrayToBuffer(&__ec, v37 + 60, 16) & 1) == 0 )
goto LABEL_71;
break;
case 5u:
if ( (TA_LIB_NS::ReadFiles::ReadByteArrayToBuffer(&__ec, v37 + 76, 16) & 1) != 0 )
break;
v23 = "Failed to open IV.";
LABEL_71:
v31 = __cxa_allocate_exception(8u);
*v31 = v23;
__cxa_throw(v31, (struct type_info *)&`typeinfo for'char const*, 0);
我们只关注这里几个比较有意思的tag,不难推断出他们的value是什么
3 -> BER encoded Public Key
4 -> AES Key
5 -> AES IV
我们来自己解析一下看看值是什么,cpp实现如下
pdinfo_t& pdinfo = pdets.pdinfo;
istringstream iss(pdets.payload, ios::binary);
while ((type = iss.get()) != -1) {
vector<byte> tmp;
switch (type) {
case 3: // Public Key
read_data(iss, tmp);
pdinfo.publicKey = decode_key<RSA::PublicKey>(tmp);
cout << pdinfo.publicKey.GetModulus() << endl;
break;
case 4: // Key
read_data(iss, pdinfo.key);
print_data(pdinfo.key);
break;
case 5: // IV
read_data(iss, pdinfo.iv);
print_data(pdinfo.iv);
break;
default:
read_data(iss, tmp);
}
}
这是输出,没错,和我们前面分析得到的公钥,Key和IV都对上了,说明这就是存储公钥的地方
22539662531888887334029278021245928204023278788354358575996255351559122703413614630765097962567786395096989186084658090456926146095948213053530992771926531859094441606612568917307879457980302330044794677158066896556611893918771948839145595123462010181067533927188249179645521074010095855922125505977973511188342618430458563710349493196156024330954843462184272083346337357140004487927394064798897251976424597079169797912804332377519422070767562904432750193757635847427791113863141160006844195770321406253477296432519370336002873569984297384554751049732532936491707332633917789897120131885026509402744020723846722453981.
da8d76a9cef0ce108a2daefdc6210645
7148905f40d50990e26f5c4f86cbb855
替换公钥
用CryptoPP我们可以很方便地直接生成一个编码后长度相同的公钥,只要固定模数大小和公开指数就行了
AutoSeededRandomPool rng;
int bits = pdinfo.publicKey.GetModulus().BitCount();
Integer e = pdinfo.publicKey.GetPublicExponent();
pdinfo.privateKey.GenerateRandom(rng, MakeParameters("ModulusSize", bits)("PublicExponent", e));
但是直接替换应该是不行的,上面还有一个signature字段,不过根据Signature invalid.这个字符串我们很容易定位用来验证的函数
CryptoPP::PK_FinalTemplate<CryptoPP::TF_VerifierImpl<CryptoPP::TF_SignatureSchemeOptions<CryptoPP::TF_SS<CryptoPP::RSA,CryptoPP::PKCS1v15,CryptoPP::SHA1,int>,CryptoPP::RSA,CryptoPP::PKCS1v15_SignatureMessageEncodingMethod,CryptoPP::SHA1>>>::PK_FinalTemplate(
&__ec,
v37 + 96 + *(_QWORD *)(*(_QWORD *)(v37 + 96) - 24LL));
if ( v42 >= 0 )
v28 = (const unsigned __int8 *)&v40;
else
v28 = (const unsigned __int8 *)v40;
if ( v42 >= 0 )
v29 = HIBYTE(v42);
else
v29 = v41;
if ( (CryptoPP::PK_Verifier::VerifyMessage((CryptoPP::PK_Verifier *)&__ec, v28, v29, v38, v43[0]) & 1) == 0 )
{
v36 = __cxa_allocate_exception(8u);
*v36 = "Signature invalid.";
__cxa_throw(v36, (struct type_info *)&`typeinfo for'char const*, 0);
}
到这里可以说一目了然了,它用的签名算法是PKCS1v15+SHA1,而且用的是内层的公钥对整个外层私钥解密出来的数据签名
cpp实现的签名函数如下,我们直接用生成的私钥签名然后替换掉原本的signature段就行了
vector<byte> sign_data(const string &message, const RSA::PrivateKey& privateKey) {
vector<byte> signature;
AutoSeededRandomPool rng;
RSASSA_PKCS1v15_SHA_Signer signer(privateKey);
size_t length = signer.MaxSignatureLength();
signature.resize(length);
length = signer.SignMessage(rng, reinterpret_cast<const byte *>(message.data()), message.size(), signature.data());
signature.resize(length);
return signature;
}
然后打开app重新生成激活请求文件,不出意外的话用我们的私钥就可以顺利解密了
实现如下,用的也是RSAES_OAEP_SHA1
int parseActivationRequest(const char *docname, string& payload, const RSA::PrivateKey &privateKey) {
xmlDocPtr doc = xmlParseFile(docname);
xmlNodePtr cur = xmlDocGetRootElement(doc);
cur = cur->xmlChildrenNode;
xmlChar *data = xmlGetProp(cur, BAD_CAST("data"));
string encrypted = string(reinterpret_cast<char *>(data));
AutoSeededRandomPool rng;
RSAES_OAEP_SHA_Decryptor decryptor(privateKey);
StringSource(encrypted, true,
new Base64Decoder(
new PK_DecryptorFilter(rng, decryptor,
new StringSink(payload))));
xmlFreeDoc(doc);
return 0;
}
到这里公钥替换就完成了
生成激活响应
参考开发者提供的激活指南,我们把请求文件发过去会得到一个响应文件,用Lightkey打开这个响应文件就可以激活
要让app可以打开,一般会在Info.plist里面注册好后缀名
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Lightkey Activation Response</string>
<key>UTTypeIdentifier</key>
<string>de.monospc.lightkey.activationresponse</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>lightkeyactivationresponse</string> <!-- 这里! -->
</dict>
</dict>
我们创建一个空的Activation Response.lightkeyactivationresponse文件,双击打开看看什么反应
嗯,意料之内,我们可以从-[LXAppDelegate application:openFile:]这个函数入手
往下追可以找到-[LXTurbo activateWithActivationRequestFileAt:error:]
再继续分析可以确定核心的校验逻辑从sub_100DD63E4开始
// sub_100DD63E4 (片段)
v9 = v19;
sub_100DF56CC(v7, v8, a2, 112, 1);
if ( LODWORD(v7[0]) || (v6 = pugi::xml_node::child((int)v8, "Response")) == 0 )
v4 = 1;
else
v4 = Activation::ParseVerifyActivationXML(a1, (int)&v6, 0, 0);
sub_100DF5658(v8);
return v4;
用到了pugixml来解析,说明也是xml文件,并且有一个根节点为Response,这和请求文件里的ActivationRequest是对应的
继续分析这个Activation::ParseVerifyActivationXML
// sub_100DD5208 (片段)
if ( a4 ) // a4为0
v8 = "genuine";
else
v8 = "activation";
v53 = pugi::xml_node::child(a2, v8); // 一个子节点
if ( !v53
|| (v9 = pugi::xml_node::attribute((int)&v53, "data"), v10 = pugi::xml_node::attribute((int)&v53, "exp"), !v9)
|| (v11 = v10) == 0 )
{
LABEL_66:
exception = __cxa_allocate_exception(4u);
*exception = 4;
__cxa_throw(exception, (struct type_info *)&`typeinfo for'int, 0);
}
v12 = pugi::xml_node::attribute((int)&v53, "feats");
v13 = "";
if ( v12 )
{
if ( *(_QWORD *)(v12 + 16) )
v14 = *(const char **)(v12 + 16);
else
v14 = "";
}
else
{
v14 = 0;
}
前面这几行可以确定很多东西了,Response下面有一个子节点activation,而且必须有data和exp属性
还有一个feats好像是可有可无,继续往下看,由于函数比较大,相对没用的我这里就跳过去了
LABEL_35:
ts = pugi::xml_node::attribute((pugi::xml_node *)&ablock, "ts");
if ( !ts )
goto throw_exception;
if ( *(_QWORD *)(ts + 16) )
ts_value = *(Activation **)(ts + 16);
else
ts_value = (Activation *)&str_3;
ts_integer = Activation::Base64ToUint64T(ts_value, v26);
这说明ts段也是必须的,而且它是一个base64编码过的64位整数,但好像并没有校验它的有效性
// sub_100DD654C (片段)
v18.tm_mon = BYTE1(v8) - 1; // 月
v18.tm_year = HIWORD(v8) - 1900; // 年
*(_QWORD *)&v18.tm_sec = 0;
v18.tm_hour = 0;
v18.tm_mday = (unsigned __int8)v8; // 日
v18.tm_isdst = 0;
v9 = time(0);
if ( v9 > timegm(&v18) ) // 与当前时间做比较
{
v12 = __cxa_allocate_exception(4u);
*v12 = 13;
__cxa_throw(v12, (struct type_info *)&`typeinfo for'int, 0);
}
exp是一个base64编码过的4字节的日期,sub_100DD654C里判断了这个是否大于当前时间,也就是是否过期
结构如下
#pragma pack(push, 1)
typedef struct {
uint8_t day;
uint8_t month;
uint16_t year;
} date_t;
#pragma pack(pop)
往下我们可以看出来data是一个签名,它用来验证了一段前面拼接的文本,前面拼接的逻辑比较复杂,我们不妨下个断点来看看验证的文本是什么
if ( (Activation::VerifySignature(*(Activation **)(a1 + 128), v48, v49, (const unsigned __int8 *)data_d, v60) & 1) != 0 )
{
*(_QWORD *)(a1 + 72) = 0;
ActivationData::SetActivationBlock(a1, &__p, &data_d);
v30 = (unsigned int)ActivationData::Save((ActivationData *)a1) ^ 1;
}
else
{
v30 = 1;
}
根据之前的分析我们已经可以伪造一个大概了,让它走到验证签名这一步
<?xml version="1.0" encoding="utf-8"?>
<Response><activation data="abcdefg" exp="BwH0Bw==" ts="AAAAAAcB6gc="/></Response>
对VerifySignature里面的CryptoPP::PK_Verifier::VerifyMessage下断点
就可以读出签名校验的数据了,不难看出这里用的仍然是TLV编码
等等,有没有觉得第一条长度0x4d的数据有点点眼熟?这不就是加密写在激活请求里的数据嘛
我们继续往后看,它跳过了tag 2,直接到了tag 3,这个4个字节的数据就是上面构造的exp
然后就是tag 4,对应的是ts的64位整数,然后就是标志的0xff结束位了
太好了,我们只要用我们的私钥对这串数据签名生成data就可以通过这个签名验证了!
string message;
oss.str("");
oss.clear();
write_data(oss, 1, requestPayload);
write_data(oss, 3, exp_data);
write_data(oss, 4, ts_data);
oss.put(-1);
message = oss.str();
// 这里用的和前面一样也是 RSASSA_PKCS1v15_SHA_Signer
vector<byte> signature = sign_data(message, privateKey);
然而,结束了...吗?
签名验证通过了,它也接受了我们的许可证,但却显示已过期?我们不是设置了exp的值吗?
通过lldb追踪调用,可以找到是-[LXTurbo beginDate]和-[LXTurbo expirationDate]返回空导致的
在激活响应的解析里,并没有beginDate相关的属性名,那它怎么获取到这个值的?
id __cdecl -[LXTurbo beginDate](LXTurbo *self, SEL a2)
{
int v2; // w0
int v3; // w20
void *v4; // x19
id v5; // x20
NSString *v6; // x21
v2 = sub_100DF12C8(dword_1013B8C08, "begins", 0, 0);
if ( v2 < 1 )
{
v5 = 0;
}
else
{
v3 = v2;
v4 = malloc((unsigned int)v2);
if ( (unsigned int)sub_100DF12C8(dword_1013B8C08, "begins", v4, v3) )
{
v5 = 0;
}
else
{
v6 = objc_retainAutoreleasedReturnValue(
+[NSString stringWithCString:encoding:](
&OBJC_CLASS___NSString,
"stringWithCString:encoding:",
v4,
+[NSString defaultCStringEncoding](&OBJC_CLASS___NSString, "defaultCStringEncoding")));
v5 = objc_retainAutoreleasedReturnValue((id)sub_1000AF788());
objc_release(v6);
}
free(v4);
}
return objc_autoreleaseReturnValue(v5);
}
sub_100DF12C8(dword_1013B8C08, "begins", 0, 0);这一行很关键
但是这个函数非常复杂,而且用了一堆看起来莫名其妙的运算,不是很好分析,我猜测可能是类似哈希表的一个数据结构
到这里思路有点断了,于是上这个软件许可证系统的官方文档上看了看,发现了一个很重要的线索,它支持自定义许可证字段
没错,不出意外的话,这个软件应该用了一个名为begins的字段,但它应该存在哪里呢?
我想到了前面可有可无的feats,应该就是这个了,它也许是featuers的缩写呢
回到刚刚解析激活响应的函数,让我们看看feats是怎么解析的
if ( feats_str )
{
__p = 0;
v59 = 0;
v60 = 0;
v47 = strlen(feats_str);
Base64::Decode((Base64 *)&__p, feats_str, v47);
v48 = __p;
TA_LIB_NS::WriteFiles::WriteString(&v66, 2, __p, (int)v59, 0);
if ( v48 )
operator delete[](v48);
}
它首先也是base64编码过的,但是这里把它写入了用来验证签名的数据后没有下文了
在分析了很多地方之后还是没有追踪到解析的地方,怎么办呢
我想到了一个阴招
这个许可证系统它是提供免费试用的,那就对不住了..... (虽然这招好像用的挺多的)
咳咳,这只是为了省点时间罢了
这是一串有效的feats
$ echo AQQAAABhdHRyAhMAAAAyMDI2LTAxLTA2IDEyOjAwOjAwAQUAAABhdHRyMgIGAAAAc3R1cGlk/w== | base64 -d | xxd
00000000: 0104 0000 0061 7474 7202 1300 0000 3230 .....attr.....20
00000010: 3236 2d30 312d 3036 2031 323a 3030 3a30 26-01-06 12:00:0
00000020: 3001 0500 0000 6174 7472 3202 0600 0000 0.....attr2.....
00000030: 7374 7570 6964 ff stupid.
没啥说的,他还是那么爱他的TLV,一眼就能看出编码原理
让我们飞快地加上包含expire和begins的feats,来看看,别忘了签名验证的数据也要加上这段
time_t now = time(NULL);
struct tm *t = localtime(&now);
char buf[20];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", t);
string begins(buf);
t->tm_year += 10;
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", t);
string expires(buf);
ostringstream oss(ios::binary);
write_data(oss, 1, string("begins"));
write_data(oss, 2, begins);
write_data(oss, 1, string("expires"));
write_data(oss, 2, expires);
oss.put(-1);
string feats_data = oss.str();
至此,我们只通过修改Resources/TurboActivate.dat里的公钥便实现了离线激活
Keygen的大部分的核心代码前面都给了,出于你懂的原因,就不放完整的的源码了
这里只解锁了基础的功能,不过剩下的和前面差不多,给一个小提示吧 ZmVhdHM6ZWRpdGlvbjU=
后记
前前后后我花了几个星期的时间研究,过程中收到了一位国外友人的私信
I've tried a lot of apps, but this one works best with qlab. I run children's shows in a small town, and I really want to make it beautiful)
看完莫名有点感动,或许这就是做这些事情的意义所在之一吧