最近新添置了两个支持 ONVIF 协议的摄像头,想使用 QNAP 的 QVR 进行录像。但是打开 QVR 发现,只能添加两个通道,后续新的通道需要按年付费解锁。依稀记得原先是有 8 个免费通道可用的,于是打开官方文档,发现:
系统 | 软件 | 授权 |
QTS | QVR Pro | 自带 8 通道 |
QTS | QVR Elite | 自带 2 通道 |
QuTS hero | QVR Elite | 自带 2 通道 |
原来官方提供的 8 通道 QVR Pro 只支持 QTS 系统,不支持基于 ZFS 的 QuTS hero 系统。既然官方不支持,那只能自己动手了。
TL; DR
- GPT 太好用了,大大提升逆向效率
- 新版校验用的 Public Key 经过简单的凯撒密码加密后打包在
libqlicense.so
中 - LIF 文件的 floating 相关字段需要干掉,否则会联机校验
激活流程
在研究的过程中,发现已有前人详细分析了激活流程的各个步骤,分析了各个文件的存储、编码、加密、验证格式,并编写了相关脚本和代码。具体文章在这里,此处不多做赘述:
https://jxcn.org/2022/03/qnap-license
尝试动手
参考文章,尝试在 NAS 上进行操作。结果发现,找不到文章中提及的 qlicense_public_key.pem 文件,全盘搜索后发现依然找不到该文件。查看文章评论发现,似乎官方在 23 年左右修改了一次校验逻辑,去掉了该文件。所有 23 年之后发布的版本,都无法使用替换 key 文件的方式进行激活。
重新分析
虽然官方修改了相关逻辑,但是有前人经验参考,还是很快发现了关键所在。
先用 IDA 打开 qlicense_tool
,找到离线激活的处理函数,发现对输入进行简单处理后直接调用了 qcloud_license_offline_activate
函数,位于 libqlicense.so
动态链接库中。这个函数在几层调用之后,最终调用了 qcloud_license_internal_verify
函数,对 LIF 文件的签名进行了计算。
由于静态分析不太容易理清逻辑,于是上 GDB 准备直接调试。找了下发现 MyQNAP Repo 里有编译好的 GDB,但是安装却失败了。研究了下发现是作者把 X86 和 X41 的链接搞反了。X86 下载到的包是 X41 的,导致安装失败。于是手动下载了正确的包之后安装,一切顺利。
通过 gdb 调试发现,qcloud_license_internal_verify
函数的第一个入参就是校验用的 Public Key,打出来长这样:
-----BEGIN PUBLIC KEY-----
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEDJcPoMIFyYDlhTUzNSeT/+3ZKcByILoI
Fw8mLv07Hpy2I5qgAGQu66vF3VUjFKgpDDFKVER9jwjjmXUOpoCXX4ynvFUpEM25
ULJE86Z6WjcLqyG03Mv6d3GPYNl/cJYt
-----END PUBLIC KEY-----
通过 hook fopen 函数,想找到这个 key 存储在哪个文件,但是发现并没有结果。于是想到,这段 key 可能被打进了 so 文件本身,于是通过 Strings 搜索,但是依然没有找到相关字符串。
一筹莫展之际,在调用链路上乱点,突然注意到这样一段奇怪的代码(IDA 还原得出):
while ( 1 )
{
result[v3] = (v1[v3] + 95) % 128;
if ( ++v3 == 359 )
break;
result = (_BYTE *)*a1;
}
多看两眼之后意识到,这就是凯撒密码。(虽然凯撒密码现在看来其实更应该算是编码而非加密,但是考虑到确实有相关定义,因此后续均表述为加密和解密。)也就是说,之所以在 Strings 内找不到相关字符串,是因为写入的是通过凯撒密码加密后的字符串。于是把这段丢给 GPT,让它写个加密和解密的代码:
# -*- coding: utf-8 -*-
def decode(input_string):
result = []
for char in input_string:
# 将每个字符的 ASCII 值加 95,然后对 128 取模
encoded_char = (ord(char) + 95) % 128
result.append(chr(encoded_char))
return ''.join(result)
def encode(encoded_string):
result = []
for char in encoded_string:
# 逆向解码:恢复原始字符
decoded_char = (ord(char) - 95) % 128
result.append(chr(decoded_char))
return ''.join(result)
original_text = """-----BEGIN PUBLIC KEY-----
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEDJcPoMIFyYDlhTUzNSeT/+3ZKcByILoI
Fw8mLv07Hpy2I5qgAGQu66vF3VUjFKgpDDFKVER9jwjjmXUOpoCXX4ynvFUpEM25
ULJE86Z6WjcLqyG03Mv6d3GPYNl/cJYt
-----END PUBLIC KEY-----"""
encoded_text = encode(original_text)
decoded_text = decode(encoded_text)
print("Original:", original_text)
print("Encoded:", encoded_text)
print("Decoded:", decoded_text)
跑了一下,得到实际存储的字符串:
NNNNNcfhjoAqvcmjdAlfzNNNNN+niz\u0018fbzil\u0010{j\u001b\u000bQdbrzglUffbdjez\bbfek\u0004q\u0010njg\u001aze\r\tuv\u001bot\u0006uPLT{l\u0004c\u001ajm\u0010j+g\u0018Y\u000em\u0017QXi\u0011\u001aSjV\u0012\bbhr\u0016WW\u0017gTwv\u000bgl\b\u0011eeglwfsZ\u000b\u0018\u000b\u000b\u000eyvp\u0011\u0010dyyU\u001a\u000f\u0017gv\u0011fnSV+vmkfYW{Wx\u000b\u0004m\u0012\u001ahQTn\u0017W\u0005Thqzo\rP\u0004kz\u0015+NNNNNfoeAqvcmjdAlfzNNNNN
由于开头和结尾的 -----
会被加密为 NNNNN
因此直接在 Strings 中搜索,顺利定位到相关资源,位于 .rodata 段 38B40 的位置,长度 212。
接下来操作就简单了,生成一对新的 key 之后,直接替换指定位置即可。注意,由于长度是写死在代码里的,因此二者必须等长,否则可能会出现无法预料的错误。
再丢给 GPT 写个十六进制替换的脚本:
# -*- coding: utf-8 -*-
import binascii
def hex_to_bytes(hex_str):
"""将16进制字符串转换为字节数据。"""
return binascii.unhexlify(hex_str)
def patch_file(file_path, search_hex, replace_hex):
# 将16进制模式转换为字节数据
search_bytes = hex_to_bytes(search_hex)
replace_bytes = hex_to_bytes(replace_hex)
# 检查替换块的长度是否匹配
if len(search_bytes) != len(replace_bytes):
raise ValueError("Length mismatch! 搜索模式和替换内容长度必须相同!")
# 读取文件内容
with open(file_path, "rb") as f:
data = f.read()
# 查找并替换内容
patched_data = data.replace(search_bytes, replace_bytes)
# 如果没有找到匹配的内容
if data == patched_data:
print("No Match. 没有找到匹配的内容。")
else:
# 将修改后的内容写回文件
with open(file_path, "wb") as f:
f.write(patched_data)
print("Patch success. 文件已成功更新。")
# 使用示例
file_path = "libqlicense.so" # 需要打补丁的文件路径
old_hex = "4e4e4e4e4e6366686a6f417176636d6a64416c667a4e4e4e4e4e2b6e697a1866627a696c107b6a1b0b516462727a676c55666662646a657a086266656b0471106e6a671a7a650d0975761b6f740675504c547b6c04631a6a6d106a2b6718590e6d17515869111a536a56120862687216575717675477760b676c08116565676c7766735a0b180b0b0e7976701110647979551a0f17677611666e53562b766d6b6659577b57780b046d121a6851546e1757055468717a6f0d50046b7a152b4e4e4e4e4e666f65417176636d6a64416c667a4e4e4e4e4e" # 要搜索的16进制字符串
new_hex = "4e4e4e4e4e6366686a6f417176636d6a64416c667a4e4e4e4e4exxx4e4e4e4e4e666f65417176636d6a64416c667a4e4e4e4e4e" # 替换为的16进制字符串
# 执行替换操作
patch_file(file_path, old_hex, new_hex)
这里使用 Hex Encoding 的原因是加密后的字符串存在一些非 ASCII 字符。
替换后,用原作者的相关脚本和工具,生成了一个 LIF,尝试使用 qlicense_tool
激活。此时发现,激活死活失败,但是调试发现 verify 实际已经过了。研究了好久之后,发现是 Console Management
工具导致的。由于 qlicense_tool
在激活过程中会运行被激活服务的脚本检测 License 正确性,而 Console Management
拦截了相关命令,导致返回包不是合法的 json,导致失败。在系统设置内关闭 Console Management
后,激活成功。
检测报错
本以为到此激活就成功了。结果打开许可证中心之后,发现许可证无效了。查阅相关日志,发现许可证中心发送了一个 POST 请求到 https://license.myqnapcloud.io/v1.1/license/device/installed
,然后报错许可不属于该设备,然后将许可改为失效。
阅读原作者相关文章,结合代码,发现是因为原作者是为了 QuTS Cloud 设计的代码,由于 Cloud 版没有离线激活功能,所以会定期刷新 Token,这里一刷新就发现许可有问题了。于是修改代码,将 LIF 内的 floating 相关字段全部干掉。包括:
FloatingUUID
FloatingToken
LicenseCheckPeriod
重新生成 LIF 文件,再次激活,已经不会向 myqnapcloud
验证激活信息了。
QNAP 新版 License Center 校验流程逆向 by 桔子小窝 is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.