Inside Mimic: how Multilogin X masks canvas and WebGL, and how the masking gives itself away
Three days with an anti-detect browser, one memory watchpoint, and two masking engines that are keyed on opposite things. The result is a detector that needs no corpus and no baseline.
Two profiles in the same produced canvas hashes that differed from stock Chrome and from each other. Both were stable across restarts.
stability rules out per-read randomness, so whatever the engine does is a function of something it stores. I went looking for that function and came back with two, because canvas and WebGL in this build share no code and are keyed on opposite things.
The visual below runs the detector that came out of this work against the browser you are reading in, beside what the same probe returns on a masked Mimic profile.
reading this browser16,384 colour palette, one canvas round trip, 4,096 low-bit pairs
Reading
The same probe the detector runs, against the canvas in front of you.
Your browserMimic 150.4, masking on
Round trip with no drawing-0 px changed
Round trip after one path fill-4,838 px (29.53%)
Largest channel move-1 level
Alpha rewritten-0 px
Second read identical-yes
Colour pairs collapsing to one-0.254
WebGL readbackreading
- pixels off at 64 by 64- of that at nine times the area- of those positions survive a new image247, 1.05x and 0.047 on Mimic
Everything here is from Multilogin X 12.9.0 (EU), Mimic browser core 150.4, Chromium 150.0.7871.25, macOS arm64, analysed between 2026-08-03 and 2026-08-05. The addresses are file addresses in the bundled Chromium Framework binary, and they are build specific.
constants and the callers of the shared key derivation are what I scan for after a core update. They are also how the addresses were found in the first place.
What I did, and what is missing on purpose
I bought a Multilogin X licence, installed it on hardware I own, and analysed the client that was served to me. No account belonging to anyone else was involved, no server-side system was probed, and no key was recovered from anything other than my own installation.
Three categories of material are withheld from this writeup on purpose.
Key material. The local adapter serves each profile's spoofing parameters as an encrypted blob under symmetric key material embedded in the adapter binary. That key material, its locations, and any decryptor for it stay unpublished. The blob was internal ground truth during the investigation, and every finding below was confirmed against the running browser without it.
Verbatim machine code. Disassembly listings are replaced with descriptions of what the code does. Where the shape of a loop matters, it appears as pseudocode.
Tooling. The debugger harnesses, probe pages and detector implementations stay unreleased. The recipes here are complete enough to reproduce the work.
One path fill arms the whole canvas
Before attaching a debugger I ran the same page under a masked profile and under a masking-off profile on the same machine, and diffed the readback.
Two controls earned their extra runs. Launching the same profile twice gave byte-identical output, so the target is deterministic and conclusions from a single pair hold. Using a masking-off profile rather than a different browser held the GPU and the font stack constant.
Then I isolated by canvas operation. That is what collapsed the search space.
Readback identical
- Blank canvas
- Flat fill
- Two-tone rectangle
- Gradient
- Shadowed rectangle
- putImageData on its own
- Text, then clearRect, then refill
- Any region far from the drawing
Readback rewritten
- Path fill
- Path stroke
- fillText
- strokeText
- drawImage of a canvas that had text on itdeltas identical to the source
One path or glyph arms the whole surface
putImageData on its own comes back clean. drawImage of a canvas that already had text on it reproduces the source deltas exactly, and a region far from the drawing is untouched only until something is drawn anywhere, at which point the whole surface is in play.I read that table wrong, and it cost me the better part of a day. Clean
putImageData plus faithful drawImage deltas says the hook sits at raster time rather than at readback. Both observations are equally consistent with a readback hook that is armed by whether the canvas ever received a path draw.Two places can lie to you about pixels. The painter can paint them slightly wrong, or the clerk can alter them on the way out when you ask for a copy. The evidence above looks like a lying painter and is in fact a lying clerk, who only starts lying once the canvas has had a real drawing on it.
An operation matrix tells you what arms a transform, never where the transform runs, and I have leaned on that twice since. Do not commit to a mechanism before the debugger confirms it.
The spoofing values fall out of the profile id
ps -Ewww on the browser process shows no fingerprint values at all. There is a local client port, a session name, a port whitelist, and the usual screen and language switches. Grepping the framework for canvas_noise or hw_concurrency returns nothing, because those strings are built at runtime.The adapter is a Go binary, which is a gift. Route strings survive, and they describe a small local HTTP API keyed on profile UUID: start, close, and a handful of status and logging endpoints. The start endpoint answers with roughly 91KB of base64.
Decrypting it locally gave 30 keys of ground truth for every spoofed value on a live profile, which removed the guessing about which parameter feeds what:
canvas_1, canvas_2, gl_xshift, gl_yshift, gl_xscale, gl_yscale, plus screen, fonts, timezone and media device counts.Those six graphic values turned out to be derived from the profile UUID by one small function. Pick a parameter below and the characters it reads light up in the id.
7c1f4a02-9d3e-4b58-8a61-2f0c5e7d9b34
c[23] + c[12] + c[8] mod 7
2
2 of 3 positions are always a dash, worth a constant 45
Indices 8, 13, 18 and 23 are always a dash, so they contribute a constant 45.
canvas_1 therefore depends on a single variable character, and the whole space is 2835 classes, with canvas_1 == 4 three times rarer than its neighbours. All six matched the decrypted parameters exactly for the profile under test.That is the sort of thing a detector would love, and it is also the largest unresolved thread in the target. I never found a consumer for any of the six on either rendering path.
Static analysis found nothing, a watchpoint found it in an hour
Three approaches failed here, and the reasons generalise.
Diffing the framework against a same-branch stock Chromium build gave 0.772 similarity across the whole binary, because register allocation differs everywhere in an independently compiled tree. The signal I wanted sat below the noise floor of that comparison.
Guessing the instruction family failed too. I assumed the perturbation was saturating byte arithmetic and counted
uqadd and uqsub sites. The count matched stock Chromium exactly, which is correct and useless: it says the patch is not there, not where it is.Function-boundary recovery is unreliable on this target. Chromium frameworks ship no
LC_FUNCTION_STARTS, and the __unwind_info fallback undercounts badly, producing 27249 "functions" with 75KB ranges over a 240MB binary.The move that worked was to stop looking for code and start watching a byte pattern I chose. Prime a canvas through the DevTools protocol with a distinctive fill, find that fill in writable memory, arm a write on it, then trigger the drawing from a second shell and read the backtraces in order.
c17f3bffc17f3bff
Copy on write into the working buffer
c17f3bff3b7fc1ff
The BGRA to RGBA swizzle
3b7fc1ff3b7ec0ff
The write that changes the answer
Copy on write into the working buffer, then the BGRA to RGBA swizzle, then one write that changes the low bit of two channels. That third stop is at readback, not at raster, which is exactly the conclusion the operation matrix had talked me out of.
Three details make this harder than it should be, and each fails silently rather than erroring. Skia is BGRA on macOS, so
rgb(59,127,193) lives in the surface as c17f3bff and in an ImageData copy as 3b7fc1ff. Search for the RGBA order and you spend an hour watching the V8 heap copy. A getImageData(0,0,1,1) call before scanning is load bearing, because without a read first, copy on write relocates the buffer after you arm and the watchpoint never fires again.Two lldb behaviours belong in the same list. Batch mode runs
continue asynchronously, so every command after it executes against a running process and prints nothing, which synchronous mode fixes. And lldb block buffers when stdout is not a tty, so redirecting to a file hides all output until exit. Run it under a pty. Watchpoints also report the program counter after the storing instruction, so on arm64 the store is four bytes earlier than what you read.The lookup table at the store site is only a cache
Sliding the reported address back into the framework and disassembling around it showed a
std::lower_bound over a sorted array of 8-byte entries, each a 32-bit key and a 32-bit value, binary searched per pixel, exact match only, pixel untouched on a miss.Read literally, that says Mimic ships a colour substitution table per profile class. I wrote a detector on that basis: open the gate with a throwaway path,
putImageData a known 2298-colour palette, compare against a stored per-class table. It worked. It also needed a corpus for all 2835 classes and would have broken on the next core update.Widening the disassembly window from the store site to the whole enclosing function exposed the mistake. The function holds two loops. The first fills the map by calling a helper once per distinct key and writing the result into the entry's value. The second skips the map entirely for buffers over
0x4000 bytes and calls the same helper per pixel.What the sorted map actually is
A cache
The mechanism is one function, transform(pixel, ctx) to pixel. The table only memoises it for small buffers.
The apply site of a transform shows the shape of the output and nothing about whether you are looking at the mechanism or at an optimisation of it. That distinction only lives in the enclosing function. Disassemble the caller.
Every rewrite is one bit, and four rules decide who gets it
The transform is a leaf function with no stack frame, which is why a prologue-based boundary walk misses it. Its behaviour is short enough to state in full:
alpha != 0xFF -> unchanged
((R+1)|(G+1)|(B+1)) & 0xFF < 2 -> unchanged
max(R,G,B) - min(R,G,B) < 3 -> unchanged
no channel has (c mod 17) in [2,15] -> unchanged
h_i = splitmix64((ctx ^ ((c_i >> 1) << 8)) ^ i) for i = 0,1,2 over R,G,B
if (h_i & 0xC0) == 0: c_i = (c_i & 0xFE) | (h_i & 1)
Pick a colour and walk it through the same four rules and the same hash. The output swatch is what a masked profile would hand back.
#3b7fc1R 59 · G 127 · B 193
- Every channel at 0x00 or 0xff(R+1)|(G+1)|(B+1) = 0xfepasses
- Within three levels of greyspread = 134passes
- Repeated hex nibbles in every channelmod 17 = 8, 8, 6passes
- splitmix64 per channel, keyed on c >> 1R substitutes · G substitutes · B holdsone in four per channel
#3a7ec1
R 59 to 58, G 127 to 126
Its neighbour one level down#3b7fc1 and #3a7fc1both return #3a7ec1
The three exclusions are, in order: every channel fully saturated at
0x00 or 0xFF, near grey within three levels, and colours whose channels are all repeated hex nibbles such as 0x33, 0x66 and 0xCC. That last one is a strange choice. It exempts most of the CSS named-colour palette and most of what a designer types by hand, which reads like a concession to avoid visible artefacts on ordinary pages. I never confirmed that, and it might be a cheap way to skip flat UI fills.The whole detector rests on two consequences of that shape.
Only bit 0 of a channel ever changes, so every delta is 0 or 1 and alpha is never touched. And the hash is keyed on
c >> 1, so a colour and its neighbour one level down produce the same output whenever the substitution applies, which is exactly one time in four per channel.Masking a colour by rounding it to a nearby colour destroys information. Two inputs come out as one output, and nothing downstream can tell which one it started as. A real graphics stack never does this, because a renderer that quietly merged colours would be a broken renderer.
ctx is not derivable from the binary. I read it at runtime instead, breaking at the transform entry and taking the second argument, then breaking one function earlier and reading the string at the first. It is the page origin, hashed with a session id into a 32-byte digest whose first 8 bytes become ctx. Loading the same probe page on http://127.0.0.1:8975 and on http://localhost:8975 in one session produced different maps.That kills the per-class table idea for good, and it hands over a second detector free. A real browser produces identical canvas output on two origins. Mimic does not.
I do not trust a recovered algorithm until it reproduces a capture exactly, because anything short of that means something is still guessed.
Reimplementation against a captured masked readback
16348 / 16348
Changed count 4838 predicted against 4838 observed. The 36 pixel shortfall is the gate-opening triangle, which the harness excludes.
WebGL spends a fixed budget of bit flips
Canvas and WebGL share nothing here. A caller enumeration settles it in seconds. The canvas transform has two callers, both canvas, and neither it nor its seed builder appears anywhere on the GL path.
The shared primitive is a 32-byte key derivation with exactly three real call sites in the whole framework. One is canvas, so the other two are GL. Arming all three and running a GL blit showed which fires first. Reading the arguments at the hit gave the profile UUID as a 36-character string, the framebuffer itself, and the 32-byte output.
The consumer loop, read from disassembly and not independently validated:
for byte_index in 0..32:
b = digest[byte_index]
for k in 0..16:
if k % 8 == 0: reload b
channel = state mod 3
pixel = state mod pixel_count
buf[pixel*4 + channel] ^= (b & 1)
b >>= 1
state = (state >> 1) | feedback
512 XOR operations, each flipping one low bit with probability one half, never alpha. That predicts about 256 corrupted pixels regardless of how large the readback is, which a single probe can falsify.
247
261
244
Sixteen times the pixels, the same few hundred flipped bits. The loop spends a fixed budget of 512 XORs and stops.
A ninefold increase in area moves the count by 5%. No per-pixel probability behaves that way. And because the seed eats the framebuffer, changing the image relocates every corrupted position: the overlap between the corrupted index sets of two different palettes is 0.047.
Think of a fixed number of darts thrown at a wall. Make the wall four times bigger and you do not get more holes, you get the same holes further apart. Noise that is decided per pixel behaves the opposite way, and that difference is measurable in a single page load.
The two surfaces are keyed on opposite things
Canvas noise is a function of where the page came from. WebGL noise ignores origin entirely and keys on who the profile is and what was drawn. Change one input at a time and the two designs separate cleanly.
Two inverse designs, in one binary, presumably written by different people or at different times. For a defender that asymmetry is useful, because a single page can vary both inputs and watch which readback moves.
The neighbours of a hook are usually the other hooks
Once a hook is located, the functions next to it in the binary are usually the sibling hooks, since a patch set compiles into one object file and links contiguously. I walked the region around the canvas transform in three passes: split on prologue plus
ret, unioning in every intra-region call target because leaf functions have no prologue, scan the whole text segment for callers of each entry, then resolve the Chromium assertion and mojo file-path strings near each caller to name the surface.| entry | size | surface | how identified |
|---|---|---|---|
0x62E2D68 | 0x2c8 | canvas noise driver | calls the key derivation, holds the seed builder |
0x62E3030 | 0x1bc | canvas transform | per-pixel splitmix, no stack frame |
0x62E25D8 | 0x790 | WebGL noise | calls the key derivation twice, holds the XOR loop |
0x62E245C | 0x0e8 | WebRTC mDNS | 2 callers in mdns_responder_adapter.cc, no key derivation |
0x62E22A0 | 0x1bc | WebRTC P2P socket | 4 callers in ipc_socket_factory.cc, no key derivation |
0x62E31EC | 0x414 | masked-string unmasker | XOR 0x2e init over a __bss page, no calls |
0x62E1C00 | 0x2d8 | stock double math | IEEE-754 inf and NaN handling, indirect callers only |
0x62E21D8 and three neighbours | 0x30 | stock deleters | four wrappers on one runtime routine |
The region is a mixed compilation unit, not a pure patch blob. A caller in
reverb_convolver.cc first read to me as an audio hook and is a shared floating point add with nine unrelated audio-node callers. Assuming every function in a patch neighbourhood is a patch produces confident nonsense, and I nearly published an audio noise hook that adds two numbers.The census also produced the most useful negative of the exercise. The key derivation has three real call sites, canvas once and WebGL twice, so digest noise in this build is canvas and WebGL only, binary wide. Whatever the audio masking does, it uses a different primitive and does not live here. The WebRTC entries are proxy routing rather than noise, and they are inert without a proxy configured, which the black-box probe confirms: the real WAN address leaks via srflx in both masked and natural modes.
Six checks, and Brave leaves at the first
The first detector I shipped needed a per-class corpus of substitution tables. The second one needs nothing: no corpus, no host baseline, no knowledge of
ctx or the profile. Every check is a structural consequence of the algorithm, and the order matters as much as the checks.1Does a round trip with no drawing change pixels?Brave leaves here, farbling needs no gate
2Does one path fill change them?Safari, Arc and Mimic with masking off leave here
3Do all the moves fit in bit 0, with alpha untouched?iOS Safari dithering leaves here
4Are the three exclusions respected?
5Does a second read match the first?
6Do colours one level apart collapse near one in four?
Mimic detected0.254 measured against 0.25 predicted, no corpus, no baseline
The last check is the strongest single signal in either surface. A conformant renderer never maps two distinct colours onto one, and a perturbation-based defence has no reason to produce a collision rate of exactly one in four. Measured on Mimic class 2685: gated, delta 1, zero changes above bit 0, zero alpha changes, zero gate violations, deterministic, collapse rate 0.254 against a predicted 0.250.
The GL detector tests the budget rather than the fraction. Its decisive check is that the corrupted count barely moves across a ninefold area change, and its second is that the corrupted positions relocate when the image changes. Measured on class 2325: detected, count ratio 1.05 against nine times the area.
Same page, same machine, 2026-08-05:
| build | canvas verdict | collapse rate |
|---|---|---|
| Mimic, canvas noise on (class 2685) | Mimic detected | 0.254 |
| Mimic, canvas noise off (class 2773) | clean | zero changed pixels |
| Brave | perturbation, not gated | not reached |
| Safari | clean | not applicable |
| Arc | clean | not applicable |
perturbs a
putImageData round trip with no drawing operation at all, where Mimic needs a path or a glyph first. That is a cheaper discriminator than anything downstream, so the gate test runs first and short circuits. Get the ordering wrong and Brave stays a candidate for two more checks, which is how a privacy-conscious real user turns into a false positive.The masking-off control earns its run too. It confirms the detector keys on the noise rather than on the browser build: a Mimic profile with masking off reads as clean, which is correct, because there is nothing to detect at that surface.
Both detectors return a specific negative verdict rather than a boolean. "Perturbation, not gated" and "noise, scales with area" are different browsers, not weaker evidence of the same one.
What shipped into our own telemetry
In our own stack this became a telemetry probe rather than a live page check, because we already collect a canvas readback. The probe rebuilds a palette from a seeded generator and sends the observed readback plus a baseline pass with the gate left closed. The backend confirms the shape offline: the baseline changes zero pixels, forward and reversed palette layouts agree colour for colour, every rewrite preserves the top seven bits of the channel, and alpha is untouched.
The reversed-layout check separates a colour-keyed substitution from a position-keyed perturbation without modelling the key at all, and it rules out Brave a second time, since farbling is position dependent.
One threshold exists purely because of iOS. Capping the confirmed channel delta at one level is what stops iOS 18.7 Safari from matching, because it dithers roughly 82% of the palette by one to three levels. A confirmed colour mapping with a maximum delta above one is Safari, not a substitution table.