Challenge
Yet another unique key mission ;) (hard). Generate a license file for
your username — the last application mission.
为用户名生成一个 license 文件。程序启动时从 exe 同目录读取
.lic 文件做校验,校验失败就弹
Your trial has expired!。题目要求逆向出 license
文件格式与验证算法,为任意用户名生成有效的 license 文件。这是
Application missions 的最后一题。
压缩包里只有 app18win.exe(119808
字节,2009-12-08),没有源码、没有符号。它是个 MSVC 9 编译的
GUI 程序(PE subsystem 2),所以像 App17 那样用 pty
驱动二进制看它打印 Congratulations
的办法在这里不成立:校验结果只体现在弹不弹 MessageBox
上。下面的做法是先把校验函数从指令级读出来,再给副本打一个单字节补丁把接受/拒绝变成可观测的进程行为,用真实二进制做动态确认。
Solution
file 报
PE32 executable for MS Windows 5.00 (GUI), Intel i386, 5 sections;节表
.text RVA 0x1000、.rdata
0x3000、.data 0x5000,镜像基址
0x400000。
strings
里能直接看到题目相关的全部字符串,说明校验和字符串都留在明处:TextPad v1.8、Yet another unique key mission ;)、HTS_NQ、LIC、1.8、HTS、lic、exe、rb、Your trial has expired!\nPlease purchase a license to continue using this product、Trial has expired.。TextPad v1.8
是被克隆的 shareware 原来留下的,和题目无关。
app18win.exe 的 manifest 指向
Microsoft.VC90.CRT 9.0.21022.8,wine
下能正常加载运行。
Step 1: 定位校验入口
WinMain 注册窗口类后先调
sub_401DFA,在它内部(0x401E73)调用真正的校验函数
sub_401510(),紧接着检查一个字节全局变量:
1 2 3 4 5 6 7 8 00401E73 call 0x401510 ; 读 license 文件并校验 00401E78 cmp BYTE PTR ds:0x405038,bl ; flag_trial_expired 00401E7E jne 0x401E95 ; 非 0 -> 建窗口 + 消息循环 00401E80 push 0x10 ; MB_ICONERROR 00401E82 push 0x40340C ; "Trial has expired." 00401E87 push 0x4033B8 ; "Your trial has expired!" 00401E8C push ebx 00401E8D call DWORD PTR ds:[0x4031c0] ; MessageBoxA -> 随后 return 0
0x405038 在 .data 里初值为
0,只有走到校验函数的成功出口(0x4018CD: mov BYTE PTR ds:0x405038,1)才会被置
1。也就是说 sub_401510 就是唯一的判定点。
Step 2: license 文件格式
sub_401510
前半段的读文件逻辑(objdump -d -Mintel,地址与文件同基址)逐条读下来是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 0040154B call DWORD PTR ds:[0x40304c] ; GetModuleFileNameA -> 自己的完整路径 004015A3 call DWORD PTR ds:[0x4030d8] ; strstr(path, "exe") 004015B1 call DWORD PTR ds:[0x4030d4] ; strncpy(pos, "lic", 3) -> 扩展名换成 lic 004015C0 call DWORD PTR ds:[0x4030f4] ; fopen(path, "rb") 004015DA call DWORD PTR ds:[0x403114] ; fread(buf, 1, 0x20, fp) 004015E3 cmp eax,0x20 ; 必须读满 0x20 字节 004015EC cmp BYTE PTR [esi],0x0 ; buf[0x00] != 0 004015F5 cmp BYTE PTR [esi+0x4],0x0 ; buf[0x04] != 0 00401602 cmp BYTE PTR [edi],0x0 ; buf[0x08] != 0 00401612 cmp eax,0x9 ; strlen(buf+0x08) == 9 (edi = esi+8) 0040161E cmp BYTE PTR [eax],0x0 ; buf[0x12] != 0 0040162E cmp eax,0x9 ; strlen(buf+0x12) == 9 0040163A cmp BYTE PTR [ebx],0x0 ; buf[0x1C] != 0 00401649 call DWORD PTR ds:[strcmp] ; strcmp(buf+0x00, "LIC") == 0 00401661 call DWORD PTR ds:[strcmp] ; strcmp(buf+0x04, "1.8") == 0 00401676 call DWORD PTR ds:[strcmp] ; strcmp(buf+0x1C, "HTS") == 0
两个 9 字节十进制字段用 strtoul(..., base 10)
转成整数,然后各自被夹在合法区间里(0x401694 起):
1 2 3 4 00401694 cmp eax,0x15 ; val_A(=用户名长度) > 0x15 就原样用 0040169F cmp DWORD PTR [ebp-0x54],eax; 否则 val_A < 4 -> val_A = 4 (0x40169C: push 4) 004016B4 cmp eax,0x14 ; val_B(=密码长度) < 0x14 就原样用 004016C1 cmp DWORD PTR [ebp-0x58],eax; > 0x800 -> 0x800
随后按这两个长度分别 malloc(val_A+1) /
malloc(val_B+1)、memset 0、fread
精确读 val_A+1 / val_B+1 字节,并要求
mem1[0]!=0、mem2[0]!=0、strlen(mem1)==val_A、strlen(mem2)==val_B(0x40172F–0x401763)。最后再
fread 固定 0x1B 字节到
mem3,逐字节检查前 10 个是 0、mem3+0x0A 处
strcmp(..., "HTS_NQ")==0、随后的 10
个字节(mem3+0x11 起)也是 0:
1 2 3 4 00401784 cmp BYTE PTR [ebx+eax],0x0 ; mem3[0..9] == 0 0040178E cmp BYTE PTR [eax+esi+0x39],0x0 ; mem3[0x11..0x1A] == 0 0040179A cmp eax,0xA ; 循环 10 次 004017A8 call DWORD PTR ds:[strcmp] ; strcmp(mem3+0x0A, "HTS_NQ")
([eax+esi+0x39] 里的 esi 是
mem3-0x28,所以真实下标是
eax+0x11。)整理出的布局:
1 2 3 4 5 6 7 8 9 10 11 偏移 长度 内容 0x00 4 "LIC" + NUL 0x04 4 "1.8" + NUL 0x08 9 strlen(username) 的 9 位十进制,右补空格无关,必须正好 9 字节 0x11 1 NUL 0x12 9 strlen(password) 的 9 位十进制 0x1B 1 NUL 0x1C 4 "HTS" + NUL 0x20 len(user)+1 username 原始字节 + NUL 0x20+len(user)+1 len(pw)+1 password 原始字节(大写 hex ASCII)+ NUL 0x1B 10 * 0x00 + "HTS_NQ" + NUL + 10 * 0x00
若把 password 段起点记作
keyfile+0x21+strlen(username)、HTS_NQ 起点记作
keyfile+0x2C+strlen(username)+strlen(password),看似和
0x20、0x1B
重叠,实际不重叠:0x21+len = 0x20+len+1 正是 password
的起点,0x2C+len_u+len_pw = 0x20+(len_u+1)+(len_pw+1)+10
正是 HTS_NQ 的起点。文件总长
0x20 + (len_u+1) + (len_pw+1) + 0x1B。
Step 3: password 生成算法
文件读完后是主体计算(0x4017C6–0x4018CB)。外层计数器
i 运行 0..0x31(50 轮),内层 j 遍历 username
的每个字节,edi 就是当前 32 位 chunk:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 004017C9 xor edi,edi ; chunk = 0,整个双层循环只在这里清一次 004017D1 mov DWORD PTR [ebp-0x54],edi ; i = 0 ; ---- 外层:i 循环开始 ---- 004017D4 push DWORD PTR [esi+0x20] ; mem1 = username 004017D7 xor ebx,ebx ; j = 0 004017D9 call strlen 004017E3 movsx ecx,BYTE PTR [esi+0x1C] ; 'H' 004017E7 movsx eax,BYTE PTR [esi+0x1D] ; 'T' 004017EB add eax,ecx ; H+T 004017ED movsx ecx,BYTE PTR [esi+0x1E] ; 'S' 004017F1 add eax,ecx ; H+T+S = 0xEF 004017F3 sub edi,eax ; chunk -= 0xEF 004017F9 call DWORD PTR ds:[atoi] ; atoi(mem1+4) == atoi("1.8") == 1 004017FF mov ecx,DWORD PTR [ebp-0x54] ; CL = i 00401805 add edi,eax ; chunk += 1 0040180A movsx eax,BYTE PTR [eax+ebx] ; username[j],movsx -> signed char 0040180E or edi,eax ; chunk |= username[j] 00401810 mov eax,edi 00401812 shr eax,cl ; chunk >> i 00401814 or eax,ebx ; | j 00401816 xor edi,eax ; chunk ^= (chunk>>i)|j 00401818 imul edi,edi ; chunk *= chunk(只留低 32 位) 0040181B inc ebx 00401823 cmp ebx,eax 00401825 jb 0x4017E3 ; ---- 外层循环尾 ---- 00401829 cmp DWORD PTR [ebp-0x54],ebx ; if (i <= 0) 跳过输出 0040183C call DWORD PTR ds:[_itoa] ; _itoa(chunk, buf, 16) 0040186C cmp BYTE PTR [ebp+ebx+0xB8],0x30 ; 本轮 hex 以 '0' 开头(即 chunk==0)-> 跳过本轮 00401885 call DWORD PTR ds:[toupper] ; 逐字符 toupper 00401896 cmp cl,al ; 与 password[cursor++] 比对 00401898 jne 0x401900 ; 不匹配 -> 直接失败 004018B2 inc DWORD PTR [ebp-0x54] ; i++ 004018B5 cmp DWORD PTR [ebp-0x54],0x32 004018B9 jl 0x4017D4 ; ---- 全部轮次结束后 ---- 004018C2 call strlen ; strlen(mem2) 004018C8 cmp DWORD PTR [ebp-0x60],eax ; 被吃掉的字符数 == 密码长度? 004018CB jne 0x401900 ; 不等 -> 失败 004018CD mov BYTE PTR ds:0x405038,1 ; flag_trial_expired = 1
用 C 写出来就是:
1 2 3 4 5 6 7 8 9 chunk = 0 for i in 0..49: for j in 0..len(username)-1: chunk = (chunk - ('H'+'T'+'S') + atoi("1.8")) | (signed char)username[j] chunk ^= (chunk >> i) | j chunk *= chunk # 32 位截断 if i > 0 and chunk != 0: 追加 itoa(chunk, 16) 到期望的 password # 最后还要求"期望 password 的字符数 == 文件里密码字段的长度"
三个容易看漏的点:
chunk 在整个 50
轮之间连续累积 ,每轮结束不重置;只有进循环前清过一次零。
版本串 "1.8"
不只是格式要求,atoi("1.8") == 1 还会作为 +1
参与 chunk 运算,而且真值用的是文件里的 mem1+4。
_itoa(chunk, buf, 16) 对 radix 16
输出的是无符号 hex、不带负号 (radix != 10
时不做符号处理)。chunk 高位在 50 轮里经常会被置 1(实测
demo 第 2 轮 chunk =
-1267953664),所以这一条是决定性的:按 signed 加
- 前缀写出来的密码会被拒绝,见 Step 5 的动态验证。
Step 4: Generator
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 """HackThisSite Application Challenge 18 ("Yet another unique key mission ;)") license generator. Reconstructed from sub_401510() of app18win.exe (objdump -d -Mintel, image base 0x00400000). The verification routine is a straight port of the instructions at 0x004017D4-0x004018CD. """ import structimport sysMASK = 0xFFFFFFFF def s32 (x ): x &= MASK return x - (1 << 32 ) if x & 0x80000000 else x def chunks (username ): """Yield the 50 per-round 32-bit values the binary computes (i = 0..49).""" b = username.encode('latin-1' ) chunk = 0 for i in range (0x32 ): for j, ub in enumerate (b): u = ub - 256 if ub >= 128 else ub x = s32(chunk - 0xEF + 1 ) x = s32(x | u) chunk = s32(x ^ (s32((x & MASK) >> (i & 31 )) | j)) chunk = s32(chunk * chunk) yield chunk def gen_password (username, signed_itoa=False ): out = [] for i, chunk in enumerate (chunks(username)): if i == 0 or chunk == 0 : continue if signed_itoa and chunk < 0 : out.append('-' + ('%X' % (-chunk))) else : out.append('%X' % (chunk & MASK)) return '' .join(out) def build_license (username, signed_itoa=False ): if len (username) < 4 : raise ValueError('username must be >= 4 chars (the parser clamps to 4)' ) password = gen_password(username, signed_itoa) if len (password) > 0x800 : raise ValueError('password longer than the 0x800 clamp' ) header = (b'LIC\x00' + b'1.8\x00' + b'%09d' % len (username) + b'\x00' + b'%09d' % len (password) + b'\x00' + b'HTS\x00' ) assert len (header) == 0x20 tail = b'\x00' * 10 + b'HTS_NQ\x00' + b'\x00' * 10 assert len (tail) == 0x1B return header + username.encode('latin-1' ) + b'\x00' + password.encode('ascii' ) + b'\x00' + tail def hexdump (data ): for off in range (0 , len (data), 16 ): row = data[off:off + 16 ] hexs = ' ' .join('%02X' % c for c in row) text = '' .join(chr (c) if 32 <= c < 127 else '.' for c in row) print ('%08x %-47s |%s|' % (off, hexs, text)) if __name__ == '__main__' : for name in (sys.argv[1 :] or ['demo' ]): lic = build_license(name) with open ('app18win.lic' , 'wb' ) as fh: fh.write(lic) print ('username: %s' % name) print ('password (%d chars): %s' % (len (gen_password(name)), gen_password(name))) print ('license file: %d bytes' % len (lic)) hexdump(lic)
运行(demo 是占位用户名):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 $ python3 gen_app18.py demo username: demo password (383 chars): B46C90005ACC2790FC57F100B1493240FC61F71016D60900ED4B2D10CAC704007B6CD040F2B04000CFB19000B203F210B3DCD090B20B804010E40000EA8EA900B1A40C1083D93240C263190011170400E562400C3EDF19090660400A12D804079E1C4002367A400592C0090BE74384011848A406E597C10D175B590B46C90005ACC2790FC57F100B1493240FC61F71016D60900ED4B2D10CAC704007B6CD040F2B04000CFB19000B203F210B3DCD090B20B804010E40000EA8EA900B1A40C10 license file: 448 bytes 00000000 4C 49 43 00 31 2E 38 00 30 30 30 30 30 30 30 30 |LIC.1.8.00000000| 00000010 34 00 30 30 30 30 30 30 33 38 33 00 48 54 53 00 |4.000000383.HTS.| 00000020 64 65 6D 6F 00 42 34 36 43 39 30 30 30 35 41 43 |demo.B46C90005AC| 00000030 43 32 37 39 30 46 43 35 37 46 31 30 30 42 31 34 |C2790FC57F100B14| 00000040 39 33 32 34 30 46 43 36 31 46 37 31 30 31 36 44 |93240FC61F71016D| 00000050 36 30 39 30 30 45 44 34 42 32 44 31 30 43 41 43 |60900ED4B2D10CAC| 00000060 37 30 34 30 30 37 42 36 43 44 30 34 30 46 32 42 |704007B6CD040F2B| 00000070 30 34 30 30 30 43 46 42 31 39 30 30 30 42 32 30 |04000CFB19000B20| 00000080 33 46 32 31 30 42 33 44 43 44 30 39 30 42 32 30 |3F210B3DCD090B20| 00000090 42 38 30 34 30 31 30 45 34 30 30 30 30 45 41 38 |B804010E40000EA8| 000000a0 45 41 39 30 30 42 31 41 34 30 43 31 30 38 33 44 |EA900B1A40C1083D| 000000b0 39 33 32 34 30 43 32 36 33 31 39 30 30 31 31 31 |93240C2631900111| 000000c0 37 30 34 30 30 45 35 36 32 34 30 30 43 33 45 44 |70400E562400C3ED| 000000d0 46 31 39 30 39 30 36 36 30 34 30 30 41 31 32 44 |F19090660400A12D| 000000e0 38 30 34 30 37 39 45 31 43 34 30 30 32 33 36 37 |804079E1C4002367| 000000f0 41 34 30 30 35 39 32 43 30 30 39 30 42 45 37 34 |A400592C0090BE74| 00000100 33 38 34 30 31 31 38 34 38 41 34 30 36 45 35 39 |384011848A406E59| 00000110 37 43 31 30 44 31 37 35 42 35 39 30 42 34 36 43 |7C10D175B590B46C| 00000120 39 30 30 30 35 41 43 43 32 37 39 30 46 43 35 37 |90005ACC2790FC57| 00000130 46 31 30 30 42 31 34 39 33 32 34 30 46 43 36 31 |F100B1493240FC61| 00000140 46 37 31 30 31 36 44 36 30 39 30 30 45 44 34 42 |F71016D60900ED4B| 00000150 32 44 31 30 43 41 43 37 30 34 30 30 37 42 36 43 |2D10CAC704007B6C| 00000160 44 30 34 30 46 32 42 30 34 30 30 30 43 46 42 31 |D040F2B04000CFB1| 00000170 39 30 30 30 42 32 30 33 46 32 31 30 42 33 44 43 |9000B203F210B3DC| 00000180 44 30 39 30 42 32 30 42 38 30 34 30 31 30 45 34 |D090B20B804010E4| 00000190 30 30 30 30 45 41 38 45 41 39 30 30 42 31 41 34 |0000EA8EA900B1A4| 000001a0 30 43 31 30 00 00 00 00 00 00 00 00 00 00 00 48 |0C10...........H| 000001b0 54 53 5F 4E 51 00 00 00 00 00 00 00 00 00 00 00 |TS_NQ...........|
0x1C0 = 448 字节,HTS_NQ 落在文件偏移
0x1AF,与布局完全对得上。
Step 5: 真实二进制动态验证
直接运行原版 exe 看不到结论:license 有效时它一路走到
CreateWindowExA + GetMessageA 卡住,无效时卡在
MessageBoxA 上,无 X 显示时两者都只是挂起,stderr 只有 MESA
的告警:
1 2 3 4 5 $ timeout 25 wine app18win.exe MESA-EGL: warning: pci id for fd 33: 10de:2705, driver (null) MESA-EGL: warning: egl: failed to create dri2 screen $ echo $? 124
所以把 Step 1 里那条 MessageBoxA 调用改成
int3,就能把拒绝变成进程当场异常退出、把接受变成进程继续运行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 """Build the command-line oracle used to verify App18 licenses without a display. app18win.exe is a GUI program (PE subsystem 2). sub_401DFA calls sub_401510() and then branches on the global flag_trial_expired (byte at 0x405038): 00401E78 cmp byte ptr ds:0x405038, bl ; bl = 0 00401E7E jne 0x401E95 ; accepted -> window + message loop 00401E80 push 0x10 ; MB_ICONERROR 00401E82 push 0x40340C ; "Trial has expired." 00401E87 push 0x4033B8 ; "Your trial has expired!" 00401E8C push ebx 00401E8D call dword ptr ds:0x4031c0 ; MessageBoxA Neither branch prints anything on stdout, so the verdict is invisible on a headless box. Replacing that 6-byte MessageBoxA call with `int3` makes the reject path crash (wine prints "Unhandled exception: breakpoint") while the accept path silently falls into GetMessageA and keeps running: a timeout means "license accepted". """ import structSRC = 'app18win.exe' DST = 'app18_patched.exe' IMAGE_BASE = 0x400000 PATCH_VA = 0x401E8D PATCH = b'\xcc' + b'\x90' * 5 data = bytearray (open (SRC, 'rb' ).read()) pe = struct.unpack_from('<I' , data, 0x3c )[0 ] nsec = struct.unpack_from('<H' , data, pe + 6 )[0 ] optsz = struct.unpack_from('<H' , data, pe + 20 )[0 ] sections = [] for i in range (nsec): off = pe + 24 + optsz + i * 40 vsize, vaddr, rawsize, rawptr = struct.unpack_from('<IIII' , data, off + 8 ) sections.append((vaddr, vsize, rawptr)) def va2off (va ): rva = va - IMAGE_BASE for vaddr, vsize, rawptr in sections: if vaddr <= rva < vaddr + vsize: return rawptr + (rva - vaddr) raise ValueError('VA 0x%08X is not inside any section' % va) off = va2off(PATCH_VA) assert data[off:off + 6 ] == b'\xff\x15\xc0\x31\x40\x00' , data[off:off + 6 ].hex ()data[off:off + 6 ] = PATCH open (DST, 'wb' ).write(bytes (data))print ('patched 0x%08X (file offset 0x%04X) -> %s' % (PATCH_VA, off, DST))
驱动脚本把候选 license 写到补丁副本旁边(它按
GetModuleFileNameA + strstr("exe") +
strncpy(...,"lic",3) 找同目录同名的
.lic,所以名字必须跟着 exe 走),运行 wine,用有没有触发
breakpoint 判定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 """Drive the (patched) app18win.exe under wine and report whether it accepted a license file. The real binary is a GUI program: on success it leaves sub_401DFA through the window path and blocks in GetMessageA, while on failure it calls MessageBoxA ("Your trial has expired!") and exits. Neither outcome is visible without an X display. To turn the validator into a command-line oracle we patch exactly one instruction in a *copy* of the exe: 00401E8D ff 15 c0 31 40 00 call MessageBoxA -> 0xCC (int3) so a rejected license crashes the process (wine prints "Unhandled exception") while an accepted license walks straight into the message loop. "Accepted" is therefore "the process was still alive when the timeout expired". """ import osimport shutilimport subprocessimport sysimport tempfileHERE = os.path.dirname(os.path.abspath(__file__)) PATCHED = os.path.join(HERE, 'app18_patched.exe' ) TIMEOUT = 12 def run (lic_bytes, timeout=TIMEOUT ): """Return (verdict, detail). verdict is 'ACCEPTED', 'REJECTED' or 'ERROR'.""" tmp = tempfile.mkdtemp(prefix='app18_' ) try : exe = os.path.join(tmp, 'app18_patched.exe' ) shutil.copyfile(PATCHED, exe) with open (os.path.join(tmp, 'app18_patched.lic' ), 'wb' ) as fh: fh.write(lic_bytes) env = dict (os.environ, WINEDEBUG='-all' , DISPLAY='' ) try : p = subprocess.run(['wine' , 'app18_patched.exe' ], cwd=tmp, env=env, capture_output=True , timeout=timeout) except subprocess.TimeoutExpired: return 'ACCEPTED' , 'process still running after %ds (window path)' % timeout err = (p.stdout + p.stderr).decode('utf-8' , 'replace' ) if 'Unhandled exception' in err or 'breakpoint' in err.lower(): return 'REJECTED' , 'int3 hit - reached the "trial expired" branch' return 'ERROR' , 'exit=%s output=%r' % (p.returncode, err[-300 :]) finally : shutil.rmtree(tmp, ignore_errors=True ) if __name__ == '__main__' : for path in sys.argv[1 :]: with open (path, 'rb' ) as fh: blob = fh.read() verdict, why = run(blob) print ('%-28s -> %-8s %s' % (os.path.basename(path), verdict, why))
四个候选 license:生成器为 demo / testuser
产出的两个,同参数但把 hex 里的负 chunk 按 -
前缀写成有符号形式的一个,以及把生成结果改掉一个字节的一个:
1 2 3 4 5 6 7 8 9 $ python3 patch_app18.py patched 0x00401E8D (file offset 0x128D) -> app18_patched.exe $ python3 verify_lic.py /tmp/lic_demo.bin /tmp/lic_testuser.bin /tmp/lic_s_demo.bin /tmp/lic_tampered.bin /tmp/lic_badmagic.bin lic_demo.bin -> ACCEPTED process still running after 12s (window path) lic_testuser.bin -> ACCEPTED process still running after 12s (window path) lic_s_demo.bin -> REJECTED int3 hit - reached the "trial expired" branch lic_tampered.bin -> REJECTED int3 hit - reached the "trial expired" branch lic_badmagic.bin -> REJECTED int3 hit - reached the "trial expired" branch
lic_s_demo.bin 只是把负 chunk 写成
-xxxxxxx(其它字段、长度、用户名完全一致),它被拒就证明了
_itoa(..., 16) 的无符号语义;lic_tampered.bin
是密码段里翻转 1 bit,lic_badmagic.bin 是把
LIC 改成 XIC,两者被拒说明这个 oracle
不是什么都接受。证据等级:static + dynamic (算法来自
objdump -d -Mintel 对 sub_401510
的逐指令反汇编,正确性由打过补丁的真实二进制在 wine
下逐个接受/拒绝候选文件)。
Vulnerabilities
整套保护都在客户端:格式、长度、password 全部是明文
hex,没有任何签名或密钥,sub_401510 的每一行都可以从 PE
里读出来,任何拿到二进制的人都能为任意用户名生成 license。所谓唯一 key
只是用户名到一串无符号 hex 的确定性映射,改一个 bit
就失效只是因为校验是逐字符流式比对。修复方向:password
用只有服务器持有的密钥做
HMAC/签名,客户端只提交、服务端判定;文件名和路径推导这类信息也不该参与安全决策。
生成时把用户名换成自己的 HTS 用户名再运行生成器即可(下文 spoiler
里给的是示例用户名 demo 的 password 字段值)。
Step 6: 站点端上传
本地生成器被真实二进制接受并不等于站点会接受,所以按关卡要求把
license 上传到了 applevelup18.php(multipart,字段名
level=18、file=@app18win.lic,文件名必须与可执行文件的推导规则一致):
1 2 3 4 POST /missions/application/applevelup18.php (multipart/form-data) level=18 file=app18win.lic # 448 B,LIC/1.8/…/HTS/…/HTS_NQ 结构,password 段由生成器产出 -> Sorry, your license file is not valid.
也就是说:同一个文件在客户端被接受、在站点被判无效 。生成器没有问题,问题出在站点端校验本身,所以这一关在站点把服务端校验修好之前,任何人都无法通过;本文的结论(license
格式与 password 算法)以真实二进制的接受/拒绝对照为准。
B46C90005ACC2790FC57F100B1493240FC61F71016D60900ED4B2D10CAC704007B6CD040F2B04000CFB19000B203F210B3DCD090B20B804010E40000EA8EA900B1A40C1083D93240C263190011170400E562400C3EDF19090660400A12D804079E1C4002367A400592C0090BE74384011848A406E597C10D175B590B46C90005ACC2790FC57F100B1493240FC61F71016D60900ED4B2D10CAC704007B6CD040F2B04000CFB19000B203F210B3DCD090B20B804010E40000EA8EA900B1A40C10