Compare commits

..
26 changed files with 731 additions and 288 deletions

No files matched your search

+12
View File
@@ -135,13 +135,25 @@ def inspect_elf(path, cache):
from pwnlib.context import context
with context.local(cache_dir=str(cache), log_level="warning"):
from elftools.common.exceptions import ELFError
from elftools.common.utils import struct_parse
from elftools.elf.elffile import ELFFile
from pwnlib.elf import ELF
try:
headers = ELFFile(io.BytesIO(content))
section_count = headers.num_sections()
if section_count:
if headers.header.e_shentsize < headers.structs.Elf_Shdr.sizeof():
raise LayoutFailure("format", "ELF section-header entry is shorter than the required structure.")
location(headers.header.e_shoff, section_count * headers.header.e_shentsize, len(content))
# Upstream string/symbol/relocation access reads stored stream bytes,
# while this profile reports logical names and file coordinates.
# Admit those encodings before LayoutELF populates convenience maps.
for index in range(section_count):
raw_header = struct_parse(headers.structs.Elf_Shdr, headers.stream,
stream_pos=headers.header.e_shoff + index * headers.header.e_shentsize)
if raw_header.sh_flags & 0x800 and raw_header.sh_type in (
"SHT_STRTAB", "SHT_SYMTAB", "SHT_DYNSYM", "SHT_REL", "SHT_RELA", "SHT_RELR", "SHT_DYNAMIC"):
raise LayoutFailure("unsupported-target", f"ELF section {index} ({raw_header.sh_type}) is SHF_COMPRESSED; logical metadata and its source coordinates are not decoded by this profile.")
segment_count = headers.num_segments()
if segment_count:
location(headers.header.e_phoff, segment_count * headers.header.e_phentsize, len(content))
+87
View File
@@ -0,0 +1,87 @@
"""Producer admission regressions; no pwntools imports or target execution."""
from contextlib import nullcontext
import importlib.metadata
from pathlib import Path
import runpy
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
layout = runpy.run_path(str(Path(__file__).with_name("layout_impl.py")))
class UpstreamReached(RuntimeError):
pass
class LayoutStorageTests(unittest.TestCase):
def inspect(self, section_type, flags, section_stride=64):
class Headers:
header = types.SimpleNamespace(e_shoff=64, e_shentsize=section_stride, e_shstrndx=0)
structs = types.SimpleNamespace(Elf_Shdr=types.SimpleNamespace(sizeof=lambda: 64))
stream = object()
def num_sections(self): return 2
def num_segments(self): return 0
def get_shstrndx(self): return 0
def _get_section_header(self, index):
return types.SimpleNamespace(sh_type="SHT_NULL" if index == 0 else section_type,
sh_flags=0 if index == 0 else flags)
class Elf:
def __init__(self, *_args, **_kwargs):
raise UpstreamReached("No artifact decoding or emulation is allowed in this test")
modules = {name: types.ModuleType(name) for name in (
"pwnlib", "pwnlib.context", "pwnlib.elf", "elftools", "elftools.common",
"elftools.common.exceptions", "elftools.common.utils", "elftools.elf", "elftools.elf.elffile")}
modules["pwnlib.context"].context = types.SimpleNamespace(local=lambda **_kwargs: nullcontext())
modules["pwnlib.elf"].ELF = Elf
modules["elftools.common.exceptions"].ELFError = type("ELFError", (Exception,), {})
modules["elftools.elf.elffile"].ELFFile = lambda _stream: Headers()
modules["elftools.common.utils"].struct_parse = lambda _struct, _stream, stream_pos: Headers()._get_section_header((stream_pos - 64) // max(section_stride, 1))
content = bytearray(64 + 2 * max(section_stride, 64))
content[:7] = b"\x7fELF\x02\x01\x01"
content[16:18] = (1).to_bytes(2, "little") # ET_REL
content[18:20] = (62).to_bytes(2, "little")
content[40:48] = (64).to_bytes(8, "little")
content[58:60] = section_stride.to_bytes(2, "little")
content[60:62] = (2).to_bytes(2, "little")
with tempfile.TemporaryDirectory() as directory:
snapshot = Path(directory) / "object.snapshot"
snapshot.write_bytes(content)
with patch.dict(sys.modules, modules), patch.object(importlib.metadata, "version", side_effect=layout["PACKAGES"].__getitem__):
return layout["inspect_elf"](snapshot, Path(directory) / "cache")
def test_compressed_interpreted_storage_refuses_before_upstream_decoding(self):
for section_type in ("SHT_STRTAB", "SHT_SYMTAB", "SHT_DYNSYM", "SHT_REL", "SHT_RELA", "SHT_RELR", "SHT_DYNAMIC"):
with self.subTest(section_type=section_type):
with self.assertRaises(layout["LayoutFailure"]) as raised:
self.inspect(section_type, 0x800)
self.assertEqual(raised.exception.reason, "unsupported-target")
self.assertIn("SHF_COMPRESSED", str(raised.exception))
with self.assertRaises(UpstreamReached):
self.inspect(section_type, 0)
def test_uninterpreted_compressed_payload_does_not_change_admission(self):
with self.assertRaises(UpstreamReached):
self.inspect("SHT_PROGBITS", 0x800)
def test_short_section_header_stride_refuses_before_raw_header_scan(self):
for stride in (0, 1, 63):
with self.subTest(stride=stride):
with self.assertRaises(layout["LayoutFailure"]) as raised:
self.inspect("SHT_PROGBITS", 0, stride)
self.assertEqual(raised.exception.reason, "format")
self.assertIn("section-header entry", str(raised.exception))
for stride in (64, 96):
with self.subTest(stride=stride):
with self.assertRaises(UpstreamReached):
self.inspect("SHT_PROGBITS", 0, stride)
if __name__ == "__main__":
unittest.main()
+12
View File
@@ -44,6 +44,11 @@ before dependency seeks. Structural inventory and the convenience-map engine
still parse the snapshot separately; eliminating that remaining duplication
requires preserving the upstream GOT/PLT and mitigation facts.
The layout profile returns `unsupported_target` for `SHF_COMPRESSED` string,
symbol, relocation or dynamic metadata sections before upstream convenience-map
decoding. Their logical names and entry coordinates cannot be read from the
compressed file bytes. Other compressed payload sections remain opaque.
## Interpreting results
- Artifact path, SHA-256 and size identify original selected bytes. All parsing
@@ -478,6 +483,13 @@ section directory with neither a dynamic symbol table nor a dynamic segment
reports a complete empty inventory. Mach-O exports are reported by
`inspect_macho`.
ELF export tables marked `SHF_COMPRESSED` are not decompressed. A compressed
dynamic symbol table or its string table reports `unsupported` coverage rather
than treating stored bytes as symbols or names. Compressed version tables or
their string tables keep readable export rows with `partial` coverage and
unresolved versions. When version definition storage is compressed, requirement
names also remain unknown because unseen definitions may override them.
`max_file_bytes` defaults to 256 MiB (maximum 512 MiB; CLI `--max-file-bytes`).
Export directories in PE headers are read like those in sections. An export or
dynamic symbol table above 262144 entries, more than 262144 rows, or more than
+7
View File
@@ -173,5 +173,12 @@ on one page share a fixed 16 MiB text allowance; a page that exceeds it fails
with a resource error whose recovery is a smaller `limit`, because
`max_ranges` and `max_file_bytes` do not raise it.
Compressed symbol or name tables and symbol-table links that do not name a
file-backed `SHT_STRTAB` section leave symbol names unknown and coverage
incomplete; decoded FDE ranges remain available.
A compressed dynamic section cannot supply `DT_PLTGOT`. Without another
observed GOT base, affected data-relative unwind pointers remain unresolved.
Malformed ELF headers fail without partial success. This workflow does not
execute the image, evaluate CFA programs, or follow indirect pointers.
@@ -150,6 +150,11 @@ A symbol name that cannot be read, because its string table is missing or its
offset lies outside the table, is reported as no name and makes the report
`incomplete` with a counted limitation. Name offset 0 is the empty name.
ELF sections marked `SHF_COMPRESSED` are not decoded as initializer slots,
symbols, names or relocation records. Affected facts remain unresolved and
coverage is incomplete; other readable tables remain available. An explicit
`DT_SYMTAB` must identify the decoded symbol table used for dynamic relocations.
The dynamic relocation tables are read in steps that yield to other work and
observe cancellation, so a large `DT_RELR` table does not block the server.
@@ -5,8 +5,6 @@ import { expect, it, vi } from "vitest";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { apiContractSchema } from "../../domain/apiContract/apiContract.js";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import { parseEvidence } from "../../domain/evidence.js";
import { projectAnalysisView } from "../../domain/analysisView/analysisView.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { inspectApiContractEvidence } from "./ApiContractService.js";
import * as readOwner from "./ApiContractRead.js";
@@ -319,89 +317,3 @@ it("cancels a cooperative document traversal after the actual verified file read
await resources.close();
}
});
it.each(["between-paths", "after-projection"] as const)(
"preserves authenticated OpenAPI facts on cancellation %s",
async (stage) => {
const bytes = JSON.stringify({
openapi: "3.1.2",
info: { title: "Cancellation", version: "1" },
paths: { "/first": { get: {} }, "/second": { post: {} } },
});
const { path } = await inputFile(bytes);
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const realParse = parser.parseApiContractSteps;
try {
const baseline = await inspectApiContractEvidence({ path }, resources);
if (!baseline.ok) throw baseline.error;
const complete = apiContractSchema.parse(
baseline.value.normalized_result,
);
const scheduled = vi
.spyOn(parser, "parseApiContractSteps")
.mockImplementationOnce(function* (...args) {
const steps = realParse(...args);
if (stage === "between-paths") {
expect(steps.next().done).toBe(false);
expect(steps.next().done).toBe(false);
setImmediate(() => controller.abort(stage));
return yield* steps;
}
const result = yield* steps;
setImmediate(() => controller.abort(stage));
return result;
});
try {
const result = await inspectApiContractEvidence({ path }, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(stage);
const evidence = parseEvidence(result.error.partialObservation);
const retained = apiContractSchema.parse(evidence.normalized_result);
expect(evidence.subject).toEqual(baseline.value.subject);
expect(evidence.parameters).toEqual(baseline.value.parameters);
expect(retained.artifact).toEqual(complete.artifact);
expect(retained.contract_id).toBe(complete.contract_id);
if (stage === "between-paths") {
expect(retained.operations).toEqual(complete.operations.slice(0, 1));
expect(retained.source_coverage.status).toBe("partial");
expect(evidence.evidence_id).not.toBe(baseline.value.evidence_id);
const page = projectAnalysisView(
{
evidenceId: evidence.evidence_id,
operation: evidence.operation,
predicateType: evidence.predicate_type,
normalizedResult: evidence.normalized_result,
limitations: evidence.limitations,
},
{
kind: "api-contract",
collection: "operations",
offset: 0,
limit: 1,
},
);
expect(page).toMatchObject({
ok: true,
value: {
parent_evidence_id: evidence.evidence_id,
items: [{ path: "/first", state: "declared" }],
coverage: { exhausted: true },
source_coverage: { status: "partial" },
},
});
} else {
expect(retained).toEqual(complete);
expect(evidence.evidence_id).toBe(baseline.value.evidence_id);
}
} finally {
scheduled.mockRestore();
}
} finally {
await resources.close();
}
},
);
@@ -11,8 +11,6 @@ import { err, ok, type Result } from "../../domain/result.js";
import {
API_CONTRACT_OPERATION as operation,
API_CONTRACT_PROVIDER,
apiContractSchema,
type ApiContract,
type ApiContractArtifact,
} from "../../domain/apiContract/apiContract.js";
import {
@@ -72,32 +70,8 @@ function* documentSteps(document: unknown, artifact: ApiContractArtifact) {
return yield* parseApiContractSteps(document, artifact);
}
type OpenApiInput = Extract<
InspectApiContractInput,
{ format: "openapi-json" }
>;
const documentEvidence = (input: OpenApiInput, result: ApiContract): Evidence =>
createEvidence(
{ path: input.path, format: "file", sha256: result.artifact.sha256 },
API_CONTRACT_PROVIDER,
{
operation,
predicateType: "rea.api-contract-inventory",
parameters: {
path: input.path,
format: input.format,
max_file_bytes: input.max_file_bytes,
},
result,
confidence: "observed",
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: input.path }],
},
);
const importDocument = async (
input: OpenApiInput,
input: Extract<InspectApiContractInput, { format: "openapi-json" }>,
bytes: Buffer,
sha256: string,
signal?: AbortSignal,
@@ -112,16 +86,26 @@ const importDocument = async (
}),
signal,
);
if (!parsed.ok) {
const partial = apiContractSchema.safeParse(
parsed.error.partialObservation,
);
if (partial.success)
parsed.error.partialObservation = documentEvidence(input, partial.data);
return parsed;
}
if (!parsed.ok) return parsed;
const result = parsed.value;
return completeProviderEvidence(
documentEvidence(input, parsed.value),
createEvidence(
{ path: input.path, format: "file", sha256 },
API_CONTRACT_PROVIDER,
{
operation,
predicateType: "rea.api-contract-inventory",
parameters: {
path: input.path,
format: input.format,
max_file_bytes: input.max_file_bytes,
},
result,
confidence: "observed",
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: input.path }],
},
),
signal,
);
};
-2
View File
@@ -1,7 +1,6 @@
import type { PartialTaskMeasurement } from "./desktop/taskDifficulty.js";
import type { AppleIpsPartialObservation } from "./native/appleIps.js";
import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
import type { ApiContract } from "./apiContract/apiContract.js";
import type { GraphqlContract } from "./apiContract/graphqlContract.js";
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
import type { AnalysisSettingLimit } from "./analysisSettings.js";
@@ -499,7 +498,6 @@ export type AnalysisPartialObservation =
}
| { readonly kind: "unretained-evidence"; readonly evidence: Evidence }
| { readonly kind: "retained-evidence"; readonly evidence_id: string }
| ApiContract
| GraphqlContract
| PlaywrightTrace
| SavedAnalysisSnapshotPartialObservation
+1 -1
View File
@@ -185,7 +185,7 @@ export const projectApiContractView = (
unknowns:
context.source_coverage.status === "partial"
? [
"This inventory is partial. Inspect limitations for coverage and query path-item-references and diagnostics for any retained source pointers.",
"Unresolved or malformed declarations limit this inventory; query path-item-references and diagnostics for exact source pointers.",
]
: [],
};
@@ -3,7 +3,6 @@ import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { apiContractSchema, type ApiContractArtifact } from "./apiContract.js";
import { parseApiContractSteps } from "./parseApiContract.js";
import { AnalysisCancelledError } from "../analysisErrorCore.js";
const artifact: ApiContractArtifact = {
path: "/contract.json",
@@ -306,39 +305,9 @@ it("handles 3.2 QUERY and case-sensitive additional methods, diagnosing invalid
it("bounds expanded identities and source pointers before building a giant row", () => {
const result = parse(
document({
"/retained": { get: {} },
[`/${"a".repeat(1_100_000)}`]: { get: {} },
}),
document({ [`/${"a".repeat(1_100_000)}`]: { get: {} } }),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a retained-facts refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
const partial = apiContractSchema.parse(result.error.partialObservation);
expect(partial.operations.map((row) => row.path)).toEqual(["/retained"]);
expect(partial.source_coverage.status).toBe("partial");
});
it("retains an interpreted path when cancellation interrupts traversal", () => {
const input = document({
"/first": { get: { operationId: "first" } },
"/second": { get: { operationId: "second" } },
});
const steps = parseApiContractSteps(input, artifact);
for (let checkpoint = 0; checkpoint < 3; checkpoint++)
expect(steps.next().done).toBe(false);
const cancellation = new AnalysisCancelledError("inspect_api_contract");
const stopped = steps.throw(cancellation);
if (!stopped.done || stopped.value.ok)
throw new Error("Expected cancellation");
expect(stopped.value.error).toBe(cancellation);
const partial = apiContractSchema.parse(cancellation.partialObservation);
const complete = report(input);
expect(partial.operations).toEqual(complete.operations.slice(0, 1));
expect(partial.summary.operation_slots).toBe(1);
expect(partial.source_coverage).toMatchObject({
status: "partial",
path_entries_examined: 1,
});
expect(complete.source_coverage.status).toBe("complete-within-scope");
if (!result.ok)
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
});
+39 -72
View File
@@ -1,5 +1,4 @@
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
AnalysisUnsupportedTargetError,
@@ -317,17 +316,14 @@ function* pathItem(
return ok(undefined);
}
interface ContractReportContext {
readonly openapi: string;
readonly info: ApiContract["info"];
readonly pathsState: ApiContract["source_coverage"]["paths_state"];
examined: number;
}
const contractReport = (
const completedInventory = (
inventory: ContractInventory,
context: ContractReportContext,
incomplete = false,
context: {
openapi: string;
info: ApiContract["info"];
pathsState: ApiContract["source_coverage"]["paths_state"];
examined: number;
},
): ApiContract => {
const artifact = inventory.artifact;
let declared = 0;
@@ -352,9 +348,7 @@ const contractReport = (
source_coverage: {
scope: "inline-path-operations",
status:
incomplete ||
inventory.references.length > 0 ||
inventory.diagnostics.length > 0
inventory.references.length > 0 || inventory.diagnostics.length > 0
? "partial"
: "complete-within-scope",
paths_state: context.pathsState,
@@ -364,11 +358,6 @@ const contractReport = (
duplicate_json_members: "last-value-wins",
},
limitations: [
...(incomplete
? [
"Traversal stopped before the inventory was complete. Counts describe the retained rows.",
]
: []),
"Operations are document declarations, not evidence of runtime implementation, reachability or authorization.",
"Only inline operations under paths are inventoried; callbacks, webhooks, components, servers, parameters, schemas, request/response contracts and security are not analyzed.",
"No reference is resolved, including local fragments. A Path Item reference makes its effective operations unknown; adjacent inline slots are preserved without merging referenced definitions.",
@@ -379,32 +368,6 @@ const contractReport = (
};
};
function* collectPaths(
sourcePaths: Record<string, unknown>,
inventory: ContractInventory,
context: ContractReportContext,
modern: boolean,
): Generator<void, Result<void, AnalysisError>> {
for (const path in sourcePaths) {
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
yield;
context.examined++;
// Reserve expanded pointer/path strings before escaping or building a row.
const admitted = inventory.reserve(512 + path.length * 16);
if (!admitted.ok) return admitted;
const pointer = `/paths/${pointerPart(path)}`;
const read = path.startsWith("/")
? yield* pathItem(sourcePaths[path], { inventory, path, pointer, modern })
: inventory.diagnostic(
"invalid-path-key",
pointer,
"A Paths entry must start with /; it was not interpreted as an endpoint.",
);
if (!read.ok) return read;
}
return ok(undefined);
}
/** Enumerate declarations only; never resolve references, servers, callbacks or runtime expressions. */
export function* parseApiContractSteps(
value: unknown,
@@ -454,32 +417,36 @@ export function* parseApiContractSteps(
);
if (!diagnosed.ok) return diagnosed;
}
const context: ContractReportContext = {
openapi: root.openapi,
info: info.value,
pathsState,
examined: 0,
};
const interrupted = (error: AnalysisError) => {
const retainedRows =
inventory.operations.length +
inventory.references.length +
inventory.diagnostics.length;
if (retainedRows > 0)
error.retainPartialObservation(contractReport(inventory, context, true));
return err(error);
};
try {
const read = yield* collectPaths(
paths ?? {},
inventory,
context,
version[1] === "2",
);
if (!read.ok) return interrupted(read.error);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return interrupted(cause);
throw cause;
let examined = 0;
const sourcePaths = paths ?? {};
for (const path in sourcePaths) {
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
yield;
examined++;
// Reserve expanded pointer/path strings before escaping or building a row.
const admitted = inventory.reserve(512 + path.length * 16);
if (!admitted.ok) return admitted;
const pointer = `/paths/${pointerPart(path)}`;
const read = path.startsWith("/")
? yield* pathItem(sourcePaths[path], {
inventory,
path,
pointer,
modern: version[1] === "2",
})
: inventory.diagnostic(
"invalid-path-key",
pointer,
"A Paths entry must start with /; it was not interpreted as an endpoint.",
);
if (!read.ok) return read;
}
return ok(contractReport(inventory, context));
return ok(
completedInventory(inventory, {
openapi: root.openapi,
info: info.value,
pathsState,
examined,
}),
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ const sectionBytes = (
section: NativeSection,
budget?: CudaMetadataBudget,
): Promise<Buffer> => {
if (!section.file_backed || (section.flags & 0x800n) !== 0n)
if (!section.file_backed || section.compressed)
rejectGpu("GPU metadata section has no supported uncompressed file range");
budget?.read(gpuCount(section.size));
return source.read(gpuCount(section.offset), gpuCount(section.size));
+1 -4
View File
@@ -194,10 +194,7 @@ export class GpuInventory {
continue;
}
if (!isGpuSection(section.name)) continue;
if (
!section.file_backed ||
(directory.format === "elf" && (section.flags & 0x800n) !== 0n)
) {
if (!section.file_backed || section.compressed) {
this.discovery = "partial";
const message = `GPU section ${section.index} has no supported uncompressed file range`;
this.#retain(message);
+1 -5
View File
@@ -340,11 +340,7 @@ export const inspectAndroidJniLibrary = async (
for (const section of directory.sections) {
if ((section.flags & 3n) !== 3n || section.size === 0n) continue;
active();
if (
(section.flags & 0x800n) !== 0n ||
!section.file_backed ||
section.type === 8
) {
if (section.compressed || !section.file_backed || section.type === 8) {
tablesStatus = "partial";
unknowns.add(
"Allocated writable sections without ordinary file bytes were not scanned; runtime-built tables remain unknown",
+55
View File
@@ -462,6 +462,33 @@ const findSection = (bytes: Buffer, name: string) => {
throw new Error(`fixture has no ${name}`);
};
it.each(["compressed-symbols", "compressed-names", "non-string-names"])(
"retains unwind ranges without interpreting %s as ordinary symbol storage",
(storage) => {
const bytes = elfUnwindFixture({ header: "match" });
expect(parse(bytes).ranges[0]?.symbol?.text).toBe("alpha");
const section = findSection(
bytes,
storage === "compressed-symbols" ? ".symtab" : ".strtab",
);
if (storage === "non-string-names")
bytes.writeUInt32LE(1, section.headerOffset + 4);
else bytes.writeBigUInt64LE(0x800n, section.headerOffset + 8);
const report = parse(bytes);
expect(report.ranges.map(({ start }) => start)).toEqual([
address(UNWIND_FN_A),
address(UNWIND_FN_B),
]);
expect(report.ranges.map(({ symbol }) => symbol)).toEqual([null, null]);
expect(report.coverage).toMatchObject({
status: "incomplete",
ranges_with_symbols: 0,
eh_frame_hdr_agreement: "agree",
});
},
);
it("keeps .eh_frame ranges when .debug_frame is SHT_NOBITS", () => {
const report = parse(
elfUnwindFixture({
@@ -591,6 +618,18 @@ it("caps symbol lookup across .symtab and .dynsym together", () => {
);
});
it("keeps the symbol scan allowance for later tables when an earlier table has no string table", () => {
const bytes = elfUnwindFixture({ header: "match", symbolCap: true });
const section = findSection(bytes, ".symtab");
bytes.writeUInt32LE(0, section.headerOffset + 40);
const report = parse(bytes);
expect(report.ranges[0]?.symbol?.text).toBe("alpha");
expect(report.coverage.status).toBe("incomplete");
expect(report.limitations).not.toContain(
"Symbol tables were capped at 65536 combined entries.",
);
});
it.each([".eh_frame", ".eh_frame_hdr"] as const)(
"reports coverage incomplete when a duplicate %s is ignored",
(duplicate) => {
@@ -665,6 +704,22 @@ it.each([
},
);
it("keeps the GOT unknown when compressed dynamic storage resembles decoded entries", () => {
const bytes = elfUnwindFixture({ header: "match", datarel: "dynamic" });
const section = findSection(bytes, ".dynamic");
// The payload still resembles ordinary DT_PLTGOT/DT_NULL entries, but the
// declaration makes it opaque compressed storage, not a decoded table.
bytes.writeBigUInt64LE(0x800n, section.headerOffset + 8);
const report = parse(bytes);
expect(report.ranges.map((range) => range.start)).not.toContain(
address(UNWIND_FN_A),
);
expect(report.coverage.status).toBe("incomplete");
expect(report.limitations).toContain(
"A DW_EH_PE_datarel .eh_frame value needs a GOT base that could not be determined; that value is unresolved.",
);
});
it("caps .eh_frame_hdr rows at the max_ranges budget and marks the comparison incomplete", () => {
const report = parse(elfUnwindFixture({ header: "duplicate-row" }), {
max_ranges: 2,
+27 -14
View File
@@ -34,6 +34,7 @@ const AUG_R = 0x52;
const AUG_S = 0x53;
const AUG_B = 0x42;
const SHT_SYMTAB = 2;
const SHT_STRTAB = 3;
const SHT_DYNAMIC = 6;
const SHT_NOBITS = 8;
const SHT_DYNSYM = 11;
@@ -49,7 +50,6 @@ const SHN_ABS = 0xfff1;
const SHN_LORESERVE = 0xff00;
const SHF_ALLOC = 2n;
const SHF_EXECINSTR = 4n;
const SHF_COMPRESSED = 0x800n;
const {
cfa: CFA_LIMITATION,
@@ -639,7 +639,7 @@ export const parseElfUnwindRanges = (
? ".eh_frame_hdr is declared but not file-backed."
: ".eh_frame_hdr extends past the end of the file and was not decoded.",
);
} else if ((hdrSection.flags & SHF_COMPRESSED) !== 0n) {
} else if (hdrSection.compressed) {
headerLocation = placement.location;
headerStatus = "incomplete";
fail(".eh_frame_hdr is SHF_COMPRESSED and was not decompressed.");
@@ -705,7 +705,7 @@ export const parseElfUnwindRanges = (
} else if (frameLocation === null) {
frameStatus = "incomplete";
fail(".eh_frame lies outside the file.");
} else if ((ehSection.flags & SHF_COMPRESSED) !== 0n) {
} else if (ehSection.compressed) {
frameStatus = "incomplete";
fail(".eh_frame is SHF_COMPRESSED and was not decompressed.");
} else {
@@ -1135,7 +1135,12 @@ const resolveGotBase = (
? undefined
: { offset: dynamicProgram.offset, fileSize: dynamicProgram.size }) ??
directory.sections
.filter((section) => section.type === SHT_DYNAMIC && section.file_backed)
.filter(
(section) =>
section.type === SHT_DYNAMIC &&
section.file_backed &&
!section.compressed,
)
.map((section) => ({
offset: section.offset,
fileSize: section.size,
@@ -1814,6 +1819,10 @@ const scanSymbols = (
let remaining = MAX_ELF_UNWIND_SYMBOLS;
for (const table of tables) {
assertArtifactReadActive(signal);
if (table.compressed) {
fail("A symbol table is SHF_COMPRESSED and was not decompressed.");
continue;
}
const width = table.entry_size === 0n ? standard : Number(table.entry_size);
if (width !== standard) {
fail(
@@ -1829,6 +1838,15 @@ const scanSymbols = (
const size = Number(table.size);
if (size % width !== 0)
fail("A symbol table size is not a multiple of its entry width.");
const strings = byIndex.get(table.link);
if (strings?.type !== SHT_STRTAB || !strings.file_backed) {
fail("A symbol table does not link to a file-backed SHT_STRTAB section.");
continue;
}
if (strings.compressed) {
fail("A symbol string table is SHF_COMPRESSED and was not decompressed.");
continue;
}
let count = Math.floor(size / width);
if (count > remaining) {
fail(
@@ -1837,15 +1855,10 @@ const scanSymbols = (
count = remaining;
}
remaining -= count;
const strings = byIndex.get(table.link);
const stringBytes =
strings !== undefined && strings.file_backed
? bytes.subarray(
Number(strings.offset),
Number(strings.offset + strings.size),
)
: null;
if (stringBytes === null) fail("A symbol string table could not be read.");
const stringBytes = bytes.subarray(
Number(strings.offset),
Number(strings.offset + strings.size),
);
// Observation per string-table offset; null when the name is unreadable.
const names = new Map<number, SymbolName | null>();
for (let index = 0; index < count; index += 1) {
@@ -1878,7 +1891,7 @@ const scanSymbols = (
if (owner !== undefined) address += owner.address;
}
// Only a symbol at a decoded FDE start can name a range.
if (stringBytes === null || !wanted.has(address)) continue;
if (!wanted.has(address)) continue;
let name = names.get(symbol.name_offset);
if (name === undefined) {
const observed = observeElfStringTableEntry(
+177 -7
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { deflateSync } from "node:zlib";
import {
elfExportImage,
@@ -22,6 +23,172 @@ const parse = (bytes: Buffer) =>
parseNativeExports(bytes, artifact),
);
/** Encode real gABI compressed storage, retaining logical section links. */
const compressExportSection = (
bytes: Buffer,
sectionIndex: number,
versionStrings = false,
): Buffer => {
const wide = bytes[4] === 2;
const little = bytes[5] === 1;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const word = (at: number) =>
Number(wide ? view.getBigUint64(at, little) : view.getUint32(at, little));
const table = word(wide ? 40 : 32);
const width = wide ? 64 : 40;
const section = table + sectionIndex * width;
const offset = word(section + (wide ? 24 : 16));
const size = word(section + (wide ? 32 : 20));
const header = Buffer.alloc(wide ? 24 : 12);
const chdr = new DataView(
header.buffer,
header.byteOffset,
header.byteLength,
);
chdr.setUint32(0, 1, little); // ELFCOMPRESS_ZLIB
if (wide) {
chdr.setBigUint64(8, BigInt(size), little);
chdr.setBigUint64(16, 1n, little);
} else {
chdr.setUint32(4, size, little);
chdr.setUint32(8, 1, little);
}
const storage = Buffer.concat([
header,
deflateSync(bytes.subarray(offset, offset + size)),
]);
const count = view.getUint16(wide ? 60 : 48, little);
const result = Buffer.concat([
bytes,
Buffer.alloc(versionStrings ? width : 0),
storage,
]);
const updated = new DataView(
result.buffer,
result.byteOffset,
result.byteLength,
);
const setWord = (at: number, value: number) =>
wide
? updated.setBigUint64(at, BigInt(value), little)
: updated.setUint32(at, value, little);
const target = versionStrings ? table + count * width : section;
if (versionStrings) {
bytes.copy(result, target, section, section + width);
updated.setUint16(wide ? 60 : 48, count + 1, little);
// Definitions and requirements use the copied string table; dynsym keeps
// its original uncompressed table and still supplies trustworthy names.
for (const index of [4, 5])
updated.setUint32(
table + index * width + (wide ? 40 : 24),
count,
little,
);
}
setWord(target + 8, 0x800); // SHF_COMPRESSED, without SHF_ALLOC
setWord(
target + (wide ? 24 : 16),
bytes.length + (versionStrings ? width : 0),
);
setWord(target + (wide ? 32 : 20), storage.length);
return result;
};
it.each(
([32, 64] as const).flatMap((bits) =>
(
[
"symbols",
"strings",
"indices",
"definitions",
"requirements",
"version-strings",
] as const
).map((kind) => ({ bits, kind })),
),
)(
"keeps compressed ELF$bits $kind distinct from logical table bytes",
({ bits, kind }) => {
for (const little of [false, true]) {
const bytes = elfExportImage({
bits,
little,
symbols: [
{ name: "defined", version: 2 },
{ name: "required", version: 3 },
],
versionDefinitions: ["lib.so", "DEFINED"],
versionRequirements: [{ index: 3, name: "REQUIRED" }],
});
expect(parse(bytes)).toMatchObject({
coverage: { status: "complete", exports: 2 },
});
const index = {
symbols: 1,
strings: 2,
indices: 3,
definitions: 4,
requirements: 5,
"version-strings": 2,
}[kind];
const result = parse(
compressExportSection(bytes, index, kind === "version-strings"),
);
if (kind === "symbols" || kind === "strings") {
expect(result).toMatchObject({
exports: [],
coverage: {
status: "unsupported",
examined_entries: 0,
reason: expect.stringContaining("SHF_COMPRESSED"),
},
});
} else {
expect(result).toMatchObject({
coverage: { status: "partial", exports: 2 },
exports: [
{
name: "defined",
version:
kind === "indices"
? "unknown"
: {
index: 2,
name:
kind === "definitions" || kind === "version-strings"
? null
: "DEFINED",
},
},
{
name: "required",
version:
kind === "indices"
? "unknown"
: {
index: 3,
name:
kind === "definitions" ||
kind === "requirements" ||
kind === "version-strings"
? null
: "REQUIRED",
},
},
],
});
expect(result.issues).toContainEqual(
expect.objectContaining({
code: "version_unresolved",
message: expect.stringContaining("SHF_COMPRESSED"),
}),
);
}
}
},
);
it.each(["requirements", "definitions"] as const)(
"bounds aliased GNU version %s work and keeps exports with unknown versions",
(kind) => {
@@ -706,13 +873,12 @@ describe("ELF version requirements", () => {
});
it("uses a definition name when a requirement shares its index", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "ambiguous", version: 2 }],
versionDefinitions: ["libfixture.so", "DEFINITION"],
versionRequirements: [{ index: 2, name: "REQUIREMENT" }],
}),
);
const bytes = elfExportImage({
symbols: [{ name: "ambiguous", version: 2 }],
versionDefinitions: ["libfixture.so", "DEFINITION"],
versionRequirements: [{ index: 2, name: "REQUIREMENT" }],
});
const result = parse(bytes);
expect(result.exports).toMatchObject([
{
@@ -722,6 +888,10 @@ describe("ELF version requirements", () => {
]);
expect(result.coverage.status).toBe("complete");
expect(result.issues).toEqual([]);
expect(parse(compressExportSection(bytes, 4))).toMatchObject({
exports: [{ name: "ambiguous", version: { index: 2, name: null } }],
coverage: { status: "partial" },
});
});
it("retains exports and reports a truncated version-requirement chain", () => {
+48 -1
View File
@@ -294,12 +294,22 @@ const versionSectionData = (
table: { readonly offset: number; readonly size: number };
names: Buffer;
} => {
if (section.compressed)
throw new BinaryFormatFailure(
"unsupported",
`${label} are SHF_COMPRESSED and were not decompressed`,
);
const strings = sections[section.link];
if (strings?.type !== SHT_STRTAB)
throw new BinaryFormatFailure(
"malformed",
`${label} do not link to a string table`,
);
if (strings.compressed)
throw new BinaryFormatFailure(
"unsupported",
`${label} strings are SHF_COMPRESSED and were not decompressed`,
);
const stringRange = reader.fileRange(
strings.offset,
strings.size,
@@ -545,7 +555,13 @@ function* readVersionNames(
}
}
} catch (cause) {
if (cause instanceof ElfVersionWorkFailure) namesByIndex.clear();
// An unread compressed definition table may replace any requirement
// index. Its absence cannot justify falling back to requirement names.
if (
cause instanceof ElfVersionWorkFailure ||
(cause instanceof BinaryFormatFailure && cause.kind === "unsupported")
)
namesByIndex.clear();
issues.push({
code: "version_unresolved",
location: hex(verdef.offset),
@@ -605,6 +621,32 @@ function* parseElfExports(
};
const width = directory.bits === 64 ? 24 : 16;
const strings = sections[dynsym.link];
const compressed = dynsym.compressed
? dynsym
: strings?.type === SHT_STRTAB && strings.compressed
? strings
: null;
if (compressed !== null) {
const reason = `The ELF ${compressed === dynsym ? "dynamic symbol table" : "dynamic string table"} is SHF_COMPRESSED and was not decompressed; export identities remain unknown`;
return {
...result,
exports: [],
coverage: {
status: "unsupported",
reason,
examined_entries: 0,
exports: 0,
},
issues: [
{
code: "table_unavailable",
location: hex(compressed.offset),
message: reason,
},
],
limitations: ELF_LIMITATIONS,
};
}
if (
dynsym.entry_size !== BigInt(width) ||
dynsym.size % BigInt(width) !== 0n ||
@@ -647,6 +689,11 @@ function* parseElfExports(
let versions: number | null = null;
if (versym !== undefined) {
try {
if (versym.compressed)
throw new BinaryFormatFailure(
"unsupported",
"ELF symbol versions are SHF_COMPRESSED and were not decompressed",
);
if (versym.size !== BigInt(count * 2))
throw new BinaryFormatFailure(
"malformed",
@@ -1405,13 +1405,40 @@ it("sizes an empty DT_GNU_HASH table only from an agreeing SHT_DYNSYM", () => {
strings.readBigUInt64LE(elfSectionAt(strings, SHT_STRTAB) + 32) - 1n,
elfSectionAt(strings, SHT_STRTAB) + 32,
);
for (const bytes of [moved, offset, strings])
const compressedSymbols = Buffer.from(agreeing);
compressedSymbols.writeBigUInt64LE(0x800n, section + 8);
const compressedStrings = Buffer.from(agreeing);
compressedStrings.writeBigUInt64LE(
0x800n,
elfSectionAt(compressedStrings, SHT_STRTAB) + 8,
);
const wrongStringType = Buffer.from(agreeing);
wrongStringType.writeUInt32LE(
1,
elfSectionAt(wrongStringType, SHT_STRTAB) + 4,
);
for (const bytes of [
moved,
offset,
strings,
compressedSymbols,
compressedStrings,
wrongStringType,
])
expect(
findRow(readElfHardening(bytes).rows, "stack_protector"),
).toMatchObject({
state: "unknown",
note: expect.stringContaining("DT_GNU_HASH"),
});
// A loader-owned hash count needs no section fallback, so compressed section
// declarations cannot hide imports named by DT_SYMTAB and DT_STRTAB.
const hashed = elfHardeningFixture({ stackChk: true });
for (const type of [SHT_DYNSYM, SHT_STRTAB])
hashed.writeBigUInt64LE(0x800n, elfSectionAt(hashed, type) + 8);
expect(findRow(readElfHardening(hashed).rows, "stack_protector").state).toBe(
"enabled",
);
});
it("keeps an unresolved symbol count distinct from an absent table", () => {
+10 -8
View File
@@ -51,6 +51,7 @@ import {
const ET_EXEC = 2;
const ET_DYN = 3;
const SHT_SYMTAB = 2;
const SHT_STRTAB = 3;
const SHT_NOTE = 7;
const SHT_DYNSYM = 11;
const DT_NULL = 0n;
@@ -71,7 +72,6 @@ const DF_1_PIE = 0x08000000n;
const PF_X = 1;
const GNU_PROPERTY_AARCH64_FEATURE_1_AND = 0xc0000000;
const GNU_PROPERTY_X86_FEATURE_1_AND = 0xc0000002;
const SHF_COMPRESSED = 0x800n;
/** Tags hardening reads as a single value. Repeating tags such as DT_NEEDED stay. */
const SINGULAR_DYNAMIC_TAGS = new Set<bigint>([
DT_HASH,
@@ -175,7 +175,6 @@ export const readElfHardening = (
assertElfDeclaredRangesFromBuffer(bytes);
const { bits, little, machine, programs, sections } = directory;
const headerWidth = bits === 64 ? 56 : 32;
const symbolWidth = bits === 64 ? 24 : 16;
const dynamicWidth = bits === 64 ? 16 : 8;
const propertyAlign = bits === 64 ? 8 : 4;
const loads = programs.filter(isSpan).filter((program) => program.type === 1);
@@ -546,10 +545,14 @@ const agreeingSectionCount = (
if (dynsym === undefined) return null;
const strings = sections[dynsym.link];
if (
dynsym.compressed ||
strings === undefined ||
strings.compressed ||
strings.type !== SHT_STRTAB ||
dynsym.address !== symtab.value ||
strings?.address !== dynamic.entries.get(DT_STRTAB)?.value ||
strings?.size !== dynamic.entries.get(DT_STRSZ)?.value ||
strings?.offset !== BigInt(dynamic.stringFileOffset ?? -1)
strings.address !== dynamic.entries.get(DT_STRTAB)?.value ||
strings.size !== dynamic.entries.get(DT_STRSZ)?.value ||
strings.offset !== BigInt(dynamic.stringFileOffset ?? -1)
)
return null;
const entry = dynsym.entry_size === 0n ? BigInt(width) : dynsym.entry_size;
@@ -897,11 +900,10 @@ const readFeatureNotes = (
if (section.type !== SHT_NOTE) continue;
// An empty note section owns no bytes, so it has nothing to walk.
if (section.size === 0n) continue;
const compressed = (section.flags & SHF_COMPRESSED) !== 0n;
if (compressed)
if (section.compressed)
incomplete ??=
"A compressed SHT_NOTE section may hold GNU property notes, and its descriptors were not read.";
if (compressed) {
if (section.compressed) {
if (!section.file_backed) continue;
otherSpans.push({
offset: exact(section.offset, "SHT_NOTE offset"),
@@ -124,11 +124,17 @@ const stringAt = (
return { name: text, missing: false };
};
const tableBytes = (
const stringTableBytes = (
bytes: Buffer,
section: NativeSection | undefined,
): Buffer | null => {
if (section === undefined || !section.file_backed) return null;
if (
section === undefined ||
section.type !== 3 ||
!section.file_backed ||
section.compressed
)
return null;
const offset = Number(section.offset);
const size = Number(section.size);
if (offset < 0 || size < 0 || offset > bytes.length - size) return null;
@@ -144,9 +150,19 @@ const readTable = (
): {
readonly symbols: ElfInitializerSymbol[];
readonly truncated: boolean;
readonly incomplete: boolean;
} => {
const width = facts.bits === 64 ? 24 : 16;
const strings = tableBytes(facts.bytes, sections[section.link]);
const strings = stringTableBytes(facts.bytes, sections[section.link]);
const compressedStrings = sections[section.link]?.compressed === true;
if (compressedStrings)
notes.add(
"A symbol string table is compressed; its names were not decoded.",
);
else if (strings === null)
notes.add(
"A symbol table does not link a readable SHT_STRTAB section; its names were not decoded.",
);
const start = Number(section.offset);
const declared = section.size / BigInt(width);
const count = Number(declared < BigInt(budget) ? declared : BigInt(budget));
@@ -177,7 +193,7 @@ const readTable = (
type: parsed.value.type,
});
}
return { symbols, truncated: truncated || nameMissing };
return { symbols, truncated, incomplete: nameMissing || strings === null };
};
/** Load SHT_SYMTAB and SHT_DYNSYM once. Defined STT_FUNC names win ties. */
@@ -223,10 +239,20 @@ export const loadElfSymbolIndex = (
sections.forEach((section, index) => {
if (truncated) return;
if (section.type !== 2 && section.type !== 11) return;
if (
!section.file_backed ||
section.size % BigInt(facts.bits === 64 ? 24 : 16) !== 0n
) {
if (section.compressed) {
notes.add("A symbol table is compressed; its symbols were not decoded.");
unreadable = true;
return;
}
const width = BigInt(facts.bits === 64 ? 24 : 16);
if (section.entry_size !== 0n && section.entry_size !== width) {
notes.add(
"A symbol table has an unexpected entry size; its symbols were not decoded.",
);
unreadable = true;
return;
}
if (!section.file_backed || section.size % width !== 0n) {
notes.add(
"A symbol table is not stored in the file or its size is not a whole number of entries, so its names were not read.",
);
@@ -237,6 +263,7 @@ export const loadElfSymbolIndex = (
budget -= table.symbols.length;
tables.set(index, table.symbols);
truncated ||= table.truncated;
unreadable ||= table.incomplete;
for (const symbol of table.symbols) consider(symbol);
});
return {
@@ -440,7 +467,11 @@ export const sectionRelocations = async (
for (const [position, section] of input.sections.entries()) {
const encoding = applicable(input, section);
if (encoding === null) continue;
if (!section.file_backed) {
if (!section.file_backed || section.compressed) {
if (section.compressed)
input.notes.add(
"A relocation section is compressed; its records were not decoded.",
);
skip(section);
continue;
}
+17 -3
View File
@@ -269,7 +269,7 @@ const indexSections = (sections: readonly NativeSection[]): ElfSectionIndex => {
: left.index - right.index,
);
const backed = buildAddressRangeIndex(
ranges((section) => section.file_backed),
ranges((section) => section.file_backed && !section.compressed),
(left, right) => left.index - right.index,
);
return {
@@ -354,7 +354,8 @@ const findSections = (
const withinSection =
(section: NativeSection, width: number) =>
(address: bigint): number | null => {
if (!section.file_backed || address < section.address) return null;
if (!section.file_backed || section.compressed || address < section.address)
return null;
const delta = address - section.address;
if (delta + BigInt(width) > section.size) return null;
return Number(section.offset + delta);
@@ -374,6 +375,13 @@ const sectionSource = (
section.name.length <= MAX_NATIVE_INITIALIZER_NAME_BYTES
? section.name
: fallbackName;
if (section.compressed) {
note(
scan,
`${label} is compressed. Its initializer slots were not decoded.`,
);
return { kind, sentinel, detail: label, slots: [] };
}
if (!section.file_backed) {
note(scan, `${label} is not file-backed. Its slots were omitted.`);
return { kind, sentinel, detail: label, slots: [] };
@@ -671,6 +679,7 @@ const loadWrites = async (
};
const dynamicSymbols = (
scan: ElfScan,
sections: readonly NativeSection[],
tags: ReadonlyMap<bigint, DynTag>,
tables: ReadonlyMap<number, readonly ElfInitializerSymbol[]>,
@@ -683,6 +692,11 @@ const dynamicSymbols = (
(section.type === 11 || section.type === 2),
);
if (match >= 0) return tables.get(match);
note(
scan,
"DT_SYMTAB does not identify a decoded symbol table; dynamic relocation symbols are unresolved.",
);
return undefined;
}
const dynsym = sections.findIndex((section) => section.type === 11);
return dynsym < 0 ? undefined : tables.get(dynsym);
@@ -1042,7 +1056,7 @@ export const readElfInitializers = async (
loaded,
sectionRels.found,
sectionRels.clobbered,
dynamicSymbols(directory.sections, tags, symbols.tables),
dynamicSymbols(scan, directory.sections, tags, symbols.tables),
symbols,
loaded.trustRaw && sectionRels.complete,
directory.sections,
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { deflateSync } from "node:zlib";
import { readNativeInitializers } from "./NativeInitializersProvider.js";
/** Pass-through spies on the shared mapping module, so tests can count lookups made from other modules. */
@@ -320,6 +321,106 @@ describe("ELF slot semantics", () => {
});
});
describe("ELF section content admission", () => {
it("keeps symbol relocations unresolved for a conflicting symbol entry width", async () => {
const bytes = elfRelocatableCtors();
bytes.writeBigUInt64LE(16n, 0x500 + 4 * 64 + 56);
const report = await read(bytes);
expect(
report.entries.find((entry) => entry.kind === "init_array"),
).toMatchObject({
resolution: "unresolved",
value_va: null,
symbol: null,
});
expect(report.status).toBe("incomplete");
expect(report.limitations.join("\n")).toContain("entry size");
});
it.each([
{ section: 7, label: "initializer array" },
{ section: 4, label: "symbol table" },
{ section: 5, label: "symbol string table" },
{ section: 8, label: "relocation table" },
])("preserves uncertainty for a compressed $label", async ({ section }) => {
const base = elfRelocatableCtors();
const at = 0x500 + section * 64;
const offset = Number(base.readBigUInt64LE(at + 24));
const size = Number(base.readBigUInt64LE(at + 32));
const header = Buffer.alloc(24);
header.writeUInt32LE(1, 0);
header.writeBigUInt64LE(BigInt(size), 8);
header.writeBigUInt64LE(1n, 16);
const compressed = Buffer.concat([
header,
deflateSync(base.subarray(offset, offset + size)),
]);
const bytes = Buffer.concat([base, compressed]);
bytes.writeBigUInt64LE(base.readBigUInt64LE(at + 8) | 0x800n, at + 8);
bytes.writeBigUInt64LE(BigInt(base.length), at + 24);
bytes.writeBigUInt64LE(BigInt(compressed.length), at + 32);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(report.coverage.total_entries).toBeNull();
expect(report.limitations.join("\n")).toContain("compressed");
if (section === 7)
expect(report.entries.some((entry) => entry.kind === "init_array")).toBe(
false,
);
else {
const init = report.entries.find((entry) => entry.kind === "init_array");
expect(init).toBeDefined();
if (section === 5) expect(init?.symbol).toBeNull();
else
expect(init).toMatchObject({
resolution: "unresolved",
value_va: null,
});
}
// The uncompressed fixture remains the working comparison.
expect((await read(base)).status).toBe("parsed");
});
it("does not substitute an unrelated dynamic symbol table for DT_SYMTAB", async () => {
const bytes = elf64PieInitializers();
bytes.writeBigUInt64LE(0x777n, 0x300 + 14 * 16 + 8);
const report = await read(bytes);
expect(
report.entries.find((entry) => entry.slot_va === "0x210"),
).toMatchObject({
resolution: "unresolved",
symbol: null,
value_va: null,
});
expect(report.status).toBe("incomplete");
});
it.each(["compressed", "not a string table"])(
"retains later symbol tables when an earlier name source is %s",
async (source) => {
const bytes = elf64PieInitializers();
// The first (dynamic) symbol table links section 8; its bytes still contain imp_init.
if (source === "compressed")
bytes.writeBigUInt64LE(0x802n, 0x800 + 8 * 64 + 8);
else bytes.writeUInt32LE(1, 0x800 + 8 * 64 + 4);
const report = await read(bytes);
expect(report.status).toBe("incomplete");
expect(
report.entries.find((entry) => entry.kind === "init"),
).toMatchObject({
symbol: { name: "ctor_a" },
value_va: "0x100",
});
expect(
report.entries.find((entry) => entry.slot_va === "0x210"),
).toMatchObject({
resolution: "unresolved_import",
symbol: null,
});
},
);
});
describe("ELF incomplete inputs", () => {
it("reports an unsupported relocation machine as incomplete, not as raw zeros", async () => {
const bytes = elf64PieInitializers();
+1 -2
View File
@@ -68,8 +68,7 @@ export const readElfImage = (
}
for (const section of directory.sections) {
const range = declaredFileRange(bytes.length, section.offset, section.size);
const file_backed = section.type !== 8 && range.file_backed;
const compressed = (section.flags & 0x800n) !== 0n;
const { file_backed, compressed } = section;
const read_only =
section.type === 1 &&
file_backed &&
@@ -2,6 +2,7 @@ import { expect, it } from "vitest";
import { pe } from "../../domain/binaryTarget.fixture.js";
import { elfHardeningFixture } from "../hardening/BinaryHardening.fixture.js";
import { elfUnwindSectionlessFixture } from "../elf/ElfUnwindTables.fixture.js";
import { compressedElfNoteSectionFixture } from "../elf/ElfNotes.fixture.js";
import {
ELF_PT_GNU_EH_FRAME,
ELF_PT_GNU_RELRO,
@@ -94,3 +95,35 @@ it("retains a confirmed overlapping PE when the COFF record extends past EOF", (
}),
);
});
it.each([
[32, true],
[32, false],
[64, true],
[64, false],
] as const)(
"retains ELF%i little=%s compression independently of stored section coverage",
(bits, little) => {
const bytes = compressedElfNoteSectionFixture(bits, little);
const initial = readElfDirectoryFromBuffer(bytes).sections[1]!;
expect(initial).toMatchObject({ compressed: true, file_backed: true });
expect(readElfDirectoryFromBuffer(bytes).sections[2]).toMatchObject({
compressed: false,
file_backed: true,
});
// Compression metadata describes the encoding even if its stored range
// subsequently proves incomplete. It must not be inferred from readability.
const sizeAt = initial.header_offset + (bits === 64 ? 32 : 20);
if (bits === 64) {
if (little) bytes.writeBigUInt64LE(BigInt(bytes.length), sizeAt);
else bytes.writeBigUInt64BE(BigInt(bytes.length), sizeAt);
} else if (little) bytes.writeUInt32LE(bytes.length, sizeAt);
else bytes.writeUInt32BE(bytes.length, sizeAt);
expect(readElfDirectoryFromBuffer(bytes).sections[1]).toMatchObject({
compressed: true,
file_backed: false,
offset: initial.offset,
size: BigInt(bytes.length),
});
},
);
@@ -45,7 +45,10 @@ export interface NativeSection {
readonly entry_size: bigint;
/** `sh_addralign` for ELF. PE sections have no note alignment and use 1. */
readonly alignment: bigint;
/** Declared stored bytes are present; compressed bytes are not decoded content. */
readonly file_backed: boolean;
/** ELF SHF_COMPRESSED storage encoding; always false for PE sections. */
readonly compressed: boolean;
}
/** Program header types retained for load, dynamic, hardening, and unwind consumers. */
export const ELF_PT_NOTE = 4 as const;
@@ -308,6 +311,7 @@ export function* readElfSectionDirectory(
alignment: section.alignment,
file_backed:
type !== 8 && declaredFileRange(fileSize, offset, size).file_backed,
compressed: (section.flags & 0x800n) !== 0n,
});
}
}
@@ -410,6 +414,7 @@ export function* readPeSectionDirectory(
BigInt(row.offset),
BigInt(row.size),
).file_backed,
compressed: false,
});
}
return {