吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1188|回复: 10
收起左侧

[.NET逆向] TreeSize 授权模块分析与算法还原

[复制链接]
云在天 发表于 2026-11-1 23:53

声明:本文仅用于技术研究与学习交流,请勿用于任何商业用途或非法用途。软件版权归原厂所有,请在下载后 24 小时内删除。转载请注明出处。


一、样本信息

项目 内容
文件名 LicenseManager.exe
大小 2,150,904 字节
类型 PE32 executable (console) Intel 80386
运行时 .NET Framework 4.8(CLR 头存在,IAT 仅 mscoree)
程序集 LicenseManager, Version=6.4.4.0, PublicKeyToken=13acf979d16e8a17
保护 Babel Obfuscator 11.6.0.0 / SuppressIldasm / 控制流平坦化 / 字符串加密 / 资源加密 + 程序集合并
清单资源 1 个:HUxZ,1,972,932 字节

程序集描述字段里写得很清楚,它的职责有两个:一是给安装程序/用户提供脱离主程序的授权管理入口,二是充当 COM 包装器,让非 .NET 宿主(TreeSize 本体是 Delphi)也能调用授权系统。

这句话基本定了后面的分析方向——真正的授权引擎不会在 exe 里明着放。


二、工具

dnSpy 看结构与下断点,ilspycmd 批量反编译,Mono.Cecil 做 IL 级比对与修改,Detect It Easy / ExeInfoPE 识别保护,外加几个自己写的小工具(解资源、算 SHA-256)。


三、静态初探

3.1 混淆形态

打开 dnSpy,几件事立刻就看到了:

类型名是 \uE000 到 \uE0xx 这一段的私用区单字符;方法名同样是单字符;命名空间被抹平后,一部分类型直接落在根命名空间里。

方法体没有一个能读:全被拆成

int num = 4;
while (true) {
    switch (num) {
        case 0: ... num = 2; continue;
        case 1: ... num = 7; continue;
        ...
    }
}

这种状态机,是 Babel 的招牌。字符串也全是密文,运行时由每个类各自的静态构造器从加密资源里解出一张 HashTable。

3.2 资源与引用表

Resource 面板里只有一个清单资源 HUxZ。

再看 AssemblyRef,问题就出来了:

Jam.License.Common     6.4.4.0  13acf979d16e8a17
Jam.License.Validation 6.4.4.0  13acf979d16e8a17
Jam.Logging            1.0.4.0  13acf979d16e8a17

可安装目录里这三个 dll 一个都没有,全盘搜索也没有。

命令行却跑得好好的:

> LicenseManager.exe license getType
(退出码 2)

于是基本可以断定:引擎是加密内嵌在 HUxZ 里的,运行时才解密到内存。

顺便验证了一下:进程起来以后,模块列表里只有 LicenseManager.exe 自己,确实没有任何 Jam.License.* 模块被映射。


四、外壳还原

4.1 容器格式

HUxZ 解出来是一层容器。头部结构:

offset  size        说明
0x00    u16         头部长度 hdrLen
0x02    byte[hdrLen] 头部密文
        u8          IV 长度 ivLen
        byte[ivLen] IV
        byte[]      data(余下全部)

头部先整体异或一遍:

hdr[i] ^= iv[i % ivLen]

异或完的头部是一个 BinaryWriter 序列化出来的结构:

string  名称                // 7bit 变长长度前缀 + UTF-8
u8      flags               // bit1 = 需要解密,bit0 = 需要解压
u8      algoId              // 1=DES 2=AES 3=3DES 4=Rijndael 5=RC2 6=自研
u8      keySrc              // <64:密钥紧随其后;>=64:从强名公钥里取
u8      keyLen
byte[]  key                 // 读 keyLen 字节

这里有个小陷阱,值得单独说:当 keySrc < 64 时,解析器读完 keyLen 字节之后又读了一次 keyLen 字节覆盖到同一个数组上。也就是说头部里其实存了两份等长数据,第一份是诱饵,第二份才是真密钥。只有 keySrc >= 64 的分支不会二次读取,而是去程序集强名公钥 blob 的 keySrc + 12 偏移处抠 keyLen 字节当密钥——典型做法:拿自己的签名当密钥源,省掉额外存储,代价是重签名即失效。

数据区的处理由 flags 决定:

flags 处理
bit1 置位 走 CryptoStream 解密
bit1、bit0 都置位 先解密,再解压
仅 bit0 置位 只解压
都为 0 原样拷贝

算法编号 6 是作者自己写的。它继承 SymmetricAlgorithm,但 TransformBlock 整个方法体只有一句:

_E003[num3 + _E004] = (byte)(_E000[num3 + _E001] ^ Key[num5 % num]);
// 其中 num5 = IV[num3 % IV.Length]

即 out[i] = in[i] ^ key[iv[i % ivLen] % keyLen],纯流式异或,块大小声明成 128 位纯属摆设。

解压用的是自研 inflate(窗口 32 KB,_E005(32769)),不是 DeflateStream,这也解释了为什么直接对 payload 调 System.IO.Compression 会失败。

4.2 合并程序集包

容器解出来的明文是一个“合并包”:

i32    count           ^ 0x2DF76C87
repeat count:
  string  name         // 每个字符 ^ 0x0E08
i32    length          ^ 0x5862B484
byte[length] assembly

实测 count = 3,解出来的三个名字是 Jam.Licensing.Resources.Resource.resources、JanA、rmME;紧跟着一段 1,972,736 字节的 PE。

这段 PE 是个纯资源程序集,-l c 只有一个 <Module>,版本是 0.0.65535.65535,挂着三个资源:

  • Jam.Licensing.Resources.Resource.resources(明文 .resources,BEEFCACE 开头)
  • JanA(67 字节,又是一层容器,解出一段短字符串)
  • rmME(1,818,419 字节,索引包)

4.3 索引包 rmME

rmME 的文件头只有 8 字节:

i64  indexOffset ^ 0x3B88F187

本例中异或出来是 0x1B7771(1,800,049),正好落在文件尾部。

跳过去:

i32  条目数
repeat:
  string  name        // 每个字符 ^ 0x0EA7
  i64     pos         // 该条目的数据偏移
  i32     kind        // 0 = Assembly.Load(byte[]),1 = 落临时文件后 LoadFile

每个条目在 pos 处是:

i32   length ^ 0x44999890
byte[length] 数据(又是一份 4.1 的容器)

实测 63 项,内容分布如下:

  • 40 个 Jam.License.Validation.resources, Culture=xx 语言卫星(af、ar、bg、ca、cs、da、de、el、es、fi、fr、gu、he、hi、hr、hu、id、it、ja、ko、lt、lv、nb-NO、nl、pl、pt、pt-BR、ro、ru、sk、sl、sq、sr、sv、th、tr、uk、vi、zh-Hans、zh-Hant)
  • 三个自有程序集:Jam.License.Common(104,448 B)、Jam.License.Validation(258,048 B)、Jam.Logging(24,560 B)
  • 其余是第三方:AuthenticodeExaminer、Serilog 及两个 Sink、System.CommandLine、System.Text.Json、System.Resources.Extensions、WindowsFirewallHelper 等

4.4 加载器

加载器挂在 AppDomain.AssemblyResolve 上,逻辑大致是:

解析 rmME 一次 → 建立 name → (pos, kind) 的 Hashtable
收到 Resolve 请求 →
    命中 → 定位 pos → 读长度 → 读块 → 容器解密 → 
        kind == 0 ? Assembly.Load(bytes)
                  : 写 %TEMP%\<随机目录>\<随机文件名> 后 LoadFile

顺带一提,它解析请求名时会退化到“只比程序集简单名”,所以版本号对不上也能命中——这对后面第六节的分析有直接影响。


五、授权体系

5.1 产品配置

{
  "content": {
    "productName": "TreeSize",
    "productVersion": "9.9.1",
    "productGroupId": 1,
    "productGroupName": "TreeSize",
    "signatureVersion": 8.00,
    "trialLengthDays": 14,
    "previousTrialLengthDays": null,
    "activationType": "UserAndSystem",
    "requiresDemoKeyRegistration": false
  },
  "schemaVersion": 1,
  "signature": "aUBOIhts……==",
  "timestamp": "2026-10-13T18:33:29.3630781Z"
}

签名长度 344 字符 base64,即 256 字节,对应 RSA-2048 / SHA-256 / PKCS#1 v1.5。验签方是 SignedLicenseConfiguration + SignedJsonConverter<LicenseConfiguration>,公钥取自 Jam.License.Common.Resources.LicenseConfigurationRsaPublicKey。

笔者最初的想法是把 trialLengthDays 改成 3650 再重签。看了下钥匙,这条路不通:这把公钥跟后面验授权用的不是同一对,而且只以公钥形式存在。厂家这里做得很规矩。

5.2 许可证密钥

Jam.License.Common.LicenseKey 的规则:

  • 归一化:ToUpperInvariant() → 去 - → 0→O、1→I、8→B → Trim
  • 正则:^[A-Z2-7]{42}$
  • 展示:每 6 个字符插一个 -,即 7 组 × 6

界面上那个 ABCDEF-123456-ABCDEF-123456-ABCDEF-123456-ABCDEF 就是这么来的(对话框里给的是示例串)。

5.3 本地存储

  • 目录:%ProgramData%\JAM Software\Licenses\
  • 文件名:BitConverter.ToString(SHA256(seed)).Replace("-", ""),64 位大写十六进制
  • seed 的拼法(LicenseFileSchemeVersion 取枚举最大值,当前为 JamLicense60AndUp):
完整版: "{productGroupId}Full"  + "JamLicense60AndUp"
试用  : "{productGroupId}Trial" + "JamLicense60AndUp"
  • 文件内容:Convert.ToBase64String(ProtectedData.Protect(utf8Json, null, DataProtectionScope.LocalMachine))

解出来是:

{"LicenseKey":"$1$PYxcXRonzPpVJ3K/56pq1w==$HQJGP/ZLhrA9SB5Q56rqH9ZsKcrDMwCSzgArIBGKsgo=","SchemaVersion":2}

$1$ 是这个引擎自己的令牌封装:"$1$" + base64(IV) + "$" + base64(密文),AES-CBC,密钥由编译期常量经 KDF 派生 32 字节,IV 随每次写入重新生成。

顺带说个实测结果:这个 DPAPI 块用 CurrentUser 和 LocalMachine 两种 Scope 都能解开。正常写法应该只有一种能解,出现这种情况一般是因为保护时用了 CRYPTPROTECT_LOCAL_MACHINE 而又没有附加熵,等同于把钥匙绑在机器上而不是用户上。

5.4 正式授权与验签

正式授权是一段服务端签名的 JSON,反序列化成 SignedDto<LicenseInformation>。

验签逻辑(JsonSignatureVerifier):

1. 在根对象里找 "signature"(找不到再找 "Signature")
2. 取它的 RawText,若为 null 或 "" 直接失败
3. 用 Utf8JsonWriter 重建整个对象:遇到 signature 键就写 null,其余按原顺序 WriteTo
4. 序列化成字符串 → UTF-8 字节
5. SHA256(字节)
6. RSACryptoServiceProvider.VerifyHash(hash, sig, SHA256, RSASignaturePadding.Pkcs1)

第 3 步是关键:签名覆盖的是“把 signature 字段置 null 之后重新序列化”的结果,所以只要对象结构一样,空白、属性顺序、数值写法都必须与签名时一致。

校验结果用一个三元组表示,再由上层映射成 LicenseState:

条件 内部状态 映射
签名验证失败 1 Invalid
内容里的 LicenseKey 与请求的密钥不一致 3 Invalid(但会带回内容对象)
通过 0 Valid

SignedDto<T> 的结构:

{
  "content": { LicenseInformation },
  "schemaVersion": 1,
  "signature": "<base64 RSA-4096/SHA-256/PKCS#1>",
  "timestamp": "2026-11-01T12:00:00Z"
}

LicenseInformation 的字段(JsonSerializerDefaults.Web,即 camelCase;枚举用 JsonStringEnumConverter 序列化成字符串):

customerID     int
entitlements   { productID, name, metric, limit?, isUnlimited }[]
expireDate     DateTime(ISO-8601,形如 2126-10-08T00:00:00Z)
licenseKey     42 位 Base32,字符串形式
licenseType    None / Perpetual / Trial / Freeware / Subscription
maintenance    { endDate, reminderType, autoRenewal }
product        { id, name, edition, productGroupID }
schemaVersion  int
state          Unknown/Valid/Invalid/Expired/Corrupted/NotFound/VersionNotSupported/ManualActivationRequired

三个枚举的数值:LicenseType 为 None=0、Perpetual=1、Trial=2、Freeware=3、Subscription=4;ProductEdition 为 None=0、Classic=1、DataCentral=2、Enterprise=3、Personal=4、Professional=5、SmallBusiness=6、Standard=7。

踩过的两个坑记一下:

其一,maintenance 不能为 null。 校验函数里有一句直接取 content.maintenance.endDate,字段缺了就是 NullReferenceException,整个流程直接抛出去,CLI 会以异常退出码 0xE0434352 结束。

其二,product.edition 决定界面形态。 不给值(或给 None)时,TreeSize 的“关于”窗口显示成 Personal,功能集也按 Personal 走;填 5 才显示 Professional。这一条在第四节之后才想明白,之前一直以为是注册表里的 Edition 值在起作用,实测不是,那个键只是安装时写进去的痕迹。

5.5 试用与反回拨

试用期的计算在 _E021 这个类里,逻辑是:

若本地已有到期日 → 直接用本地值(不会因为删除文件而重置)
否则 → 到期日 = 今天 + trialLengthDays(14)
校验时以 TimeProvider 的本地时间与到期日比较

反回拨有两道:

  1. 本地存的到期日优先,改系统时间不会让它变远;
  2. SignedLicenseConfigurationCompileDateProvider 从签名配置里取编译时间戳当下界,往前调时间会被这个下界卡住。

另外还有个 6 小时的缓存(TimeSpan.FromHours(6)),避免每次都读文件、解 DPAPI。

TrialDaysAvailable 只在 State == Valid 时才有意义,否则一律返回 0——所以界面上的“剩余天数”跟授权状态是绑死的。

5.6 激活

  • 基址:https://api.jam-software.de/
  • 端点:激活 / 设备激活 / 系统激活 / 反激活 / 校验 / 手工激活(activationResponse)
  • 请求侧用客户端密钥对签名,响应侧用服务端公钥验
  • 手工激活返回的同样是服务端签名过的 SignedDto<LicenseInformation>,客户端只做验签与落盘

5.7 对外接口

COM 三件套(Jam.License.COM.TLB 在二进制里能看到引用):

IComLicenseManager
    InstallLicense / UninstallLicense / CreatePortableLicense /
    GetLicenseInformation / TryUpdateLicense / Events
IComLicenseInformation
    CustomerID / LicenseKey / ShortLicenseKey / State / LicType /
    ExpirationDate / MaintenanceEndDate / Entitlements / Product / IsPortable
IComEntitlements
    ProductID / Name / Metric / Limit / IsUnlimited / NextEntitlement

事件回调用 Dispatcher.Invoke 回到创建线程,典型的 WinForms/WPF 混编写法。

命令行这边:

license install   --licenseKey <key>
license activate  --activationResponse <base64>
license deactivate / uninstall / query
license getType   → 退出码 0=None 1=Full 2=Trial

getType 的返回值直接来自 ILicenseInformation.Type,与 COM 属性同源。


六、关键代码路径

混淆后类名不可读,但接口名和方法签名还在,所以用“符号”来定位比读平坦化代码快得多。

第一处:验签器。 Jam.License.Common.Signature.DigitalSignatureVerifierRsa,两个重载:

bool VerifySignature(ByteSequence pubKeyBlob, IEnumerable<byte> data, ByteSequence sig)
bool VerifySignature(string pubKeyXml,  IEnumerable<byte> data, ByteSequence sig)

两个方法体里都只有一处 RSACryptoServiceProvider.VerifyHash,其余都是取哈希、跑 SHA256、RSAPadding.Pkcs1。

第二处:授权信息对象。 Jam.License.Validation 里有三个类型实现了 ILicenseInformation(一个用于正常状态,两个用于 NotFound / VersionNotSupported 的兜底)。它们的 State、Type、Product 都是只读属性,直接返回字段。

第三处:管理器。 LicenseManagerFactory.CreateLicenseManager 里把存储、校验器、激活器三样拼起来,再交给 ILicenseManager 的实现。

第四处:存储。 负责文件名计算的是个只有几行的类(种子字符串 + SHA-256 + BitConverter.ToString),负责读写的是 DPAPI 那一层。

把这四处串起来,整条链就清楚了:

LicenseConfiguration.json(验签:配置公钥)
        ↓
LicenseManagerFactory → 存储(%ProgramData% 下的 DPAPI 文件)
        ↓
SignedDto<LicenseInformation>(验签:服务端公钥)
        ↓
LicenseState / LicenseType
        ↓
ILicenseInformation → COM 属性 / CLI 退出码

七、问题清单

7.1 客户端里带了一把完整的 RSA 私钥

Jam.License.Validation.Resources 里有两个字符串资源:

资源名 Modulus 长度 是否含私钥
ClientSideSigningPrivatePublicKey 684 字符(512 字节,RSA-4096) 是,含 P、Q、DP、DQ、InverseQ、D
ServerSideSigningPublicKey 684 字符(512 字节,RSA-4096) 否

另外 Jam.License.Common.Resources 里还有一把 LicenseConfigurationRsaPublicKey,344 字符(256 字节,RSA-2048),只用于验配置。

把私钥打进客户端,本意应该是给请求和本地数据做防篡改,但这样做的防护意义基本为零——任何拿到二进制的人都能签出“合法”的客户端数据。

顺带记一下文件里的偏移(未打包的 Jam.License.Validation.dll,258,048 字节):

0x219F5  <RSAKeyValue> 客户端密钥对(含私钥)
0x226AD  <RSAKeyValue> 服务端公钥

打包状态下这两个偏移没有意义,HUxZ 里是层层加密的。

7.2 引擎可以被同名程序集顶掉

这是笔者认为最要命的一点。

引擎的加载路径是 Assembly.Load(byte[]),而 .NET Framework 4 对“非 GAC、完全信任”的程序集不校验强名称签名。同时,CLR 的探测顺序是先查应用基目录,再触发 AssemblyResolve。

结果就是:往安装目录放一个 Jam.License.Validation.dll,只要程序集名、版本、公钥标记对得上,它就会被优先加载,厂商的解密器根本轮不上。

实测下来确实如此,进程模块列表里也能看到这个 dll 被映射了。

7.3 没有任何完整性自检

不校验模块哈希,不校验 Authenticode,不做内存完整性检查,也没有 IsDebuggerPresent 之类的反调试。7.2 能成立,全靠这一点。

7.4 判定结果单点集中

CLI 退出码、COM 属性、界面显示,最后都来自同一个对象的 State 和 Type。而且这两个属性是 ldfld 直接返回字段,改起来连逻辑都不用动。

7.5 本地令牌用编译期常量做密钥

$1$ 令牌的 AES 密钥是常量派生,不绑机器、不绑用户。虽然它只保护试用数据,但性质上和 7.1 是一样的:密钥在客户端。

7.6 做对的地方

也得说句公道话:

  • 正式许可只认服务端私钥签出来的数据,客户端拿不到私钥,离线造号这条路走不通;
  • 配置签名用的又是独立的一对密钥,且只发公钥,改配置这条路也走不通。

笔者试过往授权目录塞一份自己用客户端私钥签出来的 SignedDto<LicenseInformation>(格式、字段、时间戳全部对齐,用客户端公钥能验过),结果 CLI 返回 0,日志里留下:

License check 0xA04C0031 failed.

原因就是验签用的是服务端公钥,而不是客户端那把。


八、验证

条件 license getType
原版,未做任何改动 2(Trial)
把引擎里三个实现类的 State/Type 改成常量 1(Full)
只放客户端私钥签出来的许可文件,引擎不动 0,日志报 0xA04C0031
先把镜像里验签公钥的 Modulus 前 24 个字符改成 A,再做同样改动 1(Full)
只改 Product 的 Edition(4→5),其余不动 关于窗口由 Personal 变 Professional

第四行值得多说两句。公钥被改成垃圾以后结果不变,说明生效的不是“换掉了哪把钥匙”,而是“验签这个动作被短路了”。所以讨论“厂家以后换公钥怎么办”意义有限——只要验签还在客户端调 VerifyHash,换几把都一样。

顺带说下定位这类“验签动作”的方法:不认类名、不认密钥,直接在 IL 里找返回 bool、且方法体内调用了 RSA/ECDsa/AsymmetricAlgorithm.VerifyHash|VerifyData|VerifySignature 的方法。这类方法在正常工程里本就很少,一抓一个准。


九、结语与建议

这套壳的工程量不小:三层加密资源、自研压缩与异或流、控制流平坦化、字符串加密、运行时解密加载。静态阅读成本确实高,作者是下过功夫的。

文中涉及的具体路径、密钥、许可证编号均已做处理;容器里的异或常量以“常量”代称。

水平有限,如有疏漏或错误,欢迎各位大牛指正。

免费评分

参与人数 6吾爱币 +6 热心值 +5 收起 理由
GS9452 + 1 谢谢@Thanks!
aquar + 1 + 1 谢谢@Thanks!
AndyKuen + 1 + 1 大佬牛啊
港湾999 + 1 + 1 膜拜大佬
chinawolf2000 + 1 + 1 热心回复!
buluo533 + 2 用心讨论,共获提升!

查看全部评分

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

wyesheng 发表于 2026-11-2 00:05
哦豁,这个软件正好刚用到,又可以学习一下了,感谢大佬。
Emperormummy 发表于 2026-11-2 01:08
大佬可否能分析一下fontcreator自从3070版本改了算法后注册机全失效了

点评

[attachimg]2890535[/attachimg] 是这个吧  详情 回复 发表于 2026-11-2 12:29
smallmouse228 发表于 2026-11-2 07:45
头像被屏蔽
Cleverwwh 发表于 2026-11-2 10:07
提示: 作者被禁止或删除 内容自动屏蔽
lzrlzr 发表于 2026-11-2 10:27
学习一下,这么细的操作过程,赞
 楼主| 云在天 发表于 2026-11-2 12:29
Emperormummy 发表于 2026-11-2 01:08
大佬可否能分析一下fontcreator自从3070版本改了算法后注册机全失效了

image.png
是这个吧
oe3675 发表于 2026-11-2 20:10
支持楼主,和wiztree经常用的软件!
albion 发表于 2026-11-2 20:42

支持楼主 像博主学习  fontcreator这个软件注册机很容易破解
Emperormummy 发表于 2026-11-3 00:01

对的对的
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

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

GMT+8, 2026-11-4 03:03

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

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