吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 3419|回复: 38
收起左侧

[MacOS逆向] 某软件许可证系统离线激活分析与Keygen

  [复制链接]
skrets 发表于 2026-1-7 23:07
本帖最后由 skrets 于 2026-1-8 15:20 编辑

开头先来,祝大家2026年新年快乐

最近研究了一个偏专业的小众软件,用来控制舞台灯光的,发现比想的要复杂不少

研究的过程中发现使用了 TGltZUxN 这个软件许可证系统,虽然较小众但功能挺全还跨平台

它支持在线激活和离线激活,本帖就来分析一下它的离线激活流程(基于ARM架构)

该软件下载链接:

aHR0cHM6Ly9ocmVmLmxpLz9odHRwczovL2xpZ2h0a2V5YXBwLmNvbS9tZWRpYS9wYWdlcy9kb3dubG9hZC9MaWdodGtleS01LTctNC83YmYzOTg1NTA3LTE3NjU3OTA2MjYvTGlnaHRrZXlJbnN0YWxsZXIuemlw

用到的代码/工具:

  • 静态分析 Hopper / IDA
  • 动态调试 lldb
  • 注入框架 tinyhook
  • 加密库 CryptoPP

免责声明

本文内容仅用于技术研究与安全分析目的,旨在帮助开发者理解软件授权系统的设计思路及潜在安全风险,以促进更安全的软件工程实践。

文中涉及的分析基于公开可获取的软件行为与环境,不包含任何源代码泄露、私钥披露或未授权访问行为。

本文不鼓励、也不支持任何形式的软件盗版、破解、绕过授权机制或侵犯知识产权的行为。任何个人或组织因使用本文内容所产生的法律责任,均由其自行承担,与本文作者无关。

许可证密钥格式分析

许可证首先有会本地的格式校验

image-20260106210800013.png

我们打开直接选择 No Internet connection?,会弹出离线激活的保存激活请求文件的弹窗

image-20260106211332087.png

这里可以看出密钥的格式是用-分隔的7段长度为4的字符串,随便输入一串这个格式的点Save File试试

image-20260106211724714.png

我们可以从这个字符串入手来找到它校验许可证的地方,在app里搜索一下这个字符串

image-20260106213025290.png

找到了,可以看到它对应的是ActivationError-InvalidProductKey这个字符串

那是时候打开Hopper了,在主程序里搜索这个字符串然后查一下引用

image-20260106213306884.png

根据函数名其实不难猜到我们这个弹窗应该是在这个函数里面触发的

-[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就好了

然后你就会发现这个函数看起来非常的复杂,但是别急我们慢慢来,先看头部这几个小判断

image-20260106222930635.png

第一个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_开头的地址,点一个进去看看

image-20260106223806077.png

嘿嘿,这不是我们的老朋友CryptoPP嘛,没想到这里也能碰到你

虽然开发者把符号抹的很干净,但虚表还在,这才认出了CryptoPP

根据字符串,我们可以去CryptoPP的动态库里找找看,还原出一部分的符号,方便我们的分析

image-20260106225509923.png

这个AES_DEC我暂时没有找到比较合适的,但基本可以确定就是AES并且是CBC

下面还用了BaseN_Decoderw2为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,我们把断点设在许可证解密的后面,输入这个生成的许可证

断下了就说明解密阶段成功了,我们往下分析

image-20260106235823588.png

动态调试可以方便地读取到寄存器的值以及内存中的字符串,这一段其实就是在判断解密出来的初始值长度是否足够

我们在下一个分支处下断点

Ghostty 2026-01-07 00.02.44.png

这里的w20是第5位的数据,它应该为0,下面还有一个类似的校验

image-20260107000541018.png

经过几次调试,可以确定这里的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,再拿去试试

image-20260107001143157.png

这样我们就通过了本地的许可证格式校验,总结一下就是

含固定位初始值 -> 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

成功断下,我们就可以从参数里读取出加密前的数据了

image-20260107120057303.png

数据长度为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里面搜索这个字符串

image-20260107125010337.png

很快就锁定了读取的文件是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

image-20260107182616521.png

基于此我们可以还原出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文件,双击打开看看什么反应

image-20260107200927671.png

嗯,意料之内,我们可以从-[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,而且必须有dataexp属性

还有一个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下断点

image-20260107211113952.png

就可以读出签名校验的数据了,不难看出这里用的仍然是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);

然而,结束了...吗?

image-20260107212232244.png

签名验证通过了,它也接受了我们的许可证,但却显示已过期?我们不是设置了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,一眼就能看出编码原理

让我们飞快地加上包含expirebeginsfeats,来看看,别忘了签名验证的数据也要加上这段

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();

image-20260107220044472.png

至此,我们只通过修改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)

看完莫名有点感动,或许这就是做这些事情的意义所在之一吧

免费评分

参与人数 29吾爱币 +29 热心值 +24 收起 理由
52pojieplayer + 1 谢谢@Thanks!
hy4260 + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
5omggx + 1 + 1 用心讨论,共获提升!
luolifu + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
aigc + 1 我很赞同!
BrutusScipio + 1 + 1 用心讨论,共获提升!
TX8250 + 1 + 1 我很赞同!
zgy123 + 1 + 1 谢谢@Thanks!
allspark + 1 + 1 用心讨论,共获提升!
zckiszj + 1 + 1 热心回复!
qwq2233 + 1 用心讨论,共获提升!
imanityed + 1 + 1 用心讨论,共获提升!
ninja2ren + 1 + 1 谢谢@Thanks!
shenxing + 1 + 1 热心回复!
evea + 1 + 1 谢谢@Thanks!
星塘 + 1 + 1 谢谢@Thanks!
mscsky + 1 + 1 我很赞同!
laozhang4201 + 1 + 1 热心回复!
qiuqiuone + 1 我很赞同!
eric + 1 谢谢@Thanks!
nmweizi + 1 + 1 用心讨论,共获提升!
showwindows + 1 + 1 我很赞同!
LuckyHXF + 1 + 1 用心讨论,共获提升!
chenfann + 1 + 1 很厉害!!
Vvvvvoid + 3 + 1 鼓励转贴优秀软件安全工具和文档!
phper + 1 鼓励转贴优秀软件安全工具和文档!
江南小虫虫 + 1 用心讨论,共获提升!
gaosld + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
anning666 + 1 + 1 我很赞同!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

炫迈 发表于 2026-1-9 10:41
这个TLV编码格式在许可证系统里很常见,你抓的tag对应关系很准,不过高版本的TGltZUxN会把feats字段加密,用AES-CBC模式,key是产品ID的sha256,这个坑我踩过,调试时发现解出来是乱码,后来才摸到规律

公钥替换那块,你用tinyhook动态dump的方法很巧妙,我之前是直接在IDA里找模数特征,从.data段扫2048bit的大整数,效率低还容易漏,你的方法更直接,特别是对符号被strip干净的程序很实用

签名验证绕过那里,exp字段的base64解码要注意字节序,我遇到过小端序的坑,解出来年份变成1900+256*highbyte+lowbyte,调了好久才发现,你用timegm处理得很到位

feats字段分析那段,begins和expires确实是关键,但有些软件还会加hardware_id绑定,就是把主板序列号或者硬盘号hash后塞进去,激活时会校验,这个你提到的国外友人那个场景,舞台电脑一般不会换硬件,可以不用处理

TurboActivate.dat替换后要记得改文件权限,chmod 444,不然app启动时会重新生成,我第一次搞完激活成功,第二天启动又变试用版,排查半天才发现是权限问题
星塘 发表于 2026-1-9 16:20
最后的后记别说你感动了,把我也感动坏了,老外在一个小镇上做儿童剧的演出,且这个软件是和qlab配合最好的。

我没想到在吾爱还能碰到和我自己平时工作这么相关的一群人。楼主你确实挺小众的,哈哈哈哈……

我在一个四线城市也是做着儿童剧演出的工作,平常生活中虽然不懂代码,但是喜欢捣鼓各种软件,所以没事也喜欢在吾爱逛逛。

记得几年前合作排一个新西兰的儿童剧,他们那边就是在一台mac上用qlab做音效和视效,然后用一台win本控灯光,两台电脑搞定所有效果。当时就对这套东西很感兴趣。后来就开始自己学习qlab这个软件。

讲真国内舞台软件这方面还真挺匮乏的。很多好用的软件都是国外开发的,但是由于真的小众,都没有中文,对我英语不好的我来说研究起来挺费劲的。现在qlab的音效和视效我也研究了个半斤八两,唯独灯光还没有研究。

所以楼主你能明白我看完这篇帖子的心情吗,真的是太激动了,感觉马上又可以开始研究新的东西了。
Vvvvvoid 发表于 2026-1-8 17:19
本帖最后由 Vvvvvoid 于 2026-1-8 17:30 编辑

授人以鱼不如授人以渔
小众软件也有大大的爱啊,  感谢分享
孤狼微博 发表于 2026-1-8 16:13
看着大神一顿膜拜
anning666 发表于 2026-1-8 16:27
看到最后,大佬居然还惊动了外国友人~~~~大佬真的非常耐心,又乐意分享,必须加分~~~
q3125418 发表于 2026-1-8 18:34
一看就是精品。曾经也花了好几天弄了一款软件,成就感满满
eagleangle 发表于 2026-1-8 19:10
太有耐心了,收获满满
osforum 发表于 2026-1-8 20:46
有谁和我一样从头滑到尾,只看有没有kengen的?
zwmfyy 发表于 2026-1-8 20:59
专业,太复杂了,
jsea 发表于 2026-1-8 22:17
佩服,学习了
010xml 发表于 2026-1-9 00:49
有个程序是根据硬件得出一组机器码组合,软件根据机器码要激活码激活,和你这个差不多吗,谢谢
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-1-13 08:25

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表