Compare commits

...
Author SHA1 Message Date
morluto be2ac55513 fix(desktop): validate ELF dependency string mappings
Recover the preserved desktop-domain audit repair omitted from the library-identity publication. Reuse the canonical virtual span mapper for complete, unambiguous file backing and shared PT_LOAD admission; retain the original inert malformed/alias controls with an adjacent-mapping control.
2026-11-11 06:07:22 -05:00
2 changed files with 151 additions and 27 deletions

No files matched your search

+110
View File
@@ -196,6 +196,28 @@ describe("ELF dependency identity and failures", () => {
}
});
it.each(["unmapped strings", "invalid load sizes", "truncated load bytes"])(
"does not derive library names from %s",
async (failure) => {
const valid = elfImage({ needed: ["libc.so.6"] });
expect(await dependencies(valid)).toMatchObject({
ok: true,
value: { needed: ["libc.so.6"] },
});
const malformed = Buffer.from(valid);
if (failure === "unmapped strings")
// The NUL at the beginning of DT_STRTAB is mapped; the library name is not.
malformed.writeBigUInt64LE(177n, 64 + 32);
else if (failure === "invalid load sizes")
malformed.writeBigUInt64LE(0n, 64 + 40);
else {
malformed.writeBigUInt64LE(BigInt(valid.length + 1), 64 + 32);
malformed.writeBigUInt64LE(BigInt(valid.length + 1), 64 + 40);
}
expect(await dependencies(malformed)).toMatchObject({ ok: false });
},
);
it("refuses a script, a truncated image and a dynamic table without DT_NULL", async () => {
const script = Buffer.from('#!/bin/sh\nexec /opt/app/app "$@"\n');
expect(await dependencies(script)).toEqual({
@@ -220,3 +242,91 @@ describe("ELF dependency identity and failures", () => {
});
});
});
it.each([false, true])(
"rejects conflicting string-table mappings and accepts identical aliases (reversed: %s)",
async (reversed) => {
const base = elfImage({ needed: ["libc.so.6"] });
const alternate = Buffer.from("\0libevilso\0");
const headersAt = base.length + alternate.length;
const image = Buffer.concat([base, alternate, Buffer.alloc(3 * 56)]);
base.copy(image, headersAt, 64, 64 + 2 * 56);
image.writeBigUInt64LE(BigInt(headersAt), 32);
image.writeUInt16LE(3, 56);
const alias = headersAt + 2 * 56;
image.writeUInt32LE(1, alias);
image.writeBigUInt64LE(BigInt(base.length), alias + 8);
image.writeBigUInt64LE(176n, alias + 16);
image.writeBigUInt64LE(BigInt(alternate.length), alias + 32);
image.writeBigUInt64LE(BigInt(alternate.length), alias + 40);
if (reversed) {
const first = Buffer.from(image.subarray(headersAt, headersAt + 56));
image.copy(image, headersAt, alias, alias + 56);
first.copy(image, alias);
}
expect(await dependencies(image)).toMatchObject({
ok: false,
error: {
kind: "unreadable",
identity: { machine: 183, bits: 64, byte_order: "little" },
reason: "ELF dynamic string table has ambiguous file mappings",
},
});
// Two load declarations that translate to the same bytes are not ambiguous.
const aliasAt = reversed ? headersAt : alias;
image.writeBigUInt64LE(176n, aliasAt + 8);
expect(await dependencies(image)).toMatchObject({
ok: true,
value: { needed: ["libc.so.6"] },
});
// A conflicting mapping of even one string byte keeps the table ambiguous.
image.writeBigUInt64LE(177n, aliasAt + 16);
image.writeBigUInt64LE(BigInt(base.length + 1), aliasAt + 8);
image.writeBigUInt64LE(1n, aliasAt + 32);
image.writeBigUInt64LE(1n, aliasAt + 40);
expect(await dependencies(image)).toMatchObject({
ok: false,
error: {
kind: "unreadable",
identity: { machine: 183, bits: 64, byte_order: "little" },
reason: "ELF dynamic string table has ambiguous file mappings",
},
});
image.writeBigUInt64LE(177n, aliasAt + 8);
expect(await dependencies(image)).toMatchObject({
ok: true,
value: { needed: ["libc.so.6"] },
});
// Zero-fill overlaps conflict with the file-backed table as well.
image.writeBigUInt64LE(0n, aliasAt + 32);
expect(await dependencies(image)).toMatchObject({
ok: false,
error: {
kind: "unreadable",
identity: { machine: 183, bits: 64, byte_order: "little" },
reason: "ELF dynamic string table has ambiguous file mappings",
},
});
// Consistent adjacent declarations can cover the table together.
const originalAt = reversed ? alias : headersAt;
image.writeBigUInt64LE(177n, originalAt + 32);
image.writeBigUInt64LE(177n, originalAt + 40);
image.writeBigUInt64LE(177n, aliasAt + 8);
image.writeBigUInt64LE(BigInt(alternate.length - 1), aliasAt + 32);
image.writeBigUInt64LE(BigInt(alternate.length - 1), aliasAt + 40);
expect(await dependencies(image)).toMatchObject({
ok: true,
value: { needed: ["libc.so.6"] },
});
image.writeBigUInt64LE(BigInt(base.length), originalAt + 32);
image.writeBigUInt64LE(BigInt(base.length), originalAt + 40);
// An empty load has no file bytes to admit, regardless of its file offset.
image.writeBigUInt64LE(0x100000n, aliasAt + 8);
image.writeBigUInt64LE(0n, aliasAt + 32);
image.writeBigUInt64LE(0n, aliasAt + 40);
expect(await dependencies(image)).toMatchObject({
ok: true,
value: { needed: ["libc.so.6"] },
});
},
);
+41 -27
View File
@@ -9,6 +9,7 @@ import {
readElfHeaderFacts,
} from "../elfFixedHeader.js";
import {
admitElfLoadSegment,
readElfProgramHeader,
type ElfProgramHeader,
} from "../elfProgramHeader.js";
@@ -17,6 +18,7 @@ import {
observeElfStringTableEntry,
} from "../elfStringTable.js";
import { err, ok, type Result } from "../result.js";
import { resolveMappedFileOffset } from "../virtualFileMapping.js";
/** Read up to `length` bytes at `offset`; fewer at the end of the file. */
export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
@@ -79,21 +81,6 @@ const readExactly = async (
return bytes.byteLength === Number(length) ? Buffer.from(bytes) : undefined;
};
/** The file offset of a virtual address inside a file-backed PT_LOAD segment. */
const fileOffset = (
loads: readonly ElfProgramHeader[],
address: bigint,
): bigint | undefined => {
const load = loads.find(
(segment) =>
address >= segment.virtualAddress &&
address < segment.virtualAddress + segment.fileSize,
);
return load === undefined
? undefined
: load.offset + address - load.virtualAddress;
};
const programHeaders = async (
read: ReadAt,
header: Buffer,
@@ -130,6 +117,15 @@ const programHeaders = async (
little,
);
if (!parsed.ok) return err(parsed.error.message);
if (parsed.value.type === PT_LOAD) {
const admitted = admitElfLoadSegment(parsed.value, bits);
if (!admitted.ok) return err(admitted.error.message);
if (
parsed.value.fileSize > 0n &&
parsed.value.offset + parsed.value.fileSize > BigInt(size)
)
return err("ELF load segment is truncated");
}
headers.push(parsed.value);
}
return ok(headers);
@@ -317,18 +313,36 @@ const readDependencyTables = async (
const tags = dynamicTags(table, bits, little, policy);
if (!tags.ok) return tags;
const { strtab, strsz, needed, runpath, rpath } = tags.value;
const stringsAt =
strtab === undefined
? undefined
: fileOffset(
headers.value.filter((program) => program.type === PT_LOAD),
strtab,
);
if (stringsAt === undefined || strsz === undefined)
return needed.size + runpath.size + rpath.size === 0
? ok(none)
: err("ELF dynamic string table is not mapped from the file");
const strings = { size, at: stringsAt, bytes: strsz };
if (needed.size + runpath.size + rpath.size === 0) return ok(none);
if (strtab === undefined || strsz === undefined)
return err("ELF dynamic string table is not mapped from the file");
if (strsz > BigInt(size))
return err("ELF dynamic string table is not mapped from the file");
const loads = headers.value.filter((program) => program.type === PT_LOAD);
const stringsAt = resolveMappedFileOffset(
loads
.filter((segment) => segment.fileSize > 0n)
.map((segment) => ({
address: segment.virtualAddress,
offset: Number(segment.offset),
size: Number(segment.fileSize),
})),
strtab,
Number(strsz),
loads
.filter((segment) => segment.memorySize > segment.fileSize)
.map((segment) => ({
address: segment.virtualAddress + segment.fileSize,
size: segment.memorySize - segment.fileSize,
})),
);
if (!stringsAt.ok)
return err(
stringsAt.error.reason === "ambiguous"
? "ELF dynamic string table has ambiguous file mappings"
: "ELF dynamic string table is not mapped from the file",
);
const strings = { size, at: BigInt(stringsAt.value), bytes: strsz };
const names = await dynamicStrings(read, strings, needed, policy);
if (!names.ok) return names;
const search = await dynamicStrings(