TscanPlus 授权逻辑分析与二进制补丁破解
| 作者 | 修订时间 |
|---|---|
| 2026-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_plus | Mach-O arm64 | 3.5.3 | ✓ | ✗(运行时解码) | ✓ |
| B | TscanPlus_Mac_Amd64.dmg 内 Contents/MacOS/TscanPlus | Mach-O x86_64 | 3.5.4 | ✓ | ✗(未认证 无代码引用) | ✓(Rosetta) |
| C | ~/Downloads/TscanPlus_Win_Amd64.exe | PE x86-64(UPX) | 3.5.3 | ✓ | ✓ | ✓(Windows 实测) |
| D | ~/Downloads/TscanPlus_Win_Arm64.zip 内 exe | PE 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.runLicenseCheck | 0x102a89330 |
| Key 解析 / 字段校验 | conf.B0Uu8RPrb2 | 0x10047ad30 |
| Key 格式校验 | conf.Tmfc2rYXRKJT | 0x100476d10 |
签名校验 B7CIv4PLX("phase3", f1) == f4 | conf.O7rc6qW0L | 0x10047a2c0 |
时间字段校验 f3 < 1797379200 | conf.PKVcmcz2RL7Dh | 0x10047fc10 |
| murmur3-32(seed 0) + nibble 折叠 | conf.B7CIv4PLX / conf.XSMB4O0fIIE2W | 0x100476a70 / 0x100470c90 |
| 硬件指纹(sysctl CPU 品牌 + 物理网卡 MAC) | conf.SOPBYsgV / conf.Z5NrYd0V4gEq | 0x1004809c0 / 0x100480a70 |
| 嵌入常量自一致性校验 | conf.CSfeFGrv / conf.RVNs3NL5L | 0x1004ab7f0 / 0x1004ad068 |
| 过期判断(永不过期补丁点) | conf.Qwprp5uSfv32rKY | 0x100439230 |
| 结果标志 | conf.XrgIp0N_yO | 0x100451b70 |
| 显示字符串来源(运行时解码,不可静态改) | conf.UNXx_ve6 | 0x10047f900 |
Key 格式 f1.f2.f3.f4.f5,正则 ^\d+\.\d+\.\d+(\.\d+)?$:
f2 ≤ 30000f3 < 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 |
|---|---|---|---|---|
0x102a89394 | CBZ → 0x102a893cc | B | 跳过"无效的Key"错误分支 | 0x1400000e |
0x102a893d4 | CBZ → 0x102a893d8 | B | 强制走 VIP 分支 | 0x14000001 |
0x102a893e0 | TBZ → 0x102a893f4 | B | XrgIp0N_yO 结果被忽略 | 0x14000001 |
0x1004aba58 | CBZ → 0x1004abaf8 | B | CSfeFGrv 折叠校验跳过 | 0x14000028 |
0x1004ad198 | CBZ → 0x1004ad238 | B | RVNs3NL5L IV 折叠校验跳过 | 0x14000028 |
0x10043932c | TBNZ → 0x100439370 | B | 永不过期 | 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),当前 blobXDO+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)。
实测:
- 用 IDA 名字里的
runtime.printstring(0x1000492c0)→fatal error: unexpected signal during runtime execution; - 用运行期回溯里真正的
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,只有两条路
- 数据层:完整复刻
TH_VJih0zt的逐段种子/长度逻辑(种子表与长度来自运行时 relocation,需要动态确认),把LdJuJbd0Sx07的 4 段重新编码成2099/.12/./31,原地覆盖 →Expired和Vip状态会同时变。 - 动态确认:
lldb断在runtime.printstring/runtime.convTstring,把运行时真正使用的ZptlguuF3yD5+24打出来,再反推 4 段编码。
6. 复现清单
cp /tmp/ctf/tscan_plus /tmp/ctf_crack/tscan_plus.cracked- 按第 2 节写 6 个
B指令(用 capstone 逐条校验 imm) codesign --remove-signature+codesign -s - --forcekeygen.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) | vsize | raw | rawsize |
|---|---|---|---|---|
.text | 0x1000 | 0x27504a0 | 0x400 | 0x2750600 |
.rdata | 0x2d2f000 | 0x52082d0 | 0x2d2ce00 | 0x5208400 |
.pdata | 0x7f39000 | 0xe6088 | 0x7f35400 | 0xe6200 |
.bss | 0x8027000 | 0xa5c20 | — | — |
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)。
| 地址 | 原指令(字节) | 改成 | 作用 |
|---|---|---|---|
0x1404cc900 | cmp rsp,[r14+0x10]; jbe; push rbp; mov rbp,rsp | xor eax,eax; xor ebx,ebx; ret; nop×4 | conf.B0Uu8RPrb2 恒返回 nil error(key 校验整体放行) |
0x142515e15 | 75 20 jne 0x142515e37 | eb 20 | cli.runLicenseCheck 跳过"请输入 Key / 用法"分支 |
0x142515e48 | 74 31 je 0x142515e7b | eb 31 | 强制走成功分支 |
0x142515e83 | 74 1d je 0x142515ea2 | 90 90 | 不再选 社区Free版 |
0x142515e91 | 74 0f je 0x142515ea2 | 90 90 | 恒选 认证Vip版 |
0x140499800 | 49 3b 66 10 0f 86 | b8 01 00 00 00 c3(mov eax,1; ret) | 授权标志 getter 恒为 true(前端/功能门控共用) |
0x1404cbbf7 | 8b 8c 24 ac 00 00 00 | 89 c1 + nop×5 | mov ecx,[rsp+0xac]→mov ecx,eax:签名比较强制相等 |
0x1404caeb9 | 8b 8c 24 84 00 00 00 | 89 c1 + nop×5 | 同上(另一处 phase3 签名比较) |
0x1404cbc0a / 0x1404caeca | 0f 84 … | 90×6 | field==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"
- 手敲的 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,不要自己编数字。 - web 路径和 CLI 路径不同:前端 JS 调的是
GetAppLic/CheckAppLic(文件里能看到这段 JS 桥接桩),Go 侧第一步是读取已存 license 记录(License.lic+ sqlite),记录不存在就直接返回错误 —— 这就是"无效 key"。所以只改runLicenseCheck的分支不够,要改的是共用的conf.B0Uu8RPrb2/ 标志 getter(8.2 里那两条已经把这条路打通:校验恒返回 nil、flag 恒 true)。 - 文件里搜不到"无效的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.3 | B x86_64 Mach-O 3.5.4 | C Windows PE 3.5.3 | D Windows PE("Arm64",实际 x86-64)3.5.4 |
|---|---|---|---|---|
runLicenseCheck 入口 | 0x102a89330 | 0x10326b580 | 0x142515de0 | 0x142557760 |
| 跳过 usage/exit | 0x102a89394 CBZ→B | 0x10326b5b5 75 20→eb 20 | 0x142515e15 75 20→eb 20 | 0x142557795 75 20→eb 20 |
| 强制成功分支 | 0x102a893d4 CBZ→B | 0x10326b5e8 74 31→eb 31 | 0x142515e48 74 31→eb 31 | 0x1425577c8 74 31→eb 31 |
不选 社区Free版 | 0x102a893e0 TBZ→B | 0x10326b623 74 1d→90 90 | 0x142515e83 74 1d→90 90 | 0x142557803 74 1d→90 90 |
恒选 认证Vip版 | — | 0x10326b631 74 0f→90 90 | 0x142515e91 74 0f→90 90 | 0x142557811 74 0f→90 90 |
| key 校验入口(恒 nil) | 0x10047c380 | 0x10050e580 | 0x1404cc900 | 0x1404ceda0 |
| 授权标志 getter(恒 true) | 0x100452d90 | 0x1004daec0 | 0x140499800 | 0x14049b840 |
| 签名比较强制相等 | 0x1004aba58/0x1004ad198 | 0x10050cb59/0x10050d877 | 0x1404cbbf7/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. 边界(哪些事做不到)
- 激活码不能离线伪造。校验函数
0x1404d9c40走 ECDSA:要求 65 字节未压缩公钥(公钥长度错误:应为65字节),并有公钥数据格式错误、公钥数据长度不足、解密公钥失败: %v、ECDSA公钥未初始化,请先运行 obfuscate_license.py 脚本—— 二进制里只有公钥,没有私钥。记录字段:sign、version、app_ver、poc_kind、svip。 - 一个 key 不能多机器用。硬件绑定是实测结论:同一条记录在 Mac 上接受,在 Windows 上
license硬件校验失败;Windows 读注册表SOFTWARE\Microsoft\Cryptography(MachineGuid,0x140f90d00,另有HostID incorrect: %q)和wmic.exe。 - 过期后能否下载 PoC:更新走服务端 ——
gettoken.json?app_key=%s、CheckTpUpdate Error: %v、服务端未返回下载凭证、pocs.zip、下载%s失败: %w/HTTP %d (%s)。需要服务端下发的下载凭证,不是本地能造的。顺带一条纠错:PoC 更新路径的字符串里没有文件不存在,那条报错来自 MCP/workspace 的文件工具,不是这个程序。 - 2099 是显示层,不是把授权真买到 2099:
Expired:那行是(ptr,len)重定向的结果;f3 < 1797379200(2026-12-16)这条硬限制仍在代码里,过期判断函数0x1404996e0里的add rsi, 0x15180(86400 s = 1 天宽限)也只是宽限。 - 所以这套东西的准确定性是:补丁绕过 + 显示改写。想要"ECDSA 校验恒为真"的补丁(把 verify 函数改成直接返回 true)也是可以的,但那同样是绕过,不是伪造 —— 而且它改不了硬件绑定那一层。