3. 固件中第三方组件清单生成与版本比对

固件中第三方组件清单生成与版本比对 学习卡片

从固件文件系统到组件清单:提取、归一化与漏洞比对

固件解包后,第三方组件通常以三种形态存在:可执行文件/共享库(ELF、.so)、包管理元数据(opkg status、dpkg status、Buildroot 的 manifest)、语言级依赖清单(Python 的 METADATA、Node 的 package.json、Go 的 buildinfo)。本节的任务是把这些异构证据统一成一张可比的组件表,再与漏洞库做版本区间匹配。

关键难点不在“找文件”,而在版本可比性:同一组件可能被记录为 1.2.3-r4(OpenWrt 修订号)、1.2.3+git20220101(快照)、v1.2.3(前缀),而漏洞库的受影响区间通常写成 >=1.0.0, <1.2.4。因此流程必须是:提取 → 归一化 → 去重合并 → 版本区间比对 → 风险标记。

① 证据来源(解包后的根文件系统) ELF / .so 二进制 strings 提取版本串 如 OpenSSL 1.1.1k /usr/lib/libssl.so.1.1 包管理元数据 /usr/lib/opkg/status /var/lib/dpkg/status Package / Version 字段 语言级依赖清单 *.dist-info/METADATA node_modules/*/package.json Go buildinfo / go.mod 构建残留与清单文件 /etc/*.manifest、build-info /usr/share/doc/*/copyright (含上游版本与许可证) ② 归一化与去重合并 去前缀 v / 剥离 -rN 修订号 / 展开 +git 快照 同一组件多来源冲突时保留“最旧可解析版本” ③ 归一化后的组件表(SBOM 核心字段) 组件名 归一化版本 来源证据 路径 许可证 openssl 1.1.1k ELF strings /usr/lib/libssl.so.1.1 Apache-2.0 busybox 1.35.0 opkg status /bin/busybox GPL-2.0 libcurl 7.83.1 opkg status /usr/lib/libcurl.so.4 curl ④ 与漏洞库版本区间比对 affected: >=1.0.2, <1.1.1n → 命中 无匹配区间 → 标记为“未命中” 高风险组件标记 openssl 1.1.1k → CVE-2022-0778 libcurl 7.83.1 → CVE-2022-32221 比对前必须校验 版本号是否可解析 区间边界是否含等号

提取脚本:三类证据统一入库

下面脚本在已解包的固件根目录上运行,输出 sbom.csv。它不依赖外部漏洞库,只负责把组件与版本可靠地抽出来——这是后续比对的前提。

#!/usr/bin/env python3
# sbom_extract.py —— 从固件根文件系统提取第三方组件清单
import os, re, csv, json, sys

ROOT = sys.argv[1] if len(sys.argv) > 1 else "./rootfs"
rows = []

# 1) opkg / dpkg status:最可靠的包名+版本来源
def parse_status(path, fmt):
    if not os.path.isfile(path):
        return
    pkg = ver = lic = None
    with open(path, errors="ignore") as f:
        for line in f:
            line = line.rstrip("\n")
            if line.startswith("Package:"):
                pkg = line.split(":", 1)[1].strip()
            elif line.startswith("Version:"):
                ver = line.split(":", 1)[1].strip()
            elif line.startswith("License:"):
                lic = line.split(":", 1)[1].strip()
            elif line == "" and pkg and ver:
                rows.append({"name": pkg, "version": ver,
                             "source": fmt, "path": path, "license": lic or ""})
                pkg = ver = lic = None

parse_status(os.path.join(ROOT, "usr/lib/opkg/status"), "opkg")
parse_status(os.path.join(ROOT, "var/lib/dpkg/status"), "dpkg")

# 2) ELF 二进制:用版本串兜底(仅当包管理未覆盖时)
VER_RE = re.compile(rb"(OpenSSL|curl|BusyBox|zlib|libxml2)[ /-]?v?(\d+\.\d+(?:\.\d+)?[a-z]?)")
for dirpath, _, files in os.walk(ROOT):
    for fn in files:
        fp = os.path.join(dirpath, fn)
        if not (fn.endswith(".so") or fn.endswith(".so.1") or fn == "busybox"):
            continue
        try:
            with open(fp, "rb") as f:
                head = f.read(4096)
        except OSError:
            continue
        for m in VER_RE.finditer(head):
            rows.append({"name": m.group(1).decode().lower(),
                         "version": m.group(2).decode(),
                         "source": "elf-strings", "path": fp, "license": ""})

# 3) Python dist-info:语言级依赖
for dirpath, dirs, _ in os.walk(ROOT):
    for d in list(dirs):
        if d.endswith(".dist-info"):
            meta = os.path.join(dirpath, d, "METADATA")
            if os.path.isfile(meta):
                name = ver = ""
                with open(meta, errors="ignore") as f:
                    for line in f:
                        if line.startswith("Name:"):
                            name = line.split(":", 1)[1].strip()
                        elif line.startswith("Version:"):
                            ver = line.split(":", 1)[1].strip()
                        if name and ver:
                            break
                if name and ver:
                    rows.append({"name": name.lower(), "version": ver,
                                 "source": "python-metadata",
                                 "path": meta, "license": ""})

# 4) 归一化:去 v 前缀、剥离 -rN 修订号、展开 +git 快照
def normalize(v):
    v = v.strip().lstrip("vV")
    v = re.sub(r"[-+]r\d+$", "", v)          # 1.2.3-r4 → 1.2.3
    v = re.sub(r"\+git\d+.*$", "", v)        # 1.2.3+git20220101 → 1.2.3
    return v

seen = {}
for r in rows:
    r["version"] = normalize(r["version"])
    key = (r["name"], r["version"])
    if key not in seen:
        seen[key] = r
    else:
        # 多来源冲突:保留来源优先级更高的记录
        prio = {"opkg": 3, "dpkg": 3, "python-metadata": 2, "elf-strings": 1}
        if prio.get(r["source"], 0) > prio.get(seen[key]["source"], 0):
            seen[key] = r

with open("sbom.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=["name", "version", "source", "path", "license"])
    w.writeheader()
    for r in sorted(seen.values(), key=lambda x: x["name"]):
        w.writerow(r)

print(f"[+] 提取组件 {len(seen)} 条,已写入 sbom.csv")
运行方式:python3 sbom_extract.py ./rootfs。若固件为 SquashFS,先解包到 ./rootfs 再执行。脚本只读文件,不修改固件内容。

版本区间比对:把 SBOM 变成风险清单

漏洞库中每条记录包含 affected 区间。比对时不能做字符串比较("1.10" < "1.9" 为真但语义错误),必须按数字段解析。下面函数处理常见的 >=、<、<= 组合。

#!/usr/bin/env python3
# vuln_match.py —— 将 sbom.csv 与漏洞区间比对,输出高风险组件
import csv, re

def parse_ver(v):
    """把 1.1.1k 解析为 (1,1,1,'k'),字母后缀参与比较"""
    m = re.match(r"^(\d+(?:\.\d+)*)([a-z]?)$", v)
    if not m:
        return None
    nums = tuple(int(x) for x in m.group(1).split("."))
    return nums + (m.group(2),)

def cmp_ver(a, b):
    pa, pb = parse_ver(a), parse_ver(b)
    if pa is None or pb is None:
        return None
    # 补齐长度,缺失段视为 0
    n = max(len(pa), len(pb))
    pa = pa + (0,) * (n - len(pa))
    pb = pb + (0,) * (n - len(pb))
    return (pa > pb) - (pa < pb)

# 示例漏洞库(教学用,非真实 CVE 数据)
VULN_DB = [
    {"cve": "CVE-2022-0778", "pkg": "openssl",
     "range": [(">=", "1.0.2"), ("<", "1.1.1n")]},
    {"cve": "CVE-2022-32221", "pkg": "libcurl",
     "range": [(">=", "7.0.0"), ("<", "7.86.0")]},
    {"cve": "CVE-2022-28391", "pkg": "busybox",
     "range": [(">=", "1.30.0"), ("<", "1.35.0")]},
]

def in_range(ver, conds):
    for op, bound in conds:
        c = cmp_ver(ver, bound)
        if c is None:
            return False
        if op == ">=" and c < 0: return False
        if op == ">"  and c <= 0: return False
        if op == "<=" and c > 0: return False
        if op == "<"  and c >= 0: return False
    return True

hits = []
with open("sbom.csv") as f:
    for row in csv.DictReader(f):
        for v in VULN_DB:
            if row["name"] == v["pkg"] and in_range(row["version"], v["range"]):
                hits.append({**row, "cve": v["cve"]})

print(f"[!] 命中高风险组件 {len(hits)} 条")
for h in hits:
    print(f"  {h['cve']}  {h['name']} {h['version']}  ({h['path']})")

以示例数据运行,预期输出为:

[!] 命中高风险组件 2 条
  CVE-2022-0778  openssl 1.1.1k  (/usr/lib/libssl.so.1.1)
  CVE-2022-32221  libcurl 7.83.1  (/usr/lib/libcurl.so.4)
busybox 1.35.0 未命中,因为区间上界为 <1.35.0,等号边界必须严格区分。若把 < 误写成 <=,会多报一条假阳性。

结果判读与交付物

比对完成后,需要人工确认三件事,避免把“版本命中”直接当成“可利用”:

  • 组件是否真的被加载:/usr/lib/libssl.so.1.1 存在,但若设备只跑静态链接的 busybox,该库可能从未被调用。
  • 版本串是否来自上游:ELF strings 抽出的版本可能被厂商 patch 过,需与 opkg status 交叉验证。
  • 漏洞触发条件是否满足:如 CVE-2022-0778 需要解析恶意证书,若设备不校验证书链则风险等级下降。

最终交付一份可复查的风险清单,字段如下:

组件版本CVE证据路径是否加载处置建议
openssl1.1.1kCVE-2022-0778/usr/lib/libssl.so.1.1待确认升级至 1.1.1n 或禁用证书解析路径
libcurl7.83.1CVE-2022-32221/usr/lib/libcurl.so.4是升级至 7.86.0 以上
busybox1.35.0—/bin/busybox是无需处置(区间外)
验证方法:把 sbom.csv 中 openssl 版本手工改为 1.1.1n 后重跑 vuln_match.py,命中数应从 2 降为 1,说明区间边界判断正确。