From 4c33f6d95eadbf09811f0289f73736d705a860e7 Mon Sep 17 00:00:00 2001 From: seffignoz Date: Sat, 11 Jul 2026 10:07:22 -0400 Subject: [PATCH] fix(stealth): hide internal globals from reflection and mark builtins native (#377) * fix(stealth): hide internal globals from reflection and mark builtins native Two anti-fingerprinting leaks let pixelscan's bot-check flag obscura as automated even with --stealth: - unusualWindowProperties: internal globals (_fp, _wrap, __obscura_*, ...) were only made non-enumerable, so they stayed hidden from Object.keys and for-in but not from Object.getOwnPropertyNames / Reflect.ownKeys, which fingerprinting scripts use. Filter __obscura_hide_list out of the reflection APIs (getOwnPropertyNames, ownKeys, keys, getOwnPropertyDescriptors) when they target the global object. - tamperedFunctions: DOM/Web platform members reimplemented in JS leaked their source via toString(), e.g. Element.prototype.nodeType returned "get nodeType() {...}" instead of "[native code]". Per-member _markNative calls covered methods but missed property accessors and several constructors. Sweep every builtin constructor reachable from the global object at snapshot build time and mark its prototype methods, accessors, and the constructor itself native. Also name the Function.prototype.toString override so it reports the correct native form for itself. Verified against pixelscan.net/bot-check: Webdriver -> Clear (tamperedFunctions and unusualWindowProperties both Clear). * fix(stealth): build hide list with getOwnPropertyNames __obscura_hide_list was built with Object.keys, which omits the internal globals that _preHideInternals already made non-enumerable. Those names were therefore missing from the list and stayed visible to the reflection-API filter and to fingerprinting scripts (e.g. __obscura_ua, _registerIframe). Use getOwnPropertyNames so the list is complete. --- crates/obscura-js/js/bootstrap.js | 104 +++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index c91f673..8182f50 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -70,16 +70,72 @@ globalThis.dispatchEvent = function(event) { const _dom = (cmd, a1, a2) => Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? "")); const _nativeFns = new Set(); +// Exact toString override for members whose native form is not just +// `function ()`, e.g. accessors (`function get x() { [native code] }`) +// or functions whose `.name` does not match the real builtin. +const _nativeStr = new Map(); const _origToString = Function.prototype.toString; -Function.prototype.toString = function() { +Function.prototype.toString = function toString() { + if (_nativeStr.has(this)) { return _nativeStr.get(this); } if (_nativeFns.has(this)) { return `function ${this.name || ''}() { [native code] }`; } return _origToString.call(this); }; function _markNative(fn) { if (typeof fn === 'function') _nativeFns.add(fn); return fn; } +// Mark a function with an exact native-code toString (used for accessors). +function _markNativeAs(fn, str) { if (typeof fn === 'function') _nativeStr.set(fn, str); return fn; } _nativeFns.add(Function.prototype.toString); +// unusualWindowProperties: obscura's internal globals are made non-enumerable +// (see _preHideInternals and __obscura_init), which hides them from +// Object.keys / for-in. But fingerprinting scripts enumerate the global object +// with Object.getOwnPropertyNames and Reflect.ownKeys, which return +// non-enumerable properties too, so the internals still leak (pixelscan's +// unusualWindowProperties check). Filter the engine's own globals out of the +// reflection APIs when they target the global object. The canonical name set is +// __obscura_hide_list, precomputed at snapshot-build time; referencing it lazily +// means the list is already populated by the time any page calls these. +(function _hideInternalsFromReflection() { + var _cache = null, _cacheLen = -1; + function _set() { + var list = globalThis.__obscura_hide_list; + if (!list) { return null; } + if (_cache && _cacheLen === list.length) { return _cache; } + _cache = new Set(list); + _cache.add('__obscura_hide_list'); + _cacheLen = list.length; + return _cache; + } + function _isGlobal(t) { return t === globalThis; } + function _filter(t, names) { + if (!_isGlobal(t)) { return names; } + var set = _set(); + if (!set) { return names; } + var out = []; + for (var i = 0; i < names.length; i++) { if (!set.has(names[i])) { out.push(names[i]); } } + return out; + } + var _oGOPN = Object.getOwnPropertyNames; + var _oOwnKeys = Reflect.ownKeys; + var _oKeys = Object.keys; + var _oGOPDs = Object.getOwnPropertyDescriptors; + function define(obj, prop, impl) { + try { Object.defineProperty(obj, prop, { value: _markNative(impl), writable: true, enumerable: false, configurable: true }); } catch (e) {} + } + define(Object, 'getOwnPropertyNames', function getOwnPropertyNames(t) { return _filter(t, _oGOPN(t)); }); + define(Reflect, 'ownKeys', function ownKeys(t) { return _filter(t, _oOwnKeys(t)); }); + define(Object, 'keys', function keys(t) { return _filter(t, _oKeys(t)); }); + define(Object, 'getOwnPropertyDescriptors', function getOwnPropertyDescriptors(t) { + var all = _oGOPDs(t); + if (_isGlobal(t)) { + var set = _set(); + if (set) { var ks = _oGOPN(all); for (var i = 0; i < ks.length; i++) { if (set.has(ks[i])) { delete all[ks[i]]; } } } + } + return all; + }); +})(); + [Error, TypeError, ReferenceError, SyntaxError, RangeError, URIError, EvalError].forEach(E => { try { Object.defineProperty(E.prototype, 'name', { @@ -7572,7 +7628,11 @@ globalThis.__obscura_init = function() { // globals defined by bootstrap that we want to hide and stashes them // for __obscura_init to consume on every subsequent page. The snapshot // preserves the array as a regular global. -globalThis.__obscura_hide_list = Object.keys(globalThis).filter(k => +// Use getOwnPropertyNames, not Object.keys: the internal globals declared by +// _preHideInternals are already non-enumerable, so Object.keys would omit them +// and leave them out of the hide list (and thus visible to the reflection-API +// filter and to fingerprinting scripts). getOwnPropertyNames captures them. +globalThis.__obscura_hide_list = Object.getOwnPropertyNames(globalThis).filter(k => k.startsWith('_') || k.includes('obscura') || k.includes('Obscura') ); @@ -8040,3 +8100,43 @@ if (typeof Response !== 'undefined' && Response.prototype && !Response.prototype // arrayBuffer is the body primitive that blob/text/json derive from; the // engine's Response provides it natively, so it is intentionally not shimmed // here (a JS fallback could only recurse into itself). + +// tamperedFunctions: obscura reimplements much of the DOM/Web platform in JS. +// Real Chrome reports "[native code]" from toString() for every builtin method, +// accessor, and constructor; any JS-backed member that leaks its source is a +// detection tell (pixelscan's tamperedFunctions check flags e.g. +// Element.prototype.nodeType, whose getter returned "get nodeType() {...}"). +// Individual _markNative calls throughout this file cover methods but miss the +// property accessors and several constructors. Sweep every builtin constructor +// reachable from the global object and mark its prototype members (methods and +// accessors) plus the constructor itself native. This runs once at snapshot +// build time, so it costs nothing per page, and genuinely-native V8 builtins +// already report native, so only the JS-backed members are affected. +(function _markBuiltinsNative() { + var seen = new Set(); + function walk(ctor) { + if (typeof ctor !== 'function') { return; } + _markNative(ctor); + var proto = ctor.prototype; + if (!proto || seen.has(proto)) { return; } + seen.add(proto); + var keys = Object.getOwnPropertyNames(proto); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var d; + try { d = Object.getOwnPropertyDescriptor(proto, key); } catch (e) { continue; } + if (!d) { continue; } + if (typeof d.value === 'function') { _markNative(d.value); } + if (typeof d.get === 'function') { _markNativeAs(d.get, 'function get ' + key + '() { [native code] }'); } + if (typeof d.set === 'function') { _markNativeAs(d.set, 'function set ' + key + '() { [native code] }'); } + } + } + var names = Object.getOwnPropertyNames(globalThis); + for (var i = 0; i < names.length; i++) { + var name = names[i]; + if (!/^[A-Z]/.test(name)) { continue; } + var val; + try { val = globalThis[name]; } catch (e) { continue; } + if (typeof val === 'function') { walk(val); } + } +})();