Files
rea/src/native/elf/KernelModuleParser.ts
T

635 lines
19 KiB
TypeScript

import { createHash } from "node:crypto";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { elfObjectTypeName } from "../../domain/native/elfNotes.js";
import {
KERNEL_MODULE_VERSION_ENTRY_BYTES,
MAX_KERNEL_MODULE_KEY_BYTES,
MAX_KERNEL_MODULE_RAW_BYTES,
MAX_KERNEL_MODULE_SECTION_NAME_BYTES,
MAX_KERNEL_MODULE_VALUE_BYTES,
MODULE_SIGNATURE_INFO_BYTES,
MODULE_SIGNATURE_MAGIC,
MODULE_SIGNATURE_PKCS7_ID_TYPE,
type KernelModule,
type InspectKernelModuleInput,
} from "../../domain/native/kernelModule.js";
import type {
ImageSection,
BinaryContainer,
} from "../metadata/BinaryContainer.js";
import { readElfImage } from "../metadata/ElfImage.js";
type ElfSection = Extract<ImageSection, { format: "elf" }>;
type Report = KernelModule;
type Limitations = string[];
const MAGIC = Buffer.from(MODULE_SIGNATURE_MAGIC, "latin1");
const GZIP = Buffer.from([0x1f, 0x8b]);
const XZ = Buffer.from([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]);
const ZSTD = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
const sha256 = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
const exceeds = (examined: number, maxEntries: number): void => {
if (examined > maxEntries)
throw new ArtifactReaderFailure(
"limit",
`Kernel module entries exceed the ${String(maxEntries)}-entry budget.`,
{ boundary: "capacity" },
);
};
const fits = (offset: bigint, size: bigint, fileLength: number): boolean =>
offset >= 0n &&
size >= 0n &&
offset <= BigInt(fileLength) &&
size <= BigInt(fileLength) - offset;
const locationOf = (
section: ElfSection,
): { offset: number; bytes: number } | null => {
if (
!fits(
section.declared_range.offset,
section.declared_range.size,
Number.MAX_SAFE_INTEGER,
)
)
return null;
return {
offset: Number(section.declared_range.offset),
bytes: Number(section.declared_range.size),
};
};
const inFile = (section: ElfSection, fileLength: number): boolean =>
fits(section.declared_range.offset, section.declared_range.size, fileLength);
const rawHex = (bytes: Buffer): string | null =>
bytes.length > MAX_KERNEL_MODULE_RAW_BYTES ? null : bytes.toString("hex");
const compressionCodec = (bytes: Buffer): Report["compression"] => {
if (bytes.subarray(0, GZIP.length).equals(GZIP))
return { codec: "gzip", status: "unsupported" };
if (bytes.length >= XZ.length && bytes.subarray(0, XZ.length).equals(XZ))
return { codec: "xz", status: "unsupported" };
if (
bytes.length >= ZSTD.length &&
bytes.subarray(0, ZSTD.length).equals(ZSTD)
)
return { codec: "zstd", status: "unsupported" };
return null;
};
const baseLimitations = (): Limitations => [
"This workflow does not execute the module, resolve imported symbols, or validate an appended signature.",
"struct module in .gnu.linkonce.this_module is kernel-version specific and is not decoded.",
"__ksymtab entry layout is kernel-version specific and is not decoded.",
"Only classic __versions records are decoded. Their absence does not establish the absence of CONFIG_EXTENDED_MODVERSIONS metadata in __version_ext_names and __version_ext_crcs.",
"Metadata strings use a byte-preserving Latin1 projection; their original text encoding is unobserved.",
];
const headerFacts = (
image: Extract<BinaryContainer, { format: "elf" }>,
): Pick<Report, "format" | "byte_order" | "machine" | "type" | "type_name"> => {
const { type } = image.elf;
return {
format: image.bits === 64 ? "elf64" : "elf32",
byte_order: image.byte_order,
machine: image.elf.machine,
type,
type_name: elfObjectTypeName(type),
};
};
const unsupportedCompression = (
artifact: Report["artifact"],
compression: NonNullable<Report["compression"]>,
): Report => ({
artifact,
format: null,
byte_order: null,
machine: null,
type: null,
type_name: null,
applicability: "unsupported",
compression,
next_probe: {
operation: "external_decompression",
rationale: `Decompress this ${compression.codec} container with an external ${compression.codec} utility into a separate file, then call inspect_kernel_module on the decompressed file. REA does not decode ${compression.codec} containers.`,
},
modinfo: null,
versions: null,
this_module: null,
ksymtab: null,
init_text: null,
exit_text: null,
signature: null,
coverage: { status: "unsupported", examined_entries: 0 },
limitations: [
...baseLimitations(),
"gzip, xz, and zstd kernel-module containers are not decoded. The existing ELF section decoder does not cover these whole-file codecs.",
"A compressed container is not proof the payload is a kernel module.",
],
});
const notApplicable = (
artifact: Report["artifact"],
facts: ReturnType<typeof headerFacts>,
): Report => ({
artifact,
...facts,
applicability: "not_applicable",
compression: null,
next_probe: null,
modinfo: null,
versions: null,
this_module: null,
ksymtab: null,
init_text: null,
exit_text: null,
signature: null,
coverage: { status: "not_applicable", examined_entries: 0 },
limitations: [
...baseLimitations(),
"Kernel module metadata is defined for ET_REL objects. This ELF type is not applicable, so .modinfo, __versions, and the signature trailer were not examined.",
],
});
interface SectionUse {
readonly namesComplete: boolean;
readonly limitations: Limitations;
}
const sectionsNamed = (
sections: readonly ElfSection[],
name: string,
): readonly ElfSection[] => sections.filter((section) => section.name === name);
const parseModinfo = (
bytes: Buffer,
section: ElfSection | undefined,
use: SectionUse,
examined: { count: number },
maxEntries: number,
): Report["modinfo"] => {
if (section === undefined)
return {
status: use.namesComplete ? "absent" : "unknown",
location: null,
sha256: null,
entries: [],
reason: null,
};
const location = locationOf(section);
if (section.type === 8 || location === null || !inFile(section, bytes.length))
return {
status: "malformed",
location: null,
sha256: null,
entries: [],
reason: "modinfo_section_not_file_backed",
};
const stored = bytes.subarray(
location.offset,
location.offset + location.bytes,
);
if (section.compressed)
return {
status: "unsupported",
location,
sha256: sha256(stored),
entries: [],
reason: "modinfo_section_compressed",
};
const entries: NonNullable<Report["modinfo"]>["entries"] = [];
let at = 0;
while (at < stored.length) {
if (stored[at] === 0) {
at += 1;
continue;
}
const end = stored.indexOf(0, at);
const terminated = end >= 0;
const stop = terminated ? end : stored.length;
const record = stored.subarray(at, stop);
const raw = stored.subarray(at, terminated ? end + 1 : stored.length);
examined.count += 1;
exceeds(examined.count, maxEntries);
const separator = record.indexOf(0x3d);
let key: string | null = null;
let value: string | null = null;
let reason: NonNullable<Report["modinfo"]>["entries"][number]["reason"] =
terminated ? null : "unterminated_modinfo_entry";
if (separator < 0) reason ??= "modinfo_missing_separator";
else {
const keyBytes = record.subarray(0, separator);
const valueBytes = record.subarray(separator + 1);
if (keyBytes.length === 0) reason ??= "modinfo_empty_key";
if (
keyBytes.length > MAX_KERNEL_MODULE_KEY_BYTES ||
valueBytes.length > MAX_KERNEL_MODULE_VALUE_BYTES
)
reason ??= "modinfo_field_exceeds_limit";
if (keyBytes.length > 0 && keyBytes.length <= MAX_KERNEL_MODULE_KEY_BYTES)
key = keyBytes.toString("latin1");
if (valueBytes.length <= MAX_KERNEL_MODULE_VALUE_BYTES)
value = valueBytes.toString("latin1");
}
entries.push({
index: entries.length,
location: {
offset: location.offset + at,
bytes: raw.length,
},
key,
value,
raw_sha256: sha256(raw),
raw_hex: rawHex(raw),
status: reason === null ? "observed" : "malformed",
reason,
});
if (!terminated) break;
at = end + 1;
}
return {
status: "present",
location,
sha256: sha256(stored),
entries,
reason: null,
};
};
const parseVersions = (
bytes: Buffer,
section: ElfSection | undefined,
bits: 32 | 64,
little: boolean,
use: SectionUse,
examined: { count: number },
maxEntries: number,
): Report["versions"] => {
const layout =
bits === 64 ? "elf64_unsigned_long_name56" : "elf32_unsigned_long_name60";
const entryBytes = KERNEL_MODULE_VERSION_ENTRY_BYTES;
if (section === undefined)
return {
status: use.namesComplete ? "absent" : "unknown",
layout,
entry_bytes: entryBytes,
location: null,
sha256: null,
entries: [],
reason: null,
};
const location = locationOf(section);
if (section.type === 8 || location === null || !inFile(section, bytes.length))
return {
status: "malformed",
layout,
entry_bytes: entryBytes,
location: null,
sha256: null,
entries: [],
reason: "versions_section_not_file_backed",
};
const stored = bytes.subarray(
location.offset,
location.offset + location.bytes,
);
if (section.compressed)
return {
status: "unsupported",
layout,
entry_bytes: entryBytes,
location,
sha256: sha256(stored),
entries: [],
reason: "versions_section_compressed",
};
if (stored.length % entryBytes !== 0)
return {
status: "malformed",
layout,
entry_bytes: entryBytes,
location,
sha256: sha256(stored),
entries: [],
reason: "versions_size_not_multiple",
};
const nameBytes = bits === 64 ? 56 : 60;
const crcBytes = bits === 64 ? 8 : 4;
const count = stored.length / entryBytes;
exceeds(examined.count + count, maxEntries);
const entries: NonNullable<Report["versions"]>["entries"] = [];
for (let index = 0; index < count; index += 1) {
const start = index * entryBytes;
const record = stored.subarray(start, start + entryBytes);
const crc =
bits === 64
? little
? record.readBigUInt64LE(0)
: record.readBigUInt64BE(0)
: BigInt(little ? record.readUInt32LE(0) : record.readUInt32BE(0));
const nameField = record.subarray(crcBytes, crcBytes + nameBytes);
const end = nameField.indexOf(0);
entries.push({
index,
location: { offset: location.offset + start, bytes: entryBytes },
crc_hex: crc.toString(16).padStart(crcBytes * 2, "0"),
name: end < 0 ? null : nameField.subarray(0, end).toString("latin1"),
status: end < 0 ? "malformed" : "observed",
reason: end < 0 ? "version_name_unterminated" : null,
});
}
examined.count += count;
return {
status: "present",
layout,
entry_bytes: entryBytes,
location,
sha256: sha256(stored),
entries,
reason: null,
};
};
const presence = (
section: ElfSection | undefined,
namesComplete: boolean,
fileLength: number,
): {
readonly status: "present" | "absent" | "unknown" | "unsupported";
readonly location: { offset: number; bytes: number } | null;
} => {
if (section === undefined)
return { status: namesComplete ? "absent" : "unknown", location: null };
if (section.type === 8 || !inFile(section, fileLength))
return { status: "present", location: null };
const location = locationOf(section);
if (section.compressed) return { status: "unsupported", location };
return { status: "present", location };
};
const sized = (
bytes: Buffer,
section: ElfSection | undefined,
namesComplete: boolean,
limitations: Limitations,
label: string,
): Report["init_text"] => {
if (section === undefined)
return {
status: namesComplete ? "absent" : "unknown",
location: null,
bytes: null,
compressed: null,
};
if (!inFile(section, bytes.length) || section.type === 8) {
limitations.push(
`${label} is declared but not file-backed, so its size is unknown.`,
);
return {
status: "present",
location: null,
bytes: null,
compressed: null,
};
}
const location = locationOf(section);
if (section.compressed)
limitations.push(
`${label} is stored compressed. The reported size is the file-backed storage size; the decoded size is unknown.`,
);
return {
status: "present",
location,
bytes: location?.bytes ?? null,
compressed: section.compressed,
};
};
const parseSignature = (bytes: Buffer): Report["signature"] => {
if (
bytes.length < MAGIC.length ||
!bytes.subarray(bytes.length - MAGIC.length).equals(MAGIC)
)
return {
status: "absent",
magic: null,
info: null,
pkcs7: null,
reason: null,
};
const magic = {
offset: bytes.length - MAGIC.length,
bytes: MAGIC.length,
};
if (magic.offset < MODULE_SIGNATURE_INFO_BYTES)
return {
status: "malformed",
magic,
info: null,
pkcs7: null,
reason: "signature_trailer_truncated",
};
const infoOffset = magic.offset - MODULE_SIGNATURE_INFO_BYTES;
const info = {
location: { offset: infoOffset, bytes: MODULE_SIGNATURE_INFO_BYTES },
algo: bytes[infoOffset] ?? 0,
hash: bytes[infoOffset + 1] ?? 0,
id_type: bytes[infoOffset + 2] ?? 0,
signer_len: bytes[infoOffset + 3] ?? 0,
key_id_len: bytes[infoOffset + 4] ?? 0,
pad: [
bytes[infoOffset + 5] ?? 0,
bytes[infoOffset + 6] ?? 0,
bytes[infoOffset + 7] ?? 0,
] as [number, number, number],
sig_len: bytes.readUInt32BE(infoOffset + 8),
};
if (info.sig_len >= infoOffset)
return {
status: "malformed",
magic,
info,
pkcs7: null,
reason: "signature_length_inconsistent",
};
if (info.id_type !== MODULE_SIGNATURE_PKCS7_ID_TYPE)
return {
status: "unsupported",
magic,
info,
pkcs7: null,
reason: "signature_id_type_not_pkcs7",
};
const parametersNonzero =
info.algo !== 0 ||
info.hash !== 0 ||
info.signer_len !== 0 ||
info.key_id_len !== 0 ||
info.pad.some((value) => value !== 0);
if (parametersNonzero)
return {
status: "malformed",
magic,
info,
pkcs7: null,
reason: "signature_parameters_nonzero",
};
const pkcs7 = bytes.subarray(infoOffset - info.sig_len, infoOffset);
return {
status: "present",
magic,
info,
pkcs7: {
location: { offset: infoOffset - info.sig_len, bytes: info.sig_len },
sha256: sha256(pkcs7),
},
reason: null,
};
};
const signatureLimitation = (signature: Report["signature"]): string => {
if (signature?.status === "absent")
return "No '~Module signature appended~' trailer was present. That is not proof the module is unsigned in another container.";
if (signature?.status === "present")
return "PKCS#7 bytes are identified by the module_signature trailer and hashed. Certificate chain, signer, and digest coverage are unobserved.";
return "The appended signature trailer was not accepted as an unvalidated PKCS#7 range.";
};
/** Decode kernel-module metadata from one in-memory ET_REL image. */
export const parseKernelModule = (
bytes: Buffer,
artifact: Report["artifact"],
input: InspectKernelModuleInput,
): Report => {
const compression = compressionCodec(bytes);
if (compression !== null)
return unsupportedCompression(artifact, compression);
const image = readElfImage(bytes);
const facts = headerFacts(image);
if (facts.type !== 1) return notApplicable(artifact, facts);
const sections = image.sections.filter(
(section): section is ElfSection => section.format === "elf",
);
const namesComplete =
image.section_names_limitation === undefined &&
sections.every((section) => section.name !== null);
const limitations = baseLimitations();
if (image.section_names_limitation !== undefined)
limitations.push(image.section_names_limitation);
const use = { namesComplete, limitations };
const examined = { count: 0 };
const noteDuplicates = (name: string, found: readonly ElfSection[]): void => {
if (found.length > 1)
limitations.push(
`Multiple ${name} sections are declared; the first is examined.`,
);
};
const modinfoSections = sectionsNamed(sections, ".modinfo");
const versionSections = sectionsNamed(sections, "__versions");
const moduleSections = sectionsNamed(sections, ".gnu.linkonce.this_module");
noteDuplicates(".modinfo", modinfoSections);
noteDuplicates("__versions", versionSections);
noteDuplicates(".gnu.linkonce.this_module", moduleSections);
const modinfo = parseModinfo(
bytes,
modinfoSections[0],
use,
examined,
input.max_entries,
);
const versions = parseVersions(
bytes,
versionSections[0],
image.bits,
image.byte_order === "little",
use,
examined,
input.max_entries,
);
const modulePresence = presence(
moduleSections[0],
namesComplete,
bytes.length,
);
const ksymtabNamed = sections.filter(
(section) => section.name?.startsWith("__ksymtab") === true,
);
const ksymtabSections = ksymtabNamed.filter(
(section) => section.type !== 8 && inFile(section, bytes.length),
);
const unbacked = ksymtabNamed.length - ksymtabSections.length;
if (unbacked > 0)
limitations.push(
`${String(unbacked)} __ksymtab* section(s) are declared but not file-backed, so their ranges are unknown.`,
);
const ksymtab = ksymtabSections.flatMap((section) => {
const location = locationOf(section);
if (section.name === null || location === null) return [];
const fitsName =
section.name.length <= MAX_KERNEL_MODULE_SECTION_NAME_BYTES;
return [
{
name: fitsName ? section.name : null,
location,
compressed: section.compressed,
reason: fitsName ? null : ("ksymtab_name_exceeds_limit" as const),
},
];
});
// Each listed section repeats its name, so the list shares the entry budget.
examined.count += ksymtab.length;
exceeds(examined.count, input.max_entries);
if (!namesComplete)
limitations.push(
"Section names are incomplete, so a missing .modinfo, __versions, this_module, __ksymtab, .init.text, or .exit.text section is unknown rather than absent.",
);
const signature = parseSignature(bytes);
if (modinfo?.status === "absent")
limitations.push("No .modinfo section was declared.");
if (versions?.status === "absent")
limitations.push("No __versions section was declared.");
limitations.push(signatureLimitation(signature));
return {
artifact,
...facts,
applicability: "applicable",
compression: null,
next_probe: null,
modinfo,
versions,
this_module: {
status: modulePresence.status,
location: modulePresence.location,
layout: "unknown",
},
ksymtab: {
status: namesComplete ? "observed" : "unknown",
sections: ksymtab,
},
init_text: sized(
bytes,
sectionsNamed(sections, ".init.text")[0],
namesComplete,
limitations,
".init.text",
),
exit_text: sized(
bytes,
sectionsNamed(sections, ".exit.text")[0],
namesComplete,
limitations,
".exit.text",
),
signature,
coverage: { status: "complete", examined_entries: examined.count },
limitations,
};
};