【セキュリティアドバイザリ】CVE-2026-31431「Copy Fail」— QNAP NASユーザー向け影響の説明

AIのおかげで、テストスクリプトをすばやく用意できました。

QTS 5.2.8 (2025/12/25) を搭載した h874 をランダムに選び、Claude で生成し、Codex でダブルチェックしたこのスクリプトを実行して、QTS でエントリーポイントが存在するかを検証しました:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""CVE-2026-31431 サーフェスチェック。Python 2.7 / 3.x 両対応。バインド専用、安全。"""
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コンテナから:

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の古いバージョンでも問題なさそうですね。

※私はセキュリティチームのプロではありません。あくまで個人の感想・メモとして共有します。