From 435efe7afcc6a286a9b450dbef682df67252ebf5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:50:50 +0800 Subject: [PATCH] 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 --- .../frida/FridaInstrumentationManager.test.ts | 65 +++++++++++++++++++ src/domain/jsonStringByteLength.test.ts | 44 +++++++++++++ src/domain/jsonStringByteLength.ts | 28 ++++++++ src/dotnet/ManagedDecodeBudget.ts | 26 +------- src/dotnet/ManagedMemberRows.ts | 8 +-- src/frida/FridaInstrumentationManager.ts | 41 ++---------- src/ghidra/GhidraPeProfile.ts | 28 +------- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 42 +++++++++++- 8 files changed, 191 insertions(+), 91 deletions(-) create mode 100644 src/domain/jsonStringByteLength.test.ts create mode 100644 src/domain/jsonStringByteLength.ts diff --git a/src/application/frida/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts index 030e8f942..0d178a840 100644 --- a/src/application/frida/FridaInstrumentationManager.test.ts +++ b/src/application/frida/FridaInstrumentationManager.test.ts @@ -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; diff --git a/src/domain/jsonStringByteLength.test.ts b/src/domain/jsonStringByteLength.test.ts new file mode 100644 index 000000000..0d3ca93c7 --- /dev/null +++ b/src/domain/jsonStringByteLength.test.ts @@ -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([]); +}); diff --git a/src/domain/jsonStringByteLength.ts b/src/domain/jsonStringByteLength.ts new file mode 100644 index 000000000..906a3869d --- /dev/null +++ b/src/domain/jsonStringByteLength.ts @@ -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; +}; diff --git a/src/dotnet/ManagedDecodeBudget.ts b/src/dotnet/ManagedDecodeBudget.ts index fba6229a2..aca84f254 100644 --- a/src/dotnet/ManagedDecodeBudget.ts +++ b/src/dotnet/ManagedDecodeBudget.ts @@ -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"); /** diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 1a5af8f0b..1e69d3d29 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -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 = ( diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 5056c9d46..f41d2807c 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -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 diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts index 7f60edb91..d85bc17cf 100644 --- a/src/ghidra/GhidraPeProfile.ts +++ b/src/ghidra/GhidraPeProfile.ts @@ -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. */ diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index 74c0c629e..d61ba68c1 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -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();