mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-21 20:28:24 +08:00
fix(stealth): address security review findings
1. HIGH — Function.toString Map exfiltration: Replaced Map with WeakMap + bound methods. A malicious page could monkeypatch Map.prototype.has to capture the override store, then use it to cloak malicious functions as [native code]. WeakMap with pre-bound has/get methods prevents this side-channel. 2. MEDIUM — Static GPU fingerprint: Default GPU renderer now randomly selects from 5 common Apple chip variants (M1, M1 Pro, M1 Max, M2, M3) per session. Prevents sites from building a static GStack-specific fingerprint signature. 3. Tests updated: 54 total (35 unit + 19 e2e), 0 failures. Added tests for WeakMap usage and GPU randomization.
This commit is contained in:
@@ -53,8 +53,17 @@ export async function applyStealthPatches(
|
|||||||
gpuVendor?: string;
|
gpuVendor?: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Default GPU strings match common real-world Mac hardware.
|
||||||
|
// Vary slightly across sessions to avoid creating a static fingerprint.
|
||||||
|
const gpuVariants = [
|
||||||
|
'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)',
|
||||||
|
'ANGLE (Apple, Apple M2, OpenGL 4.1)',
|
||||||
|
'ANGLE (Apple, Apple M1, OpenGL 4.1)',
|
||||||
|
'ANGLE (Apple, Apple M3, OpenGL 4.1)',
|
||||||
|
'ANGLE (Apple, Apple M1 Max, OpenGL 4.1)',
|
||||||
|
];
|
||||||
const gpuVendor = options?.gpuVendor ?? 'Google Inc. (Apple)';
|
const gpuVendor = options?.gpuVendor ?? 'Google Inc. (Apple)';
|
||||||
const gpuRenderer = options?.gpuRenderer ?? 'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)';
|
const gpuRenderer = options?.gpuRenderer ?? gpuVariants[Math.floor(Math.random() * gpuVariants.length)];
|
||||||
|
|
||||||
await context.addInitScript(
|
await context.addInitScript(
|
||||||
([vendor, renderer]: [string, string]) => {
|
([vendor, renderer]: [string, string]) => {
|
||||||
@@ -259,11 +268,18 @@ export async function applyStealthPatches(
|
|||||||
// 9. FUNCTION toString PROTECTION
|
// 9. FUNCTION toString PROTECTION
|
||||||
// ========================================
|
// ========================================
|
||||||
// Make overridden functions look native to .toString() checks.
|
// Make overridden functions look native to .toString() checks.
|
||||||
|
// SECURITY: Use a WeakMap with a frozen lookup to prevent malicious pages
|
||||||
|
// from exfiltrating the map via Map.prototype.has/get monkeypatching.
|
||||||
|
// WeakMap doesn't iterate and can't be fully leaked via prototype hooks.
|
||||||
const nativeStr = Function.prototype.toString;
|
const nativeStr = Function.prototype.toString;
|
||||||
const overrides = new Map<Function, string>();
|
const overrides = new WeakMap<Function, string>();
|
||||||
|
// Freeze a reference to the original WeakMap methods before any page
|
||||||
|
// script can monkeypatch them.
|
||||||
|
const wmHas = WeakMap.prototype.has.bind(overrides);
|
||||||
|
const wmGet = WeakMap.prototype.get.bind(overrides);
|
||||||
|
|
||||||
Function.prototype.toString = function () {
|
Function.prototype.toString = function () {
|
||||||
if (overrides.has(this)) return overrides.get(this)!;
|
if (wmHas(this)) return wmGet(this)!;
|
||||||
return nativeStr.call(this);
|
return nativeStr.call(this);
|
||||||
};
|
};
|
||||||
overrides.set(Function.prototype.toString, 'function toString() { [native code] }');
|
overrides.set(Function.prototype.toString, 'function toString() { [native code] }');
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe('stealth e2e — fingerprint verification', () => {
|
|||||||
expect(vendor).not.toContain('SwiftShader');
|
expect(vendor).not.toContain('SwiftShader');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WebGL renderer is spoofed to Apple M1 Pro', async () => {
|
test('WebGL renderer is spoofed to an Apple chip', async () => {
|
||||||
const renderer = await page.evaluate(() => {
|
const renderer = await page.evaluate(() => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const gl = canvas.getContext('webgl');
|
const gl = canvas.getContext('webgl');
|
||||||
@@ -75,7 +75,7 @@ describe('stealth e2e — fingerprint verification', () => {
|
|||||||
return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
|
return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
|
||||||
});
|
});
|
||||||
expect(renderer).toBeTruthy();
|
expect(renderer).toBeTruthy();
|
||||||
expect(renderer).toContain('Apple M1 Pro');
|
expect(renderer).toMatch(/Apple.*M[123]/);
|
||||||
expect(renderer).not.toContain('SwiftShader');
|
expect(renderer).not.toContain('SwiftShader');
|
||||||
expect(renderer).not.toContain('llvmpipe');
|
expect(renderer).not.toContain('llvmpipe');
|
||||||
});
|
});
|
||||||
@@ -91,7 +91,7 @@ describe('stealth e2e — fingerprint verification', () => {
|
|||||||
});
|
});
|
||||||
// WebGL2 might not be available in all environments
|
// WebGL2 might not be available in all environments
|
||||||
if (renderer !== null) {
|
if (renderer !== null) {
|
||||||
expect(renderer).toContain('Apple M1 Pro');
|
expect(renderer).toMatch(/Apple.*M[123]/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ describe('init script coverage', () => {
|
|||||||
expect(source).toContain('[native code]');
|
expect(source).toContain('[native code]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('uses WeakMap (not Map) for toString overrides to prevent exfiltration', () => {
|
||||||
|
// Security: Map can be exfiltrated via Map.prototype.has monkeypatching.
|
||||||
|
// WeakMap with bound methods prevents this attack vector.
|
||||||
|
expect(source).toContain('new WeakMap');
|
||||||
|
expect(source).toContain('WeakMap.prototype.has.bind');
|
||||||
|
expect(source).toContain('WeakMap.prototype.get.bind');
|
||||||
|
// Must NOT use plain Map for the override store
|
||||||
|
expect(source).not.toMatch(/new Map[<(]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GPU renderer varies across sessions (anti-fingerprint)', () => {
|
||||||
|
expect(source).toContain('gpuVariants');
|
||||||
|
expect(source).toContain('Math.random');
|
||||||
|
});
|
||||||
|
|
||||||
test('handles mediaDevices for containers', () => {
|
test('handles mediaDevices for containers', () => {
|
||||||
expect(source).toContain('mediaDevices');
|
expect(source).toContain('mediaDevices');
|
||||||
expect(source).toContain('enumerateDevices');
|
expect(source).toContain('enumerateDevices');
|
||||||
@@ -184,7 +199,8 @@ describe('applyStealthPatches API', () => {
|
|||||||
await applyStealthPatches(mockContext);
|
await applyStealthPatches(mockContext);
|
||||||
const [vendor, renderer] = receivedArg as [string, string];
|
const [vendor, renderer] = receivedArg as [string, string];
|
||||||
expect(vendor).toContain('Apple');
|
expect(vendor).toContain('Apple');
|
||||||
expect(renderer).toContain('M1 Pro');
|
// Renderer varies across sessions but should always be an Apple chip
|
||||||
|
expect(renderer).toMatch(/Apple.*M[123]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('init script function is serializable (no closures over Node APIs)', async () => {
|
test('init script function is serializable (no closures over Node APIs)', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user