Google Knitsail and SG_SS: Generation Logic and Its Role in Distinguishing Automated from Human-Like Traffic
Abstract
SG_SS is not a plain hash and not a conventional signature token. More precisely, it is a packaged binary telemetry frame produced by Google's Knitsail VM execution path: an embedded VM is first decrypted and executed, runtime signals are collected into internal buffers, the main payload is assembled, a header and random bytes are added, and the result is finally base64url-encoded with a leading *.
The key question is not how opaque the final string looks. The real question is what the VM reads before the string is produced. On this execution path, the emphasis is not on identifying who the user is. The emphasis is on deciding whether the current runtime looks like a real browser executing naturally inside a real page. That is enough to support anti-automation decisions, and it also creates clear device-fingerprint and environment-fingerprint implications.
I. SG_SS is not a simple hash or signature
Structurally, SG_SS is not any of the following:
SHA256(data)HMAC(key, data)RSA.sign(data)JSON.stringify(payload)
It is much closer to this pipeline:
function buildSgSs(p, env) {
const decoded = base64Decode(p.slice(3));
const key = readU32BE(decoded, 0);
const encryptedBytecode = decoded.slice(4);
const bytecode = decryptBytecode(encryptedBytecode, key);
const vmState = runVm(bytecode, env);
const main447 = buildMainChannel(vmState);
const framed = finalize(main447);
return "*" + base64urlEncode(framed);
}
In other words, the core of SG_SS is not “apply one cryptographic algorithm to a blob of data.” The core is “execute a dedicated VM program and encode aspects of the runtime environment into an output frame.”
II. What data exists before SG_SS is computed
1. The p parameter contains program material, not user payload
The body of p can be split into two parts:
const decoded = base64Decode(p.slice(3));
const key = readU32BE(decoded, 0);
const encryptedBytecode = decoded.slice(4);
The bytecode is then recovered with an 8-byte keystream generated block by block:
function decryptBytecode(encryptedBytes, key) {
const out = new Uint8Array(encryptedBytes.length);
for (let block = 0; block * 8 < encryptedBytes.length; block++) {
const stream = xjKeystream(14, key, block, 1104, [0, 0, 0, 0]);
for (let i = 0; i < 8 && block * 8 + i < encryptedBytes.length; i++) {
out[block * 8 + i] = encryptedBytes[block * 8 + i] ^ stream[i];
}
}
return out;
}
This matters because p is not the “message to be signed.” It is the program body that defines what to sample and how to encode it.
2. The actual inputs are runtime environment signals
Once the VM starts, it reads values from the host environment. The most consistently observed signals are:
performance.now()performance.timingdocument.readyStatewindow.trustedTypes
Beyond that, the differential experiments around navigator, screen, and location suggest that the VM is sensitive to a broader browser profile, something like:
const browserProfile = {
navigator: {
userAgent,
language,
languages,
platform,
hardwareConcurrency,
deviceMemory,
pluginsLength,
mimeTypesLength,
cookieEnabled,
onLine,
},
screen: {
width,
height,
colorDepth,
availWidth,
availHeight,
},
location: {
href,
},
timing: {
now,
timing,
},
documentState: {
readyState,
trustedTypes,
}
};
Two separate points need to be kept apart.
First, the VM is clearly consuming this class of environment data.
Second, “changing a field changed the output” is not yet the same as “that field is stably serialized into the final frame,” because the output also contains random bytes.
III. The core algorithmic path
1. Xj: the same primitive is used for bytecode decryption and channel obfuscation
Xj is a 14-round ARX-style keystream generator. Reduced to a readable form, it looks like this:
function xjKeystream(rounds, r, o, z, d) {
let u = d[2] | 0;
let y = d[3] | 0;
for (let i = 0; i < rounds; i++) {
o = (o >>> 8) | (o << 24);
o = (o + r) | 0;
y = (y >>> 8) | (y << 24);
o ^= (u + z) | 0;
r = (r << 3) | (r >>> 29);
r ^= o;
y = (y + u) | 0;
y ^= (i + z) | 0;
u = (u << 3) | (u >>> 29);
u ^= y;
}
return [
(r >>> 24) & 255, (r >>> 16) & 255, (r >>> 8) & 255, r & 255,
(o >>> 24) & 255, (o >>> 16) & 255, (o >>> 8) & 255, o & 255,
];
}
This is not a browser-provided codec. It is custom logic written specifically for this VM and its output channels.
2. The VM reads a bitstream rather than plain sequential bytes
The instruction reader is effectively a bitstream parser:
function readBits(bitCount, state, encrypted) {
let pos = state.pcBits;
let out = 0;
while (bitCount > 0) {
const byteIndex = pos >> 3;
const bitOffset = pos & 7;
const available = 8 - bitOffset;
const take = Math.min(available, bitCount);
let byte = state.lf[byteIndex];
if (encrypted) {
if (state.currentBlock !== (pos >> 6)) {
state.currentBlock = pos >> 6;
state.stream = xjKeystream(14, state.K, state.currentBlock, 1104, [0, 0, seed1, seed2]);
}
byte ^= state.stream[byteIndex & state.mask];
}
const part = (byte >> (8 - bitOffset - take)) & ((1 << take) - 1);
out |= part << (bitCount - take);
bitCount -= take;
pos += take;
}
state.pcBits = pos;
return out;
}
That design mixes opcodes, immediates, indexes, and branch targets into a compressed bitstream, which raises the reverse-engineering cost.
3. The main loop is a standard VM dispatcher
The dispatch loop can be expressed like this:
function runLoop(state, fuel) {
if (state.stopped) return;
state.depth++;
try {
const endPc = state.programEnd;
while (--fuel) {
let op;
if (state.pendingQueue) {
op = shiftPending(state.pendingQueue, state);
} else {
if (state.pcBits >= endPc) break;
const slot = decodeNextSlot(state);
op = state.slots[slot];
}
if (op && (op.flags & 2048)) {
op(state, fuel);
} else {
handleVmError(state, ["vm_error", 21, slot]);
}
}
} catch (err) {
handleVmError(state, err);
}
state.depth--;
}
So SG_SS is not template assembly. It is the output of a full virtual machine execution.
4. Intermediate values are written into multiple channels
The VM does not write the final result directly. It first accumulates data into several internal channels:
function writeBytes(bytes, channelId, state, prefixByte) {
const channel = getChannel(state, channelId);
const push = isObfuscatedChannel(channelId)
? (b) => channel.push(b ^ nextChannelKeystreamByte(channel))
: (b) => channel.push(b);
if (prefixByte !== undefined) push(prefixByte & 0xff);
for (const b of bytes) push(b);
}
The important detail is that channels such as 273, 348, and 135 are not stored in plain form. Their bytes are XOR-obfuscated again on write.
That means some parts of the final SG_SS string are already a second-order packaging of prior VM output.
5. The last step is only base64url
The final encoding itself is straightforward:
function base64urlEncode(bytes) {
let s = "";
for (let i = 0; i < bytes.length; i += chunkSize) {
s += String.fromCharCode.apply(null, bytes.slice(i, i + chunkSize));
}
return btoa(s)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
So the last step is not where the real complexity lives. The meaningful part is the frame content before encoding.
IV. Why SG_SS changes even in the same environment
Because the packaging stage explicitly introduces randomness.
The random byte generator is very direct:
function randomBytes(n) {
const out = [];
while (n--) out.push((Math.random() * 255) | 0);
return out;
}
The final framing logic is approximately:
function finalize(main447) {
const q = toBytesBE16(main447.length + 2);
const K = randomBytes(2).concat(main447);
K[1] = K[0] ^ 60;
K[3] = K[1] ^ q[0];
K[4] = K[1] ^ q[1];
return "*" + base64urlEncode(K);
}
Additional random padding may also be appended:
if (paddingLength > 0) {
writeBytes(toBytesBE16(paddingLength).concat(randomBytes(paddingLength)), 447, state, 53);
}
As a result, at least four kinds of bytes are mixed together in the final output:
- actual environment-derived data or its transforms
- structural bytes
- length-related bytes
- random header bytes and random padding
That is why the final string cannot be read as a clean plaintext field list.
V. How this mechanism distinguishes automated from human-like traffic
This question needs precise wording.
The most accurate statement is not “the code directly identifies a human.” The more accurate statement is:
it first evaluates whether the current runtime looks like a natural execution inside a real browser, and that environment-authenticity signal is then useful for anti-automation decisions.
In practical terms, this mechanism is strongest against:
- low-quality automation
- stripped or headless environments
- Node-based simulation
- spoofed environments whose fields do not fit together
It is not a magic “human detector.”
1. It checks whether timing looks browser-like
The clearest signal is performance.now().
This is not about human reaction time. It is about the micro-timing texture of the VM execution itself.
What this really answers is:
- does this execution resemble a real browser?
- does the event loop behave naturally?
- does the clock pattern look synthetic?
The logic can be expressed like this:
function timingLooksBrowserLike(samples) {
return (
samples.count > threshold &&
samples.jitterInExpectedRange &&
samples.distributionIsNotTooFlat &&
samples.distributionIsNotTooSynthetic
);
}
Real browser timing is shaped by JIT behavior, DOM access cost, task scheduling, page state, and main-thread load. That naturally creates small irregularities. Many automated environments can fake static properties, but not a long, coherent stream of realistic execution timing.
2. It checks whether the page lifecycle looks natural
document.readyState is consistently read.
That means the VM cares about whether it is running at a plausible point in the page lifecycle.
function lifecycleLooksNormal(document) {
return document.readyState === "complete" || document.readyState === "interactive";
}
A stripped-down or oddly injected environment often gets lifecycle details wrong.
3. It checks whether platform capabilities match the claimed browser
The read of window.trustedTypes is a strong hint that the code is interested in platform capability signals rather than content alone.
function platformLooksConsistent(windowObj) {
return typeof windowObj.trustedTypes !== "undefined";
}
A value like this is not personal data, but it is useful for spotting simulated or incomplete environments. Lightweight runtimes and patched environments often expose the wrong capability surface.
4. It likely relies on profile consistency rather than any single field
The differential behavior around navigator, screen, and location strongly suggests that the VM is interested in a coherent browser profile, not a single isolated value.
The decision logic is more like this:
function profileLooksConsistent(env) {
if (env.uaClaimsDesktopChrome && env.screen.width < 500) return false;
if (env.platform === "Win32" && env.languageSetLooksMobileOnly) return false;
if (env.deviceMemoryTooSmall && env.uaClaimsHighEndDesktop) return false;
if (env.hardwareConcurrencyLooksFake && env.timingLooksSynthetic) return false;
return true;
}
So the mechanism is not “read one field and identify a human.” It is “test whether a collection of environment traits forms a believable whole.”
5. Why this helps separate machine traffic from ordinary browser traffic
Because much automated traffic does not run inside a truly natural browser environment. Common cases include:
- Node scripts
- headless browsers
- cloud browser fleets
- lightweight VM-based environments
- browsers heavily modified by spoofing patches
Typical weaknesses of those environments are:
- timing that is too flat, too rigid, or too clean
- abnormal page lifecycle state
- missing platform features
- contradictions between UA, screen, hardware, and locale data
Taken together, the system is effectively doing something like:
function scoreEnvironment(env) {
let score = 0;
if (!timingLooksBrowserLike(env.timing)) score += 2;
if (!lifecycleLooksNormal(env.document)) score += 1;
if (!platformLooksConsistent(env.window)) score += 1;
if (!profileLooksConsistent(env)) score += 2;
return score;
}
A high score does not prove “this user is not human.” More accurately, it means “this execution environment does not look like a natural browser environment associated with ordinary user traffic.”
6. Why patched Node.js environments are still easy to detect
The main problem is usually not “one property was missing.” The main problem is that a browser environment is not just a bag of key-value pairs. It is a full runtime with correlated semantics.
A typical patched Node.js environment looks something like this:
global.window = global;
global.document = { readyState: "complete" };
global.navigator = { userAgent: "Mozilla/5.0 ..." };
global.performance = { now: () => 123.456, timing: {} };
global.screen = { width: 1920, height: 1080 };
That kind of patch can make the surface look present, but it does not make the environment behave like a browser.
A VM like the one behind SG_SS is not limited to checking whether a property exists. It can also observe whether the objects behave like real browser host objects while the program is running.
The first mismatch is object semantics. In a real browser, window, document, navigator, location, and screen are host objects with specific prototype chains, property descriptors, enumerability rules, method binding behavior, and toString output. Plain objects, proxies, and quick polyfills in Node.js often diverge on those details.
The second mismatch is lifecycle behavior. Setting document.readyState = "complete" is easy, but in a real page that value evolves together with DOMContentLoaded, load, listener registration, and task-queue behavior. Replacing one final value is not the same as reproducing the page lifecycle.
The third mismatch is timing texture. In a real browser, performance.now() is shaped by JIT behavior, DOM calls, event-loop scheduling, and main-thread load. The output has natural jitter. In Node.js, patched implementations often return fixed increments, fixed steps, or overly smooth sequences. That becomes obvious when sampled repeatedly.
The fourth mismatch is cross-object consistency. Values such as window.location.href, document.location.href, page state, UA, screen dimensions, and platform capabilities need to fit together. Patched Node.js environments are often assembled one field at a time, which produces an internally inconsistent profile.
The fifth mismatch is incomplete side effects. Real browser APIs do not just “return a value.” They also bring exception types, stack formats, object identity rules, event ordering, iterability behavior, and host-object boundaries. A patched function in Node.js can often fake a return value, but not the whole behavioral chain around it.
That difference can be summarized like this:
function looksBrowserLike(env) {
return (
hasExpectedValues(env) &&
hasExpectedTimingTexture(env) &&
hasExpectedLifecycle(env) &&
hasExpectedPrototypeGraph(env) &&
hasExpectedCrossObjectConsistency(env)
);
}
So a patched Node.js environment is easy to detect not because it forgot one field such as navigator.userAgent, but because a browser is a coherent execution model. Faking a few visible properties is easy; matching consistency, timing, and behavior at the same time is not.
7. Why this still should not be described as direct human identification
Because it measures the environment before it measures the person.
An automated system driving a real Chrome instance on a real device can look very close to normal traffic.
Conversely, a real user on a cloud desktop, a VM, a remote browser, or an enterprise-managed browser with deep instrumentation may look unusual.
So what this mechanism provides is:
- an environment-authenticity signal
- a stronger automation suspicion score
- a way to recognize abnormal execution contexts
It does not, by itself, amount to a standalone human-authentication system.
VI. Privacy implications
From a privacy perspective, the main issue is not whether the system directly reveals a person’s name, email, or phone number. The more important issue is that it supports two capabilities.
1. Increased distinguishability
If a bundle of environment traits is stable enough, it can separate one device or one class of runtime from the broader population.
That has privacy significance on its own because it increases the ability to single out a visitor.
2. Increased linkability
Even without traditional cookies, repeated exposure of similar environment structure, timing behavior, and framing patterns makes it easier to associate multiple sessions with the same device or runtime class.
That risk is best described as:
- strengthened device fingerprinting
- strengthened environment fingerprinting
- stronger session linkage
rather than direct identity disclosure.
VII. Conclusion
Taken as a whole, the nature of SG_SS is fairly clear:
SG_SSis the output of a custom VM-based browser telemetry pipeline.
The VM is decrypted and executed, runtime signals such as timing, page state, and platform capabilities are read, intermediate values are written into internal channels, and the final frame is assembled with a random header and random padding before beingbase64url-encoded.
Its immediate purpose is not to identify a human being directly. Its purpose is to decide whether the execution context looks like a natural browser environment and to use that signal to improve detection of automation, emulation, and other abnormal runtimes.
From a privacy standpoint, it clearly has device-fingerprinting and environment-fingerprinting significance.