Compare commits

..
24 changed files with 693 additions and 199 deletions

No files matched your search

+12
View File
@@ -135,13 +135,25 @@ def inspect_elf(path, cache):
from pwnlib.context import context from pwnlib.context import context
with context.local(cache_dir=str(cache), log_level="warning"): with context.local(cache_dir=str(cache), log_level="warning"):
from elftools.common.exceptions import ELFError from elftools.common.exceptions import ELFError
from elftools.common.utils import struct_parse
from elftools.elf.elffile import ELFFile from elftools.elf.elffile import ELFFile
from pwnlib.elf import ELF from pwnlib.elf import ELF
try: try:
headers = ELFFile(io.BytesIO(content)) headers = ELFFile(io.BytesIO(content))
section_count = headers.num_sections() section_count = headers.num_sections()
if section_count: 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)) 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() segment_count = headers.num_segments()
if segment_count: if segment_count:
location(headers.header.e_phoff, segment_count * headers.header.e_phentsize, len(content)) 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 still parse the snapshot separately; eliminating that remaining duplication
requires preserving the upstream GOT/PLT and mitigation facts. 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 ## Interpreting results
- Artifact path, SHA-256 and size identify original selected bytes. All parsing - 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 reports a complete empty inventory. Mach-O exports are reported by
`inspect_macho`. `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`). `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 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 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 with a resource error whose recovery is a smaller `limit`, because
`max_ranges` and `max_file_bytes` do not raise it. `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 Malformed ELF headers fail without partial success. This workflow does not
execute the image, evaluate CFA programs, or follow indirect pointers. 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 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. `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 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. observe cancellation, so a large `DT_RELR` table does not block the server.
@@ -20,11 +20,6 @@ it("reads a Python 3.9 reference-flagged PYZ table", () => {
]); ]);
}); });
it("reads marshal one-byte string tags as Latin-1", () => {
for (const hex of ["6101000000e9", "4101000000e9", "7a01e9", "5a01e9"])
expect(readMarshalSubset(bytes(hex))).toEqual({ ok: true, value: "é" });
});
it("decodes CPython marshal Unicode with surrogatepass", () => { it("decodes CPython marshal Unicode with surrogatepass", () => {
expect( expect(
readMarshalSubset( readMarshalSubset(
+8 -2
View File
@@ -168,8 +168,14 @@ const readAsciiBytes = (
): Result<string, PyInstallerFailure> => { ): Result<string, PyInstallerFailure> => {
const bytes = readBytes(cursor, length); const bytes = readBytes(cursor, length);
if (!bytes.ok) return bytes; if (!bytes.ok) return bytes;
// These tags use CPython's one-byte Unicode constructor, not an ASCII check. if (bytes.value.some((byte) => byte > 0x7f))
return ok(bytes.value.toString("latin1")); return err(
pyInstallerFailure(
"format",
"PYZ marshal ASCII string contains a non-ASCII byte",
),
);
return ok(bytes.value.toString("ascii"));
}; };
const readUtf8 = ( const readUtf8 = (
+1 -1
View File
@@ -43,7 +43,7 @@ const sectionBytes = (
section: NativeSection, section: NativeSection,
budget?: CudaMetadataBudget, budget?: CudaMetadataBudget,
): Promise<Buffer> => { ): 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"); rejectGpu("GPU metadata section has no supported uncompressed file range");
budget?.read(gpuCount(section.size)); budget?.read(gpuCount(section.size));
return source.read(gpuCount(section.offset), gpuCount(section.size)); return source.read(gpuCount(section.offset), gpuCount(section.size));
+1 -4
View File
@@ -194,10 +194,7 @@ export class GpuInventory {
continue; continue;
} }
if (!isGpuSection(section.name)) continue; if (!isGpuSection(section.name)) continue;
if ( if (!section.file_backed || section.compressed) {
!section.file_backed ||
(directory.format === "elf" && (section.flags & 0x800n) !== 0n)
) {
this.discovery = "partial"; this.discovery = "partial";
const message = `GPU section ${section.index} has no supported uncompressed file range`; const message = `GPU section ${section.index} has no supported uncompressed file range`;
this.#retain(message); this.#retain(message);
+1 -5
View File
@@ -340,11 +340,7 @@ export const inspectAndroidJniLibrary = async (
for (const section of directory.sections) { for (const section of directory.sections) {
if ((section.flags & 3n) !== 3n || section.size === 0n) continue; if ((section.flags & 3n) !== 3n || section.size === 0n) continue;
active(); active();
if ( if (section.compressed || !section.file_backed || section.type === 8) {
(section.flags & 0x800n) !== 0n ||
!section.file_backed ||
section.type === 8
) {
tablesStatus = "partial"; tablesStatus = "partial";
unknowns.add( unknowns.add(
"Allocated writable sections without ordinary file bytes were not scanned; runtime-built tables remain unknown", "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}`); 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", () => { it("keeps .eh_frame ranges when .debug_frame is SHT_NOBITS", () => {
const report = parse( const report = parse(
elfUnwindFixture({ 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)( it.each([".eh_frame", ".eh_frame_hdr"] as const)(
"reports coverage incomplete when a duplicate %s is ignored", "reports coverage incomplete when a duplicate %s is ignored",
(duplicate) => { (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", () => { it("caps .eh_frame_hdr rows at the max_ranges budget and marks the comparison incomplete", () => {
const report = parse(elfUnwindFixture({ header: "duplicate-row" }), { const report = parse(elfUnwindFixture({ header: "duplicate-row" }), {
max_ranges: 2, max_ranges: 2,
+27 -14
View File
@@ -34,6 +34,7 @@ const AUG_R = 0x52;
const AUG_S = 0x53; const AUG_S = 0x53;
const AUG_B = 0x42; const AUG_B = 0x42;
const SHT_SYMTAB = 2; const SHT_SYMTAB = 2;
const SHT_STRTAB = 3;
const SHT_DYNAMIC = 6; const SHT_DYNAMIC = 6;
const SHT_NOBITS = 8; const SHT_NOBITS = 8;
const SHT_DYNSYM = 11; const SHT_DYNSYM = 11;
@@ -49,7 +50,6 @@ const SHN_ABS = 0xfff1;
const SHN_LORESERVE = 0xff00; const SHN_LORESERVE = 0xff00;
const SHF_ALLOC = 2n; const SHF_ALLOC = 2n;
const SHF_EXECINSTR = 4n; const SHF_EXECINSTR = 4n;
const SHF_COMPRESSED = 0x800n;
const { const {
cfa: CFA_LIMITATION, cfa: CFA_LIMITATION,
@@ -639,7 +639,7 @@ export const parseElfUnwindRanges = (
? ".eh_frame_hdr is declared but not file-backed." ? ".eh_frame_hdr is declared but not file-backed."
: ".eh_frame_hdr extends past the end of the file and was not decoded.", : ".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; headerLocation = placement.location;
headerStatus = "incomplete"; headerStatus = "incomplete";
fail(".eh_frame_hdr is SHF_COMPRESSED and was not decompressed."); fail(".eh_frame_hdr is SHF_COMPRESSED and was not decompressed.");
@@ -705,7 +705,7 @@ export const parseElfUnwindRanges = (
} else if (frameLocation === null) { } else if (frameLocation === null) {
frameStatus = "incomplete"; frameStatus = "incomplete";
fail(".eh_frame lies outside the file."); fail(".eh_frame lies outside the file.");
} else if ((ehSection.flags & SHF_COMPRESSED) !== 0n) { } else if (ehSection.compressed) {
frameStatus = "incomplete"; frameStatus = "incomplete";
fail(".eh_frame is SHF_COMPRESSED and was not decompressed."); fail(".eh_frame is SHF_COMPRESSED and was not decompressed.");
} else { } else {
@@ -1135,7 +1135,12 @@ const resolveGotBase = (
? undefined ? undefined
: { offset: dynamicProgram.offset, fileSize: dynamicProgram.size }) ?? : { offset: dynamicProgram.offset, fileSize: dynamicProgram.size }) ??
directory.sections directory.sections
.filter((section) => section.type === SHT_DYNAMIC && section.file_backed) .filter(
(section) =>
section.type === SHT_DYNAMIC &&
section.file_backed &&
!section.compressed,
)
.map((section) => ({ .map((section) => ({
offset: section.offset, offset: section.offset,
fileSize: section.size, fileSize: section.size,
@@ -1814,6 +1819,10 @@ const scanSymbols = (
let remaining = MAX_ELF_UNWIND_SYMBOLS; let remaining = MAX_ELF_UNWIND_SYMBOLS;
for (const table of tables) { for (const table of tables) {
assertArtifactReadActive(signal); 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); const width = table.entry_size === 0n ? standard : Number(table.entry_size);
if (width !== standard) { if (width !== standard) {
fail( fail(
@@ -1829,6 +1838,15 @@ const scanSymbols = (
const size = Number(table.size); const size = Number(table.size);
if (size % width !== 0) if (size % width !== 0)
fail("A symbol table size is not a multiple of its entry width."); 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); let count = Math.floor(size / width);
if (count > remaining) { if (count > remaining) {
fail( fail(
@@ -1837,15 +1855,10 @@ const scanSymbols = (
count = remaining; count = remaining;
} }
remaining -= count; remaining -= count;
const strings = byIndex.get(table.link); const stringBytes = bytes.subarray(
const stringBytes = Number(strings.offset),
strings !== undefined && strings.file_backed Number(strings.offset + strings.size),
? bytes.subarray( );
Number(strings.offset),
Number(strings.offset + strings.size),
)
: null;
if (stringBytes === null) fail("A symbol string table could not be read.");
// Observation per string-table offset; null when the name is unreadable. // Observation per string-table offset; null when the name is unreadable.
const names = new Map<number, SymbolName | null>(); const names = new Map<number, SymbolName | null>();
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
@@ -1878,7 +1891,7 @@ const scanSymbols = (
if (owner !== undefined) address += owner.address; if (owner !== undefined) address += owner.address;
} }
// Only a symbol at a decoded FDE start can name a range. // 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); let name = names.get(symbol.name_offset);
if (name === undefined) { if (name === undefined) {
const observed = observeElfStringTableEntry( const observed = observeElfStringTableEntry(
+177 -7
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { deflateSync } from "node:zlib";
import { import {
elfExportImage, elfExportImage,
@@ -22,6 +23,172 @@ const parse = (bytes: Buffer) =>
parseNativeExports(bytes, artifact), 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)( it.each(["requirements", "definitions"] as const)(
"bounds aliased GNU version %s work and keeps exports with unknown versions", "bounds aliased GNU version %s work and keeps exports with unknown versions",
(kind) => { (kind) => {
@@ -706,13 +873,12 @@ describe("ELF version requirements", () => {
}); });
it("uses a definition name when a requirement shares its index", () => { it("uses a definition name when a requirement shares its index", () => {
const result = parse( const bytes = elfExportImage({
elfExportImage({ symbols: [{ name: "ambiguous", version: 2 }],
symbols: [{ name: "ambiguous", version: 2 }], versionDefinitions: ["libfixture.so", "DEFINITION"],
versionDefinitions: ["libfixture.so", "DEFINITION"], versionRequirements: [{ index: 2, name: "REQUIREMENT" }],
versionRequirements: [{ index: 2, name: "REQUIREMENT" }], });
}), const result = parse(bytes);
);
expect(result.exports).toMatchObject([ expect(result.exports).toMatchObject([
{ {
@@ -722,6 +888,10 @@ describe("ELF version requirements", () => {
]); ]);
expect(result.coverage.status).toBe("complete"); expect(result.coverage.status).toBe("complete");
expect(result.issues).toEqual([]); 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", () => { 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 }; table: { readonly offset: number; readonly size: number };
names: Buffer; names: Buffer;
} => { } => {
if (section.compressed)
throw new BinaryFormatFailure(
"unsupported",
`${label} are SHF_COMPRESSED and were not decompressed`,
);
const strings = sections[section.link]; const strings = sections[section.link];
if (strings?.type !== SHT_STRTAB) if (strings?.type !== SHT_STRTAB)
throw new BinaryFormatFailure( throw new BinaryFormatFailure(
"malformed", "malformed",
`${label} do not link to a string table`, `${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( const stringRange = reader.fileRange(
strings.offset, strings.offset,
strings.size, strings.size,
@@ -545,7 +555,13 @@ function* readVersionNames(
} }
} }
} catch (cause) { } 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({ issues.push({
code: "version_unresolved", code: "version_unresolved",
location: hex(verdef.offset), location: hex(verdef.offset),
@@ -605,6 +621,32 @@ function* parseElfExports(
}; };
const width = directory.bits === 64 ? 24 : 16; const width = directory.bits === 64 ? 24 : 16;
const strings = sections[dynsym.link]; 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 ( if (
dynsym.entry_size !== BigInt(width) || dynsym.entry_size !== BigInt(width) ||
dynsym.size % BigInt(width) !== 0n || dynsym.size % BigInt(width) !== 0n ||
@@ -647,6 +689,11 @@ function* parseElfExports(
let versions: number | null = null; let versions: number | null = null;
if (versym !== undefined) { if (versym !== undefined) {
try { try {
if (versym.compressed)
throw new BinaryFormatFailure(
"unsupported",
"ELF symbol versions are SHF_COMPRESSED and were not decompressed",
);
if (versym.size !== BigInt(count * 2)) if (versym.size !== BigInt(count * 2))
throw new BinaryFormatFailure( throw new BinaryFormatFailure(
"malformed", "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, strings.readBigUInt64LE(elfSectionAt(strings, SHT_STRTAB) + 32) - 1n,
elfSectionAt(strings, SHT_STRTAB) + 32, 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( expect(
findRow(readElfHardening(bytes).rows, "stack_protector"), findRow(readElfHardening(bytes).rows, "stack_protector"),
).toMatchObject({ ).toMatchObject({
state: "unknown", state: "unknown",
note: expect.stringContaining("DT_GNU_HASH"), 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", () => { 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_EXEC = 2;
const ET_DYN = 3; const ET_DYN = 3;
const SHT_SYMTAB = 2; const SHT_SYMTAB = 2;
const SHT_STRTAB = 3;
const SHT_NOTE = 7; const SHT_NOTE = 7;
const SHT_DYNSYM = 11; const SHT_DYNSYM = 11;
const DT_NULL = 0n; const DT_NULL = 0n;
@@ -71,7 +72,6 @@ const DF_1_PIE = 0x08000000n;
const PF_X = 1; const PF_X = 1;
const GNU_PROPERTY_AARCH64_FEATURE_1_AND = 0xc0000000; const GNU_PROPERTY_AARCH64_FEATURE_1_AND = 0xc0000000;
const GNU_PROPERTY_X86_FEATURE_1_AND = 0xc0000002; 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. */ /** Tags hardening reads as a single value. Repeating tags such as DT_NEEDED stay. */
const SINGULAR_DYNAMIC_TAGS = new Set<bigint>([ const SINGULAR_DYNAMIC_TAGS = new Set<bigint>([
DT_HASH, DT_HASH,
@@ -175,7 +175,6 @@ export const readElfHardening = (
assertElfDeclaredRangesFromBuffer(bytes); assertElfDeclaredRangesFromBuffer(bytes);
const { bits, little, machine, programs, sections } = directory; const { bits, little, machine, programs, sections } = directory;
const headerWidth = bits === 64 ? 56 : 32; const headerWidth = bits === 64 ? 56 : 32;
const symbolWidth = bits === 64 ? 24 : 16;
const dynamicWidth = bits === 64 ? 16 : 8; const dynamicWidth = bits === 64 ? 16 : 8;
const propertyAlign = bits === 64 ? 8 : 4; const propertyAlign = bits === 64 ? 8 : 4;
const loads = programs.filter(isSpan).filter((program) => program.type === 1); const loads = programs.filter(isSpan).filter((program) => program.type === 1);
@@ -546,10 +545,14 @@ const agreeingSectionCount = (
if (dynsym === undefined) return null; if (dynsym === undefined) return null;
const strings = sections[dynsym.link]; const strings = sections[dynsym.link];
if ( if (
dynsym.compressed ||
strings === undefined ||
strings.compressed ||
strings.type !== SHT_STRTAB ||
dynsym.address !== symtab.value || dynsym.address !== symtab.value ||
strings?.address !== dynamic.entries.get(DT_STRTAB)?.value || strings.address !== dynamic.entries.get(DT_STRTAB)?.value ||
strings?.size !== dynamic.entries.get(DT_STRSZ)?.value || strings.size !== dynamic.entries.get(DT_STRSZ)?.value ||
strings?.offset !== BigInt(dynamic.stringFileOffset ?? -1) strings.offset !== BigInt(dynamic.stringFileOffset ?? -1)
) )
return null; return null;
const entry = dynsym.entry_size === 0n ? BigInt(width) : dynsym.entry_size; const entry = dynsym.entry_size === 0n ? BigInt(width) : dynsym.entry_size;
@@ -897,11 +900,10 @@ const readFeatureNotes = (
if (section.type !== SHT_NOTE) continue; if (section.type !== SHT_NOTE) continue;
// An empty note section owns no bytes, so it has nothing to walk. // An empty note section owns no bytes, so it has nothing to walk.
if (section.size === 0n) continue; if (section.size === 0n) continue;
const compressed = (section.flags & SHF_COMPRESSED) !== 0n; if (section.compressed)
if (compressed)
incomplete ??= incomplete ??=
"A compressed SHT_NOTE section may hold GNU property notes, and its descriptors were not read."; "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; if (!section.file_backed) continue;
otherSpans.push({ otherSpans.push({
offset: exact(section.offset, "SHT_NOTE offset"), offset: exact(section.offset, "SHT_NOTE offset"),
@@ -124,11 +124,17 @@ const stringAt = (
return { name: text, missing: false }; return { name: text, missing: false };
}; };
const tableBytes = ( const stringTableBytes = (
bytes: Buffer, bytes: Buffer,
section: NativeSection | undefined, section: NativeSection | undefined,
): Buffer | null => { ): 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 offset = Number(section.offset);
const size = Number(section.size); const size = Number(section.size);
if (offset < 0 || size < 0 || offset > bytes.length - size) return null; if (offset < 0 || size < 0 || offset > bytes.length - size) return null;
@@ -144,9 +150,19 @@ const readTable = (
): { ): {
readonly symbols: ElfInitializerSymbol[]; readonly symbols: ElfInitializerSymbol[];
readonly truncated: boolean; readonly truncated: boolean;
readonly incomplete: boolean;
} => { } => {
const width = facts.bits === 64 ? 24 : 16; 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 start = Number(section.offset);
const declared = section.size / BigInt(width); const declared = section.size / BigInt(width);
const count = Number(declared < BigInt(budget) ? declared : BigInt(budget)); const count = Number(declared < BigInt(budget) ? declared : BigInt(budget));
@@ -177,7 +193,7 @@ const readTable = (
type: parsed.value.type, 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. */ /** Load SHT_SYMTAB and SHT_DYNSYM once. Defined STT_FUNC names win ties. */
@@ -223,10 +239,20 @@ export const loadElfSymbolIndex = (
sections.forEach((section, index) => { sections.forEach((section, index) => {
if (truncated) return; if (truncated) return;
if (section.type !== 2 && section.type !== 11) return; if (section.type !== 2 && section.type !== 11) return;
if ( if (section.compressed) {
!section.file_backed || notes.add("A symbol table is compressed; its symbols were not decoded.");
section.size % BigInt(facts.bits === 64 ? 24 : 16) !== 0n 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( 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.", "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; budget -= table.symbols.length;
tables.set(index, table.symbols); tables.set(index, table.symbols);
truncated ||= table.truncated; truncated ||= table.truncated;
unreadable ||= table.incomplete;
for (const symbol of table.symbols) consider(symbol); for (const symbol of table.symbols) consider(symbol);
}); });
return { return {
@@ -440,7 +467,11 @@ export const sectionRelocations = async (
for (const [position, section] of input.sections.entries()) { for (const [position, section] of input.sections.entries()) {
const encoding = applicable(input, section); const encoding = applicable(input, section);
if (encoding === null) continue; 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); skip(section);
continue; continue;
} }
+17 -3
View File
@@ -269,7 +269,7 @@ const indexSections = (sections: readonly NativeSection[]): ElfSectionIndex => {
: left.index - right.index, : left.index - right.index,
); );
const backed = buildAddressRangeIndex( const backed = buildAddressRangeIndex(
ranges((section) => section.file_backed), ranges((section) => section.file_backed && !section.compressed),
(left, right) => left.index - right.index, (left, right) => left.index - right.index,
); );
return { return {
@@ -354,7 +354,8 @@ const findSections = (
const withinSection = const withinSection =
(section: NativeSection, width: number) => (section: NativeSection, width: number) =>
(address: bigint): number | null => { (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; const delta = address - section.address;
if (delta + BigInt(width) > section.size) return null; if (delta + BigInt(width) > section.size) return null;
return Number(section.offset + delta); return Number(section.offset + delta);
@@ -374,6 +375,13 @@ const sectionSource = (
section.name.length <= MAX_NATIVE_INITIALIZER_NAME_BYTES section.name.length <= MAX_NATIVE_INITIALIZER_NAME_BYTES
? section.name ? section.name
: fallbackName; : 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) { if (!section.file_backed) {
note(scan, `${label} is not file-backed. Its slots were omitted.`); note(scan, `${label} is not file-backed. Its slots were omitted.`);
return { kind, sentinel, detail: label, slots: [] }; return { kind, sentinel, detail: label, slots: [] };
@@ -671,6 +679,7 @@ const loadWrites = async (
}; };
const dynamicSymbols = ( const dynamicSymbols = (
scan: ElfScan,
sections: readonly NativeSection[], sections: readonly NativeSection[],
tags: ReadonlyMap<bigint, DynTag>, tags: ReadonlyMap<bigint, DynTag>,
tables: ReadonlyMap<number, readonly ElfInitializerSymbol[]>, tables: ReadonlyMap<number, readonly ElfInitializerSymbol[]>,
@@ -683,6 +692,11 @@ const dynamicSymbols = (
(section.type === 11 || section.type === 2), (section.type === 11 || section.type === 2),
); );
if (match >= 0) return tables.get(match); 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); const dynsym = sections.findIndex((section) => section.type === 11);
return dynsym < 0 ? undefined : tables.get(dynsym); return dynsym < 0 ? undefined : tables.get(dynsym);
@@ -1042,7 +1056,7 @@ export const readElfInitializers = async (
loaded, loaded,
sectionRels.found, sectionRels.found,
sectionRels.clobbered, sectionRels.clobbered,
dynamicSymbols(directory.sections, tags, symbols.tables), dynamicSymbols(scan, directory.sections, tags, symbols.tables),
symbols, symbols,
loaded.trustRaw && sectionRels.complete, loaded.trustRaw && sectionRels.complete,
directory.sections, directory.sections,
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { deflateSync } from "node:zlib";
import { readNativeInitializers } from "./NativeInitializersProvider.js"; import { readNativeInitializers } from "./NativeInitializersProvider.js";
/** Pass-through spies on the shared mapping module, so tests can count lookups made from other modules. */ /** 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", () => { describe("ELF incomplete inputs", () => {
it("reports an unsupported relocation machine as incomplete, not as raw zeros", async () => { it("reports an unsupported relocation machine as incomplete, not as raw zeros", async () => {
const bytes = elf64PieInitializers(); const bytes = elf64PieInitializers();
+1 -2
View File
@@ -68,8 +68,7 @@ export const readElfImage = (
} }
for (const section of directory.sections) { for (const section of directory.sections) {
const range = declaredFileRange(bytes.length, section.offset, section.size); const range = declaredFileRange(bytes.length, section.offset, section.size);
const file_backed = section.type !== 8 && range.file_backed; const { file_backed, compressed } = section;
const compressed = (section.flags & 0x800n) !== 0n;
const read_only = const read_only =
section.type === 1 && section.type === 1 &&
file_backed && file_backed &&
@@ -2,6 +2,7 @@ import { expect, it } from "vitest";
import { pe } from "../../domain/binaryTarget.fixture.js"; import { pe } from "../../domain/binaryTarget.fixture.js";
import { elfHardeningFixture } from "../hardening/BinaryHardening.fixture.js"; import { elfHardeningFixture } from "../hardening/BinaryHardening.fixture.js";
import { elfUnwindSectionlessFixture } from "../elf/ElfUnwindTables.fixture.js"; import { elfUnwindSectionlessFixture } from "../elf/ElfUnwindTables.fixture.js";
import { compressedElfNoteSectionFixture } from "../elf/ElfNotes.fixture.js";
import { import {
ELF_PT_GNU_EH_FRAME, ELF_PT_GNU_EH_FRAME,
ELF_PT_GNU_RELRO, 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; readonly entry_size: bigint;
/** `sh_addralign` for ELF. PE sections have no note alignment and use 1. */ /** `sh_addralign` for ELF. PE sections have no note alignment and use 1. */
readonly alignment: bigint; readonly alignment: bigint;
/** Declared stored bytes are present; compressed bytes are not decoded content. */
readonly file_backed: boolean; 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. */ /** Program header types retained for load, dynamic, hardening, and unwind consumers. */
export const ELF_PT_NOTE = 4 as const; export const ELF_PT_NOTE = 4 as const;
@@ -308,6 +311,7 @@ export function* readElfSectionDirectory(
alignment: section.alignment, alignment: section.alignment,
file_backed: file_backed:
type !== 8 && declaredFileRange(fileSize, offset, size).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.offset),
BigInt(row.size), BigInt(row.size),
).file_backed, ).file_backed,
compressed: false,
}); });
} }
return { return {
-54
View File
@@ -407,30 +407,6 @@ it("charges long digits to the string budget and keeps large previews bounded",
); );
}); });
it("reads marshal one-byte string tags as Latin-1 with Unicode identity", () => {
for (const tag of [0x61, 0x41, 0x7a, 0x5a]) {
const encoded = Buffer.concat([
Buffer.from([tag]),
tag === 0x61 || tag === 0x41 ? u32(1) : Buffer.from([1]),
Buffer.from([0xe9]),
]);
const sameUnicode = marshalUnicode(Buffer.from("é", "utf8"));
const set = Buffer.concat([
Buffer.from([0x3c]),
u32(2),
encoded,
sameUnicode,
]);
const report = root(parse(withConsts([encoded, set])));
expect(report.consts[0]).toMatchObject({
kind: "str",
value: "é",
bytes: 1,
});
expect(report.consts[1]).toEqual({ kind: "set", length: 1 });
}
});
it("decodes marshal strings with CPython surrogatepass rules", () => { it("decodes marshal strings with CPython surrogatepass rules", () => {
const report = parse( const report = parse(
withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]), withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]),
@@ -1558,36 +1534,6 @@ it("hashes a long TYPE_REF tuple chain without recursing", () => {
expect(pythonPycSchema.safeParse(report).success).toBe(true); expect(pythonPycSchema.safeParse(report).success).toBe(true);
}); });
it("hashes shallow TYPE_REF code chains without using the JavaScript stack", () => {
const links = 3000;
const codes = Array.from({ length: links }, (_, index) =>
marshalCode("py311", {
flagged: true,
name: "f",
consts: index === 0 ? [Buffer.from([0x4e])] : [marshalRef(index - 1)],
}),
);
const bytes = Buffer.concat([
pythonPycHeader(3531),
marshalCode("py311", {
constsObject: Buffer.concat([
Buffer.from([0x28]),
u32(links + 1),
...codes,
Buffer.from([0x3c]),
u32(1),
marshalRef(links - 1),
]),
}),
]);
const report = parseWith(bytes, { max_objects: 65_536 });
expect(root(report).consts.at(-1)).toEqual({ kind: "set", length: 1 });
expect(report.coverage).toMatchObject({
status: "complete",
code_objects: links + 1,
});
});
it("rejects code CPython treats as corrupt instead of complete coverage", () => { it("rejects code CPython treats as corrupt instead of complete coverage", () => {
const file = ( const file = (
layout: "py37" | "py38" | "py311", layout: "py37" | "py38" | "py311",
+17 -83
View File
@@ -332,7 +332,7 @@ class MarshalReader {
readonly #codeKeys = new WeakMap<CodeNode, string>(); readonly #codeKeys = new WeakMap<CodeNode, string>();
readonly #codeIdentities = new WeakMap<CodeNode, number>(); readonly #codeIdentities = new WeakMap<CodeNode, number>();
readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>(); readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>();
/** Completed Python hashability checks per code object. */ /** Python hashability per code object. False while that check is in progress. */
readonly #codeHashable = new WeakMap<CodeNode, boolean>(); readonly #codeHashable = new WeakMap<CodeNode, boolean>();
/** Tuples whose items are still being read; CPython cannot hash them yet. */ /** Tuples whose items are still being read; CPython cannot hash them yet. */
readonly #readingTuples = new WeakSet<MarshalValue>(); readonly #readingTuples = new WeakSet<MarshalValue>();
@@ -436,19 +436,13 @@ class MarshalReader {
}; };
case TYPE_INTERNED: case TYPE_INTERNED:
case TYPE_UNICODE: case TYPE_UNICODE:
return this.string( return this.utf8(this.nonNegativeInt32("Marshal string length"), false);
this.nonNegativeInt32("Marshal string length"),
"utf8",
);
case TYPE_ASCII: case TYPE_ASCII:
case TYPE_ASCII_INTERNED: case TYPE_ASCII_INTERNED:
return this.string( return this.utf8(this.nonNegativeInt32("Marshal string length"), true);
this.nonNegativeInt32("Marshal string length"),
"latin1",
);
case TYPE_SHORT_ASCII: case TYPE_SHORT_ASCII:
case TYPE_SHORT_ASCII_INTERNED: case TYPE_SHORT_ASCII_INTERNED:
return this.string(this.u8(), "latin1"); return this.utf8(this.u8(), true);
case TYPE_TUPLE: case TYPE_TUPLE:
return this.sequenceValue( return this.sequenceValue(
"tuple", "tuple",
@@ -1315,70 +1309,13 @@ class MarshalReader {
#isCodeHashable(node: CodeNode): boolean { #isCodeHashable(node: CodeNode): boolean {
const known = this.#codeHashable.get(node); const known = this.#codeHashable.get(node);
if (known !== undefined) return known; if (known !== undefined) return known;
const frames: { value: MarshalValue; finish: boolean }[] = [ this.#codeHashable.set(node, false);
{ value: { kind: "code", value: node }, finish: false }, const material =
]; this.#codeEquality.get(node) ??
const gray = new Set<MarshalValue | CodeNode>(); malformed("Code object is missing equality material.");
const complete = new Set<MarshalValue>(); const hashable = this.#equalityKey(material.constsTuple, "hash") !== null;
const reject = (): false => { this.#codeHashable.set(node, hashable);
this.#codeHashable.set(node, false); return hashable;
return false;
};
while (frames.length > 0) {
this.#takeKeyStep();
const frame = frames.pop()!;
const value = frame.value;
const identity = value.kind === "code" ? value.value : value;
if (frame.finish) {
gray.delete(identity);
if (value.kind === "code") {
const material =
this.#codeEquality.get(value.value) ??
malformed("Code object is missing equality material.");
// Child code checks are now complete, so the existing tuple key
// walker preserves its equality and expansion budgets without recursion.
const hashable =
this.#equalityKey(material.constsTuple, "hash") !== null;
this.#codeHashable.set(value.value, hashable);
if (!hashable) return reject();
} else complete.add(value);
continue;
}
if (value.kind === "code") {
const cached = this.#codeHashable.get(value.value);
if (cached === false) return reject();
if (cached === true) continue;
} else {
const cached = this.#keyCache.get(value);
if (cached === null) return reject();
if (cached !== undefined || complete.has(value)) continue;
}
if (
gray.has(identity) ||
this.#readingTuples.has(value) ||
value.kind === "list" ||
value.kind === "set" ||
value.kind === "dict" ||
value.kind === "null"
)
return reject();
const children =
value.kind === "code"
? [
(
this.#codeEquality.get(value.value) ??
malformed("Code object is missing equality material.")
).constsTuple,
]
: value.kind === "tuple" || value.kind === "frozenset"
? value.items
: [];
gray.add(identity);
frames.push({ value, finish: true });
for (let index = children.length - 1; index >= 0; index -= 1)
frames.push({ value: children[index]!, finish: false });
}
return true;
} }
#codeIdentity(node: CodeNode): number { #codeIdentity(node: CodeNode): number {
@@ -1555,20 +1492,17 @@ class MarshalReader {
); );
} }
string(length: number, encoding: "latin1" | "utf8"): MarshalValue { utf8(length: number, ascii: boolean): MarshalValue {
const bytes = this.blob(length); const bytes = this.blob(length);
if (encoding === "latin1") { if (ascii) {
// CPython reads the ASCII-tagged forms as one-byte Unicode, even when if (bytes.some((byte) => byte > 0x7f))
// high bytes appear. Identity must agree with the UTF-8 string form. malformed("Marshal ASCII string contains a non-ASCII byte.");
const value = bytes.toString("latin1");
return { return {
kind: "str", kind: "str",
value, value: bytes.toString("latin1"),
previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT), previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT),
bytes: length, bytes: length,
raw: bytes.some((byte) => byte > 0x7f) raw: bytes,
? Buffer.from(value, "utf8")
: bytes,
}; };
} }
const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT); const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT);