mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(managed): verify P/Invokes against portable native export Evidence
verify_managed_native_boundaries accepted only inspect_macho exports and one analyze_function dossier per function, so checking a library's P/Invokes on a non-macOS host took one engine call per declaration. It now also accepts inspect_native_exports Evidence: one record supplies a whole PE export directory or ELF dynamic symbol table. An exact name verifies only when a by-name lookup binds it; a PE forwarder or an ELF symbol with only a hidden or unreadable version is inferred with basis qualified-export-candidate and a limitation naming the reason. Ordinal-only PE exports match no declaration. Fixes #4057 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
65a3128873
commit
6be1c9e272
5 files changed
+263
-10
No files matched your search
@@ -415,6 +415,15 @@ function implements a specific managed method.
|
|||||||
`verify_managed_native_boundaries` compares declared P/Invoke module and entry
|
`verify_managed_native_boundaries` compares declared P/Invoke module and entry
|
||||||
point identities with supplied native export or function evidence. Name/module
|
point identities with supplied native export or function evidence. Name/module
|
||||||
matches establish declaration links; they do not establish that a call executed.
|
matches establish declaration links; they do not establish that a call executed.
|
||||||
|
|
||||||
|
Native observations may be `inspect_macho`, `analyze_function`, or
|
||||||
|
`inspect_native_exports` Evidence. One `inspect_native_exports` record supplies
|
||||||
|
a whole PE export directory or ELF dynamic symbol table, on any host. An exact
|
||||||
|
name verifies only when it is what a by-name lookup binds: a PE export that
|
||||||
|
forwards to another DLL, or an ELF symbol whose only version is hidden or whose
|
||||||
|
version could not be read, is `inferred` with basis
|
||||||
|
`qualified-export-candidate` and a limitation naming the reason. Ordinal-only
|
||||||
|
PE exports have no name and match no declaration.
|
||||||
When either side lacks a usable module identity, a matching name remains
|
When either side lacks a usable module identity, a matching name remains
|
||||||
unresolved and retains the candidate evidence; REA reports a module mismatch
|
unresolved and retains the candidate evidence; REA reports a module mismatch
|
||||||
only when both identities are available and differ.
|
only when both identities are available and differ.
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
|
|||||||
name: "verify_managed_native_boundaries",
|
name: "verify_managed_native_boundaries",
|
||||||
...toolContractMetadata("verify_managed_native_boundaries"),
|
...toolContractMetadata("verify_managed_native_boundaries"),
|
||||||
description:
|
description:
|
||||||
"Verify managed P/Invoke/native-boundary declarations against authenticated native export or function Evidence without executing managed code or translating managed metadata tokens into native addresses. The workflow preserves declaration-only, verified, inferred, contradicted, and unresolved states; a name candidate with missing managed or native module identity remains unresolved with its candidate evidence retained.",
|
"Verify managed P/Invoke/native-boundary declarations against authenticated native export or function Evidence (inspect_macho, inspect_native_exports or analyze_function) without executing managed code or translating managed metadata tokens into native addresses. The workflow preserves declaration-only, verified, inferred, contradicted, and unresolved states; a name candidate with missing managed or native module identity remains unresolved with its candidate evidence retained.",
|
||||||
kind: "application",
|
kind: "application",
|
||||||
inputSchema: managedNativeVerificationInputSchema,
|
inputSchema: managedNativeVerificationInputSchema,
|
||||||
outputSchema: nativeVerificationOutputSchema,
|
outputSchema: nativeVerificationOutputSchema,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { parseEvidence } from "../evidence.js";
|
|||||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||||
import { functionDossierSchema } from "../hopperValues.js";
|
import { functionDossierSchema } from "../hopperValues.js";
|
||||||
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
||||||
|
import {
|
||||||
|
nativeExportsSchema,
|
||||||
|
type NativeExports,
|
||||||
|
} from "../native/nativeExports.js";
|
||||||
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
|
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
|
||||||
import {
|
import {
|
||||||
pinvokeVerificationSchema,
|
pinvokeVerificationSchema,
|
||||||
@@ -47,6 +51,19 @@ const symbolsForEvidence = (
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (evidence.operation === "inspect_native_exports")
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
symbols: nativeExportSymbols(
|
||||||
|
nativeExportsSchema.parse(evidence.normalized_result),
|
||||||
|
).map((symbol) => ({
|
||||||
|
evidence_id: evidence.evidence_id,
|
||||||
|
operation: evidence.operation,
|
||||||
|
module_name: moduleName(evidence),
|
||||||
|
module_path: modulePath(evidence),
|
||||||
|
...symbol,
|
||||||
|
})),
|
||||||
|
};
|
||||||
if (evidence.operation === "analyze_function") {
|
if (evidence.operation === "analyze_function") {
|
||||||
const dossier = functionDossierSchema.parse(evidence.normalized_result);
|
const dossier = functionDossierSchema.parse(evidence.normalized_result);
|
||||||
return {
|
return {
|
||||||
@@ -67,6 +84,36 @@ const symbolsForEvidence = (
|
|||||||
return { supported: false, symbols: [] };
|
return { supported: false, symbols: [] };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Named export rows; ordinal-only PE rows have no name to match. */
|
||||||
|
const nativeExportSymbols = (
|
||||||
|
inventory: NativeExports,
|
||||||
|
): Pick<NativeSymbol, "name" | "address" | "source" | "export_qualifier">[] =>
|
||||||
|
inventory.format === "pe"
|
||||||
|
? inventory.exports.flatMap((row) =>
|
||||||
|
row.name === null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: row.name,
|
||||||
|
address: row.address,
|
||||||
|
source: "pe-export" as const,
|
||||||
|
...(row.forwarder === null
|
||||||
|
? {}
|
||||||
|
: { export_qualifier: "forwarded" as const }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: inventory.exports.map((row) => ({
|
||||||
|
name: row.name,
|
||||||
|
address: row.value,
|
||||||
|
source: "elf-export" as const,
|
||||||
|
...(row.version === "unknown"
|
||||||
|
? { export_qualifier: "unknown-version" as const }
|
||||||
|
: row.version?.hidden === true
|
||||||
|
? { export_qualifier: "non-default-version" as const }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const collectNativeSymbols = (
|
export const collectNativeSymbols = (
|
||||||
observations: readonly ManagedNativeVerificationInput["native_observations"][number][],
|
observations: readonly ManagedNativeVerificationInput["native_observations"][number][],
|
||||||
): {
|
): {
|
||||||
@@ -199,16 +246,21 @@ const matchPinvoke = (
|
|||||||
|
|
||||||
const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining];
|
const candidates: [NativeSymbol, ...NativeSymbol[]] = [first, ...remaining];
|
||||||
const scopeKnown = declaredModuleIdentity(managed.import_scope_name) !== null;
|
const scopeKnown = declaredModuleIdentity(managed.import_scope_name) !== null;
|
||||||
|
const exactName = (symbol: NativeSymbol) =>
|
||||||
|
managed.import_name === normalizedSymbolName(symbol) &&
|
||||||
|
moduleCompatible(managed.import_scope_name, symbol);
|
||||||
const exact = candidates.find(
|
const exact = candidates.find(
|
||||||
(symbol) =>
|
(symbol) => exactName(symbol) && symbol.export_qualifier === undefined,
|
||||||
managed.import_name === normalizedSymbolName(symbol) &&
|
);
|
||||||
moduleCompatible(managed.import_scope_name, symbol),
|
const qualified = candidates.find(
|
||||||
|
(symbol) => exactName(symbol) && symbol.export_qualifier !== undefined,
|
||||||
);
|
);
|
||||||
const decorated = candidates.find((symbol) =>
|
const decorated = candidates.find((symbol) =>
|
||||||
moduleCompatible(managed.import_scope_name, symbol),
|
moduleCompatible(managed.import_scope_name, symbol),
|
||||||
);
|
);
|
||||||
const selected =
|
const selected =
|
||||||
exact ??
|
exact ??
|
||||||
|
qualified ??
|
||||||
decorated ??
|
decorated ??
|
||||||
(scopeKnown
|
(scopeKnown
|
||||||
? candidates.find((symbol) => !hasModuleIdentity(symbol))
|
? candidates.find((symbol) => !hasModuleIdentity(symbol))
|
||||||
@@ -219,14 +271,24 @@ const matchPinvoke = (
|
|||||||
match: {
|
match: {
|
||||||
status: "verified",
|
status: "verified",
|
||||||
basis:
|
basis:
|
||||||
selected.source === "macho-export"
|
selected.source === "function-dossier"
|
||||||
? "exact-export-name"
|
? "exact-function-name"
|
||||||
: "exact-function-name",
|
: "exact-export-name",
|
||||||
confidence: "observed",
|
confidence: "observed",
|
||||||
matched_native: selected,
|
matched_native: selected,
|
||||||
candidates,
|
candidates,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
if (qualified !== undefined)
|
||||||
|
return {
|
||||||
|
match: {
|
||||||
|
basis: "qualified-export-candidate",
|
||||||
|
status: "inferred",
|
||||||
|
confidence: "inferred",
|
||||||
|
matched_native: selected,
|
||||||
|
candidates,
|
||||||
|
},
|
||||||
|
};
|
||||||
if (decorated !== undefined)
|
if (decorated !== undefined)
|
||||||
return {
|
return {
|
||||||
match: {
|
match: {
|
||||||
@@ -258,6 +320,15 @@ const matchPinvoke = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QUALIFIED_EXPORT_LIMITATIONS = {
|
||||||
|
forwarded:
|
||||||
|
"The exactly named PE export forwards to another DLL, which was not inspected.",
|
||||||
|
"non-default-version":
|
||||||
|
"The exactly named ELF symbol has only a hidden version, which a by-name lookup does not select.",
|
||||||
|
"unknown-version":
|
||||||
|
"The exactly named ELF symbol's version could not be read, so whether a by-name lookup selects it is unknown.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
const pinvokeLimitations = (
|
const pinvokeLimitations = (
|
||||||
match: PinvokeMatch,
|
match: PinvokeMatch,
|
||||||
managed: PinvokeImport,
|
managed: PinvokeImport,
|
||||||
@@ -266,11 +337,15 @@ const pinvokeLimitations = (
|
|||||||
managed.import_scope_name.trim().length === 0
|
managed.import_scope_name.trim().length === 0
|
||||||
? ["Managed declaration does not name an import scope/module."]
|
? ["Managed declaration does not name an import scope/module."]
|
||||||
: []),
|
: []),
|
||||||
...(match.status === "inferred"
|
...(match.status === "inferred" && match.basis === "decorated-name-candidate"
|
||||||
? [
|
? [
|
||||||
"Native symbol matched a decorated candidate rather than the exact declared import name.",
|
"Native symbol matched a decorated candidate rather than the exact declared import name.",
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(match.status === "inferred" &&
|
||||||
|
match.basis === "qualified-export-candidate"
|
||||||
|
? [QUALIFIED_EXPORT_LIMITATIONS[match.matched_native.export_qualifier!]]
|
||||||
|
: []),
|
||||||
...(match.status === "contradicted"
|
...(match.status === "contradicted"
|
||||||
? [
|
? [
|
||||||
"A symbol name candidate exists, but the supplied native Evidence module identity does not match the managed import scope.",
|
"A symbol name candidate exists, but the supplied native Evidence module identity does not match the managed import scope.",
|
||||||
|
|||||||
@@ -19,7 +19,20 @@ const nativeSymbolSchema = z.strictObject({
|
|||||||
address: z.string().nullable(),
|
address: z.string().nullable(),
|
||||||
module_name: z.string().nullable(),
|
module_name: z.string().nullable(),
|
||||||
module_path: z.string().nullable(),
|
module_path: z.string().nullable(),
|
||||||
source: z.enum(["macho-export", "function-dossier"]),
|
source: z.enum([
|
||||||
|
"macho-export",
|
||||||
|
"pe-export",
|
||||||
|
"elf-export",
|
||||||
|
"function-dossier",
|
||||||
|
]),
|
||||||
|
/**
|
||||||
|
* Present when an exact export name does not establish what a by-name
|
||||||
|
* lookup binds: a PE forwarder, or an ELF symbol whose version is hidden
|
||||||
|
* or unreadable.
|
||||||
|
*/
|
||||||
|
export_qualifier: z
|
||||||
|
.enum(["forwarded", "non-default-version", "unknown-version"])
|
||||||
|
.exactOptional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NativeSymbol = z.infer<typeof nativeSymbolSchema>;
|
export type NativeSymbol = z.infer<typeof nativeSymbolSchema>;
|
||||||
@@ -64,7 +77,7 @@ export const pinvokeVerificationSchema = z.union([
|
|||||||
z.strictObject({
|
z.strictObject({
|
||||||
...pinvokeVerificationContextShape,
|
...pinvokeVerificationContextShape,
|
||||||
status: z.literal("inferred"),
|
status: z.literal("inferred"),
|
||||||
basis: z.literal("decorated-name-candidate"),
|
basis: z.enum(["decorated-name-candidate", "qualified-export-candidate"]),
|
||||||
confidence: z.literal("inferred"),
|
confidence: z.literal("inferred"),
|
||||||
matched_native: nativeSymbolSchema,
|
matched_native: nativeSymbolSchema,
|
||||||
candidates: observedCandidatesSchema,
|
candidates: observedCandidatesSchema,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { verifyManagedNativeBoundariesEvidence } from "../../../../src/application/managed/ManagedNativeVerificationService.js";
|
import { verifyManagedNativeBoundariesEvidence } from "../../../../src/application/managed/ManagedNativeVerificationService.js";
|
||||||
@@ -12,6 +13,12 @@ import { managedNativeBoundaryInspectionSchema } from "../../../../src/domain/ma
|
|||||||
import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js";
|
import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js";
|
||||||
|
|
||||||
import { verifyManagedNativeBoundaries } from "../../../../src/domain/managed/managedNativeVerification.js";
|
import { verifyManagedNativeBoundaries } from "../../../../src/domain/managed/managedNativeVerification.js";
|
||||||
|
import { NATIVE_EXPORTS_PROVIDER } from "../../../../src/application/InvestigationProviders.js";
|
||||||
|
import { parseNativeExports } from "../../../../src/native/exports/NativeExportParser.js";
|
||||||
|
import {
|
||||||
|
elfExportImage,
|
||||||
|
peExportImage,
|
||||||
|
} from "../../../fixtures/binaryDiagnostics/nativeExports.js";
|
||||||
const exampleInput = () =>
|
const exampleInput = () =>
|
||||||
managedNativeVerificationInputSchema.parse(
|
managedNativeVerificationInputSchema.parse(
|
||||||
MANAGED_NATIVE_VERIFICATION_EXAMPLE,
|
MANAGED_NATIVE_VERIFICATION_EXAMPLE,
|
||||||
@@ -477,3 +484,152 @@ it("does not treat a raw Mach-O ABI name as an exact underscored C identifier",
|
|||||||
});
|
});
|
||||||
expect(result.pinvoke_imports[0]?.candidates).toEqual([]);
|
expect(result.pinvoke_imports[0]?.candidates).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("portable native export inventories", () => {
|
||||||
|
/** Managed Evidence declaring each name from `scope`, cloned from the example. */
|
||||||
|
const managedImports = (scope: string, names: readonly string[]) => {
|
||||||
|
const managed = exampleInput().managed_boundaries;
|
||||||
|
const normalized = managedNativeBoundaryInspectionSchema.parse(
|
||||||
|
managed.normalized_result,
|
||||||
|
);
|
||||||
|
const [template] = normalized.pinvoke_imports;
|
||||||
|
if (template === undefined) throw new Error("missing P/Invoke example");
|
||||||
|
return createEvidence(undefined, managed.provider, {
|
||||||
|
operation: managed.operation,
|
||||||
|
parameters: managed.parameters,
|
||||||
|
result: {
|
||||||
|
...normalized,
|
||||||
|
pinvoke_imports: names.map((import_name, index) => ({
|
||||||
|
...template,
|
||||||
|
token: `0x1c00000${String(index + 1)}`,
|
||||||
|
import_name,
|
||||||
|
import_scope_name: scope,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
rawResult: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportEvidence = (path: string, bytes: Buffer) => {
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
const result = parseNativeExports(bytes, {
|
||||||
|
path,
|
||||||
|
sha256,
|
||||||
|
bytes: bytes.length,
|
||||||
|
});
|
||||||
|
return createEvidence(
|
||||||
|
{ path, sha256, format: result.format },
|
||||||
|
NATIVE_EXPORTS_PROVIDER,
|
||||||
|
{
|
||||||
|
operation: "inspect_native_exports",
|
||||||
|
parameters: { path, max_file_bytes: 268435456 },
|
||||||
|
result,
|
||||||
|
confidence: "observed",
|
||||||
|
limitations: result.limitations,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verify = (
|
||||||
|
scope: string,
|
||||||
|
names: readonly string[],
|
||||||
|
native: ReturnType<typeof exportEvidence>,
|
||||||
|
) =>
|
||||||
|
new Map(
|
||||||
|
verifyManagedNativeBoundaries({
|
||||||
|
managed_boundaries: managedImports(scope, names),
|
||||||
|
native_observations: [native],
|
||||||
|
}).pinvoke_imports.map((item) => [item.managed.import_name, item]),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("verifies exact ELF exports and keeps hidden or unread versions inferred", () => {
|
||||||
|
const elf = exportEvidence(
|
||||||
|
"/native/libfixture.so",
|
||||||
|
elfExportImage({
|
||||||
|
symbols: [
|
||||||
|
{ name: "plain_fn" },
|
||||||
|
{ name: "current_fn", version: 2 },
|
||||||
|
{ name: "old_fn", version: 0x8003 },
|
||||||
|
],
|
||||||
|
versionDefinitions: ["libfixture.so", "V2", "V1"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const results = verify(
|
||||||
|
"fixture",
|
||||||
|
["plain_fn", "current_fn", "old_fn", "absent_fn"],
|
||||||
|
elf,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of ["plain_fn", "current_fn"])
|
||||||
|
expect(results.get(name)).toMatchObject({
|
||||||
|
status: "verified",
|
||||||
|
basis: "exact-export-name",
|
||||||
|
matched_native: {
|
||||||
|
name,
|
||||||
|
source: "elf-export",
|
||||||
|
evidence_id: elf.evidence_id,
|
||||||
|
module_path: "/native/libfixture.so",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(results.get("old_fn")).toMatchObject({
|
||||||
|
status: "inferred",
|
||||||
|
basis: "qualified-export-candidate",
|
||||||
|
matched_native: { export_qualifier: "non-default-version" },
|
||||||
|
limitations: [expect.stringContaining("hidden version")],
|
||||||
|
});
|
||||||
|
expect(results.get("absent_fn")).toMatchObject({
|
||||||
|
status: "unresolved",
|
||||||
|
basis: "no-native-candidate",
|
||||||
|
});
|
||||||
|
|
||||||
|
const unread = exportEvidence(
|
||||||
|
"/native/libfixture.so",
|
||||||
|
elfExportImage({
|
||||||
|
symbols: [{ name: "plain_fn" }],
|
||||||
|
versionDefinitions: ["libfixture.so"],
|
||||||
|
truncatedVersions: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
verify("fixture", ["plain_fn"], unread).get("plain_fn"),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "inferred",
|
||||||
|
matched_native: { export_qualifier: "unknown-version" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies named PE exports and keeps forwarders inferred", () => {
|
||||||
|
const pe = exportEvidence(
|
||||||
|
"/native/fixture.dll",
|
||||||
|
peExportImage({
|
||||||
|
functions: [
|
||||||
|
{ rva: 0x2000 },
|
||||||
|
{ forwarder: "OTHER.Target" },
|
||||||
|
{ rva: 0x2010 },
|
||||||
|
],
|
||||||
|
names: [
|
||||||
|
{ name: "Primary", index: 0 },
|
||||||
|
{ name: "Forwarded", index: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const results = verify("fixture.dll", ["Primary", "Forwarded"], pe);
|
||||||
|
|
||||||
|
expect(results.get("Primary")).toMatchObject({
|
||||||
|
status: "verified",
|
||||||
|
basis: "exact-export-name",
|
||||||
|
matched_native: { source: "pe-export", address: "0x180002000" },
|
||||||
|
});
|
||||||
|
expect(results.get("Forwarded")).toMatchObject({
|
||||||
|
status: "inferred",
|
||||||
|
basis: "qualified-export-candidate",
|
||||||
|
matched_native: { export_qualifier: "forwarded", address: null },
|
||||||
|
limitations: [expect.stringContaining("forwards to another DLL")],
|
||||||
|
});
|
||||||
|
// A matching name under another module is a contradiction, not a match.
|
||||||
|
expect(verify("other.dll", ["Primary"], pe).get("Primary")).toMatchObject({
|
||||||
|
status: "contradicted",
|
||||||
|
basis: "module-mismatch",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user