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。因此流程必须是:提取 → 归一化 → 去重合并 → 版本区间比对 → 风险标记。
提取脚本:三类证据统一入库
下面脚本在已解包的固件根目录上运行,输出 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 | 证据路径 | 是否加载 | 处置建议 |
|---|---|---|---|---|---|
| openssl | 1.1.1k | CVE-2022-0778 | /usr/lib/libssl.so.1.1 | 待确认 | 升级至 1.1.1n 或禁用证书解析路径 |
| libcurl | 7.83.1 | CVE-2022-32221 | /usr/lib/libcurl.so.4 | 是 | 升级至 7.86.0 以上 |
| busybox | 1.35.0 | — | /bin/busybox | 是 | 无需处置(区间外) |
验证方法:把
sbom.csv 中 openssl 版本手工改为 1.1.1n 后重跑 vuln_match.py,命中数应从 2 降为 1,说明区间边界判断正确。