声明:本文仅用于技术研究与学习交流,请勿用于任何商业用途或非法用途。软件版权归原厂所有,请在下载后 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 的本地时间与到期日比较
反回拨有两道:
- 本地存的到期日优先,改系统时间不会让它变远;
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 的方法。这类方法在正常工程里本就很少,一抓一个准。
九、结语与建议
这套壳的工程量不小:三层加密资源、自研压缩与异或流、控制流平坦化、字符串加密、运行时解密加载。静态阅读成本确实高,作者是下过功夫的。
文中涉及的具体路径、密钥、许可证编号均已做处理;容器里的异或常量以“常量”代称。
水平有限,如有疏漏或错误,欢迎各位大牛指正。