Tiana随笔

补丁发了四十一天,NAS 还是被锁了

· Linux

标签:#安全 #Linux #部署

补丁发了四十一天,NAS 还是被锁了

十月底一批群晖 NAS 被 C3RB3R 勒索软件加密,文件名多出 .L0CK3D。攻击者用的两个洞 CVSS 9.8,不用登录就能读写任意文件,补丁 9 月 18 日就发了。这轮真正暴露的不是 DSM,是补丁到设备之间的那段空窗,以及和主数据躺在同一台机器上的那份备份。

十月底这几天,一批群晖 NAS 用户打开设备时看到的是同一个画面:共享文件夹里的文件名后面多了一截 .L0CK3D,每个目录下躺着一封 read-me3.txt。照片、文档、代码仓库,还有放在同一台机器上的备份目录,一起变成了密文。

这轮攻击用的是老勒索家族 Cerber 的一个变种,圈内叫它 C3RB3R。它这几个月一直盯着 NAS 这类设备。

真正该看的不是它,是它进门的那个洞:那个洞的补丁在 9 月 18 日就发了。

门是怎么开的

360 反勒索团队处置的几起事件,入口指向同一个地方,两个 CVE:CVE-2026-13639 和 CVE-2026-13684。

群晖自己的公告把它们定在 Critical,CVSS 3.1 基础分 9.8,向量是 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H。拆开看:网络可达、攻击复杂度低、不需要任何权限、不需要用户点任何东西,机密性完整性可用性三项全高。说人话就是,只要 DSM 的对应端口能从外面够得着,不用登录就能打。

两个洞的性质不一样。CVE-2026-13639 出在登录逻辑的随机数上(CWE-331),熵不够,本来该猜不出来的东西变得可以猜。CVE-2026-13684 出在 SCGI 组件的输出编码上(CWE-116),DSM 处理 Web 请求的那一层没把输出编好码,攻击者就能顺着它往不该写的地方写东西。

官方给的缓解措施一栏写的是:无。唯一解法是升级。

修复版本是这四档:

DSM 7.4    -> 7.4-90075
DSM 7.3    -> 7.3.2-86009-4
DSM 7.2.2  -> 7.2.2-72806-9
DSM 7.2.1  -> 7.2.1-69057-12

这是 9 月 18 日的事。攻击大规模发生在十月下旬。中间隔了大约四十天。

这不是 0day

事发时传得最广的说法是"最新 DSM 也中招,群晖出了 0day,全网皆靶子"。官方研判不是这样:这波利用的是已经修好的旧洞。

这个区别很重要,因为它决定了你该怎么办。如果是 0day,除了拔网线你确实没什么可做。如果是 1day,那问题就不在群晖,在你。

1day 攻击的逻辑很朴素:补丁发布不等于设备更新。漏洞公开那一刻,攻击方和防守方拿到的是同一份信息,但两边的动作速度完全不同。防守方要排窗口、要担心升级出事、要等一个"合适的时间";攻击方只需要写一段扫描脚本,然后挂在那里跑。空窗期越长,可收割的面积越大,而且这段面积是公开可查的:版本号在互联网上是能扫出来的。

我见过太多 NAS 的实际状态。装好那年是最新版,之后三年没动过。5000 端口直接映射出去,admin 密码还是初始那套,SSH 和 FTP 开着,问起来就是"以后可能用得到"。

黑群晖(非官方渠道装的 DSM)在这轮里风险更高,不是因为系统本身更脆弱,是因为这条路拿不到官方的自动更新通道,补丁到设备之间那四十天会被拉长成无限长。

还有一点要说清楚:确实有报告说跑了最新版 DSM 的机器也被加密了。这事的结论官方还没给。可能的解释是自装的套件、弱口令、或者别的暴露面,而不是"最新版有洞"。看到"最新版中招"就推出"最新版有洞",是把两件事搞混了。

备份为什么跟着一起没了

这一轮最扎心的不是数据被锁,是备份被锁。

很多人买 NAS 的初衷就是做备份。装好之后,主数据在共享文件夹,备份放在同一个共享文件夹的另一个目录,或者挂在同一台机器上的 Hyper Backup 目录。平时看着是两份,勒索软件眼里是一份:它拿到的是系统底层的执行权限,从根目录多线程往下扫,遍历所有挂载的存储,凡是这台机器能写的,它都能加密。

RAID 在这儿帮不上忙。RAID 1、RAID 5、RAID 6 防的是物理硬盘损坏,它 redundant 的是盘,不是文件。操作系统层面的恶意改写,RAID 会忠实地把它同步到每一块盘上。你花在冗余上的钱买的是硬盘故障的保险,这张保单不覆盖勒索。

快照也一样,要分两种。DSM 里那个黄色挂锁标记,意思是"别被自动清理策略删掉",它防的是清理策略,不防拿到 root 的人,撕掉它一秒钟。真正有用的是蓝色盾牌的不可变快照,保护期内谁都删不掉,改系统时间也没用,它按硬盘实际通电小时数独立倒计时。这个功能的门槛是 DSM 7.2 以上、Btrfs 文件系统、20 系及以后的机型,DS918+ 那批老机器不在官方支持列表里。

360 在样本分析里还原了它的作业习惯:先跳过 27 个系统目录(/boot、/proc、/sys、/dev、/etc 这些),把系统加密到崩溃对它没好处;跳过常见可执行文件和脚本;跳过已经加密过的文件。剩下的才是它要锁的。每生成一个 256 位密钥加 64 位 nonce,用 ChaCha8 加密文件内容,再用内置的 RSA 公钥把密钥和加密位置这些元信息包起来,附在密文尾部。各家对算法的描述不完全一致,有报道写的是 RSA-4096 配 AES 或 ChaCha20,但结论是一致的:没有通用免费解密工具,数学上不可行。

赎金信里要求用比特币付款,单一媒体报道的区间是三千到六千美元。付不付是另一回事,付了也没有任何人能保证给你密钥。

同一周的另外两个入口

这轮攻击不只一条路。同一周公开的另外两个洞,也能通向 NAS。

PHP2Shell。 CVE-2026-109618 和 CVE-2026-109619,CVSS 4.0 下 9.2 分,未授权远程代码执行。一个是内存耗尽后的清理不干净,一个是请求之间残留的全局状态。PHP 官方 10 月 21 日发补丁,CISA 在 10 月 26 日把它们加进了 KEV 目录,意味着有实际在野利用的证据。影响所有处理 Web 请求的 PHP 安装方式,官方认为 Apache 的 mod_php 风险最高,PHP-FPM、php-cgi、FrankenPHP、LiteSpeed 都在内。NAS 上跑的那些 PHP 套件,走的正是这条线。修复版本是 PHP 8.5.12、8.4.27、8.3.36、8.2.35,装完还要重启 PHP worker 或者 Web 服务。

GitLab。 CVE-2026-97229,CVSS 10.0,攻击者通过 CI/CD 配置里构造的正则表达式执行代码,不需要登录。CISA 10 月 29 日入 KEV。Alpine Linux 公开说自己的实例在 10 月 20 和 21 日被人试过,worker 进程崩了八次。不少团队在 NAS 的 Docker 里自建 GitLab,端口直接映射出去,这条路就通了。

这两条合到一起,指向同一件事:NAS 早就不是一台只会存文件的盒子了。它上面跑着容器、跑着 PHP 应用、跑着自建的代码仓库,每一个都是一扇门。你按"存储设备"的态度管它,它按"服务器"的方式被打。

一个能跑的自查

下面这个脚本干两件事。第一件算补丁暴露窗口,你给它漏洞公布日和你的设备更新日期,它告诉你裸奔了多少天。第二件检查备份是不是真能救你,按 3-2-1 的口径算:三份副本、两种介质、一份离线。

#!/usr/bin/env python3
"""NAS 暴露窗口与备份可恢复性自查。

用法:
    python3 nas_readiness.py                  # 用内置示例数据跑
    python3 nas_readiness.py --config a.json  # 用你自己的设备清单
"""
from __future__ import annotations

import argparse
import json
import os
from datetime import date, datetime

# 漏洞公布日 -> 涉及的 CVE(补丁同日发布)
CVE_TIMELINE = {
    "2026-09-18": ["CVE-2026-13639", "CVE-2026-13684"],     # DSM,修到 7.4-90075
    "2026-10-21": ["CVE-2026-109618", "CVE-2026-109619"],   # PHP2Shell,修到 8.5.12
    "2026-10-29": ["CVE-2026-97229"],                       # GitLab CI/CD 正则 RCE
}

CONFIG_SAMPLE = {
    "devices": [
        {"name": "客厅 NAS", "last_update": "2026-06-02", "exposed": True},
        {"name": "公司 NAS", "last_update": "2026-09-20", "exposed": False},
    ],
    "backups": [
        {"path": "/volume1/backup", "same_device": True,
         "last_ok": "2026-10-30", "verified_restore": None},
        {"path": "/Volumes/OfflineDisk", "same_device": False,
         "last_ok": "2026-08-11", "verified_restore": "2026-05-04"},
    ],
    "snapshot_immutable": False,
}

MAX_PATCH_GAP = 14       # 补丁发布后允许的最长更新延迟(天)
MAX_BACKUP_AGE = 7       # 备份新鲜度上限(天)
MAX_RESTORE_AGE = 90     # 恢复演练有效期(天)


def days_between(start: str, end: str) -> int:
    """两个 YYYY-MM-DD 之间隔了多少天。"""
    a = datetime.strptime(start, "%Y-%m-%d").date()
    b = datetime.strptime(end, "%Y-%m-%d").date()
    return (b - a).days


def check_patch_window(devices: list[dict], today: str) -> list[dict]:
    """只看还没补上的洞:公布日晚于设备最后一次更新的,就是未修补。"""
    rows = []
    for dev in devices:
        unpatched = [pub for pub in CVE_TIMELINE
                     if days_between(dev["last_update"], pub) > 0]
        gap = max((days_between(pub, today) for pub in unpatched), default=0)
        if not unpatched:
            status = "已修补"
        elif gap <= MAX_PATCH_GAP:
            status = f"窗口内,还剩 {MAX_PATCH_GAP - gap} 天"
        else:
            status = "已超窗"
        rows.append({"name": dev["name"], "exposed": dev["exposed"],
                     "unpatched": len(unpatched), "gap": gap, "status": status})
    return rows


def check_backups(backups: list[dict], immutable: bool, today: str) -> dict:
    """按 3-2-1 口径清点:几份副本、有没有离线的、有没有真恢复过。"""
    restored = [b for b in backups if b["verified_restore"]
                and days_between(b["verified_restore"], today) <= MAX_RESTORE_AGE]
    return {
        "副本数": len(backups),
        "可达副本": sum(1 for b in backups if os.path.exists(b["path"])),
        "离线副本": sum(1 for b in backups if not b["same_device"]),
        "七天内更新过": sum(1 for b in backups
                        if days_between(b["last_ok"], today) <= MAX_BACKUP_AGE),
        "九十天内验证过恢复": len(restored),
        "最旧一份距今": max((days_between(b["last_ok"], today)
                        for b in backups), default=-1),
        "不可变快照": immutable,
    }


def verdict(rows: list[dict], backup: dict) -> list[str]:
    """把检查结果翻成人要做的事。"""
    todo = []
    for r in rows:
        if not r["unpatched"]:
            continue
        tip = "先断公网再升级" if r["exposed"] else "排窗口升级"
        todo.append(f"{r['name']}:还有 {r['unpatched']} 个洞没补,"
                    f"最长已裸奔 {r['gap']} 天,{tip}")
    if not backup["离线副本"]:
        todo.append("没有离线副本,现有备份都能被同一台机器上的勒索软件加密")
    if not backup["九十天内验证过恢复"]:
        todo.append("近九十天没有一份备份被真正恢复过,备份存在不等于能恢复")
    if backup["最旧一份距今"] > MAX_BACKUP_AGE:
        todo.append(f"最旧一份备份已经 {backup['最旧一份距今']} 天没更新")
    if not backup["不可变快照"]:
        todo.append("快照没开不可变保护,拿到 root 的人能删掉它")
    return todo or ["当前配置没有明显缺口"]


def render(rows: list[dict], backup: dict, todo: list[str], today: str) -> None:
    print(f"自查日期 {today}\n")
    print("== 补丁窗口 ==")
    for r in rows:
        net = "公网" if r["exposed"] else "内网"
        print(f"  {r['name']:<10} {net:<6} 未补 {r['unpatched']} 个  "
              f"已裸奔 {r['gap']:>3} 天  {r['status']}")
    print("\n== 备份可恢复性 ==")
    for k, v in backup.items():
        print(f"  {k}: {v}")
    print("\n== 该做的事 ==")
    for i, t in enumerate(todo, 1):
        print(f"  {i}. {t}")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", help="设备与备份清单的 JSON 路径")
    ap.add_argument("--today", default=date.today().isoformat())
    args = ap.parse_args()

    if args.config:
        cfg = json.load(open(args.config, encoding="utf-8"))
    else:
        cfg = CONFIG_SAMPLE
        print("没有给 --config,下面是内置示例数据的结果\n")

    rows = check_patch_window(cfg["devices"], args.today)
    backup = check_backups(cfg["backups"], cfg["snapshot_immutable"], args.today)
    render(rows, backup, verdict(rows, backup), args.today)


if __name__ == "__main__":
    main()

用示例数据跑出来的结果长这样:

== 补丁窗口 ==
  客厅 NAS     公网     未补 3 个  已裸奔  45 天  已超窗
  公司 NAS     内网     未补 2 个  已裸奔  12 天  窗口内,还剩 2 天

== 备份可恢复性 ==
  副本数: 2
  可达副本: 0
  离线副本: 1
  七天内更新过: 1
  九十天内验证过恢复: 0
  最旧一份距今: 83
  不可变快照: False

== 该做的事 ==
  1. 客厅 NAS:还有 3 个洞没补,最长已裸奔 45 天,先断公网再升级
  2. 公司 NAS:还有 2 个洞没补,最长已裸奔 12 天,排窗口升级
  3. 近九十天没有一份备份被真正恢复过,备份存在不等于能恢复
  4. 最旧一份备份已经 83 天没更新
  5. 快照没开不可变保护,拿到 root 的人能删掉它

第一行里的"未补 3 个"说的是这台机器最后一次更新停在六月,之后公布的三批洞一个都没装上;"已裸奔 45 天"说的是其中最早那批从公布到现在过了多久。第二台机器只漏了最近两批,还在十四天的窗口里,所以它排的是升级窗口而不是应急。

最该盯的是第三条。绝大多数人的备份从来没有被真正恢复过一次,备份的存在感和它的可用性是两件事,只有恢复演练能把这两件事对上。第四条同理:一份八十三天没更新的备份,在出事那天能救回来的数据比你以为的少得多。

脚本里的阈值是我拍的:十四天补丁窗口、七天备份新鲜度、九十天演练有效期。你可以按自己的情况改,但别把它改成让自己舒服的数字。

今晚就该做的六件事

按投入产出排,从上往下做:

断公网。 登录路由器,把 NAS 管理端口的映射删掉。5000 和 5001 别裸在外面。要远程访问,走 VPN 或者厂商的中继服务。这一步是唯一能立刻把风险降到接近零的动作,其他都是降低概率。

升级 DSM 和套件。 控制面板里检查更新,然后打开自动更新。套件也要一起升,很多 NAS 上出事的不是 DSM,是套件。

清点端口。 SSH、FTP、WebDAV、Docker 里映射出去的每一个端口,问一遍"这个现在还需要吗"。不需要就关。每多开一个就多一扇门,而这些门你平时根本不会去看。

改账号。 停用默认的 admin,新建一个强密码管理员账号,开两步验证,开自动 IP 封锁。

开不可变快照。 认准蓝色盾牌那个,黄色挂锁不算。保护期官方建议七到十四天。老机型不在支持列表里的,别指望它,把预算花在离线备份上。

做一份真离线的备份。 三份副本、两种介质、一份离线。那份离线的平时不要和 NAS 连着,连着的就不叫离线。存完还要定期恢复一次看看,能恢复的才叫备份。

最后一句

这轮攻击里没有任何技术上的新东西。漏洞是旧的,补丁是现成的,加密方式是教科书式的混合加密,被锁的人里有相当一部分三年前就知道自己该升级。

NAS 这类设备的处境很尴尬:它在物理上是个家电,安静地待在角落,没人会定期去看它;它在网络上是一台服务器,跑着十几个服务,天天被全网的扫描脚本摸。这两件事之间的落差,就是攻击者收租的地方。

补丁发出来的那一刻,漏洞就公开了。你的设备什么时候装上它,是你唯一能决定的变量。

相关文章