Compare commits

..
24 changed files with 199 additions and 693 deletions

No files matched your search

-12
View File
@@ -135,25 +135,13 @@ 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
@@ -1,87 +0,0 @@
"""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,11 +44,6 @@ 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
@@ -483,13 +478,6 @@ 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,12 +173,5 @@ 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,11 +150,6 @@ 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.
@@ -20,6 +20,11 @@ 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", () => {
expect(
readMarshalSubset(
+2 -8
View File
@@ -168,14 +168,8 @@ const readAsciiBytes = (
): Result<string, PyInstallerFailure> => {
const bytes = readBytes(cursor, length);
if (!bytes.ok) return bytes;
if (bytes.value.some((byte) => byte > 0x7f))
return err(
pyInstallerFailure(
"format",
"PYZ marshal ASCII string contains a non-ASCII byte",
),
);
return ok(bytes.value.toString("ascii"));
// These tags use CPython's one-byte Unicode constructor, not an ASCII check.
return ok(bytes.value.toString("latin1"));
};
const readUtf8 = (
+1 -1
View File
@@ -43,7 +43,7 @@ const sectionBytes = (
section: NativeSection,
budget?: CudaMetadataBudget,
): Promise<Buffer> => {
if (!section.file_backed || section.compressed)
if (!section.file_backed || (section.flags & 0x800n) !== 0n)
rejectGpu("GPU metadata section has no supported uncompressed file range");
budget?.read(gpuCount(section.size));
return source.read(gpuCount(section.offset), gpuCount(section.size));
+4 -1
View File
@@ -194,7 +194,10 @@ export class GpuInventory {
continue;
}
if (!isGpuSection(section.name)) continue;
if (!section.file_backed || section.compressed) {
if (
!section.file_backed ||
(directory.format === "elf" && (section.flags & 0x800n) !== 0n)
) {
this.discovery = "partial";
const message = `GPU section ${section.index} has no supported uncompressed file range`;
this.#retain(message);
+5 -1
View File
@@ -340,7 +340,11 @@ export const inspectAndroidJniLibrary = async (
for (const section of directory.sections) {
if ((section.flags & 3n) !== 3n || section.size === 0n) continue;
active();
if (section.compressed || !section.file_backed || section.type === 8) {
if (
(section.flags & 0x800n) !== 0n ||
!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,33 +462,6 @@ 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({
@@ -618,18 +591,6 @@ 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) => {
@@ -704,22 +665,6 @@ 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,
+14 -27
View File
@@ -34,7 +34,6 @@ 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;
@@ -50,6 +49,7 @@ 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.compressed) {
} else if ((hdrSection.flags & SHF_COMPRESSED) !== 0n) {
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.compressed) {
} else if ((ehSection.flags & SHF_COMPRESSED) !== 0n) {
frameStatus = "incomplete";
fail(".eh_frame is SHF_COMPRESSED and was not decompressed.");
} else {
@@ -1135,12 +1135,7 @@ const resolveGotBase = (
? undefined
: { offset: dynamicProgram.offset, fileSize: dynamicProgram.size }) ??
directory.sections
.filter(
(section) =>
section.type === SHT_DYNAMIC &&
section.file_backed &&
!section.compressed,
)
.filter((section) => section.type === SHT_DYNAMIC && section.file_backed)
.map((section) => ({
offset: section.offset,
fileSize: section.size,
@@ -1819,10 +1814,6 @@ 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(
@@ -1838,15 +1829,6 @@ 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(
@@ -1855,10 +1837,15 @@ const scanSymbols = (
count = remaining;
}
remaining -= count;
const stringBytes = bytes.subarray(
Number(strings.offset),
Number(strings.offset + strings.size),
);
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.");
// 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) {
@@ -1891,7 +1878,7 @@ const scanSymbols = (
if (owner !== undefined) address += owner.address;
}
// Only a symbol at a decoded FDE start can name a range.
if (!wanted.has(address)) continue;
if (stringBytes === null || !wanted.has(address)) continue;
let name = names.get(symbol.name_offset);
if (name === undefined) {
const observed = observeElfStringTableEntry(
+7 -177
View File
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { deflateSync } from "node:zlib";
import {
elfExportImage,
@@ -23,172 +22,6 @@ 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) => {
@@ -873,12 +706,13 @@ describe("ELF version requirements", () => {
});
it("uses a definition name when a requirement shares its index", () => {
const bytes = elfExportImage({
symbols: [{ name: "ambiguous", version: 2 }],
versionDefinitions: ["libfixture.so", "DEFINITION"],
versionRequirements: [{ index: 2, name: "REQUIREMENT" }],
});
const result = parse(bytes);
const result = parse(
elfExportImage({
symbols: [{ name: "ambiguous", version: 2 }],
versionDefinitions: ["libfixture.so", "DEFINITION"],
versionRequirements: [{ index: 2, name: "REQUIREMENT" }],
}),
);
expect(result.exports).toMatchObject([
{
@@ -888,10 +722,6 @@ 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", () => {
+1 -48
View File
@@ -294,22 +294,12 @@ 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,
@@ -555,13 +545,7 @@ function* readVersionNames(
}
}
} catch (cause) {
// 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();
if (cause instanceof ElfVersionWorkFailure) namesByIndex.clear();
issues.push({
code: "version_unresolved",
location: hex(verdef.offset),
@@ -621,32 +605,6 @@ 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 ||
@@ -689,11 +647,6 @@ 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,40 +1405,13 @@ 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,
);
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,
])
for (const bytes of [moved, offset, strings])
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", () => {
+8 -10
View File
@@ -51,7 +51,6 @@ 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;
@@ -72,6 +71,7 @@ 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,6 +175,7 @@ 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);
@@ -545,14 +546,10 @@ 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;
@@ -900,10 +897,11 @@ 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;
if (section.compressed)
const compressed = (section.flags & SHF_COMPRESSED) !== 0n;
if (compressed)
incomplete ??=
"A compressed SHT_NOTE section may hold GNU property notes, and its descriptors were not read.";
if (section.compressed) {
if (compressed) {
if (!section.file_backed) continue;
otherSpans.push({
offset: exact(section.offset, "SHT_NOTE offset"),
@@ -124,17 +124,11 @@ const stringAt = (
return { name: text, missing: false };
};
const stringTableBytes = (
const tableBytes = (
bytes: Buffer,
section: NativeSection | undefined,
): Buffer | null => {
if (
section === undefined ||
section.type !== 3 ||
!section.file_backed ||
section.compressed
)
return null;
if (section === undefined || !section.file_backed) return null;
const offset = Number(section.offset);
const size = Number(section.size);
if (offset < 0 || size < 0 || offset > bytes.length - size) return null;
@@ -150,19 +144,9 @@ const readTable = (
): {
readonly symbols: ElfInitializerSymbol[];
readonly truncated: boolean;
readonly incomplete: boolean;
} => {
const width = facts.bits === 64 ? 24 : 16;
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 strings = tableBytes(facts.bytes, sections[section.link]);
const start = Number(section.offset);
const declared = section.size / BigInt(width);
const count = Number(declared < BigInt(budget) ? declared : BigInt(budget));
@@ -193,7 +177,7 @@ const readTable = (
type: parsed.value.type,
});
}
return { symbols, truncated, incomplete: nameMissing || strings === null };
return { symbols, truncated: truncated || nameMissing };
};
/** Load SHT_SYMTAB and SHT_DYNSYM once. Defined STT_FUNC names win ties. */
@@ -239,20 +223,10 @@ export const loadElfSymbolIndex = (
sections.forEach((section, index) => {
if (truncated) return;
if (section.type !== 2 && section.type !== 11) return;
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) {
if (
!section.file_backed ||
section.size % BigInt(facts.bits === 64 ? 24 : 16) !== 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.",
);
@@ -263,7 +237,6 @@ 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 {
@@ -467,11 +440,7 @@ 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 || section.compressed) {
if (section.compressed)
input.notes.add(
"A relocation section is compressed; its records were not decoded.",
);
if (!section.file_backed) {
skip(section);
continue;
}
+3 -17
View File
@@ -269,7 +269,7 @@ const indexSections = (sections: readonly NativeSection[]): ElfSectionIndex => {
: left.index - right.index,
);
const backed = buildAddressRangeIndex(
ranges((section) => section.file_backed && !section.compressed),
ranges((section) => section.file_backed),
(left, right) => left.index - right.index,
);
return {
@@ -354,8 +354,7 @@ const findSections = (
const withinSection =
(section: NativeSection, width: number) =>
(address: bigint): number | null => {
if (!section.file_backed || section.compressed || address < section.address)
return null;
if (!section.file_backed || address < section.address) return null;
const delta = address - section.address;
if (delta + BigInt(width) > section.size) return null;
return Number(section.offset + delta);
@@ -375,13 +374,6 @@ 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: [] };
@@ -679,7 +671,6 @@ const loadWrites = async (
};
const dynamicSymbols = (
scan: ElfScan,
sections: readonly NativeSection[],
tags: ReadonlyMap<bigint, DynTag>,
tables: ReadonlyMap<number, readonly ElfInitializerSymbol[]>,
@@ -692,11 +683,6 @@ 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);
@@ -1056,7 +1042,7 @@ export const readElfInitializers = async (
loaded,
sectionRels.found,
sectionRels.clobbered,
dynamicSymbols(scan, directory.sections, tags, symbols.tables),
dynamicSymbols(directory.sections, tags, symbols.tables),
symbols,
loaded.trustRaw && sectionRels.complete,
directory.sections,
@@ -1,5 +1,4 @@
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. */
@@ -321,106 +320,6 @@ 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();
+2 -1
View File
@@ -68,7 +68,8 @@ export const readElfImage = (
}
for (const section of directory.sections) {
const range = declaredFileRange(bytes.length, section.offset, section.size);
const { file_backed, compressed } = section;
const file_backed = section.type !== 8 && range.file_backed;
const compressed = (section.flags & 0x800n) !== 0n;
const read_only =
section.type === 1 &&
file_backed &&
@@ -2,7 +2,6 @@ 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,
@@ -95,35 +94,3 @@ 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,10 +45,7 @@ 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;
@@ -311,7 +308,6 @@ export function* readElfSectionDirectory(
alignment: section.alignment,
file_backed:
type !== 8 && declaredFileRange(fileSize, offset, size).file_backed,
compressed: (section.flags & 0x800n) !== 0n,
});
}
}
@@ -414,7 +410,6 @@ export function* readPeSectionDirectory(
BigInt(row.offset),
BigInt(row.size),
).file_backed,
compressed: false,
});
}
return {
+54
View File
@@ -407,6 +407,30 @@ 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", () => {
const report = parse(
withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]),
@@ -1534,6 +1558,36 @@ it("hashes a long TYPE_REF tuple chain without recursing", () => {
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", () => {
const file = (
layout: "py37" | "py38" | "py311",
+83 -17
View File
@@ -332,7 +332,7 @@ class MarshalReader {
readonly #codeKeys = new WeakMap<CodeNode, string>();
readonly #codeIdentities = new WeakMap<CodeNode, number>();
readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>();
/** Python hashability per code object. False while that check is in progress. */
/** Completed Python hashability checks per code object. */
readonly #codeHashable = new WeakMap<CodeNode, boolean>();
/** Tuples whose items are still being read; CPython cannot hash them yet. */
readonly #readingTuples = new WeakSet<MarshalValue>();
@@ -436,13 +436,19 @@ class MarshalReader {
};
case TYPE_INTERNED:
case TYPE_UNICODE:
return this.utf8(this.nonNegativeInt32("Marshal string length"), false);
return this.string(
this.nonNegativeInt32("Marshal string length"),
"utf8",
);
case TYPE_ASCII:
case TYPE_ASCII_INTERNED:
return this.utf8(this.nonNegativeInt32("Marshal string length"), true);
return this.string(
this.nonNegativeInt32("Marshal string length"),
"latin1",
);
case TYPE_SHORT_ASCII:
case TYPE_SHORT_ASCII_INTERNED:
return this.utf8(this.u8(), true);
return this.string(this.u8(), "latin1");
case TYPE_TUPLE:
return this.sequenceValue(
"tuple",
@@ -1309,13 +1315,70 @@ class MarshalReader {
#isCodeHashable(node: CodeNode): boolean {
const known = this.#codeHashable.get(node);
if (known !== undefined) return known;
this.#codeHashable.set(node, false);
const material =
this.#codeEquality.get(node) ??
malformed("Code object is missing equality material.");
const hashable = this.#equalityKey(material.constsTuple, "hash") !== null;
this.#codeHashable.set(node, hashable);
return hashable;
const frames: { value: MarshalValue; finish: boolean }[] = [
{ value: { kind: "code", value: node }, finish: false },
];
const gray = new Set<MarshalValue | CodeNode>();
const complete = new Set<MarshalValue>();
const reject = (): false => {
this.#codeHashable.set(node, false);
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 {
@@ -1492,17 +1555,20 @@ class MarshalReader {
);
}
utf8(length: number, ascii: boolean): MarshalValue {
string(length: number, encoding: "latin1" | "utf8"): MarshalValue {
const bytes = this.blob(length);
if (ascii) {
if (bytes.some((byte) => byte > 0x7f))
malformed("Marshal ASCII string contains a non-ASCII byte.");
if (encoding === "latin1") {
// CPython reads the ASCII-tagged forms as one-byte Unicode, even when
// high bytes appear. Identity must agree with the UTF-8 string form.
const value = bytes.toString("latin1");
return {
kind: "str",
value: bytes.toString("latin1"),
value,
previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT),
bytes: length,
raw: bytes,
raw: bytes.some((byte) => byte > 0x7f)
? Buffer.from(value, "utf8")
: bytes,
};
}
const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT);