Skip to main content

TscanPlus 授权逻辑分析与二进制补丁破解

作者修订时间
wjlin02026-10-18 19:35:21

由于传播、利用本文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,公众号及作者不为此承担任何责任,一旦造成后果请自行承担!如有侵权烦请告知,我们会立即删除并致歉。谢谢!

结论先说:这是绕过 + 显示层改写,不是 keygen 破解。授权记录走 ECDSA(二进制里只有公钥),激活码无法离线伪造;记录还绑硬件,跨机器不可复用。下面所有"破解"指的是:让校验分支恒不触发,让界面显示 认证Vip版 和一个假日期。


0. 样本​

四份样本,同一套 garble 名字(B0Uu8RPrb2、O7rc6qW0L、Tmfc2rYXRKJT、CSfeFGrv、RVNs3NL5L、XrgIp0N_yO、UNXx_ve6、Ju1zV6nVMwrMz0、PKVcmcz2RL7Dh),但地址每份都不同 —— 用第 9 节的表逐份对照,别把 A 的地址用在 B 上。

编号样本格式版本分支补丁显示串(2099)实测
A/tmp/ctf/tscan_plusMach-O arm643.5.3✓✗(运行时解码)✓
BTscanPlus_Mac_Amd64.dmg 内 Contents/MacOS/TscanPlusMach-O x86_643.5.4✓✗(未认证 无代码引用)✓(Rosetta)
C~/Downloads/TscanPlus_Win_Amd64.exePE x86-64(UPX)3.5.3✓✓✓(Windows 实测)
D~/Downloads/TscanPlus_Win_Arm64.zip 内 exePE x86-64(UPX,文件名误标 arm64)3.5.4✓✓✓(Windows 实测)

产物与脚本:/tmp/ctf_crack/(A)、/tmp/ctf_crack_win/(C)、/tmp/ctf_work/patch_*.py;打包在 ~/Downloads/Tscan_Plus_3.5.4.patched_all.zip。

项目为 TideSec TscanPlus(Wails GUI),授权逻辑在 TscanGui/conf + TscanGui/internal/cli。


1. 授权架构(IDA 定位,样本 A)​

作用函数地址
CLI 授权闸门(真正校验入口)TscanGui_internal_cli.runLicenseCheck0x102a89330
Key 解析 / 字段校验conf.B0Uu8RPrb20x10047ad30
Key 格式校验conf.Tmfc2rYXRKJT0x100476d10
签名校验 B7CIv4PLX("phase3", f1) == f4conf.O7rc6qW0L0x10047a2c0
时间字段校验 f3 < 1797379200conf.PKVcmcz2RL7Dh0x10047fc10
murmur3-32(seed 0) + nibble 折叠conf.B7CIv4PLX / conf.XSMB4O0fIIE2W0x100476a70 / 0x100470c90
硬件指纹(sysctl CPU 品牌 + 物理网卡 MAC)conf.SOPBYsgV / conf.Z5NrYd0V4gEq0x1004809c0 / 0x100480a70
嵌入常量自一致性校验conf.CSfeFGrv / conf.RVNs3NL5L0x1004ab7f0 / 0x1004ad068
过期判断(永不过期补丁点)conf.Qwprp5uSfv32rKY0x100439230
结果标志conf.XrgIp0N_yO0x100451b70
显示字符串来源(运行时解码,不可静态改)conf.UNXx_ve60x10047f900

Key 格式 f1.f2.f3.f4.f5,正则 ^\d+\.\d+\.\d+(\.\d+)?$:

  • f2 ≤ 30000
  • f3 < 1797379200 = 0x6b21d480 → 2026-12-16(我早先记成 2027-02-15,是错的,这里已改)
  • f5 ∈ 0..5(能力位)
  • f4 = fold(first8digits(murmur3_32_seed0("phase3"+f1))) ^ 6

核心字节变换(反汇编验证):out[i] = ROR(in[i] ^ (19*i + seed), seed % 8) ^ (seed + i),Yal9faJZY[i] = i*19, seed(k) = DL1UvTAsmAyf4zI[k] ^ 158(g0=0x7a, g1=0x3b, g2=0x2e, g3=0x5d);C 系列 getter 用固定种子 156/143/125/126。


2. 方法 B:分支补丁(样本 A,已验证可用)​

arm64 无条件跳转:word = 0x14000000 | ((target - addr) / 4 & 0x3FFFFFF)

地址原指令改为效果写入 word
0x102a89394CBZ → 0x102a893ccB跳过"无效的Key"错误分支0x1400000e
0x102a893d4CBZ → 0x102a893d8B强制走 VIP 分支0x14000001
0x102a893e0TBZ → 0x102a893f4BXrgIp0N_yO 结果被忽略0x14000001
0x1004aba58CBZ → 0x1004abaf8BCSfeFGrv 折叠校验跳过0x14000028
0x1004ad198CBZ → 0x1004ad238BRVNs3NL5L IV 折叠校验跳过0x14000028
0x10043932cTBNZ → 0x100439370B永不过期0x14000011

补丁脚本(__text 段:文件偏移 = vaddr − 0x100000000):

import struct
f = open('/tmp/ctf/tscan_plus','r+b'); base = 0x100000000
def patch(addr, target):
imm = (target - addr) // 4
f.seek(addr - base); f.write(struct.pack('<I', 0x14000000 | (imm & 0x3FFFFFF)))
for addr, target in [(0x102a89394,0x102a893cc),(0x102a893d4,0x102a893d8),(0x102a893e0,0x102a893e4),
(0x1004aba58,0x1004abaf8),(0x1004ad198,0x1004ad238),(0x10043932c,0x100439370)]:
patch(addr, target)

必须重新签名(Mach-O 代码签名覆盖 __text,改字节后不重签名会直接闪退):

codesign --remove-signature tscan_plus.cracked
codesign -s - --force tscan_plus.cracked

3. Keygen(murmur3-32 seed 0 + nibble 折叠)​

公式在样本 C 的反汇编里被直接确认过(见 8.4),所以这条是准的 —— 但公式对的 key 仍然不能认证,因为后面还有硬件校验和记录校验。

def murmur3_32(data, seed=0):
h = seed; c1, c2 = 0xcc9e2d51, 0x1b873593
for i in range(0, len(data), 4):
k = int.from_bytes(data[i:i+4], 'little')
k = (k*c2 & 0xFFFFFFFF); k = (k<<15 | k>>17) & 0xFFFFFFFF; k = (k*c1) & 0xFFFFFFFF
h ^= k; h = (h<<13 | h>>19) & 0xFFFFFFFF; h = (h*5 + 0xe6546b64) & 0xFFFFFFFF
h ^= len(data)
h ^= h>>16; h = (h*0x85ebca6b) & 0xFFFFFFFF
h ^= h>>13; h = (h*0xc2b2ae35) & 0xFFFFFFFF
h ^= h>>16
return h

def fold(s): # murmur3 十进制结果前 8 位按 base-16 解释
return int(s[:8], 16)

def sig(f1, prefix="phase3"):
return fold(str(murmur3_32((prefix + str(f1)).encode()))) ^ len(prefix)

f1 = 12345
print(f"{f1}.12345.12345.{sig(f1)}.1") # -> 12345.12345.12345.658924676.1

4. 运行验证(样本 A)​

原始二进制:

$ ./tscan_plus -license 12345.12345.12345.658924676.1
[ERROR] 认证失败: 无效的Key (exit 1)

破解后(方法 B + 重签名):

$ ./tscan_plus.cracked -license 12345.12345.12345.658924676.1
Version: 3.5.3 Expired: 2027.06.01 认证Vip版
[INFO] 认证成功,版本类型: 认证Vip版
[INFO] Vip状态: 2027.06.01 (exit 0)

5. "把 Vip状态 认证到 2099 年" —— 样本 A 上未闭合,原因记录​

5.1 为什么不能直接改字符串​

  • 全文件在纯文本、XOR、ADD,以及 sub_102B68A44 的字节形态和 int32-rune 形态(反推 256 个种子)下都搜不到 2027.06.01;
  • Vip状态 走 conf.UNXx_ve6 → *(string*)(ZptlguuF3yD5 + 24),ZptlguuF3yD5 是 conf.Init 运行时构造的堆对象;
  • 该字符串由 conf.LdJuJbd0Sx07 把 4 段 garble 片段解码后 concatstring4 拼出;conf.init 里另一条 UoEhcDLClOvs3(base64 @0x102DF13F1, 24) 走 AES-CBC,key = IV = TIDETideTidEtide(key = GWKfJgPdH0RoV8iN 的四段 ^0xD8),当前 blob XDO+lmEHawuskT+iyi2twg== 解出 2028.01.01;把 2099.12.31 重新 PKCS7+CBC 加密得 xvUwHiQzJwM6INqkKCYNbw==(正好 24 字符,可原地覆盖)—— 但这条写的是 qword_10974DC20 那个变量,不是 Vip状态 用的那个,所以显示不变。

5.2 试过的"直路"(runtime.printstring)与失败原因​

在 runLicenseCheck 的 9 个指令槽 0x102a89420..0x102a89440 改成:

movabs x0, <rodata 空闲槽 0x102F00700>   ; 'Vip状态: 2099.12.31\n'(22B,位于 __rodata 未被引用的 61B 空隙 0x102F006F6)
mov x1, #22
bl runtime.printstring
nop; nop; nop; nop

编码已逐条用 capstone 反汇编校验(ADRP: immlo 在 bit 29–30、immhi 在 bit 5–23、Rd 在 bit 4–0;BL: 0x94000000 | (imm & 0x3FFFFFF),imm 必须按 bl 自身地址计算;MOVZ/MOVK 基值 0xD2800000 / 0xF2800000 + hw<<21)。

实测:

  1. 用 IDA 名字里的 runtime.printstring(0x1000492c0)→ fatal error: unexpected signal during runtime execution;
  2. 用运行期回溯里真正的 runtime.printstring(0x10076d2c0)→ SIGSEGV code=0x2 addr=0x102f00700。

原因:0x10076d2c0 会执行 str x1, [x0, #0x18](把类型指针写进 x0)—— 它把第一个参数当作可写的 *String 头,而 __rodata / __DATA_CONST 运行时只读 ⇒ 任何指向 rodata 的字符串都会崩。__bss(0x109744DE0 起)可写但文件里 offset 0、没有静态内容可预填 ⇒ 静态补丁给不出可写的 String 头。要成立必须让头落在运行时堆上(复刻 concatstrings 的 alloc),纯静态补丁做不到。

5.3 样本 A 上若仍要 2099,只有两条路​

  1. 数据层:完整复刻 TH_VJih0zt 的逐段种子/长度逻辑(种子表与长度来自运行时 relocation,需要动态确认),把 LdJuJbd0Sx07 的 4 段重新编码成 2099/.12/./31,原地覆盖 → Expired 和 Vip状态 会同时变。
  2. 动态确认:lldb 断在 runtime.printstring / runtime.convTstring,把运行时真正使用的 ZptlguuF3yD5+24 打出来,再反推 4 段编码。

6. 复现清单​

  1. cp /tmp/ctf/tscan_plus /tmp/ctf_crack/tscan_plus.cracked
  2. 按第 2 节写 6 个 B 指令(用 capstone 逐条校验 imm)
  3. codesign --remove-signature + codesign -s - --force
  4. keygen.py 造 key,运行 -license 验证

7. 工具与目录约定(为什么脚本和数据分开放)​

规则是:下载/解包的不可信数据各自独占一个空目录,自己写的脚本放在另一个目录(解释器会从脚本所在目录和当前目录加载代码,一个 planted json.py 会在 import json 时执行)。所以样本在 /tmp/ctf/、产物在 /tmp/ctf_crack/、脚本在 /tmp/ctf_work/,读数据的 Python 一律 python3 -I。


8. Windows 版(样本 C)—— IDA 里看不到函数/字符串的原因与破解​

8.1 为什么 IDA 的 IDB 是空的​

~/Downloads/TscanPlus_Win_Amd64/TscanPlus_Win_Amd64.exe 是 UPX 加壳的 Go PE(段名 UPX0/UPX1,UPX0 rawsize=0)。所以 IDA 里只有 start、list_strings_filter 全空 —— pclntab 和代码都还在压缩流里。

解包(标准 UPX,可直接 upx -d):

upx -d exe.orig -o exe.unpacked        # 42,532,152 -> 135,535,928,win64/pe

解包后:Go 1.25.10 / amd64,同样是 garble 混淆(bogus error 2、BogusInterface、selfcheck: hash mismatch、bogus_hardware、bogus_data 是它的自一致性校验与诱饵分支)。 go tool nm 对这个文件报 no symbols(Go 1.25 的 pclntab 布局 + PE 无 COFF 符号表,objfile 读不到),所以函数名只能靠 .pdata(PE exception table) 拿边界:每条 12 字节 {BeginRVA, EndRVA, UnwindRVA},30678 个函数,边界完整可用;再配 capstone 反汇编。

段表(解包后):

段vaddr(RVA)vsizerawrawsize
.text0x10000x27504a00x4000x2750600
.rdata0x2d2f0000x52082d00x2d2ce000x5208400
.pdata0x7f390000xe60880x7f354000xe6200
.bss0x80270000xa5c20——

imagebase 0x140000000,file offset = vaddr - 0x140000000 - 0xC00(.text)。

8.2 x64 版方法 B(长度不变,改完直接能跑,不需要重签名)​

arm64 上是 B;x64 上等价做法是 jcc rel8 → jmp rel8(0x74→0xEB,0x75→0xEB),或者把 jcc 直接改成等长 nop(0x90)。

地址原指令(字节)改成作用
0x1404cc900cmp rsp,[r14+0x10]; jbe; push rbp; mov rbp,rspxor eax,eax; xor ebx,ebx; ret; nop×4conf.B0Uu8RPrb2 恒返回 nil error(key 校验整体放行)
0x142515e1575 20 jne 0x142515e37eb 20cli.runLicenseCheck 跳过"请输入 Key / 用法"分支
0x142515e4874 31 je 0x142515e7beb 31强制走成功分支
0x142515e8374 1d je 0x142515ea290 90不再选 社区Free版
0x142515e9174 0f je 0x142515ea290 90恒选 认证Vip版
0x14049980049 3b 66 10 0f 86b8 01 00 00 00 c3(mov eax,1; ret)授权标志 getter 恒为 true(前端/功能门控共用)
0x1404cbbf78b 8c 24 ac 00 00 0089 c1 + nop×5mov ecx,[rsp+0xac]→mov ecx,eax:签名比较强制相等
0x1404caeb98b 8c 24 84 00 00 0089 c1 + nop×5同上(另一处 phase3 签名比较)
0x1404cbc0a / 0x1404caeca0f 84 …90×6field==0 → error 分支跳过

补丁脚本:/tmp/ctf_work/patch_win_final.py(逐条校验原始字节再写)。

8.3 在 Windows 机器上的实测(ssh [email protected])​

这个 exe 是 Subsystem = 2(GUI),从 SSH/cmd 跑时它的日志走 Wails 前端,stdout 只有横幅。为了能看到输出,我把同一补丁集的副本把 Subsystem 改成 3(CUI)再跑 —— 行为一致,只是输出可见。

基线(未改,伪造 key):

$ base_console.exe -license 111.222.333.444.1
Version: 3.5.3 社区Free版 (RC=0)

破解后(同一 key):

$ cracked_console_e.exe -license 111.222.333.444.1
Version: 3.5.3 Expired: 未认证 认证Vip版 (RC=0)
$ cracked_console_e.exe -license 999.111.222.333.2
Version: 3.5.3 Expired: 未认证 认证Vip版 (RC=0)

即:任意伪造 key 不再被拒绝,版本类型显示 认证Vip版,进程正常退出、不崩。

中途的一个实证细节:只把 B0Uu8RPrb2 里的 jne error 改 nop(不处理记录)会崩:

panic: runtime error: invalid memory address or nil pointer dereference
TscanGui/conf.Ju1zV6nVMwrMz0(0x0) conf/license.go:468
TscanGui/conf.B0Uu8RPrb2({...}) conf/license.go:1037
TscanGui/internal/cli.runLicenseCheck({..., 0x11}) cli/license.go:20
TscanGui/internal/cli.Dispatch(...) cli/dispatch.go:32
main.main() main.go:35

这条栈正好确认了函数映射:0x1404cc900 = conf.B0Uu8RPrb2、0x1404c8300 = conf.Ju1zV6nVMwrMz0、0x142515de0 = cli.runLicenseCheck、0x1425149a0 = cli.Dispatch —— 和 arm64 IDB 里的名字是同一套 garble 名(O7rc6qW0L、Tmfc2rYXRKJT 也在文件里出现)✓。

8.4 回答"web 里输入伪造 key 提示无效 key"​

  1. 手敲的 key 本来就该失败:f4 必须等于 fold(first8(murmur3_32_seed0("phase3"+f1))) ^ 6。这条在 Windows 版反汇编里被直接确认了 —— 0x1404c7220 的尾巴是 mov rax,[rsp+0x60](= 前缀长度 6)xor eax, edx(edx = 前 8 个字符按 nibble 折叠)✓,和 keygen.py 的公式一致。f2 ≤ 30000(cmp …, 30000 在 0x1404cda62、0x1404d2c88)、f3 < 1797379200、f5 ∈ 0..5、正则 ^\d+\.\d+\.\d+(\.\d+)?$。用 keygen.py 出来的 key,不要自己编数字。
  2. web 路径和 CLI 路径不同:前端 JS 调的是 GetAppLic / CheckAppLic(文件里能看到这段 JS 桥接桩),Go 侧第一步是读取已存 license 记录(License.lic + sqlite),记录不存在就直接返回错误 —— 这就是"无效 key"。所以只改 runLicenseCheck 的分支不够,要改的是共用的 conf.B0Uu8RPrb2 / 标志 getter(8.2 里那两条已经把这条路打通:校验恒返回 nil、flag 恒 true)。
  3. 文件里搜不到"无效的Key"字样 ✓(两个版本都是运行时解码),这本身就是"提示来自另一条解码路径"的证据。

8.5 2099 在 Windows 版上是可以做到的(已实测)​

第 5 节里"改不了 2099"的结论只对 arm64 那份成立(字符串在只读 __rodata、且 __bss 没有文件内容可预填)。Windows 版不同:

  • 它的字符串是 movabs r64, imm64 里塞两个 rune(例:0x6548000065e0 = 无,效 ✓),运行时走 utf8.EncodeRune(0x140076a40)拼串 —— 立即数在 .text 里,可改 ✓;

  • 显示用的 (ptr,len) 是 conf 初始化里写进堆对象的:

    0x1404f1bfb  48 c7 40 20 09 00 00 00   mov qword [rax+0x20], 9      ; len('未认证')
    0x1404f1c03 48 8d 0d 3f be 07 03 lea rcx, [rip+0x307be3f] ; -> .rdata 0x14356da49 = '未认证'

    长度和指针都是可改的立即数 ✓,而 .rdata 里有 19524 段未被任何 lea 引用的空闲区 ✓(取 file 0x2ee7448,24 字节)。

所以把这条 (ptr,len) 重定向到写好的 2099.12.31:

SLOT = 0x2ee7448                      # .rdata 内未被引用的空闲区(vaddr 0x142ee9648)
d[SLOT:SLOT+10] = b'2099.12.31'
d[vaddr2file(0x1404f1bfb)+4] = 10 # len 9 -> 10(等长)
d[vaddr2file(0x1404f1c03)+3:+7] = struct.pack('<i', 0x142ee9648 - (0x1404f1c03+7))

在你机器上跑(同一个文件把 Subsystem 改成 3 以便看到输出,其余字节完全一致):

$ cracked_console.exe -license 999.111.222.333.2
Version: 3.5.3 Expired: 2099.12.31 认证Vip版 RC=0

$ cracked_console_2099.exe -license 111.222.333.444.1
Version: 3.5.3 Expired: 2099.12.31 认证Vip版 RC=0

对照未改:Version: 3.5.3 社区Free版 ✓。

最终产物:/tmp/ctf_crack_win/TscanPlus_Win_Amd64.cracked.exe(分支补丁 + 字符串重定向,Subsystem 保持 2),cracked_console.exe 是同一份改 Subsystem 用于看输出,脚本 /tmp/ctf_work/patch_win_final.py。

顺带说明为什么 IDA 帮不上:MCP 连接的 IDB 是加壳那份,list_functions 只有 start、list_strings_filter 全空 —— 这是 UPX 的结果,不是文件没有符号。解包后 go tool nm 也报 no symbols(Go 1.25 的 pclntab 布局 + PE 无 COFF 符号表),所以函数边界用 .pdata(30678 条 RUNTIME_FUNCTION)拿,反汇编用 capstone。如果你把 /tmp/ctf_crack_win/exe.unpacked 单独开一个 IDA 实例,上面这些地址会直接显示成函数,名称能靠 cli.Dispatch 那条 panic 栈对齐(conf.B0Uu8RPrb2、conf.Ju1zV6nVMwrMz0、cli.runLicenseCheck ✓)。


9. 跨构建补丁点对照​

同一逻辑、四份地址。要点是函数角色不变、地址随构建漂移,所以拿到新样本要先用 .pdata(或 __text 反汇编)重新定位这四类点:① runLicenseCheck 的四个分支、② key 校验入口、③ 授权标志 getter、④ 签名比较 + field==0 → error。

角色A arm64 3.5.3B x86_64 Mach-O 3.5.4C Windows PE 3.5.3D Windows PE("Arm64",实际 x86-64)3.5.4
runLicenseCheck 入口0x102a893300x10326b5800x142515de00x142557760
跳过 usage/exit0x102a89394 CBZ→B0x10326b5b5 75 20→eb 200x142515e15 75 20→eb 200x142557795 75 20→eb 20
强制成功分支0x102a893d4 CBZ→B0x10326b5e8 74 31→eb 310x142515e48 74 31→eb 310x1425577c8 74 31→eb 31
不选 社区Free版0x102a893e0 TBZ→B0x10326b623 74 1d→90 900x142515e83 74 1d→90 900x142557803 74 1d→90 90
恒选 认证Vip版—0x10326b631 74 0f→90 900x142515e91 74 0f→90 900x142557811 74 0f→90 90
key 校验入口(恒 nil)0x10047c3800x10050e5800x1404cc9000x1404ceda0
授权标志 getter(恒 true)0x100452d900x1004daec00x1404998000x14049b840
签名比较强制相等0x1004aba58/0x1004ad1980x10050cb59/0x10050d8770x1404cbbf7/0x1404caeb9同 C 的角色(B/D 表内已含)
显示串 (ptr,len)✗ 运行时解码✗ 未认证 无代码引用0x1404f1bfb(len) / 0x1404f1c03(lea)0x1404f439b(len) / 0x1404f43a3(lea)
空闲槽(写 2099.12.31)——file 0x2ee7448 (vaddr 0x142ee9648)file 0x2d72640 (vaddr 0x142d74040)

样本 D 的脚本:/tmp/ctf_work/patch_win_x64b.py(跑完复现 C 那份已验证文件的 md5 893e84d6f2bc144a6243649eb1ac5b35 ✓,说明这套模式在两个 Windows 构建上可平移)。样本 B 的脚本:/tmp/ctf_work/patch_amd64.py,输出 md5 46dbdf1bc3efd516d0b5005734f8f1ba。

样本 B 实测(重签名后运行):

$ ./TscanPlus.amd64.cracked.app/Contents/MacOS/TscanPlus -license 555.666.777.888.3
Version: 3.5.4 Expired: 2027.06.01 认证Vip版
[+][+ ] 认证成功,版本类型: 认证Vip版
[+][+ ] Vip状态: 2027.06.01

样本 D 实测:

$ new_console.exe -license 321.321.321.321.1
Version: 3.5.4 Expired: 2099.12.31 认证Vip版 RC=0

10. 踩坑记录(这些坑我都实际踩过)​

  • Mach-O 改 __text 必须重签名,PE 不需要(PE 无 ad-hoc 签名覆盖代码段)。
  • otool -l 的 offset 是十进制,不是十六进制 —— 我按 hex 解析过一次,段表映射全错。Mach-O header 的 ncmds 在偏移 16(我一开始读的是 32)。
  • ADRP 位域:(w>>26)&0x3F == 0b100001,不是 (w>>24)&0x1F;写错 → 扫描 0 命中。arm64 的 B 编码器不能用在 x86-64 Mach-O 上。
  • capstone 取 lea reg,[rip+disp] 的目标:操作数索引是 operands[1],类型 3(MEM),不是 2;用 i.insn_name/i.op_str,没有 i.op 属性。
  • 只 nop jne error 分支会崩(nil 记录指针被解引用)—— 正确做法是让校验函数直接返回 nil,不走进后续 deref。
  • Windows 二进制是 GUI subsystem,SSH 里看不到输出;要验证就复制一份把 Subsystem 改成 3(只改 OptionalHeader,其余字节一致)。
  • UPX 加壳时 IDA 是空的:先 upx -d;Go 1.25 的 PE 没有 COFF 符号表,go tool nm 报 no symbols,用 .pdata 拿函数边界。
  • garble 字符串有两种存储形态:① .rdata/__TEXT.__rodata 里连续拼接(无 NUL 分隔,长度只能靠配对的 mov reg, imm);② movabs r64, imm64 装两个 int32 rune,运行时 utf8.EncodeRune 解码。形态 ② 的立即数可改。
  • 程序每次运行都会重写 License.lic 尾部(~/.config/TscanPlus/config/License.lic,608 hex 字符 = 304 字节)—— 动手前留备份。

11. 边界(哪些事做不到)​

  1. 激活码不能离线伪造。校验函数 0x1404d9c40 走 ECDSA:要求 65 字节未压缩公钥(公钥长度错误:应为65字节),并有 公钥数据格式错误、公钥数据长度不足、解密公钥失败: %v、ECDSA公钥未初始化,请先运行 obfuscate_license.py 脚本 —— 二进制里只有公钥,没有私钥。记录字段:sign、version、app_ver、poc_kind、svip。
  2. 一个 key 不能多机器用。硬件绑定是实测结论:同一条记录在 Mac 上接受,在 Windows 上 license硬件校验失败;Windows 读注册表 SOFTWARE\Microsoft\Cryptography(MachineGuid,0x140f90d00,另有 HostID incorrect: %q)和 wmic.exe。
  3. 过期后能否下载 PoC:更新走服务端 —— gettoken.json?app_key=%s、CheckTpUpdate Error: %v、服务端未返回下载凭证、pocs.zip、下载%s失败: %w / HTTP %d (%s)。需要服务端下发的下载凭证,不是本地能造的。顺带一条纠错:PoC 更新路径的字符串里没有 文件不存在,那条报错来自 MCP/workspace 的文件工具,不是这个程序。
  4. 2099 是显示层,不是把授权真买到 2099:Expired: 那行是 (ptr,len) 重定向的结果;f3 < 1797379200(2026-12-16)这条硬限制仍在代码里,过期判断函数 0x1404996e0 里的 add rsi, 0x15180(86400 s = 1 天宽限)也只是宽限。
  5. 所以这套东西的准确定性是:补丁绕过 + 显示改写。想要"ECDSA 校验恒为真"的补丁(把 verify 函数改成直接返回 true)也是可以的,但那同样是绕过,不是伪造 —— 而且它改不了硬件绑定那一层。