fix(frida): share exact JSON string byte accounting
Count the serialized JSON representation at its owner, including escaped lone UTF-16 surrogates. Frida now retains the correct bounded prefix and reports truncation in script results and subsequent session Evidence. Move the existing exact managed string counter into the domain layer and use it for Frida, managed projections and Ghidra inline PDB caveats. Remove the superseded counters while keeping Ghidra's bounded length guards and NativeAOT's distinct raw-byte admission before string allocation. Validated with 296 focused tests, check, docs and exact test discovery. The Unicode oracle checks every UTF-16 code unit and adjacent fragments against native JSON encoding. Frida execution uses simulated native handles through the real MCP handlers/client; real target instrumentation is unverified. Fixes #5097 Co-authored-by: Prabhuram Karki <prabhuramkarki07@gmail.com>
This commit is contained in:
1 parent
4cbcead46c
commit
435efe7afc
8 files changed
+191
-91
No files matched your search
@@ -10,6 +10,7 @@ import type {
|
||||
Session,
|
||||
Signal,
|
||||
} from "frida";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
@@ -1252,6 +1253,70 @@ it("bounds oversized Frida message metadata before retaining it", async () => {
|
||||
await manager.closeAll();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "ASCII at the limit",
|
||||
input: "x".repeat(32_768),
|
||||
retained: "x".repeat(32_768),
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
name: "a trailing unpaired high surrogate",
|
||||
input: "x".repeat(32_765) + "\ud800",
|
||||
retained: "x".repeat(32_765),
|
||||
truncated: true,
|
||||
},
|
||||
{
|
||||
name: "a trailing unpaired low surrogate",
|
||||
input: "x".repeat(32_765) + "\udc00",
|
||||
retained: "x".repeat(32_765),
|
||||
truncated: true,
|
||||
},
|
||||
{
|
||||
name: "a valid surrogate pair at the limit",
|
||||
input: "x".repeat(32_764) + "\ud800\udc00",
|
||||
retained: "x".repeat(32_764) + "\ud800\udc00",
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
name: "repeated unpaired high surrogates",
|
||||
input: "\ud800".repeat(10_922),
|
||||
retained: "\ud800".repeat(5_461),
|
||||
truncated: true,
|
||||
},
|
||||
])(
|
||||
"bounds JSON metadata bytes for $name",
|
||||
async ({ input, retained, truncated }) => {
|
||||
const { bindings, manager, started } = await startLocalAttachedSession();
|
||||
onTestFinished(() => manager.closeAll());
|
||||
if (!started.ok) throw started.error;
|
||||
bindings.device.session.nextMessage = {
|
||||
type: "error",
|
||||
description: input,
|
||||
stack: input,
|
||||
fileName: input,
|
||||
};
|
||||
const loaded = await manager.loadScript(started.value.sessionId, {
|
||||
sourceKind: "inline",
|
||||
source: "// inert fixture",
|
||||
});
|
||||
if (!loaded.ok) throw loaded.error;
|
||||
const message = jsonObjectSchema.parse(loaded.value.messages[0]);
|
||||
for (const field of ["description", "stack", "file_name"]) {
|
||||
expect(
|
||||
Buffer.byteLength(JSON.stringify(message[field])) - 2,
|
||||
).toBeLessThanOrEqual(32_768);
|
||||
expect(message[field]).toBe(retained);
|
||||
expect(message[`${field}_truncated`] ?? false).toBe(truncated);
|
||||
}
|
||||
expect(loaded.value.messagesTruncated).toBe(truncated);
|
||||
expect(manager.status(started.value.sessionId)).toMatchObject({
|
||||
messages: [message],
|
||||
messagesTruncated: truncated,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("serializes script loading against concurrent close and unloads the loaded script", async () => {
|
||||
const bindings = new FakeDeviceManager();
|
||||
let finishScriptLoad: (() => void) | undefined;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { jsonStringByteLength } from "./jsonStringByteLength.js";
|
||||
|
||||
it("matches native JSON encoding for every individual UTF-16 code unit", () => {
|
||||
const mismatches: number[] = [];
|
||||
for (let unit = 0; unit <= 0xffff; unit += 1) {
|
||||
const value = String.fromCharCode(unit);
|
||||
if (
|
||||
jsonStringByteLength(value) !== Buffer.byteLength(JSON.stringify(value))
|
||||
)
|
||||
mismatches.push(unit);
|
||||
}
|
||||
expect(mismatches).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts adjacent surrogates, escapes and multibyte text as native JSON encodes them", () => {
|
||||
const fragments = [
|
||||
"",
|
||||
"a",
|
||||
"\u0000",
|
||||
"\b\t\n\f\r",
|
||||
'"\\',
|
||||
"é",
|
||||
"€",
|
||||
"\ud7ff",
|
||||
"\ue000",
|
||||
"\ud800",
|
||||
"\udbff",
|
||||
"\udc00",
|
||||
"\udfff",
|
||||
"😀",
|
||||
];
|
||||
const mismatches: string[] = [];
|
||||
for (const left of fragments)
|
||||
for (const right of fragments) {
|
||||
const value = left + right;
|
||||
if (
|
||||
jsonStringByteLength(value) !== Buffer.byteLength(JSON.stringify(value))
|
||||
)
|
||||
mismatches.push(value);
|
||||
}
|
||||
expect(mismatches).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Exact UTF-8 bytes of a JSON string literal, counted without serializing it. */
|
||||
export const jsonStringByteLength = (value: string): number => {
|
||||
let bytes = 2;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit === 0x22 || unit === 0x5c) bytes += 2;
|
||||
else if (unit < 0x20)
|
||||
bytes +=
|
||||
unit === 0x08 ||
|
||||
unit === 0x09 ||
|
||||
unit === 0x0a ||
|
||||
unit === 0x0c ||
|
||||
unit === 0x0d
|
||||
? 2
|
||||
: 6;
|
||||
else if (unit < 0x80) bytes += 1;
|
||||
else if (unit < 0x800) bytes += 2;
|
||||
else if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index++;
|
||||
} else bytes += 6;
|
||||
} else if (unit >= 0xdc00 && unit <= 0xdfff) bytes += 6;
|
||||
else bytes += 3;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
|
||||
|
||||
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
||||
import { managedFailure } from "./ManagedReaderFailure.js";
|
||||
|
||||
@@ -124,32 +126,10 @@ export const managedDecodeBudget = (
|
||||
return budget;
|
||||
};
|
||||
|
||||
/** Exact UTF-8 bytes of a JSON string literal, counted without serializing it. */
|
||||
export const managedJsonStringBytes = (value: string): number => {
|
||||
let bytes = 2;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit === 0x22 || unit === 0x5c) bytes += 2;
|
||||
else if (unit < 0x20)
|
||||
bytes += [0x08, 0x09, 0x0a, 0x0c, 0x0d].includes(unit) ? 2 : 6;
|
||||
else if (unit < 0x80) bytes += 1;
|
||||
else if (unit < 0x800) bytes += 2;
|
||||
else if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index++;
|
||||
} else bytes += 6;
|
||||
} else if (unit >= 0xdc00 && unit <= 0xdfff) bytes += 6;
|
||||
else bytes += 3;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** Exact UTF-8 JSON bytes of a decoded scalar or key, including escapes. */
|
||||
const jsonBytes = (item: unknown): number =>
|
||||
typeof item === "string"
|
||||
? managedJsonStringBytes(item)
|
||||
? jsonStringByteLength(item)
|
||||
: Buffer.byteLength(JSON.stringify(item) ?? "null");
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
|
||||
import {
|
||||
admitManagedProjection,
|
||||
managedDecodeBudget,
|
||||
managedJsonStringBytes,
|
||||
type ManagedDecodeBudget,
|
||||
} from "./ManagedDecodeBudget.js";
|
||||
import type { ManagedPeLayout } from "./ManagedPeReader.js";
|
||||
@@ -481,9 +481,9 @@ const callerName = (method: ManagedMethod): string =>
|
||||
/** JSON bytes of `callerName`, from its parts: the joining dot adds one byte. */
|
||||
const callerNameBytes = (method: ManagedMethod): number =>
|
||||
method.declaring_type === null
|
||||
? managedJsonStringBytes(method.name)
|
||||
: managedJsonStringBytes(method.declaring_type) +
|
||||
managedJsonStringBytes(method.name) -
|
||||
? jsonStringByteLength(method.name)
|
||||
: jsonStringByteLength(method.declaring_type) +
|
||||
jsonStringByteLength(method.name) -
|
||||
1;
|
||||
|
||||
const budgetedSignature = (
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
unknownFridaScript,
|
||||
@@ -1233,7 +1234,7 @@ const boundedRedactedString = (
|
||||
}
|
||||
if (nextSecretStart === cursor && nextSecret !== undefined) {
|
||||
const replacement = "[redacted]";
|
||||
const replacementBytes = jsonStringBytes(replacement) - 2;
|
||||
const replacementBytes = jsonStringByteLength(replacement) - 2;
|
||||
if (outputBytes + replacementBytes > maximumBytes) {
|
||||
truncated = true;
|
||||
break;
|
||||
@@ -1248,7 +1249,7 @@ const boundedRedactedString = (
|
||||
const codePoint = input.codePointAt(cursor);
|
||||
if (codePoint === undefined) break;
|
||||
const character = String.fromCodePoint(codePoint);
|
||||
const characterBytes = jsonStringBytes(character) - 2;
|
||||
const characterBytes = jsonStringByteLength(character) - 2;
|
||||
if (
|
||||
cursor + character.length > nextSecretStart ||
|
||||
outputBytes + characterBytes > maximumBytes
|
||||
@@ -1343,7 +1344,7 @@ const boundedJsonValue = (
|
||||
break;
|
||||
}
|
||||
const key = boundedKey.value;
|
||||
const keyBytes = jsonStringBytes(key) + 1;
|
||||
const keyBytes = jsonStringByteLength(key) + 1;
|
||||
const remaining = budget - usedBytes - separatorBytes - keyBytes - 1;
|
||||
if (remaining < 8) {
|
||||
truncated = true;
|
||||
@@ -1378,40 +1379,6 @@ const boundedJsonValue = (
|
||||
: { ...result, bytes };
|
||||
};
|
||||
|
||||
const jsonStringBytes = (value: string): number => {
|
||||
let bytes = Buffer.byteLength(value) + 2;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (
|
||||
code === 34 ||
|
||||
code === 92 ||
|
||||
code === 8 ||
|
||||
code === 9 ||
|
||||
code === 10 ||
|
||||
code === 12 ||
|
||||
code === 13
|
||||
)
|
||||
bytes += 1;
|
||||
else if (code < 32) bytes += 5;
|
||||
else if (
|
||||
code >= 0xd800 &&
|
||||
code <= 0xdbff &&
|
||||
(value.charCodeAt(index + 1) < 0xdc00 ||
|
||||
value.charCodeAt(index + 1) > 0xdfff)
|
||||
)
|
||||
bytes += 3;
|
||||
else if (
|
||||
code >= 0xdc00 &&
|
||||
code <= 0xdfff &&
|
||||
(index === 0 ||
|
||||
value.charCodeAt(index - 1) < 0xd800 ||
|
||||
value.charCodeAt(index - 1) > 0xdbff)
|
||||
)
|
||||
bytes += 3;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const remoteOptions = (remote: FridaRemoteConnection): RemoteDeviceOptions => ({
|
||||
...(remote.token === undefined ? {} : { token: remote.token }),
|
||||
...(remote.certificate === undefined
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
|
||||
import type {
|
||||
BinaryTarget,
|
||||
PeCodeViewRecord,
|
||||
@@ -46,32 +47,7 @@ const reportedPdbMatchesDeclaration = (
|
||||
const pdbLocatorFitsInlineBudget = (value: string): boolean => {
|
||||
if (value.length * 6 + 2 <= PDB_LOCATOR_INLINE_BYTE_BUDGET) return true;
|
||||
if (value.length + 2 > PDB_LOCATOR_INLINE_BYTE_BUDGET) return false;
|
||||
let bytes = 2;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const point = value.codePointAt(index) ?? 0;
|
||||
if (point > 0xffff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else if (point >= 0xd800 && point <= 0xdfff) {
|
||||
// JSON.stringify escapes unpaired UTF-16 surrogates as six ASCII bytes.
|
||||
bytes += 6;
|
||||
} else if (point < 0x20) {
|
||||
bytes +=
|
||||
point === 8 ||
|
||||
point === 9 ||
|
||||
point === 10 ||
|
||||
point === 12 ||
|
||||
point === 13
|
||||
? 2
|
||||
: 6;
|
||||
} else if (point === 0x22 || point === 0x5c) {
|
||||
bytes += 2;
|
||||
} else {
|
||||
bytes += point < 0x80 ? 1 : point < 0x800 ? 2 : 3;
|
||||
}
|
||||
if (bytes > PDB_LOCATOR_INLINE_BYTE_BUDGET) return false;
|
||||
}
|
||||
return true;
|
||||
return jsonStringByteLength(value) <= PDB_LOCATOR_INLINE_BYTE_BUDGET;
|
||||
};
|
||||
|
||||
/** Use reported Program facts first, retaining declared PE facts as a fallback. */
|
||||
|
||||
@@ -76,6 +76,7 @@ const nativeFixture = () => {
|
||||
resumes: 0,
|
||||
remoteRemovals: 0,
|
||||
removalFailures: 0,
|
||||
message: undefined as FridaMessage | undefined,
|
||||
};
|
||||
let detached = false;
|
||||
const nativeSession: Session = {
|
||||
@@ -112,7 +113,10 @@ const nativeFixture = () => {
|
||||
async load() {
|
||||
await controls.load();
|
||||
loaded.push(source);
|
||||
listener?.({ type: "send", payload: source }, null);
|
||||
listener?.(
|
||||
controls.message ?? { type: "send", payload: source },
|
||||
null,
|
||||
);
|
||||
controls.afterLoad();
|
||||
},
|
||||
async unload() {
|
||||
@@ -231,6 +235,42 @@ const connectedFixture = async () => {
|
||||
return { ...native, manager, session, server, client, start, load };
|
||||
};
|
||||
|
||||
it.each([
|
||||
["a trailing high surrogate", "x".repeat(32_765) + "\ud800"],
|
||||
["repeated high surrogates", "\ud800".repeat(10_922)],
|
||||
])(
|
||||
"retains bounded Frida error text through MCP for %s",
|
||||
async (_name, description) => {
|
||||
const { controls, client, start, load } = await connectedFixture();
|
||||
controls.message = { type: "error", description };
|
||||
const sessionId = await start();
|
||||
const response = await load(sessionId, "// inert fixture");
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = toolContract("load_frida_script").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
const result = evidence.normalized_result;
|
||||
expect(result.messages_truncated).toBe(true);
|
||||
const retained = jsonObjectSchema.parse(result.messages[0]);
|
||||
expect(retained.description_truncated).toBe(true);
|
||||
expect(
|
||||
Buffer.byteLength(JSON.stringify(retained.description)) - 2,
|
||||
).toBeLessThanOrEqual(32_768);
|
||||
const status = await client.callTool({
|
||||
name: "frida_session_status",
|
||||
arguments: { session_id: sessionId },
|
||||
});
|
||||
expect(status.isError).not.toBe(true);
|
||||
expect(
|
||||
toolContract("frida_session_status").outputSchema.parse(
|
||||
status.structuredContent,
|
||||
),
|
||||
).toMatchObject({
|
||||
normalized_result: { messages: [retained], messages_truncated: true },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("declares non-destructive session mutation when status retains Evidence", async () => {
|
||||
const { client, start } = await connectedFixture();
|
||||
const id = await start();
|
||||
|
||||
Reference in new issue
Block a user