Compare commits

...
Author SHA1 Message Date
Kaoru0822 4ff226e98e Merge remote-tracking branch 'origin/main' into fix/nativeaot-pe-virtual-extent 2026-10-11 03:10:16 +00:00
morluto e5bc4e85ec fix(ci): close speculative browser fixture connections 2026-10-11 11:05:08 +08:00
38221cb12e fix(native): bound PE resource RVAs by section virtual size (#1678)
The PE resource reader treated each section as covering
`max(VirtualSize, SizeOfRawData)` bytes, the mapping #1596 removed from
managed RVA reads. A resource payload in the raw alignment padding past
VirtualSize was reported with a hash of bytes the image loader never maps.
A later section whose virtual range starts inside that padding was refused
as an overlapping mapping.

Containment and overlap now use the loader's virtual extent: VirtualSize,
or SizeOfRawData when VirtualSize is zero. Ranges must still be file-backed.

Refs #1596

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:01:45 -07:00
Kaoru0822andClaude Opus 5.5 9f30b01387 style(native): format NativeAOT diagnostics after lint fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:50:20 +00:00
Kaoru0822andClaude Opus 5.5 707797d80f fix(native): bound NativeAOT PE reads by section virtual size
The NativeAOT PE parser from 76d8f68cb treated a section as
`max(VirtualSize, SizeOfRawData)` bytes when reading, and as
SizeOfRawData bytes when classifying executable code and choosing scan
ranges. These are the extents #1596 removed from managed RVA reads.
Raw alignment padding past VirtualSize could supply frozen strings with
complete status, and vtable slots pointing into it counted as executable.

Reads, executable checks, hydration extents and the image-extent check now
use the loader's virtual extent (VirtualSize, or SizeOfRawData when it is
zero). Pointer scans cover only bytes that are both file-backed and mapped.

Refs #1596

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:47:37 +00:00
3749ccec47 feat(ghidra): recover signed AArch64 relative jump tables (#1592)
* feat(ghidra): recover signed AArch64 relative jump tables

Generalise the AArch64 relative-table matcher to signed byte/halfword entries, UXTW indexes, byte or LSL #2 offsets, B.CS/B.HS bounds and a CBZ route for zero entries. Cross-check it against Ghidra's own load table when one exists, and add a fixture modelled on the RenderScript blend kernel.

Refs #487

* fix(ghidra): preserve typed labels for normalized switch selectors

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-11 10:45:35 +08:00
a67dbf6a48 fix: reject directory paths for the Wakaru limiter and mitmdump
`REA_JAVASCRIPT_PRLIMIT_COMMAND` and `REA_MITMDUMP_COMMAND` were admitted
after an access check alone, which a searchable directory passes. Admission
then succeeded, and the failure surfaced later as a launch-time adapter
error instead of the capability refusal that names the configured path.

Require a regular file, as #751 did for the firmware engine and limiter.
Linux-only tests mirror that change; both checks only run on Linux hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:43:48 +08:00
morlutoandClaude Opus 5.5 bd3a9bbbaf fix(native): classify PE resource input failures
Report missing and non-file selections as input issues, malformed PE images as format errors, and unstable reads as artifact changes. Keep existing limit, permission, cancellation, and other I/O classifications.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:43:48 +08:00
morlutoandClaude Opus 5.5 74050978be fix(javascript): keep dependency manifests out of application roots
Select the shallowest inventoried nondependency manifest and retain dependency package evidence without assigning Electron entry roles. Keep the artifact root when no application manifest is observed.

Adapted from PR #1392, with compiled CLI and stdio MCP regressions for root selection and dependency-only inputs.

Co-authored-by: Marnix Pluim <marnixpluim@users.noreply.github.com>

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:43:48 +08:00
22 changed files with 1152 additions and 287 deletions
+389 -184
View File
@@ -2004,9 +2004,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
RecoveredAArch64RelativeTable recoveredAArch64 =
loadTables.length == 0
? recoverAArch64RelativeTable(jumpTable)
: null;
recoverAArch64RelativeTable(jumpTable);
if (recoveredAArch64 != null && loadTables.length > 0) {
// A decoded table only adds evidence when Ghidra's own load table
// describes the same bytes; any disagreement keeps Ghidra's view.
boolean agrees = false;
for (JumpTable.LoadTable loadTable : loadTables) {
if (loadTable.getAddress().equals(recoveredAArch64.tableAddress) &&
loadTable.getSize() == recoveredAArch64.entrySize &&
loadTable.getNum() == recoveredAArch64.mappings.size()) {
agrees = true;
}
}
if (!agrees) recoveredAArch64 = null;
}
List<Address> referencedDataAddresses = loadTables.length == 0
? dispatchPathDataReferences(jumpTable)
: List.of();
@@ -2040,7 +2051,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
source.add("evidence", evidence);
dataSources.add(source);
}
if (recoveredAArch64 != null) {
if (recoveredAArch64 != null && loadTables.length == 0) {
JsonObject source = new JsonObject();
source.addProperty(
"address",
@@ -2055,9 +2066,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
inferenceEvidence(
"jump-table",
"ghidra-instruction-and-memory",
"Decoded " + recoveredAArch64.mappings.size() +
"Decoded " + recoveredAArch64.mappings.size() + " " +
(recoveredAArch64.signedEntries ? "signed" : "unsigned") +
" entries of size " + recoveredAArch64.entrySize + " bytes from " + canonicalAddress(recoveredAArch64.tableAddress) +
"; the AArch64 LDRB-or-LDRH/ADR/ADD/BR chain uses a selector guarded by an unsigned upper-bound branch."
"; the AArch64 ADRP/ADD table, indexed load, ADR base, ADD" +
(recoveredAArch64.offsetShift == 0 ? "" : " LSL #" + recoveredAArch64.offsetShift) +
"/BR chain uses a selector bounded by CMP #" + recoveredAArch64.compareImmediate +
" and " + recoveredAArch64.guardMnemonic + " on one straight-line path" +
(recoveredAArch64.nullTarget == null
? "."
: "; zero entries are routed by CBZ to " + canonicalAddress(recoveredAArch64.nullTarget) + ".")
)
);
source.add("evidence", evidence);
@@ -2096,6 +2114,33 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("data_sources", dataSources);
Address[] targets = jumpTable.getCases();
TypedJumpTableCases typedCases = typedJumpTableCases(jumpTable, caseTokens);
// Table indices identify the register bounded by CMP. Typed labels may
// instead identify a selector before normalization (for example, SUB
// index, selector, #10). Do not merge those distinct value identities.
if (recoveredAArch64 != null && recoveredAArch64.priorSelectorDefinition != null) {
typedCases.limitations.add(
"REA decoded the relative table, but the compared selector has an earlier register definition at " +
canonicalAddress(recoveredAArch64.priorSelectorDefinition) + "; its table indices are not proven to use the typed switch selector's case values. Ghidra's typed labels are retained and additional case labels remain unknown."
);
recoveredAArch64 = null;
}
if (recoveredAArch64 != null) {
for (Map.Entry<Long, Address> typed : typedCases.cases.entrySet()) {
long value = typed.getKey();
if (value < 0 || value >= recoveredAArch64.mappings.size()) {
typedCases.limitations.add("Typed case labels and decoded table indices do not use a verified common selector; Ghidra's typed labels are retained.");
recoveredAArch64 = null;
break;
}
RecoveredAArch64Mapping decoded = recoveredAArch64.mappings.get((int) value);
boolean divertedZero = decoded.nullEntry && typed.getValue().equals(recoveredAArch64.unreachableZeroTarget);
if (!typed.getValue().equals(decoded.targetAddress) && !divertedZero) {
typedCases.limitations.add("Typed case evidence and the decoded relative table disagree on a destination; Ghidra's typed labels are retained.");
recoveredAArch64 = null;
break;
}
}
}
JsonArray mappings = new JsonArray();
JsonArray defaultTargets = new JsonArray();
for (Address target : typedCases.defaults) {
@@ -2103,27 +2148,30 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
Set<Address> coveredTargets = new HashSet<>(typedCases.cases.values());
coveredTargets.addAll(typedCases.defaults);
if (recoveredAArch64 != null && recoveredAArch64.unreachableZeroTarget != null) {
Address unreachable = recoveredAArch64.unreachableZeroTarget;
coveredTargets.add(unreachable);
for (RecoveredAArch64Mapping recovered : recoveredAArch64.mappings) {
if (recovered.nullEntry && unreachable.equals(typedCases.cases.get((long) recovered.tableIndex))) {
typedCases.cases.remove((long) recovered.tableIndex);
}
}
typedCases.limitations.add(
"Ghidra recovered destination " + canonicalAddress(unreachable) +
" as the zero-offset decode of the relative table, but the CBZ before the dispatch diverts zero entries to " +
canonicalAddress(recoveredAArch64.nullTarget) + ", so the indirect branch never reaches it for an in-range case."
);
}
if (recoveredAArch64 != null) {
for (int index = 0; index < recoveredAArch64.mappings.size(); index += 1) {
monitor.checkCancelled();
RecoveredAArch64Mapping recovered = recoveredAArch64.mappings.get(index);
if (
typedCases.unknownTargets.contains(recovered.targetAddress) ||
typedCases.ambiguousValues.contains((long) recovered.caseValue)
typedCases.ambiguousValues.contains((long) recovered.tableIndex)
) continue;
Address typedTarget = typedCases.cases.get((long) recovered.caseValue);
if (typedTarget != null && !typedTarget.equals(recovered.targetAddress)) {
typedCases.cases.remove((long) recovered.caseValue);
typedCases.ambiguousValues.add((long) recovered.caseValue);
typedCases.unknownTargets.add(typedTarget);
typedCases.unknownTargets.add(recovered.targetAddress);
typedCases.limitations.add(
"Typed case evidence and the verified relative table disagree on a destination; both destinations remain unknown for that case."
);
continue;
}
JsonObject mapping = new JsonObject();
mapping.addProperty("case_value", recovered.caseValue);
mapping.addProperty("case_value", recovered.tableIndex);
String targetAddress = canonicalAddress(recovered.targetAddress);
mapping.addProperty("target_address", targetAddress);
mapping.addProperty("confidence", "high");
@@ -2132,14 +2180,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
inferenceEvidence(
"jump-table",
"ghidra-instruction-and-memory",
"Case value " + recovered.caseValue + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
(recovered.caseValue * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
"; the loaded unsigned entry selects a four-byte instruction offset from " +
canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + "."
"Case value " + recovered.tableIndex + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
(recovered.tableIndex * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
(recovered.nullEntry
? "; the entry is zero, so the CBZ before the dispatch branches to " + targetAddress + "."
: "; the loaded " + (recoveredAArch64.signedEntries ? "signed" : "unsigned") +
" entry " + recovered.encodedEntry + " selects a " +
(recoveredAArch64.offsetShift == 2 ? "four-byte instruction" : "byte") +
" offset from " + canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + ".")
)
);
mapping.add("evidence", evidence);
if (!typedCases.cases.containsKey((long) recovered.caseValue)) {
if (!typedCases.cases.containsKey((long) recovered.tableIndex)) {
mappings.add(mapping);
}
coveredTargets.add(recovered.targetAddress);
@@ -2213,7 +2265,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
}
if (recoveredAArch64 == null && loadTables.length == 0 && currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
limitations.add("REA did not verify a supported byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
limitations.add("REA did not verify a supported signed or unsigned byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
}
if (targets.length == 0) {
limitations.add(
@@ -2343,12 +2395,21 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
private static final class RecoveredAArch64Mapping {
private final int caseValue;
private final int tableIndex;
private final Address targetAddress;
private final long encodedEntry;
private final boolean nullEntry;
private RecoveredAArch64Mapping(int caseValue, Address targetAddress) {
this.caseValue = caseValue;
private RecoveredAArch64Mapping(
int tableIndex,
Address targetAddress,
long encodedEntry,
boolean nullEntry
) {
this.tableIndex = tableIndex;
this.targetAddress = targetAddress;
this.encodedEntry = encodedEntry;
this.nullEntry = nullEntry;
}
}
@@ -2356,24 +2417,70 @@ public final class ReaGhidraBridge extends HeadlessScript {
private final Address tableAddress;
private final Address branchBase;
private final int entrySize;
private final Address priorSelectorDefinition;
private final boolean signedEntries;
private final int offsetShift;
private final String guardMnemonic;
private final long compareImmediate;
private final Address nullTarget;
private final Address unreachableZeroTarget;
private final List<RecoveredAArch64Mapping> mappings;
private RecoveredAArch64RelativeTable(
Address tableAddress,
Address branchBase,
int entrySize,
Address priorSelectorDefinition,
boolean signedEntries,
int offsetShift,
String guardMnemonic,
long compareImmediate,
Address nullTarget,
Address unreachableZeroTarget,
List<RecoveredAArch64Mapping> mappings
) {
this.tableAddress = tableAddress;
this.branchBase = branchBase;
this.entrySize = entrySize;
this.priorSelectorDefinition = priorSelectorDefinition;
this.signedEntries = signedEntries;
this.offsetShift = offsetShift;
this.guardMnemonic = guardMnemonic;
this.compareImmediate = compareImmediate;
this.nullTarget = nullTarget;
this.unreachableZeroTarget = unreachableZeroTarget;
this.mappings = mappings;
}
}
private static final Pattern AARCH64_DISPATCH_ADD = Pattern.compile(
"(?i)^([wx]\\d+),\\s*([wx]\\d+),\\s*([wx]\\d+)(?:,\\s*LSL\\s*#(?:0x)?([0-9a-f]+))?$"
);
private static final Pattern AARCH64_TABLE_LOAD = Pattern.compile(
"(?i)^([wx]\\d+),\\s*\\[([wx]\\d+),\\s*([wx]\\d+)(?:,\\s*(LSL|UXTW)\\s*(?:#(?:0x)?([0-9a-f]+))?)?\\s*\\]$"
);
private static final Pattern AARCH64_REGISTER_AND_ADDRESS = Pattern.compile(
"(?i)^([wx]\\d+),\\s*(0x[0-9a-f]+)$"
);
private static final Pattern AARCH64_ADD_IMMEDIATE = Pattern.compile(
"(?i)^([wx]\\d+),\\s*([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$"
);
private static final Pattern AARCH64_COMPARE_IMMEDIATE = Pattern.compile(
"(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$"
);
private static final Pattern AARCH64_MOVE_REGISTER = Pattern.compile(
"(?i)^([wx]\\d+),\\s*([wx]\\d+)$"
);
private static final Set<String> AARCH64_FLAG_PRESERVING =
Set.of("MOV", "ADRP", "ADR", "ADD");
/**
* Recover only the directly verified AArch64 byte/halfword-indexed relative branch
* table form. Other instruction sequences keep their case labels unknown.
* Recover only directly verified AArch64 relative branch tables:
* ADRP/ADD table base, LDRB/LDRH/LDRSB/LDRSH entry load with an exact
* index scale, ADR branch base, optional CBZ on the loaded entry, and
* ADD (byte or LSL #2 scale)/BR, with a selector bounded by CMP #imm and an
* unsigned B.HI or B.CS/B.HS guard on one straight-line path. Any other
* instruction sequence keeps its case labels unknown.
*/
private RecoveredAArch64RelativeTable recoverAArch64RelativeTable(
JumpTable jumpTable
@@ -2395,167 +2502,265 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (instruction.getAddress().equals(jumpTable.getSwitchAddress())) break;
}
int branchIndex = instructions.size() - 1;
if (branchIndex < 1 ||
!instructions.get(branchIndex).getMnemonicString().equalsIgnoreCase("BR")) {
if (branchIndex < 1 || !mnemonic(instructions.get(branchIndex)).equals("BR")) {
return null;
}
Set<String> recoveredTargetSet = new HashSet<>();
for (Address target : jumpTable.getCases()) recoveredTargetSet.add(canonicalAddress(target));
if (recoveredTargetSet.isEmpty()) return null;
return matchAArch64RelativeDispatch(
instructions,
branchIndex,
recoveredTargetSet,
dispatchPathDataReferences(jumpTable)
);
}
private RecoveredAArch64RelativeTable matchAArch64RelativeDispatch(
List<Instruction> instructions,
int branchIndex,
Set<String> recoveredTargetSet,
List<Address> dataAddresses
) throws Exception {
String branchRegister = normalizedRegister(operands(instructions.get(branchIndex)));
// ADD Xd, Xn, Xm{, LSL #s}; BR Xd. LSL applies to Xm only.
Instruction dispatchAdd = instructions.get(branchIndex - 1);
if (!mnemonic(dispatchAdd).equals("ADD")) return null;
java.util.regex.Matcher addMatcher = AARCH64_DISPATCH_ADD.matcher(operands(dispatchAdd));
if (!addMatcher.matches() || !addMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) return null;
if (!normalizedRegister(addMatcher.group(1)).equals(branchRegister)) return null;
int offsetShift = addMatcher.group(4) == null ? 0 : Integer.parseInt(addMatcher.group(4), 16);
if (offsetShift != 0 && offsetShift != 2) return null;
String firstSource = normalizedRegister(addMatcher.group(2));
String secondSource = normalizedRegister(addMatcher.group(3));
if (firstSource.equals(secondSource)) return null;
// The entry register is the source whose latest definition is the
// table load; the other source must be defined by ADR.
int loadIndex = -1;
String entryRegister = null;
String baseRegister = null;
for (String candidate : offsetShift == 0 ? List.of(secondSource, firstSource) : List.of(secondSource)) {
int writer = latestWriter(instructions, branchIndex - 1, candidate);
if (writer >= 0 && Set.of("LDRB", "LDRH", "LDRSB", "LDRSH").contains(mnemonic(instructions.get(writer)))) {
loadIndex = writer;
entryRegister = candidate;
baseRegister = candidate.equals(firstSource) ? secondSource : firstSource;
break;
}
}
if (loadIndex < 0) return null;
Instruction load = instructions.get(loadIndex);
String loadMnemonic = mnemonic(load);
int entrySize = loadMnemonic.endsWith("B") ? 1 : 2;
boolean signedEntries = loadMnemonic.startsWith("LDRS");
java.util.regex.Matcher loadMatcher = AARCH64_TABLE_LOAD.matcher(operands(load));
if (!loadMatcher.matches()) return null;
// A signed load into a W register sign-extends only to 32 bits; the
// 64-bit dispatch ADD would then read a zero-extended value.
if (signedEntries && !loadMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) return null;
String tableBaseRegister = normalizedRegister(loadMatcher.group(2));
String indexRegister = normalizedRegister(loadMatcher.group(3));
boolean wordIndex = loadMatcher.group(3).toLowerCase(Locale.ROOT).startsWith("w");
String extend = loadMatcher.group(4) == null ? null : loadMatcher.group(4).toUpperCase(Locale.ROOT);
int indexShift = loadMatcher.group(5) == null ? 0 : Integer.parseInt(loadMatcher.group(5), 16);
if (wordIndex ? !"UXTW".equals(extend) : "UXTW".equals(extend)) return null;
if (indexShift != (entrySize == 1 ? 0 : 1)) return null;
// Only ADR and one CBZ on the entry may sit between load and ADD.
Address nullTarget = null;
for (int index = loadIndex + 1; index < branchIndex - 1; index += 1) {
Instruction instruction = instructions.get(index);
String name = mnemonic(instruction);
if (name.equals("ADR")) continue;
if (name.equals("CBZ") && nullTarget == null) {
String[] parts = operands(instruction).split(",");
Address[] flows = instruction.getFlows();
if (parts.length != 2 || flows.length != 1 ||
!normalizedRegister(parts[0]).equals(entryRegister)) return null;
nullTarget = flows[0];
continue;
}
return null;
}
Address[] recoveredTargets = jumpTable.getCases();
Set<String> recoveredTargetSet = new HashSet<>();
for (Address target : recoveredTargets) recoveredTargetSet.add(canonicalAddress(target));
List<Address> dataAddresses = dispatchPathDataReferences(jumpTable);
int baseIndex = latestWriter(instructions, branchIndex - 1, baseRegister);
if (baseIndex < 0 || !mnemonic(instructions.get(baseIndex)).equals("ADR")) return null;
java.util.regex.Matcher baseMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(baseIndex)));
if (!baseMatcher.matches()) return null;
Address branchBase = addressFromOperand(baseMatcher.group(2));
for (int loadIndex = 0; loadIndex < branchIndex; loadIndex += 1) {
Instruction load = instructions.get(loadIndex);
int entrySize = load.getMnemonicString().equalsIgnoreCase("LDRB") ? 1 : load.getMnemonicString().equalsIgnoreCase("LDRH") ? 2 : 0;
if (entrySize == 0 || branchIndex != loadIndex + 2 || loadIndex < 3 ||
!instructions.get(loadIndex - 1).getMnemonicString().equalsIgnoreCase("ADR") ||
!instructions.get(loadIndex - 2).getMnemonicString().equalsIgnoreCase("ADD") ||
!instructions.get(loadIndex - 3).getMnemonicString().equalsIgnoreCase("ADRP")) continue;
String loadOperands = operands(load);
java.util.regex.Matcher loadMatcher = java.util.regex.Pattern
.compile(entrySize == 1 ? "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+)\\]$" : "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+),\\s*LSL\\s*#(?:0x)?1\\]$")
.matcher(loadOperands);
if (!loadMatcher.matches()) continue;
String loadedRegister = normalizedRegister(loadOperands.substring(0, loadOperands.indexOf(',')));
String tableBaseRegister = normalizedRegister(loadMatcher.group(1));
String indexRegister = normalizedRegister(loadMatcher.group(2));
int tableAddIndex = latestWriter(instructions, loadIndex, tableBaseRegister);
if (tableAddIndex < 0 || !mnemonic(instructions.get(tableAddIndex)).equals("ADD")) return null;
java.util.regex.Matcher tableAddMatcher = AARCH64_ADD_IMMEDIATE.matcher(operands(instructions.get(tableAddIndex)));
if (!tableAddMatcher.matches() ||
!normalizedRegister(tableAddMatcher.group(1)).equals(tableBaseRegister) ||
!normalizedRegister(tableAddMatcher.group(2)).equals(tableBaseRegister)) return null;
int tablePageIndex = latestWriter(instructions, tableAddIndex, tableBaseRegister);
if (tablePageIndex < 0 || !mnemonic(instructions.get(tablePageIndex)).equals("ADRP")) return null;
java.util.regex.Matcher pageMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(tablePageIndex)));
if (!pageMatcher.matches() || !normalizedRegister(pageMatcher.group(1)).equals(tableBaseRegister)) return null;
Address tableAddress = addressFromOperand(pageMatcher.group(2)).add(parseInteger(tableAddMatcher.group(3)));
if (!currentProgram.getMemory().contains(tableAddress) ||
(!dataAddresses.isEmpty() && !dataAddresses.contains(tableAddress))) return null;
Address resolvedTableAddress = null;
for (int index = loadIndex - 3; index < loadIndex - 1; index += 1) {
Instruction instruction = instructions.get(index);
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
String text = operands(instruction);
if (mnemonic.equals("ADRP") &&
text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
String[] parts = text.split(",");
if (normalizedRegister(parts[0]).equals(tableBaseRegister)) {
resolvedTableAddress = addressFromOperand(parts[1]);
}
}
else if (mnemonic.equals("ADD") && resolvedTableAddress != null &&
text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*#?(?:0x[0-9a-f]+|[0-9]+)$")) {
String[] parts = text.split(",");
if (normalizedRegister(parts[0]).equals(tableBaseRegister) &&
normalizedRegister(parts[1]).equals(tableBaseRegister)) {
resolvedTableAddress = resolvedTableAddress.add(parseInteger(parts[2].trim().replaceFirst("^#", "")));
}
}
// Unsigned guard: CMP Rs, #imm, then B.HI (imm + 1 entries) or
// B.CS/B.HS (imm entries), with only flag-preserving moves and
// address arithmetic between the compare and the load.
int guardIndex = -1;
for (int index = loadIndex - 1; index >= 0; index -= 1) {
String name = mnemonic(instructions.get(index));
if (name.startsWith("B.")) {
guardIndex = index;
break;
}
if (resolvedTableAddress == null || !currentProgram.getMemory().contains(resolvedTableAddress) ||
(!dataAddresses.isEmpty() && !dataAddresses.contains(resolvedTableAddress))) continue;
Address tableAddress = resolvedTableAddress;
Address branchBase = null;
boolean addressChain = false;
for (int index = loadIndex - 1; index < branchIndex; index += 1) {
Instruction instruction = instructions.get(index);
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
String text = operands(instruction);
if (index < loadIndex && mnemonic.equals("ADR") && text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
int comma = text.indexOf(',');
String destination = normalizedRegister(text.substring(0, comma));
if (!destination.equals(normalizedRegister(operands(instructions.get(branchIndex))))) continue;
branchBase = addressFromOperand(text.substring(comma + 1));
}
if (index > loadIndex && mnemonic.equals("ADD") && branchBase != null &&
text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*[wx]\\d+,\\s*LSL\\s*#(?:0x)?2$")) {
String[] parts = text.split(",");
String destination = normalizedRegister(parts[0]);
String base = normalizedRegister(parts[1]);
String offset = normalizedRegister(parts[2]);
if (destination.equals(base) &&
base.equals(normalizedRegister(operands(instructions.get(branchIndex)))) &&
offset.equals(loadedRegister)) {
addressChain = true;
}
}
}
if (!addressChain || branchBase == null) continue;
int entryCount = -1;
Address defaultTarget = null;
for (int compareIndex = 0; compareIndex < loadIndex; compareIndex += 1) {
Instruction compare = instructions.get(compareIndex);
if (!compare.getMnemonicString().equalsIgnoreCase("CMP")) continue;
java.util.regex.Matcher compareMatcher = java.util.regex.Pattern
.compile("(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$")
.matcher(operands(compare));
if (!compareMatcher.matches()) continue;
String comparedRegister = normalizedRegister(compareMatcher.group(1));
long upperBound = parseInteger(compareMatcher.group(2));
if (upperBound < 0 || upperBound >= 4096) continue;
for (int guardIndex = compareIndex + 1; guardIndex < loadIndex; guardIndex += 1) {
Instruction guard = instructions.get(guardIndex);
String mnemonic = guard.getMnemonicString().toUpperCase(Locale.ROOT);
if (!mnemonic.equals("B.HI")) continue;
Address[] flows = guard.getFlows();
if (flows.length != 1 || !recoveredTargetSet.contains(canonicalAddress(flows[0]))) continue;
boolean supportedSequence = true;
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex += 1) {
String sequenceMnemonic = instructions.get(sequenceIndex)
.getMnemonicString().toUpperCase(Locale.ROOT);
if (!Set.of("B.HI", "MOV", "ADRP", "ADD", "ADR").contains(sequenceMnemonic)) {
supportedSequence = false;
break;
}
}
Set<String> selectorAliases = new HashSet<>();
selectorAliases.add(comparedRegister);
Set<String> boundedIndexes = new HashSet<>();
if (compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) boundedIndexes.add(comparedRegister);
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex++) {
Instruction instruction = instructions.get(sequenceIndex);
String[] parts = operands(instruction).split(",");
if (parts.length == 0) continue;
if (instruction.getMnemonicString().equalsIgnoreCase("MOV")) {
String destination = normalizedRegister(parts[0]);
String source = parts.length == 2 ? normalizedRegister(parts[1]) : "";
boolean aliasesSelector = selectorAliases.contains(source);
boolean fullWidthBounded = boundedIndexes.contains(source) ||
(parts[0].trim().toLowerCase(Locale.ROOT).startsWith("w") && aliasesSelector);
if (aliasesSelector) selectorAliases.add(destination);
else selectorAliases.remove(destination);
if (fullWidthBounded) boundedIndexes.add(destination);
else boundedIndexes.remove(destination);
} else if (Set.of("ADRP", "ADD", "ADR").contains(instruction.getMnemonicString().toUpperCase(Locale.ROOT))) {
selectorAliases.remove(normalizedRegister(parts[0]));
boundedIndexes.remove(normalizedRegister(parts[0]));
}
}
boolean selectorFeedsTable = boundedIndexes.contains(indexRegister);
if (compareIndex < loadIndex - 3 && selectorFeedsTable && supportedSequence) {
entryCount = (int) upperBound + 1;
defaultTarget = flows[0];
break;
}
}
if (entryCount > 0) break;
}
if (entryCount <= 0 || entryCount > 4096 || defaultTarget == null) continue;
List<RecoveredAArch64Mapping> mappings = new ArrayList<>();
Set<String> mappedTargets = new HashSet<>();
boolean valid = true;
for (int caseValue = 0; caseValue < entryCount; caseValue += 1) {
Address entry = tableAddress.add((long) caseValue * entrySize);
int encodedOffset = entrySize == 1 ? currentProgram.getMemory().getByte(entry) & 0xff : currentProgram.getMemory().getShort(entry) & 0xffff;
Address target = branchBase.add((long) encodedOffset * 4L);
String canonicalTarget = canonicalAddress(target);
if (!recoveredTargetSet.contains(canonicalTarget)) {
valid = false;
break;
}
mappedTargets.add(canonicalTarget);
mappings.add(new RecoveredAArch64Mapping(caseValue, target));
}
if (!valid || mappedTargets.contains(canonicalAddress(defaultTarget))) continue;
Set<String> accountedTargets = new HashSet<>(mappedTargets);
accountedTargets.add(canonicalAddress(defaultTarget));
if (!accountedTargets.equals(recoveredTargetSet)) continue;
return new RecoveredAArch64RelativeTable(tableAddress, branchBase, entrySize, List.copyOf(mappings));
if (!AARCH64_FLAG_PRESERVING.contains(name)) return null;
}
return null;
if (guardIndex < 1) return null;
Instruction guard = instructions.get(guardIndex);
String guardMnemonic = mnemonic(guard);
if (!Set.of("B.HI", "B.CS", "B.HS").contains(guardMnemonic)) return null;
Address[] guardFlows = guard.getFlows();
if (guardFlows.length != 1) return null;
Address defaultTarget = guardFlows[0];
int compareIndex = -1;
for (int index = guardIndex - 1; index >= 0; index -= 1) {
String name = mnemonic(instructions.get(index));
if (name.equals("CMP")) {
compareIndex = index;
break;
}
if (!AARCH64_FLAG_PRESERVING.contains(name)) return null;
}
if (compareIndex < 0) return null;
java.util.regex.Matcher compareMatcher = AARCH64_COMPARE_IMMEDIATE.matcher(operands(instructions.get(compareIndex)));
if (!compareMatcher.matches()) return null;
String comparedRegister = normalizedRegister(compareMatcher.group(1));
boolean comparedFullWidth = compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x");
long compareImmediate = parseInteger(compareMatcher.group(2));
long entryCountLong = guardMnemonic.equals("B.HI") ? compareImmediate + 1 : compareImmediate;
if (entryCountLong <= 0 || entryCountLong > 4096) return null;
int entryCount = (int) entryCountLong;
// The loaded index must carry the compared value: either the compared
// register itself, unchanged since CMP, or a MOV copy made after CMP.
// A W compare bounds only the low 32 bits, so an X index needs a
// zero-extending W copy; a W index is zero-extended by UXTW.
int indexWriter = latestWriter(instructions, loadIndex, indexRegister);
boolean bounded = false;
if (indexWriter < compareIndex) {
bounded = indexRegister.equals(comparedRegister) && (wordIndex || comparedFullWidth);
}
else if (mnemonic(instructions.get(indexWriter)).equals("MOV")) {
java.util.regex.Matcher moveMatcher = AARCH64_MOVE_REGISTER.matcher(operands(instructions.get(indexWriter)));
if (moveMatcher.matches() &&
normalizedRegister(moveMatcher.group(2)).equals(comparedRegister) &&
latestWriter(instructions, indexWriter, comparedRegister) < compareIndex) {
boolean wordCopy = moveMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("w");
bounded = wordCopy || comparedFullWidth;
}
}
if (!bounded) return null;
// Every fact above holds only on one straight-line fall-through path
// with no other entry into the window.
int windowStart = Math.min(Math.min(tablePageIndex, baseIndex), compareIndex);
for (int index = windowStart + 1; index <= branchIndex; index += 1) {
monitor.checkCancelled();
Instruction previous = instructions.get(index - 1);
Instruction current = instructions.get(index);
Address fallThrough = previous.getFallThrough();
if (fallThrough == null || !fallThrough.equals(current.getAddress())) return null;
ReferenceIterator incoming = currentProgram.getReferenceManager().getReferencesTo(current.getAddress());
while (incoming.hasNext()) {
if (incoming.next().getReferenceType().isFlow()) return null;
}
}
List<RecoveredAArch64Mapping> mappings = new ArrayList<>();
Set<String> mappedTargets = new HashSet<>();
for (int tableIndex = 0; tableIndex < entryCount; tableIndex += 1) {
monitor.checkCancelled();
Address entry = tableAddress.add((long) tableIndex * entrySize);
if (!currentProgram.getMemory().contains(entry)) return null;
long encoded;
if (entrySize == 1) {
byte value = currentProgram.getMemory().getByte(entry);
encoded = signedEntries ? value : value & 0xffL;
}
else {
short value = currentProgram.getMemory().getShort(entry);
encoded = signedEntries ? value : value & 0xffffL;
}
if (encoded == 0 && nullTarget != null) {
mappings.add(new RecoveredAArch64Mapping(tableIndex, nullTarget, encoded, true));
continue;
}
Address target;
try {
target = branchBase.addNoWrap(encoded << offsetShift);
}
catch (ghidra.program.model.address.AddressOverflowException overflow) {
return null;
}
String canonicalTarget = canonicalAddress(target);
if (!recoveredTargetSet.contains(canonicalTarget)) return null;
mappedTargets.add(canonicalTarget);
mappings.add(new RecoveredAArch64Mapping(tableIndex, target, encoded, false));
}
if (mappedTargets.contains(canonicalAddress(defaultTarget))) return null;
// Ghidra's recovered destinations must be fully accounted for by the
// decoded entries, the guard default, and the CBZ null path.
Set<String> accountedTargets = new HashSet<>(mappedTargets);
accountedTargets.add(canonicalAddress(defaultTarget));
if (nullTarget != null) accountedTargets.add(canonicalAddress(nullTarget));
// Ghidra does not model the CBZ, so it may decode a zero entry as a
// branch to the base itself. Only a zero entry encodes offset 0, and
// the CBZ diverts every zero entry, so the BR never reaches the base.
Address unreachableZeroTarget = null;
boolean divertedZeroEntry = mappings.stream().anyMatch(mapping -> mapping.nullEntry);
if (divertedZeroEntry &&
recoveredTargetSet.contains(canonicalAddress(branchBase)) &&
!mappedTargets.contains(canonicalAddress(branchBase)) &&
!branchBase.equals(defaultTarget) &&
!branchBase.equals(nullTarget)) {
unreachableZeroTarget = branchBase;
accountedTargets.add(canonicalAddress(branchBase));
}
if (!accountedTargets.containsAll(recoveredTargetSet)) return null;
int selectorWriter = latestWriter(instructions, compareIndex, comparedRegister);
return new RecoveredAArch64RelativeTable(
tableAddress,
branchBase,
entrySize,
selectorWriter < 0 ? null : instructions.get(selectorWriter).getAddress(),
signedEntries,
offsetShift,
guardMnemonic,
compareImmediate,
nullTarget,
unreachableZeroTarget,
List.copyOf(mappings)
);
}
private static String mnemonic(Instruction instruction) {
return instruction.getMnemonicString().toUpperCase(Locale.ROOT);
}
/** Index of the last instruction before {@code before} that writes {@code register}, or -1. */
private static int latestWriter(List<Instruction> instructions, int before, String register) {
for (int index = before - 1; index >= 0; index -= 1) {
for (Object result : instructions.get(index).getResultObjects()) {
if (result instanceof Register written &&
normalizedRegister(written.getBaseRegister().getName()).equals(register)) {
return index;
}
}
}
return -1;
}
private static String operands(Instruction instruction) {
@@ -60,6 +60,12 @@ manifest and graph commitments, JavaScript Application Graph, static
Electron summary, reconstruction statistics, and explicit limitations. It does
not require a live Hopper, Ghidra, browser, or Electron process.
The application graph selects the shallowest `package.json` outside
`node_modules` as its package root, breaking equal-depth ties by Unicode
code-point order. Dependency manifests remain package observations but do not
declare Electron main or renderer entries. If only dependency manifests are
present, the inventory artifact remains the graph root.
## ASAR integrity
ASAR inventory checks Electron integrity metadata for embedded archive entries
+9 -9
View File
@@ -450,15 +450,15 @@ installation (`application.java.min` through `application.java.max`). Current
12.1 releases require JDK 21 or newer and set no maximum. The lanes below still
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
| Ghidra lane | Supported runner/target | Additional local tools |
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
| Ghidra lane | Supported runner/target | Additional local tools |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
The optional Swift lane compiles a source-owned fixture and checks modern and
provider-demangled procedure names through real stdio MCP, with and without the
@@ -176,6 +176,9 @@ const startStorageSite = async () => {
origin: `http://127.0.0.1:${port}`,
close: async () => {
server.close();
// Chrome can leave speculative TCP connections without an HTTP request;
// server.close() alone waits indefinitely for those owned sockets.
server.closeAllConnections();
await once(server, "close");
},
};
+4
View File
@@ -277,11 +277,13 @@ try {
throw new Error("Scenario attachment terminated its external browser");
const profilesBefore = await scenarioProfiles();
process.stderr.write("Browser verifier: scenario environment\n");
const scenarioEnvironment = await verifyScenarioEnvironment(
endpoint,
target,
site.origin,
);
process.stderr.write("Browser verifier: scenario storage\n");
const scenarioStorage = await verifyScenarioStorage({
executable,
endpoint,
@@ -339,7 +341,9 @@ try {
`Browser scenario included unselected structured value: ${unselectedValue}`,
);
process.stderr.write("Browser verifier: large screenshot\n");
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
process.stderr.write("Browser verifier: popup coverage\n");
const popupEvents = await verifyPopupEventCoverage(executable);
const networkEvidence = await verifyBrowserNetworkEvidence(
executable,
+107
View File
@@ -674,6 +674,113 @@ export async function verifyRelativeSwitch(client, procedures, entrySize) {
};
}
/**
* Oracle for relative-switch-signed.S: signed halfword byte offsets from a base
* inside the case bodies, a CBZ route for the zero entry, and a B.CS bound.
*/
const SIGNED_RELATIVE_SWITCH_CASES = [
{ value: 0, immediate: 200 },
{ value: 1, immediate: 201 },
{ value: 2, immediate: 202 },
{ value: 3, immediate: 250 },
{ value: 4, immediate: 204 },
];
export async function verifyRelativeSignedSwitch(client, selectorBias = 0) {
const names = await inventoryCall(client, "list_names", {
document: null,
address: null,
});
const procedure = requireProcedure(names, "rea_relative_switch_signed");
const dossier = await functionCall(client, "analyze_function", {
procedure: procedure.address,
});
const table = dossier.native_api?.jump_tables.find((item) =>
item.data_sources.some(
(source) =>
source.entry_size_bytes === 2 &&
source.entry_count === SIGNED_RELATIVE_SWITCH_CASES.length,
),
);
if (table === undefined)
throw new Error(
`Exact signed halfword relative table unavailable: ${JSON.stringify(dossier.native_api)}`,
);
const expectedValues = SIGNED_RELATIVE_SWITCH_CASES.map(
({ value }) => value + selectorBias,
);
if (
table.mappings.some(
(mapping) =>
mapping.case_value !== null &&
!expectedValues.includes(mapping.case_value),
)
)
throw new Error(
`Signed relative switch emitted an unsupported case label: ${JSON.stringify(table)}`,
);
if (table.mappings.length !== SIGNED_RELATIVE_SWITCH_CASES.length)
throw new Error(
`Signed relative switch has unexpected mappings: ${JSON.stringify(table)}`,
);
if (selectorBias !== 0) {
if (
!table.limitations.some((limitation) =>
limitation.includes("table indices are not proven"),
) ||
!table.mappings.some((mapping) => mapping.case_value === null) ||
table.mappings.some((mapping) => mapping.case_value === selectorBias + 3)
)
throw new Error(
`Normalized selector must retain unknown case evidence: ${JSON.stringify(table)}`,
);
}
for (const { value: tableIndex, immediate } of SIGNED_RELATIVE_SWITCH_CASES) {
const value = tableIndex + selectorBias;
// Without a proven relationship between the machine index and the typed
// switch selector, the CBZ-only case must stay unknown rather than being
// assigned the normalized index or Ghidra's unreachable base destination.
if (selectorBias !== 0 && tableIndex === 3) continue;
const mapping = table.mappings.find((item) => item.case_value === value);
if (
mapping === undefined ||
mapping.confidence !== "high" ||
(selectorBias !== 0 &&
!mapping.evidence.some(
(evidence) => evidence.source === "ghidra-clang-case-token",
))
)
throw new Error(
`Signed relative switch case ${value} was not verified: ${JSON.stringify(table)}`,
);
const instruction = await functionCall(
client,
"inspect_native_instruction",
{ address: mapping.target_address },
);
if (
instruction.status !== "decoded" ||
!instruction.operands.some((operand) =>
operand.components.some(
(component) =>
component.kind === "immediate" &&
component.value === `0x${immediate.toString(16)}`,
),
)
)
throw new Error(
`Signed relative switch target does not implement source case ${value}: ${JSON.stringify(instruction)}`,
);
}
return {
entry_size: 2,
signed: true,
selector_bias: selectorBias,
dispatch_address: table.dispatch_address,
mappings: table.mappings.length,
};
}
export async function verifyNativeValueTrace(client, procedures, target) {
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
const names = await inventoryCall(client, "list_names", {
+53 -17
View File
@@ -38,6 +38,7 @@ import {
verifyJumpTableDenseSwitch,
verifyNativeTypeLayout,
verifyRelativeSwitch,
verifyRelativeSignedSwitch,
verifyNativeValueTrace,
} from "./verify-real-ghidra-function.mjs";
const exec = promisify(execFile);
@@ -173,6 +174,36 @@ try {
]);
}
}
const signedRelativeSource = fileURLToPath(
new URL(
"../tests/conformance/ghidra/relative-switch-signed.S",
import.meta.url,
),
);
for (const selectorBias of [0, 2, 10]) {
for (const format of [
"elf",
...(expectedNativeTarget.architecture === "arm64" ? ["mach-o"] : []),
]) {
const targetPath = join(
fixtureRoot,
`relative-signed-${selectorBias}-${format}.o`,
);
await exec(clang, [
...(format === "elf" ? ["--target=aarch64-linux-gnu"] : []),
`-DREA_SELECTOR_BIAS=${selectorBias}`,
"-c",
signedRelativeSource,
"-o",
targetPath,
]);
crossTargets.push([
targetPath,
`relative-signed-${selectorBias}`,
{ format, architecture: "arm64" },
]);
}
}
await exec(clang, [
"--target=aarch64-linux-gnu",
@@ -522,25 +553,30 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
client,
})
: null;
const probes = variant.startsWith("relative-")
? await verifyRelativeSwitch(
const probes = variant.startsWith("relative-signed-")
? await verifyRelativeSignedSwitch(
client,
procedures,
Number(variant.slice(-1)),
Number(variant.split("-").at(-1)),
)
: variant === "type-layout"
? await verifyNativeTypeLayout(client, names)
: variant === "jump-table"
? await verifyJumpTableDenseSwitch(client, procedures)
: variant === "custom" || variant === "aarch64-jump-table"
? null
: await verifyInventoryOperations({
client,
variant,
procedures,
names,
strings,
});
: variant.startsWith("relative-")
? await verifyRelativeSwitch(
client,
procedures,
Number(variant.slice(-1)),
)
: variant === "type-layout"
? await verifyNativeTypeLayout(client, names)
: variant === "jump-table"
? await verifyJumpTableDenseSwitch(client, procedures)
: variant === "custom" || variant === "aarch64-jump-table"
? null
: await verifyInventoryOperations({
client,
variant,
procedures,
names,
strings,
});
const nativeValues =
variant === "debug"
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
@@ -26,6 +26,7 @@ import {
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
interface PackageRoleInput {
readonly packageNode: ApplicationNode;
@@ -281,6 +282,7 @@ export const addJavaScriptPackageNodes = (
context: JavaScriptArtifactGraphContext,
): ApplicationNode[] => {
const roots: ApplicationNode[] = [];
const rootPath = applicationManifestPath(context);
for (const packageValue of context.analysis.packages) {
const file = context.filesByPath.get(packageValue.path);
if (file === undefined) continue;
@@ -309,7 +311,7 @@ export const addJavaScriptPackageNodes = (
},
],
});
if (roots.length === 0) {
if (file.path === rootPath) {
roots.push(node);
context.accumulator.addEdge({
source_node_id: node.node_id,
@@ -330,6 +332,7 @@ export const addJavaScriptPackageNodes = (
file,
operation: "inventory-package",
});
if (dependencyManifest(file.path)) continue;
addPackageRole(context, {
packageNode: node,
packageFile: file,
@@ -346,6 +349,24 @@ export const addJavaScriptPackageNodes = (
return roots;
};
/** Dependency-only artifacts retain their inventory root. */
const applicationManifestPath = (
context: JavaScriptArtifactGraphContext,
): string | undefined =>
context.analysis.packages
.map(({ path }) => path)
.filter(
(path) => context.filesByPath.has(path) && !dependencyManifest(path),
)
.sort(
(left, right) =>
left.split("/").length - right.split("/").length ||
compareUnicodeCodePoints(left, right),
)[0];
const dependencyManifest = (path: string): boolean =>
path.split("/").slice(0, -1).includes("node_modules");
const createFileTarget = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
@@ -1,4 +1,4 @@
import { access } from "node:fs/promises";
import { access, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
@@ -32,6 +32,8 @@ export class MitmproxyCaptureAdapter implements HistoricalCaptureFormatAdapter {
);
try {
await access(command, constants.R_OK | constants.X_OK);
if (!(await stat(command)).isFile())
throw new TypeError("Configured path is not a regular file");
} catch (cause: unknown) {
const reason = `Configured mitmdump executable is unavailable: ${command}.`;
throw new AnalysisCapabilityUnavailableError(
+36
View File
@@ -103,3 +103,39 @@ describe("read-only NativeAOT PE parser", () => {
});
});
});
describe("NativeAOT PE section virtual extent", () => {
const TEXT_VIRTUAL_SIZE = 0x188 + 8;
const RDATA_VIRTUAL_SIZE = 0x188 + 40 + 8;
const parseWithVirtualSize = (at: number, virtualSize: number) => {
const bytes = nativeAotPeFixture();
bytes.writeUInt32LE(virtualSize, at);
return parseNativeAotPe(
bytes,
nativeAotPeDigest(bytes),
SUMMARY_BUDGET,
"0x140000000",
).summary;
};
it("does not treat raw padding past VirtualSize as executable code", () => {
// The 0x2280 MethodTable's only slot targets 0x1040, past .text's 0x40 bytes.
expect(parseWithVirtualSize(TEXT_VIRTUAL_SIZE, 0x40)).toMatchObject({
method_tables: 2,
});
});
it("does not read a frozen string from raw padding past VirtualSize", () => {
expect(parseWithVirtualSize(RDATA_VIRTUAL_SIZE, 0x300)).toMatchObject({
status: "partial",
frozen_strings: [],
});
});
it("maps raw bytes when VirtualSize is zero, as the image loader does", () => {
expect(parseWithVirtualSize(TEXT_VIRTUAL_SIZE, 0)).toMatchObject({
status: "complete",
method_tables: 3,
});
});
});
+20 -24
View File
@@ -366,6 +366,13 @@ const parsedSummary = (
readTypeDetail: () => undefined,
});
// The image loader maps VirtualSize bytes, using SizeOfRawData only when
// VirtualSize is zero. Raw bytes past that extent are file-alignment padding.
const virtualExtent = (section: Section): number =>
section.virtualSize === 0 ? section.rawSize : section.virtualSize;
const fileBackedExtent = (section: Section): number =>
Math.min(section.rawSize, virtualExtent(section));
class PeImage {
readonly imageBase: bigint;
readonly sizeOfImage: number;
@@ -417,10 +424,7 @@ class PeImage {
});
for (const section of this.sections) {
this.#range(section.rawOffset, section.rawSize);
if (
section.rva + Math.max(section.virtualSize, section.rawSize) >
this.sizeOfImage
)
if (section.rva + virtualExtent(section) > this.sizeOfImage)
throw new Error("PE section exceeds the declared image extent");
}
}
@@ -456,11 +460,11 @@ class PeImage {
for (const section of this.sections) {
if (this.#budget !== undefined && !this.#budget.consumeWork(1))
return null;
const extent = Math.max(section.virtualSize, section.rawSize);
const extent = virtualExtent(section);
if (relative < section.rva || relative - section.rva > extent - size)
continue;
const delta = relative - section.rva;
if (delta > section.rawSize - size) return null;
if (delta > fileBackedExtent(section) - size) return null;
const offset = section.rawOffset + delta;
return this.#bytes.subarray(offset, offset + size);
}
@@ -475,7 +479,7 @@ class PeImage {
if (
(section.characteristics & 0x20000000) !== 0 &&
rva >= BigInt(section.rva) &&
rva < BigInt(section.rva + section.rawSize)
rva < BigInt(section.rva + virtualExtent(section))
)
return true;
}
@@ -492,10 +496,7 @@ class PeImage {
if (this.#budget !== undefined && !this.#budget.consumeWork(1)) return 0;
if (
rva >= BigInt(candidate.rva) &&
rva <
BigInt(
candidate.rva + Math.max(candidate.virtualSize, candidate.rawSize),
)
rva < BigInt(candidate.rva + virtualExtent(candidate))
) {
section = candidate;
break;
@@ -503,9 +504,7 @@ class PeImage {
}
return section === undefined
? 0
: section.rva +
Math.max(section.virtualSize, section.rawSize) -
Number(rva);
: section.rva + virtualExtent(section) - Number(rva);
}
#range(offset: number, size: number): void {
if (
@@ -602,7 +601,7 @@ const findDirectory = (
)
continue;
const begin = section.rawOffset;
const end = begin + section.rawSize;
const end = begin + fileBackedExtent(section);
const first = (begin + 7) & ~7;
const candidateBytes =
first <= end - 16 ? (Math.floor((end - 16 - first) / 8) + 1) * 8 : 0;
@@ -970,10 +969,10 @@ const discoverTables = (
(section.characteristics & 0x20000000) !== 0
)
continue;
if (section.rawSize > 0) {
if (fileBackedExtent(section) > 0) {
ranges.push({
start: image.addressForRva(section.rva),
end: image.addressForRva(section.rva + section.rawSize),
end: image.addressForRva(section.rva + fileBackedExtent(section)),
});
}
}
@@ -984,9 +983,7 @@ const discoverTables = (
(candidate.characteristics & 0x20000000) === 0 &&
hydrated.base >= image.addressForRva(candidate.rva) &&
hydrated.base + BigInt(hydrated.sizeBytes) <=
image.addressForRva(
candidate.rva + Math.max(candidate.virtualSize, candidate.rawSize),
),
image.addressForRva(candidate.rva + virtualExtent(candidate)),
);
if (section !== undefined)
ranges.push({
@@ -1592,11 +1589,10 @@ export const parseNativeAotPe = (
? "raw-image-pointer-scan"
: "raw-image-and-rehydrated-pointer-scan",
},
diagnostics: [
...(hydrationError === null
diagnostics:
hydrationError === null
? []
: [`Read-only hydration stopped: ${hydrationError}`]),
],
: [`Read-only hydration stopped: ${hydrationError}`],
limitations: reportLimitations,
};
if (Buffer.byteLength(JSON.stringify(summary)) > maximumSummaryBytes)
+3 -1
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access, realpath } from "node:fs/promises";
import { access, realpath, stat } from "node:fs/promises";
import { isAbsolute } from "node:path";
import {
AnalysisCancelledError,
@@ -87,6 +87,8 @@ export const resolveWakaruCommand = async (
if (!isAbsolute(limiter))
throw new TypeError("REA_JAVASCRIPT_PRLIMIT_COMMAND must be absolute");
await access(limiter, constants.X_OK);
if (!(await stat(limiter)).isFile())
throw new TypeError(`Resource limiter is not a regular file: ${limiter}`);
return { command, limiter, ...fingerprint };
} catch (cause: unknown) {
const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`;
+11 -5
View File
@@ -28,6 +28,11 @@ interface Section {
readonly size: number;
}
// The image loader maps VirtualSize bytes, using SizeOfRawData only when
// VirtualSize is zero. Raw bytes past that extent are file-alignment padding.
const virtualExtent = (section: Section): number =>
section.virtualSize === 0 ? section.size : section.virtualSize;
/** Header admission and unambiguous file-backed RVA mapping, independent of section names. */
export const readPeResourceLayout = (bytes: Buffer) => {
requirePeRange(bytes, 0, 64);
@@ -64,7 +69,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
offset: bytes.readUInt32LE(at + 20),
size: bytes.readUInt32LE(at + 16),
};
if (section.rva + Math.max(section.virtualSize, section.size) > 0x100000000)
if (section.rva + virtualExtent(section) > 0x100000000)
peFailure("PE section RVA range overflows.");
if (section.size > 0) {
requirePeRange(bytes, section.offset, section.size);
@@ -90,15 +95,14 @@ export const readPeResourceLayout = (bytes: Buffer) => {
if (rva + size > 0x100000000) peFailure("Resource RVA range overflows.");
const candidates = sections.filter(
(section) =>
rva >= section.rva &&
rva - section.rva < Math.max(section.size, section.virtualSize),
rva >= section.rva && rva - section.rva < virtualExtent(section),
);
if (rva < headerSize) {
if (
sections.some(
(section) =>
section.rva < rva + size &&
section.rva + Math.max(section.size, section.virtualSize) > rva,
section.rva + virtualExtent(section) > rva,
) ||
size > headerSize - rva
)
@@ -111,6 +115,8 @@ export const readPeResourceLayout = (bytes: Buffer) => {
const section = candidates[0];
if (section === undefined) return peFailure("Missing resource section.");
const within = rva - section.rva;
if (size > virtualExtent(section) - within)
peFailure("Resource range leaves its section's virtual size.");
if (within > section.size || size > section.size - within)
peFailure("Resource range is not entirely file-backed in one section.");
// A second section starting inside the requested interval is ambiguous too.
@@ -119,7 +125,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
(other) =>
other !== section &&
other.rva < rva + size &&
other.rva + Math.max(other.size, other.virtualSize) > rva,
other.rva + virtualExtent(other) > rva,
)
)
peFailure("Resource interval intersects overlapping virtual sections.");
+59
View File
@@ -259,3 +259,62 @@ describe("PE header admission and bounded traversal", () => {
await expect(parsing).rejects.toThrow();
});
});
describe("PE resource section virtual extent", () => {
const sectionSizes = (fixture: ReturnType<typeof peResourceFixture>) => {
const virtualSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 8);
const rawSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 16);
expect(rawSize - virtualSize).toBeGreaterThanOrEqual(8);
return { virtualSize, rawSize };
};
it.each([
[0, "Resource RVA has missing or overlapping section mappings."],
[-2, "Resource range leaves its section's virtual size."],
])(
"rejects a payload reaching raw alignment padding past VirtualSize (start %i)",
async (start, message) => {
const fixture = peResourceFixture();
const { virtualSize } = sectionSizes(fixture);
const rva = 0x2000 + virtualSize + start;
fixture.bytes.writeUInt32LE(rva, fixture.dataOffsets[0]);
fixture.bytes.writeUInt32LE(4, (fixture.dataOffsets[0] ?? 0) + 4);
await expect(parse(fixture.bytes)).rejects.toMatchObject({
reason: "format",
message,
});
},
);
it("maps raw bytes when VirtualSize is zero, as the image loader does", async () => {
const fixture = peResourceFixture();
fixture.bytes.writeUInt32LE(0, fixture.sectionAt + 8);
expect(await parse(fixture.bytes)).toMatchObject({
coverage: { status: "complete", resources: 5 },
});
});
it("selects a later section that starts inside earlier raw padding", async () => {
const fixture = peResourceFixture();
const { virtualSize } = sectionSizes(fixture);
const laterRva = 0x2000 + Math.ceil(virtualSize / 4) * 4;
const rawOffset = fixture.bytes.length;
const payload = Buffer.from("LATE");
const bytes = Buffer.concat([fixture.bytes, payload]);
bytes.writeUInt16LE(2, 0x86);
const later = fixture.sectionAt + 40;
bytes.writeUInt32LE(payload.length, later + 8);
bytes.writeUInt32LE(laterRva, later + 12);
bytes.writeUInt32LE(payload.length, later + 16);
bytes.writeUInt32LE(rawOffset, later + 20);
bytes.writeUInt32LE(laterRva, fixture.dataOffsets[0]);
bytes.writeUInt32LE(payload.length, (fixture.dataOffsets[0] ?? 0) + 4);
const result = peResourcesSchema.parse(await parse(bytes));
expect(result.resources[0]?.payload).toMatchObject({
rva: laterRva,
location: { offset: rawOffset, bytes: payload.length },
sha256: createHash("sha256").update(payload).digest("hex"),
});
});
});
+66 -41
View File
@@ -5,7 +5,9 @@ import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import type { InspectPeResourcesInput } from "../../domain/native/peResources.js";
@@ -37,47 +39,70 @@ export class PeResourcesProvider implements PeResourcesPort {
),
);
} catch (cause) {
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError("inspect_pe_resources", { cause }),
);
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
return err(
new AnalysisResourceConstraintError(
"inspect_pe_resources",
"memory",
cause.message,
{
max_file_bytes: input.max_file_bytes,
max_entries: input.max_entries,
},
{ cause },
),
);
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "EACCES" || cause.code === "EPERM")
)
return err(
new AnalysisAccessDeniedError(
"inspect_pe_resources",
input.path,
cause.code,
{ cause },
),
);
return err(
new ArtifactOperationError(
"inspect_pe_resources",
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
undefined,
cause instanceof Error
? cause.message
: "PE artifact inspection failed.",
{ cause },
),
);
return err(peResourcesFailure(cause, input, options?.signal));
}
}
}
const peResourcesFailure = (
cause: unknown,
input: InspectPeResourcesInput,
signal: AbortSignal | undefined,
) => {
const operation = "inspect_pe_resources";
if (signal?.aborted) return new AnalysisCancelledError(operation, { cause });
if (cause instanceof ArtifactReaderFailure) {
if (cause.reason === "limit")
return new AnalysisResourceConstraintError(
operation,
"memory",
cause.message,
{
max_file_bytes: input.max_file_bytes,
max_entries: input.max_entries,
},
{ cause },
);
if (cause.reason === "integrity")
return new AnalysisArtifactChangedError(
operation,
input.path,
cause.message,
{ cause },
);
if (cause.reason === "path" || cause.reason === "format")
return new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: cause.message,
},
]);
if (cause.reason === "cancelled")
return new AnalysisCancelledError(operation, { cause });
}
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EACCES" || cause.code === "EPERM")
return new AnalysisAccessDeniedError(operation, input.path, cause.code, {
cause,
});
if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
return new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Selected PE image could not be read (${cause.code}): ${input.path}.`,
},
]);
}
return new ArtifactOperationError(
operation,
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
undefined,
cause instanceof Error ? cause.message : "PE artifact inspection failed.",
{ cause },
);
};
@@ -0,0 +1,120 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"keeps dependency manifests out of application roots and Electron entries through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-application-manifest-");
const dependency = join(root, "node_modules", "dep");
const nested = join(root, "a");
await Promise.all([mkdir(dependency, { recursive: true }), mkdir(nested)]);
await Promise.all([
writeFile(
join(nested, "package.json"),
JSON.stringify({ name: "nested" }),
),
writeFile(
join(root, "package.json"),
JSON.stringify({ name: "app", main: "main.js" }),
),
writeFile(
join(root, "main.js"),
"throw new Error('source must remain inert');",
),
writeFile(
join(dependency, "package.json"),
JSON.stringify({
name: "dep",
main: "index.js",
browser: "browser.js",
}),
),
writeFile(join(dependency, "index.js"), "module.exports = {};"),
writeFile(join(dependency, "browser.js"), "export const browser = true;"),
]);
let document: unknown;
if (surface === "CLI") {
const response = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(response.exitCode).toBe(0);
document = response.json;
} else {
const { call } = await connectLocalToolsMcp();
const response = await call("analyze_javascript_application", {
input_path: root,
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
document = response.structuredContent;
}
const { graph } = z
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
.parse(document).normalized_result;
expect(
graph.nodes
.filter(({ node_id }) => graph.root_node_ids.includes(node_id))
.flatMap(({ observations }) => observations.map(({ label }) => label)),
).toEqual(["app"]);
expect(
graph.nodes
.filter(({ kind }) => kind === "electron-main")
.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.declared_path),
),
).toEqual(["main.js"]);
expect(
graph.nodes.filter(({ kind }) => kind === "electron-renderer"),
).toEqual([]);
expect(
graph.nodes
.filter(({ kind }) => kind === "package")
.flatMap(({ observations }) => observations.map(({ label }) => label))
.sort(),
).toEqual(["app", "dep", "nested"]);
},
);
cliTest(
"keeps the artifact root when only dependency manifests are present",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-dependency-only-");
const dependency = join(root, "node_modules", "dep");
await mkdir(dependency, { recursive: true });
await writeFile(
join(dependency, "package.json"),
JSON.stringify({ name: "dep", main: "index.js" }),
);
await writeFile(join(dependency, "index.js"), "module.exports = {};");
const response = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(response.exitCode, response.stderr).toBe(0);
const { graph } = z
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
.parse(response.json).normalized_result;
expect(
graph.nodes
.filter(({ node_id }) => graph.root_node_ids.includes(node_id))
.map(({ kind }) => kind),
).toEqual(["artifact"]);
expect(
graph.nodes
.filter(({ kind }) => kind === "package")
.flatMap(({ observations }) => observations.map(({ label }) => label)),
).toEqual(["dep"]);
expect(
graph.nodes.some(
({ kind }) => kind === "electron-main" || kind === "electron-renderer",
),
).toBe(false);
},
);
@@ -8,7 +8,36 @@ import { toolContract } from "../../../src/contracts/toolContracts.js";
import { peResourcesSchema } from "../../../src/domain/native/peResources.js";
import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
const expectPeInputFailureAcrossAdapters = async (input: {
readonly cli: TestCli;
readonly client: Client;
readonly toolName: string;
readonly path: string;
readonly reason: "invalid_format" | "invalid_value";
}) => {
const { cli, client, toolName, path, reason } = input;
const result = await cli.run({
arguments: ["inspect-pe-resources", path, "--json"],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: {
operation: "inspect_pe_resources",
issues: [{ path: ["path"], reason }],
},
});
const response = await client.callTool({
name: toolName,
arguments: { path },
});
expect(response.isError).toBe(true);
expect(parseMcpToolError(response)).toEqual({ error: result.json });
};
cliTest(
"inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target",
@@ -92,6 +121,20 @@ cliTest(
).toBe(evidence.evidence_id);
expect(await readFile(path)).toEqual(fixture.bytes);
}
const malformedPath = join(root, "malformed.exe");
await writeFile(malformedPath, Buffer.from("not a PE image"));
for (const [path, reason] of [
[join(root, "missing.exe"), "invalid_value"],
[malformedPath, "invalid_format"],
] as const) {
await expectPeInputFailureAcrossAdapters({
cli,
client,
toolName: contract.name,
path,
reason,
});
}
const absent = peResourceFixture();
absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8);
const path = join(root, "absent.exe");
@@ -2,8 +2,10 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import {
access,
chmod,
mkdir,
mkdtemp,
rm,
symlink,
truncate,
writeFile,
} from "node:fs/promises";
@@ -11,6 +13,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { HistoricalCaptureDecoder } from "../../../src/browser/history/HistoricalCaptureDecoder.js";
import { MitmproxyCaptureAdapter } from "../../../src/browser/history/MitmproxyCaptureAdapter.js";
import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../../src/browser/history/CaptureRelease.js";
import {
inspectWebNetworkCaptureInputSchema,
@@ -20,6 +23,41 @@ import { historicalHar } from "../../fixtures/historicalHar.js";
import { decodeHarCapture } from "../../../src/browser/history/HarCapture.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
const linux = it.skipIf(process.platform !== "linux");
linux("refuses a directory configured as the mitmdump executable", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-mitmdump-command-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const command = join(root, "mitmdump");
await mkdir(command);
await expect(
new MitmproxyCaptureAdapter({ REA_MITMDUMP_COMMAND: command }).command(
join(root, "request.json"),
join(root, "runtime"),
),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining(command),
});
});
linux("allows a symlink to a regular mitmdump executable", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-mitmdump-command-symlink-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const command = join(root, "mitmdump");
const target = join(root, "mitmdump-bin");
await writeFile(target, "#!/bin/sh\n", { mode: 0o700 });
await symlink(target, command);
await expect(
new MitmproxyCaptureAdapter({ REA_MITMDUMP_COMMAND: command }).command(
join(root, "request.json"),
join(root, "runtime"),
),
).resolves.toMatchObject({ command });
});
it.each([true, false])(
"binds decoder replies to the complete selected provider identity (matching name: %s)",
async (matching) => {
+21 -3
View File
@@ -6,17 +6,35 @@ import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("PE resource stable filesystem boundary", () => {
it("rejects symlinks, missing files and directories without inventing empty inventory", async () => {
it("reports non-file and malformed PE selections as caller input errors", async () => {
const root = await createTestTempDirectory("rea-pe-resource-files-");
const path = join(root, "image.exe");
const alias = join(root, "alias.exe");
const malformed = join(root, "malformed.exe");
await writeFile(path, peResourceFixture().bytes);
await writeFile(malformed, Buffer.from("not a PE image"));
await symlink(path, alias);
const service = createPeResourcesService();
for (const candidate of [root, alias, join(root, "missing.exe")]) {
for (const [candidate, reason] of [
[root, "invalid_format"],
[alias, "invalid_format"],
[malformed, "invalid_format"],
[join(root, "missing.exe"), "invalid_value"],
[join(path, "child.exe"), "invalid_value"],
] as const) {
const result = await service.inspect({ path: candidate });
if (result.ok) throw new Error("Expected file acquisition failure");
expect(result.error._tag).toBe("ArtifactOperationError");
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
operation: "inspect_pe_resources",
issues: [
{
path: ["path"],
reason,
message: expect.any(String),
},
],
});
}
});
@@ -0,0 +1,47 @@
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { resolveWakaruCommand } from "../../../src/javascript/recovery/WakaruCommand.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const linuxX64 = it.skipIf(
process.platform !== "linux" || process.arch !== "x64",
);
linuxX64(
"refuses a directory configured as the Wakaru resource limiter",
async () => {
const root = await createTestTempDirectory("rea-wakaru-limiter-");
const command = join(root, "wakaru");
const limiter = join(root, "prlimit");
await writeFile(command, "#!/bin/sh\n", { mode: 0o700 });
await mkdir(limiter);
await expect(
resolveWakaruCommand({
REA_WAKARU_COMMAND: command,
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
}),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining(limiter),
});
},
);
linuxX64("allows a symlink to a regular Wakaru resource limiter", async () => {
const root = await createTestTempDirectory("rea-wakaru-limiter-symlink-");
const command = join(root, "wakaru");
const limiter = join(root, "prlimit");
const target = join(root, "prlimit-bin");
await writeFile(command, "#!/bin/sh\n", { mode: 0o700 });
await writeFile(target, "#!/bin/sh\n", { mode: 0o700 });
await symlink(target, limiter);
await expect(
resolveWakaruCommand({
REA_WAKARU_COMMAND: command,
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
}),
).resolves.toMatchObject({ limiter });
});
+8
View File
@@ -39,3 +39,11 @@ bounded, and address-bearing; it never compares their text with Hopper. The
callback fixture also proves that an unresolved targetless flow is not silently
promoted to a direct callee. Every target runs in a separate owned project and
must leave no process, socket, project, or runtime root after close.
`relative-switch-signed.S` covers signed halfword offsets, the CBZ zero-entry
route, and selector biases of 0, 2 and 10 in the AArch64 jump-table lane. The
nonzero biases exercise overlapping and disjoint typed label/index ranges. REA
retains Ghidra's typed labels when the compared selector has an earlier register
definition; additional labels remain unknown until their relationship to the
typed switch selector can be proved. The oracle rejects extra numeric labels
and verifies each retained target against the source instruction immediate.
@@ -0,0 +1,83 @@
// Hand-written signed halfword relative dispatch, modelled on the AOSP
// RenderScript blend kernel shape reported in issue #487:
// adrp/add table; cmp; b.cs default; ldrsh; cbz null; adr base; add; br.
// Entries are signed byte offsets from Lsigned_base (no scaling), so cases
// before the base encode negative offsets. A zero entry means "no case body"
// and is routed through the CBZ before the branch. The source labels and the
// immediates below are the oracle; no REA or Ghidra result defines them.
// case 0 -> 200, 1 -> 201, 2 -> 202, 3 -> null path (250), 4 -> 204,
// selector >= 5 -> default (-1).
#ifndef REA_SELECTOR_BIAS
#define REA_SELECTOR_BIAS 0
#endif
// Nonzero variants switch on the original selector (bias .. bias+4),
// while the guarded table index is selector - bias.
#if defined(__APPLE__)
.section __TEXT,__text,regular,pure_instructions
.globl _rea_relative_switch_signed
.p2align 2
_rea_relative_switch_signed:
#else
.text
.globl rea_relative_switch_signed
.type rea_relative_switch_signed,%function
.p2align 2
rea_relative_switch_signed:
#endif
#if defined(__APPLE__)
adrp x5, Lsigned_table@PAGE
add x5, x5, Lsigned_table@PAGEOFF
#else
adrp x5, Lsigned_table
add x5, x5, :lo12:Lsigned_table
#endif
#if REA_SELECTOR_BIAS != 0
sub w8, w0, #REA_SELECTOR_BIAS
cmp w8, #5
b.cs Lsigned_default
ldrsh x6, [x5, w8, uxtw #1]
#else
cmp w0, #5
b.cs Lsigned_default
ldrsh x6, [x5, w0, uxtw #1]
#endif
cbz x6, Lsigned_null
adr x5, Lsigned_base
add x6, x5, x6
br x6
Lsigned_case0:
mov w0, #200
ret
Lsigned_case1:
mov w0, #201
// The base sits inside case 1 so that no real case has a zero offset.
Lsigned_base:
ret
Lsigned_case2:
mov w0, #202
ret
Lsigned_case4:
mov w0, #204
ret
Lsigned_null:
mov w0, #250
ret
Lsigned_default:
mov w0, #-1
ret
#if !defined(__APPLE__)
.size rea_relative_switch_signed, .-rea_relative_switch_signed
#endif
#if defined(__APPLE__)
.section __TEXT,__const
#else
.section .rodata
#endif
.p2align 1
Lsigned_table:
.short Lsigned_case0-Lsigned_base
.short Lsigned_case1-Lsigned_base
.short Lsigned_case2-Lsigned_base
.short 0
.short Lsigned_case4-Lsigned_base