The kameleon trick
Kameleon is the WebAssembly module that generates Snap's web attestation token. It's the single most important piece of the snapcap puzzle: if you can run kameleon and produce a token Snap's server wi…
Kameleon is the WebAssembly module that generates Snap's web attestation token. It's the single most important piece of the snapcap puzzle: if you can run kameleon and produce a token Snap's server will accept, the rest of the auth flow is plain HTTP. If you can't, nothing else matters.
This chapter is the long version of how that works.
What kameleon is
kameleon.wasm is an Emscripten build of a C++ library that fingerprints the browser environment and outputs a 1032-character signed token. It uses Embind, the Emscripten layer for exposing C++ classes to JavaScript, so the API surface looks like this on the JS side:
const Module = await KameleonFactory({ /* opts */ });
const session = Module.AttestationSession.instance();
const token = await session.finalize(username);AttestationSession.instance() is a C++ static method that returns a singleton. finalize is a member function that takes a username and returns a base64 string. Both calls are real C++ behind Embind's _emval_* glue.
The fingerprint payload is built from things kameleon reads on the JS side via Embind glue calls. We know exactly what they are because we instrumented every glue call. Among the highlights:
navigator.userAgentnavigator.webdriver,vendor,platform,connection,share,msSaveBlob,maxTouchPoints,serviceWorker,brave,languages,deviceMemory,hardwareConcurrencyscreen.availHeight,availWidth,colorDepthperformance.timing(and a check forperformance.memory, which is Chrome-only)Math,JSON,Object,Map,Intl,File,localStorage
It does not call any network APIs during finalize. The token is fully self-contained.
Locating the loader
The accounts bundle is one big webpacked file (_app-…js) plus a webpack runtime and a handful of numbered chunks. The kameleon Module factory is a webpack module inside _app. To find it:
$ grep -boE "kameleon\.wasm" _app-7ccf4584432ba8ad.js
3986655:kameleon.wasmWalking backward from byte 3,986,655 to the nearest <id>:function(e,t,n){ boundary lands on module 58116. That module is a 60 KB blob — the Emscripten JavaScript glue. Among its exports:
- 12 calls to
_embind_register_* - 24 different
_emval_*import references - The classic Emscripten runtime helpers (
FS_*,ASSERTIONS,ALLOC_NORMAL, …)
Its last line is e.exports = g, e.exports.default = g, where g is the Module factory function — exactly the shape Emscripten generates by default.
A separate module, 59855, is the higher-level wrapper Snap uses internally:
let m = (createModule = function (wasmUrl, page) {
return Module({
instantiateWasm: (imports, success) =>
WebAssembly.instantiateStreaming(fetch(wasmUrl), imports).then(success),
}).then((g) => {
g.BootstrapAttestationSessionRequest = d.BootstrapAttestationSessionRequest;
g.BootstrapAttestationSessionResponse = d.BootstrapAttestationSessionResponse;
g.Graphene = m;
g.page = n;
g.UAParserInstance = new c.UAParser();
g.version = u.BUILD_VERSION;
g.webAttestationServiceClientInstance = new f.WebAttestationServiceClient(u.ATTESTATION_SERVICE_HOSTNAME);
return g;
});
});That g decoration is the secret sauce. Without setting Graphene, page, version, UAParserInstance, and webAttestationServiceClientInstance on the Module object, kameleon will throw Cannot pass non-string to std::string the moment you call instance().
We don't use createModule directly — we call the factory ourselves and replicate the decoration. That gives us control over instantiateWasm (we inject local bytes instead of fetching) and lets us trace the Embind calls.
The Module options
The opts we pass to the kameleon factory:
const moduleEnv = {
// Inject local WASM bytes — bypasses any fetch.
instantiateWasm: (imports, success) => {
WebAssembly.instantiate(wasmBytes, imports).then((res) => {
success(res.instance, res.module);
});
return {};
},
// Suppress Emscripten chatter in stdout.
print: () => {},
printErr: () => {},
locateFile: (name) => name,
onAbort: (reason) => { throw new Error(`kameleon aborted: ${reason}`); },
// The decoration createModule would have done for us.
page: "www_login",
version: "4.0.3",
Graphene: { increment: () => {}, addTimer: () => {} },
UAParserInstance: new UAParser(),
webAttestationServiceClientInstance: new WebAttestationServiceClient("https://session.snapchat.com"),
};Two things to call out:
instantiateWasm is the escape hatch. Emscripten checks for it before doing its own fetch + instantiateStreaming. By implementing it, we keep the WASM load entirely off the wire — kameleon.wasm lives in vendor/ as a static file, we read it once at startup, and pass the bytes directly. No fetch://file:// shenanigans.
Graphene is a metrics stub. The real one is Snap's internal observability client. Kameleon calls Graphene.increment({ metricsName, dimensions }) and Graphene.addTimer({ metricsName, milliSec }) while it runs, but it doesn't read anything back. A no-op stub satisfies the API.
Why those decorations matter
When we first ran the factory without the decorations, calling AttestationSession.instance() threw Cannot pass non-string to std::string. That error is from Embind's std::string toWireType wrapper — it means the C++ side asked the JS side for a string and got something that wasn't one.
We instrumented every Embind glue function:
for (const name of [
"_emval_get_property",
"_emval_get_global",
"_emval_get_module_property",
"_emval_call_method",
"_emval_new_cstring",
]) {
const orig = imports.env[name];
imports.env[name] = (...args) => {
const r = orig(...args);
const decoded = readCString(args[0]); // walk the heap
console.log(`[trace] ${name}("${decoded}") → ${r}`);
return r;
};
}The first failing run produced this trace:
_emval_get_module_property("Graphene") → 2 ← undefined
_emval_get_global("localStorage") → 10
_emval_get_global("navigator") → 12
_emval_get_global("document") → 14
_emval_get_global("window") → 16
…
_emval_get_module_property("page") → 2 ← undefined
…
THROW: Cannot pass non-string to std::stringHandle 2 is Embind's pre-allocated handle for undefined. Kameleon was reading Module.Graphene and Module.page, getting undefined, then trying to use them as strings on the C++ side. Setting them on the Module fixed the throw immediately.
The same trick uncovered the other required props. After tracing finalize, we saw:
_emval_get_module_property("version") → 2 ← undefinedAdding version: "4.0.3" made finalize succeed.
This kind of diagnostic — wrap every Embind import, decode the C string args from the heap, watch what's read — is reusable for any other Snap WASM. It's how we mapped the chat-bundle WASM's required init order before we let the bundle drive Fidelius end-to-end through MessagingSession.
Calling finalize
With the Module booted and decorated, the call is straight Embind:
const AS = mod.AttestationSession;
const session = AS.instance();
const token = await session.finalize(username);AS.instance() returns the singleton. session.finalize(username) takes a single string (the username, email, or phone the user is attempting to log in with) and returns a Promise of the 1032-char token.
What the token actually is
We don't have the C++ source, but two things are observable:
- Length is invariant. Every call returns exactly 1032 base64 characters. That's a fixed-size encrypted blob, not a JSON envelope. No matter what fingerprint values are read, the output length doesn't change.
- It's bound to the username. Different usernames produce different tokens; the same username produces a different token every call (so it's salted with a timestamp or random nonce inside).
Snap's server-side validation presumably decrypts the blob, verifies the embedded fingerprint passes a "looks like Chrome 147" model, and rejects anything that doesn't. Empirically, our happy-dom shim plus Linux Chrome 147 user agent passes — the very first run from Node was accepted with no fingerprint tweaking.
The full snapcap kameleon module
Here's the entire bootKameleon flow in one place. Note: the globalThis referenced inside the patched bundle source is the sandbox-realm globalThis, not the host's. Bundle source is eval'd via sandbox.runInContext(src), and sandbox.getGlobal("__snapcap_p") reads the value back from that same realm.
import { Sandbox } from "../shims/sandbox.ts";
import { installWebpackCapture } from "../shims/webpack-capture.ts";
async function bootKameleonOnce(): Promise<KameleonContext> {
const sandbox = new Sandbox({ url: "https://accounts.snapchat.com/" });
installWebpackCapture(sandbox);
// 1. Eval the accounts bundle in canonical Next.js order — inside the sandbox.
for (const file of accountsFilesInOrder) {
let src = readFileSync(file, "utf8");
if (file.startsWith("webpack-")) {
// 2. Source-patch the runtime IIFE to leak `p` to (sandbox-realm) globalThis.
src = src.replace("p.m=s,p.amdO={}", "globalThis.__snapcap_p=p,p.m=s,p.amdO={}");
}
// Wrap with newlines around src — bundle ends in a //# sourceMappingURL line
// comment with no trailing newline, which would otherwise eat the IIFE close.
sandbox.runInContext(
"(function(module, exports, require) {\n" + src + "\n})({ exports: {} }, {}, () => { throw new Error('require not available'); });",
file,
);
}
// The patched runtime stashed `p` on the sandbox-realm globalThis.
const wreq = sandbox.getGlobal<any>("__snapcap_p");
// 3. Force-require module 58116 (kameleon factory).
const factory = wreq("58116").default;
// 4. Pull supporting deps from the bundle.
const UAParser = wreq("40243").UAParser;
const grpcMod = wreq("94631");
// 5. Boot the Module with our decorations + injected WASM.
const mod = await factory({
instantiateWasm: (imports, success) => {
WebAssembly.instantiate(wasmBytes, imports).then((r) => success(r.instance, r.module));
return {};
},
page: "www_login",
version: "4.0.3",
Graphene: { increment: () => {}, addTimer: () => {} },
UAParserInstance: new UAParser(),
webAttestationServiceClientInstance: new grpcMod.WebAttestationServiceClient("https://session.snapchat.com"),
print: () => {},
printErr: () => {},
onAbort: (r) => { throw new Error(`kameleon aborted: ${r}`); },
locateFile: (n) => n,
});
// 6. Expose the AttestationSession class.
return {
finalize: async (username: string) =>
await mod.AttestationSession.instance().finalize(username),
};
}Every numbered step has a non-obvious failure mode. (3) won't work without (2) — the webpack runtime keeps __webpack_require__ private. (5) won't run without (4). (5) won't return without instantiateWasm skipping fetch. Get them all in the right order and the rest of the SDK is straightforward HTTP.
What you actually call
In the SDK, none of this is exposed. You write:
const client = new SnapcapClient({ dataStore, browser, credentials });
await client.authenticate();
const friends = await client.friends.list();Internally, the kameleon Module is booted lazily on the first cold-start authenticate() call (when no restored cookies are in the DataStore) and cached on the per-instance Sandbox. Each SnapcapClient boots its own kameleon Module — there is no process-wide singleton. The Module mints different tokens for different users because finalize(username) rebinds per call.
Persistence model
Every persistent piece of state the SDK and the Snap bundle care about lands in a single DataStore. This is the key map and the reasoning behind it.
The webpack runtime patch
To run Snap's bundle from Node, we have to call into individual webpack modules by id. That sounds simple — webpack has a __webpack_require__ function that does exactly that.