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:
morlutoandPrabhuram Karki committed 2026-10-19 17:50:50 +08:00
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;
+44
View File
@@ -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([]);
});
+28
View File
@@ -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;
};
+3 -23
View File
@@ -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");
/**
+4 -4
View File
@@ -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 = (
+4 -37
View File
@@ -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
+2 -26
View File
@@ -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. */
+41 -1
View File
@@ -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();