【安全公告】CVE-2026-31431「Copy Fail」——QNAP NAS 使用者影響說明

QNAP 目前正在調查 CVE-2026-31431,又稱 Copy Fail,我們希望為對該漏洞影響感到擔憂的用戶提供一些說明。

簡而言之,大多數 QNAP NAS 型號均不受此漏洞影響。

此問題僅影響執行特定 Linux 核心版本的部分以 ARM 為基礎的 QNAP NAS 型號。根據我們目前的評估:

  • 所有 x86 架構的 QNAP NAS 型號均不受影響。
  • 執行 QTS 4.x 的 ARM 架構 NAS 型號均不受影響。
  • 此問題僅限於執行受影響核心版本的特定 ARM 架構 NAS 型號。

請參考官方 QNAP 安全公告以取得最新資訊:

https://www.qnap.com/go/security-advisory/qsa-26-16

關於此漏洞

此漏洞屬於本地提權類型。

這意味著攻擊者必須先以一般(非管理員)用戶的身分能在 NAS 上執行程式碼,才有機會利用該漏洞。這不是一個能直接自網際網路利用的漏洞,攻擊者必須先取得某種本地存取權限。

針對 QNAP NAS 裝置,預設情況下僅管理員群組用戶可經由 SSH 與 Telnet 連線。不過,如果您執行允許其他用戶或外部網路存取的服務或容器,仍請檢查系統及應用程式之曝光程度。

建議行動

為降低整體風險,我們建議如下:

  • 非必要情況下,請勿賦予非管理員用戶 shell 存取權限。
  • 僅執行來自可信來源的容器映像檔。
  • 請檢查 Container Station 的設定,避免讓非必要用戶連接容器。
  • 持續更新應用程式、容器及相關服務。
  • 關閉未使用的服務及應用程式。
  • 若未使用內建網頁伺服器,請至 控制台 > 網頁伺服器 關閉。
  • 請將 NAS 設於防火牆後方,避免直接對外網開放。
  • 按官方安全公告操作,並於發布安全更新時即時安裝。

QNAP 正積極進行安全性修補,當有進一步資訊或修正時會於公告頁更新。

若您對自身系統設定仍有疑慮,請聯繫 QNAP 支援中心以獲得協助。

為保安全,請勿於論壇公開張貼敏感資訊,包括公用 IP 位址、用戶名稱、裝置序號、完整記錄檔或詳細系統設定等。

— QNAP 社群團隊
根據 QNAP 產品安全事件回應團隊資訊

關於容器的問題:許多容器是在像 Alpine 或 BusyBox 這樣的 Linux 版本下運行。既然這些容器執行的 Linux 跟 QNAP 上的不一樣,這樣容器本身會有什麼漏洞嗎?還是說其實不會?

在 QNAP NAS 上運行的容器與其他進程共用相同的核心,因此如果您的 NAS 受到影響,我們建議您特別留意此情況。

好的,所以如果我的 NAS 沒有受影響,那我的容器也沒問題,對嗎?

多虧了 AI,我能夠很快有一個測試腳本。

我隨機挑了一台 QTS 5.2.8(2025/12/25)的 h874,運行了這個由 Claude 生成、再經 Codex 雙重檢查的腳本,來驗證在 QTS 上是否存在這個 entrypoint:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""CVE-2026-31431 surface check. Python 2.7 / 3.x compatible. Bind-only, safe."""
from __future__ import print_function
import os, socket, sys, errno

AF_ALG, SOCK_SEQPACKET = 38, 5

def in_container():
    if os.path.exists("/.dockerenv"):
        return True
    try:
        with open("/proc/1/cgroup") as f:
            d = f.read()
        return any(k in d for k in ("docker", "lxc", "kubepods", "containerd"))
    except (IOError, OSError):
        return False

def kernel_info():
    u = os.uname()
    # u: (sysname, nodename, release, version, machine)
    print("[*] Kernel:    {0} ({1})".format(u[2], u[4]))
    print("[*] Container: {0}".format(in_container()))

def kconfig_probe():
    paths = ["/proc/config.gz", "/boot/config-" + os.uname()[2]]
    keys = ("CONFIG_CRYPTO_USER_API_AEAD", "CONFIG_CRYPTO_USER_API",
            "CONFIG_CRYPTO_AUTHENC")
    for p in paths:
        if not os.path.exists(p):
            continue
        try:
            if p.endswith(".gz"):
                import gzip
                f = gzip.open(p, "rt")
            else:
                f = open(p, "r")
            data = f.read()
            f.close()
        except Exception as e:
            print("[!] kconfig read fail {0}: {1}".format(p, e))
            continue
        print("[*] kconfig source: {0}".format(p))
        for line in data.splitlines():
            for k in keys:
                if line.startswith(k + "=") or line.startswith("# " + k + " "):
                    print("    {0}".format(line))
        return
    print("[*] kconfig: not exposed (no /proc/config.gz, no /boot/config-*)")

def module_state():
    loaded = False
    try:
        with open("/proc/modules") as f:
            for line in f:
                if line.startswith("algif_aead "):
                    loaded = True
                    break
    except (IOError, OSError) as e:
        print("[!] /proc/modules: {0}".format(e))
    sysmod = os.path.isdir("/sys/module/algif_aead")
    print("[*] algif_aead loaded:  {0}".format(loaded))
    print("[*] /sys/module/algif_aead present: {0}".format(sysmod))
    bl = []
    for d in ("/etc/modprobe.d", "/run/modprobe.d", "/lib/modprobe.d"):
        if not os.path.isdir(d):
            continue
        try:
            entries = os.listdir(d)
        except OSError:
            continue
        for fn in entries:
            full = os.path.join(d, fn)
            try:
                with open(full) as f:
                    if "algif_aead" in f.read():
                        bl.append(full)
            except (IOError, OSError):
                pass
    print("[*] blacklist refs:     {0}".format(bl if bl else "none"))
    return loaded, sysmod

def afalg_bind_probe():
    try:
        s = socket.socket(AF_ALG, SOCK_SEQPACKET, 0)
    except socket.error as e:
        en = e.args[0] if e.args else 0
        print("[+] AF_ALG socket() blocked: errno={0} ({1}) {2}".format(
            en, errno.errorcode.get(en, "?"), os.strerror(en) if en else ""))
        return False
    try:
        s.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
        print("[!] authencesn bind SUCCEEDED -- vulnerable surface EXPOSED")
        s.close()
        return True
    except socket.error as e:
        en = e.args[0] if e.args else 0
        print("[+] authencesn bind failed: errno={0} ({1}) {2}".format(
            en, errno.errorcode.get(en, "?"), os.strerror(en) if en else ""))
        s.close()
        return False

def userns_state():
    for p in ("/proc/sys/kernel/unprivileged_userns_clone",
              "/proc/sys/user/max_user_namespaces",
              "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"):
        if os.path.exists(p):
            try:
                with open(p) as f:
                    print("[*] {0} = {1}".format(p, f.read().strip()))
            except (IOError, OSError):
                pass

def main():
    kernel_info()
    kconfig_probe()
    loaded, sysmod = module_state()
    exposed = afalg_bind_probe()
    userns_state()
    print("")
    if exposed:
        print("VERDICT: VULNERABLE attack surface present.")
        if in_container():
            print("         In container -> Part 2 (host page-cache write) reachable.")
        sys.exit(2)
    elif not (loaded or sysmod):
        print("VERDICT: Module absent and bind blocked.")
        sys.exit(0)
    else:
        print("VERDICT: Module present but bind blocked -- review.")
        sys.exit(1)

if __name__ == "__main__":
    main()

然後結果如下:

從實體主機執行:

[qadmin@874QTS ~]$ uname -a
Linux 874QTS 5.10.60-qnap #1 SMP Thu Dec 25 01:08:48 CST 2025 x86_64 GNU/Linux
[qadmin@874QTS ~]$ python cpftest.py 
[*] Kernel:    5.10.60-qnap (x86_64)
[*] Container: False
[*] kconfig: not exposed (no /proc/config.gz, no /boot/config-*)
[*] algif_aead loaded:  False
[*] /sys/module/algif_aead present: False
[*] blacklist refs:     none
[+] AF_ALG socket() blocked: errno=97 (EAFNOSUPPORT) Address family not supported by protocol
[*] /proc/sys/user/max_user_namespaces = 127243

VERDICT: Module absent and bind blocked.

在同台 NAS 的 python container 裡執行:

root@1ebc107603de:/home# uname -a
Linux 1ebc107603de 5.10.60-qnap #1 SMP Thu Dec 25 01:08:48 CST 2025 x86_64 GNU/Linux
root@1ebc107603de:/home# python cpftest.py 
[*] Kernel:    5.10.60-qnap (x86_64)
[*] Container: True
[*] kconfig: not exposed (no /proc/config.gz, no /boot/config-*)
[*] algif_aead loaded:  False
[*] /sys/module/algif_aead present: False
[*] blacklist refs:     none
[+] AF_ALG socket() blocked: errno=97 (EAFNOSUPPORT) Address family not supported by protocol
[*] /proc/sys/user/max_user_namespaces = 127243

VERDICT: Module absent and bind blocked.

所以看起來就算用舊版 QTS 也沒問題。

註:我不是資安團隊的專家,只是單純分享一些自己的想法。

很有趣。我看到有人抱怨 QNAP 用的是某些函式庫的舊版本等等,結果這次有漏洞的,反而是新版的 Linux 核心!