Challenge
Keygen challenge. The program generates a license key based on the username. Reverse engineer the key generation algorithm and write a keygen that produces valid keys for any username.
密钥生成器 (keygen) 挑战。程序根据用户名生成 license key,需要逆向分析 key 生成算法,编写一个可以为任意用户名生成正确 key 的 keygen 程序。
HTS 附带的压缩包里同时有 Windows 版 (app17win.exe) 和 UNIX 版 (app17unix.tar.gz)。UNIX 版是 未 strip 的 ELF,符号表里直接留着 enc_and_check / main 的名字,用它反汇编最省事,而且可以直接跑起来做验证。
Solution
file报 ELF 64-bit LSB PIE、x86-64,not stripped。nm直接给出两个关键符号:_Z13enc_and_checkPcS_(enc_and_check(char*, char*),就是校验函数)和main。
$ nm unix/app17unix | grep -E 'enc_and|main'0000000000001919 T main000000000000148f T _Z13enc_and_checkPcS_Step 1: 从符号找校验函数
enc_and_check(rdi=username, rsi=key) 里第一个分支就限定了 key 的长度:strlen(key) <= 3 直接返回 1(失败)。接下来把 key 的前 4 个字节与一张常量表比较,而比较方式是把 key 的字符左移 2 位(lea edx,[rax*4+0]):
; enc_and_check(char* username /*rdi->[rbp-0x488]*/, char* key /*rsi->[rbp-0x490]*/)14bc mov rax,[rbp-0x490] ; key14c6 call strlen14cb cmp rax,0x3154a mov DWORD PTR [rbp-0x440],0x1201554 mov DWORD PTR [rbp-0x43c],0x150155e mov DWORD PTR [rbp-0x438],0x14c1568 mov DWORD PTR [rbp-0x434],0xb415b7 movzx eax,BYTE PTR [rax] ; key[i]15ba movsx eax,al15bd lea edx,[rax*4+0x0] ; key[i] * 415d2 cmp edx,eax ; == 表里的常量? = 'H', = 'T', = 'S', = '-'。所以 key 必须以 HTS- 开头。
Step 2: key 的格式
后面几段循环分别规定了分隔符位置和内容长度:
- 从下标 3 开始每隔 5 个字符必须是
-(add rbp-0x460, 5),即HTS-之后是每 4 个 hex 一组、用-分隔。 - 另一段循环把 key 里所有的
-去掉,用一个sprintf(buf, "%s%c", buf, key[i])拼成一个纯 hex 串。 - 最后要求这个纯 hex 串的长度恰好等于
2 * strlen(username):
16bb mov rax,[rbp-0x488] ; username16c5 call strlen16ca lea rbx,[rax+rax*1] ; rbx = 2 * strlen(username)16d5 call strlen ; strlen(hex buffer)16dd cmp rbx,rax16e0 je ok16e2 mov eax,0x1 ; 长度不符 -> 失败也就是说:每个用户名字符对应 2 个 hex 位(1 byte),整个 key 是 HTS- 加若干 4-hex 一个的分组。格式串也可以直接在 .rodata 里读到:0x3008="%s%c"、0x300d="%02X"。
Step 3: 逆向字节生成算法
主循环对用户名的每个字符 j 做一次计算。它先把第 j 对的两个 hex 字符用 sscanf(..., "%02X", ...) 解析成一个整数 hexval,然后按下面的公式算出应有的值 result,并要求 result == hexval:
171e movzx eax,BYTE PTR [buf+idx] ; 取两个 hex 字符174e call sscanf("%02X") ; -> hexval ([rbp-0x474])1773 mov eax,[rbp-0x470] ; acc(上一字节算出的值,初值 0)1779 mov [rbp-0x46c],eax ; t = acc177f cmp DWORD PTR [rbp-0x46c],0x1f1786 jle 17911788 sub DWORD PTR [rbp-0x46c],0x1f ; while (t > 31) t -= 31; -> t = acc mod 31178f jmp 177f1791 mov rdx,[rbp-0x488] ; username17a2 movzx eax,BYTE PTR [rdx+j]17a5 movsx eax,al ; u = (signed char)username[j]17a8 sub eax,[rbp-0x470] ; u - acc17ae sar eax,1 ; (u - acc) >> 1 (算术右移)17b0 mov esi,eax17c3 movzx eax,BYTE PTR [rdx+j]17c6 movsx edx,al ; u17cf mov ecx,eax(=t) ; cl = t17d1 shl edx,cl ; u << (t & 31)17d5 not eax ; ~(u << t)17d7 and eax,esi17d9 mov [rbp-0x464],eax ; result即:
t = acc mod 31 # acc<=31 时不减,等价 acc % 31result = ((signed_char)username[j] - acc) >> 1result &= ~(username[j] << (t & 31))算完之后 acc 更新为 result(18d5: mov [rbp-0x470], hexval),因为前面已经校验过 result == hexval,两者相等,所以下一轮的起点就是当前字节的值。
有一个伪装用的内层循环(0x17f7~0x18bb)遍历整个 key 和用户名,对 acc 执行一组 and/ shl / or / +1 运算。该循环位于 result 计算之后;acc 在循环结束时又通过 mov eax,[rbp-0x468] 被赋回此前保存的 hexval(0x18d5),因此这些运算的副作用不会影响 result,属于作者放置的烟雾弹。
Step 4: keygen
把上面的公式原样搬到 Python。两个细节必须对齐 x86:
username[j]是 signed char(movsx),高位字节按负数处理。- 所有中间量都是 32-bit 补码,
>> 1是算术右移;移位量按 x86 规则只取低 5 位(& 31)。 - 每个字节只有落在
0x00..0xFF才能写成两个 hex 位,所以并非所有用户名都能表示,超出范围时报告不可解。
#!/usr/bin/env python3"""HackThisSite Application Challenge 17 keygen.
Reconstructed from _Z13enc_and_checkPcS_ in app17unix (objdump -d -Mintel).
The binary checks a key of the form HTS-XXXX-XXXX-... where the number ofhex digits equals 2 * len(username). For each username byte u and the runningstate `acc` (starts at 0), it recomputes
result = ((u - acc) >> 1) & ~(u << (acc mod 31)) # 32-bit, signed
and demands that the two hex digits for that position equal `result`; the nextstate becomes `result`. `>>` is an arithmetic shift (x86 `sar`) and the shiftcount is taken modulo 32 (x86 `shl cl`). Every intermediate is 32-bit two'scomplement, but only values 0..0xFF can be written as two hex digits, so ausername is solvable only when every step lands in 0..255."""
MASK = 0xFFFFFFFF
36 collapsed lines
def s32(x): """Wrap to 32-bit signed.""" x &= MASK return x - 0x100000000 if x & 0x80000000 else x
def reduce31(acc): """Faithful port of the binary's `while (t > 31) t -= 31` for signed int.""" if acc <= 31: # negative acc never enters the loop return acc return acc % 31 # for acc > 31 the loop yields acc in [0, 31]
def key_bytes(username): """Return the list of per-character bytes the key has to encode.""" acc = 0 out = [] for ch in username.encode('latin-1'): u = ch - 256 if ch >= 128 else ch # movsx (signed char) t = reduce31(acc) res = s32(u - acc) >> 1 # sar eax, 1 shifted = s32(u << (t & 31)) # shl edx, cl res = s32(res & s32(~shifted)) # not / and out.append(res) acc = res return out
def keygen(username): payload = key_bytes(username) if any(b < 0 or b > 0xFF for b in payload): raise ValueError( 'username %r is not representable in the 2-hex-digit format: %r' % (username, payload)) hexs = ''.join('%02X' % b for b in payload) return 'HTS-' + '-'.join(hexs[i:i + 4] for i in range(0, len(hexs), 4))
if __name__ == '__main__': import sys usernames = sys.argv[1:] or ['demo'] for name in usernames: print('%-14s -> %s' % (name, keygen(name)))Step 5: Verify
用 pty 驱动真实二进制,把用户名和 keygen 算出的 key 喂进去,看它是否打印成功信息:
#!/usr/bin/env python3"""Feed keygen_final outputs to the real app17unix binary and report verdict."""import osimport ptyimport selectimport sysimport time
HERE = os.path.dirname(os.path.abspath(__file__))BIN = os.path.join(HERE, 'unix', 'app17unix')sys.path.insert(0, HERE)from keygen_final import keygen
def run(username, key, timeout=6.0): pid, fd = pty.fork() if pid == 0: os.execv(BIN, [BIN]) out = b'' sent_user = sent_key = sent_extra = False31 collapsed lines
deadline = time.time() + timeout while time.time() < deadline: r, _, _ = select.select([fd], [], [], 0.2) if r: try: data = os.read(fd, 4096) except OSError: break if not data: break out += data if not sent_user and b'Username:' in out: os.write(fd, username.encode() + b'\n') sent_user = True elif sent_user and not sent_key and b'Password:' in out: os.write(fd, key.encode() + b'\n') sent_key = True elif sent_key and not sent_extra and b'Congratulations' in out: os.write(fd, b'\n') sent_extra = True break try: os.close(fd) except OSError: pass try: os.waitpid(pid, 0) except OSError: pass return out
if __name__ == '__main__': for name in (sys.argv[1:] or ['demo']): key = keygen(name) out = run(name, key) ok = b'Congratulations' in out print('username=%-12s key=%-28s -> %s' % (name, key, 'ACCEPTED' if ok else 'rejected'))$ python3 keygen_final.py demo testuser Hash-Cat a Ademo -> HTS-1229-2206testuser -> HTS-0A2D-2328-2626-1F29Hash-Cat -> HTS-241E-2A1F-071E-2129a -> HTS-10A -> HTS-20
$ python3 verify_final.py demo testuser Hash-Cat a Ausername=demo key=HTS-1229-2206 -> ACCEPTEDusername=testuser key=HTS-0A2D-2328-2626-1F29 -> ACCEPTEDusername=Hash-Cat key=HTS-241E-2A1F-071E-2129 -> ACCEPTEDusername=a key=HTS-10 -> ACCEPTEDusername=A key=HTS-20 -> ACCEPTED