From 80b084a9b3f51adbbe6d6f15263302834496f282 Mon Sep 17 00:00:00 2001 From: daijro Date: Wed, 2 Oct 2024 21:15:46 -0500 Subject: [PATCH] feat: Add human-like cursor movement & cursor highlighter #19 beta.10 All info in README. --- README.md | 36 +++- additions/camoucfg/MouseTrajectories.hpp | 230 ++++++++++++++++++++++ additions/camoucfg/moz.build | 2 +- additions/juggler/protocol/PageHandler.js | 37 +++- patches/chromeutil.patch | 57 +++++- patches/cursor-highlighter.patch | 36 ++++ settings/properties.json | 5 +- 7 files changed, 380 insertions(+), 23 deletions(-) create mode 100644 additions/camoucfg/MouseTrajectories.hpp create mode 100644 patches/cursor-highlighter.patch diff --git a/README.md b/README.md index 9a66f60..a8b28b4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Camoufox aims to be a minimalistic browser for robust fingerprint injection & an - Spoof properties of device, viewport, screen, WebGL, battery API, location, etc. ✅ - Font spoofing & anti-fingerprinting ✅ - WebRTC IP spoofing ✅ +- Human-like mouse movement 🖱️ - Blocks & circumvents ads 🛡️ - Debloated & optimized for memory and speed ⚡ - [PyPi package](https://pypi.org/project/camoufox/) for updates & auto fingerprint injection 📦 @@ -68,7 +69,7 @@ Camoufox is built on top of Firefox/Juggler instead of Chromium because: - Continue research on potential leaks - Remote hosting Camoufox as a Playwright server - Integrate into [hrequests](https://github.com/daijro/hrequests) -- Human-like typing & mouse movement +- Human-like typing & ~~mouse movement~~ ✔️ - WebGL fingerprint spoofing through ANGLE rendering - Create integration tests - Chromium port (long term) @@ -130,6 +131,34 @@ Camoufox will automatically add the following default fonts associated your spoo +
+ +Cursor movement + + +### Human-like Cursor movement + +Camoufox supports use human-like cursor movement. The natural motion algorithm was originally from [rifosnake's HumanCursor](https://github.com/riflosnake/HumanCursor), but has been rewritten in C++ and modified for more distance-aware trajectories. + +### Demo + + + +### Properties + +| Property | Supported | Description | +| ---------------- | --------- | ------------------------------------------------------------------- | +| humanize | ✅ | Enable/disable human-like cursor movement. Defaults to False. | +| humanize:maxTime | ✅ | Maximum time in seconds for the cursor movement. Defaults to `1.5`. | +| showcursor | ✅ | Show/hide the cursor. Defaults to True. | + +**Notes:** + +- Trajectories are capped at taking 1.5 seconds to complete. +- The cursor highlighter is **not** ran in the page context. It will not be visible to the page. You don't have to worry about it leaking. + +
+
Fonts @@ -483,7 +512,7 @@ Camoufox performs well against every major WAF I've tested. (Original test sites | **Font Fingerprinting** | ✔️ | | ‣ [Browserleaks Fonts](https://browserleaks.net/fonts) | ✔️ Rotates all metrics. | | ‣ [CreepJS TextMetrics](https://abrahamjuliot.github.io/creepjs/tests/fonts.html) | ✔️ Rotates all metrics. | -| [**Incolumitas**](https://bot.incolumitas.com/) | ✔️ 0.8-r1.0 | +| [**Incolumitas**](https://bot.incolumitas.com/) | ✔️ 0.8-1.0 | | [**SannySoft**](https://bot.sannysoft.com/) | ✔️ | | [**Fingerprint.com**](https://fingerprint.com/products/bot-detection/) | ✔️ | | [**IpHey**](https://iphey.com/) | ✔️ | @@ -741,4 +770,5 @@ Patches can be edited, created, removed, and managed through here. - [Jamir-boop/minimalisticfox](https://github.com/Jamir-boop/minimalisticfox) - Inspired Camoufox's minimalistic theming - [nicoth-in/Dark-Space-Theme](https://github.com/nicoth-in/Dark-Space-Theme) - Camoufox's dark theme - [Playwright](https://github.com/microsoft/playwright/tree/main/browser_patches/firefox), [Puppeteer/Juggler](https://github.com/puppeteer/juggler) - Original Juggler implementation -- [CreepJS](https://github.com/abrahamjuliot/creepjs), [Browserleaks](https://browserleaks.com) - Valuable leak testing sites +- [CreepJS](https://github.com/abrahamjuliot/creepjs), [Browserleaks](https://browserleaks.com), [BrowserScan](https://www.browserscan.net/) - Valuable leak testing sites +- [riflosnake/HumanCursor](https://github.com/riflosnake/HumanCursor) - Original human-like cursor movement algorithm diff --git a/additions/camoucfg/MouseTrajectories.hpp b/additions/camoucfg/MouseTrajectories.hpp new file mode 100644 index 0000000..9a69669 --- /dev/null +++ b/additions/camoucfg/MouseTrajectories.hpp @@ -0,0 +1,230 @@ +#include +#include +#include +#include +#include +#include +#include "MaskConfig.hpp" + +/** + * Human-like mouse movement generator. Ported from: + * https://github.com/riflosnake/HumanCursor/blob/main/humancursor/utilities/human_curve_generator.py + * Modified to use a more human-like easing function. + */ + +class BezierCalculator { + public: + static long long factorial(int n) { + if (n < 0) return -1; // Indicate error + long long result = 1; + for (int i = 2; i <= n; i++) result *= i; + return result; + } + + static double binomial(int n, int k) { + return static_cast(factorial(n)) / + (factorial(k) * factorial(n - k)); + } + + static double bernsteinPolynomialPoint(double x, int i, int n) { + return binomial(n, i) * std::pow(x, i) * std::pow(1 - x, n - i); + } + + static std::vector bernsteinPolynomial( + const std::vector>& points, double t) { + int n = static_cast(points.size()) - 1; + double x = 0.0; + double y = 0.0; + for (int i = 0; i <= n; i++) { + double bern = bernsteinPolynomialPoint(t, i, n); + x += points[i].first * bern; + y += points[i].second * bern; + } + return {x, y}; + } + + static std::vector> calculatePointsInCurve( + int nPoints, const std::vector>& points) { + std::vector> curvePoints; + for (int i = 0; i < nPoints; i++) { + double t = static_cast(i) / (nPoints - 1); + curvePoints.push_back(bernsteinPolynomial(points, t)); + } + return curvePoints; + } +}; + +class HumanizeMouseTrajectory { + public: + HumanizeMouseTrajectory(const std::pair& fromPoint, + const std::pair& toPoint) + : fromPoint(fromPoint), toPoint(toPoint) { + generateCurve(); + } + + std::vector getPoints() const { + std::vector flatPoints; + flatPoints.reserve(points.size() * 2); + + for (const auto& point : points) { + flatPoints.push_back(static_cast(std::round(point[0]))); + flatPoints.push_back(static_cast(std::round(point[1]))); + } + + return flatPoints; + } + + private: + std::pair fromPoint; + std::pair toPoint; + std::vector> points; + + void generateCurve() { + double leftBoundary = std::min(fromPoint.first, toPoint.first) - 80.0; + double rightBoundary = std::max(fromPoint.first, toPoint.first) + 80.0; + double downBoundary = std::min(fromPoint.second, toPoint.second) - 80.0; + double upBoundary = std::max(fromPoint.second, toPoint.second) + 80.0; + + std::vector> internalKnots = + generateInternalKnots(leftBoundary, rightBoundary, downBoundary, + upBoundary, 2); + + std::vector> curvePoints = + generatePoints(internalKnots); + curvePoints = distortPoints(curvePoints, 1.0, 1.0, 0.5); + points = tweenPoints(curvePoints); + } + + double easeOutQuad(double n) const { + assert(n >= 0.0 && n <= 1.0 && "Argument must be between 0.0 and 1.0."); + return -n * (n - 2); + } + + std::vector> generateInternalKnots( + double lBoundary, double rBoundary, double dBoundary, double uBoundary, + int knotsCount) const { + assert(isNumeric(lBoundary) && isNumeric(rBoundary) && + isNumeric(dBoundary) && isNumeric(uBoundary) && + "Boundaries must be numeric values"); + assert(knotsCount >= 0 && "knotsCount must be non-negative"); + assert(lBoundary <= rBoundary && + "Left boundary must be less than or equal to right boundary"); + assert(dBoundary <= uBoundary && + "Down boundary must be less than or equal to upper boundary"); + + std::vector knotsX = + randomChoiceDoubles(lBoundary, rBoundary, knotsCount); + std::vector knotsY = + randomChoiceDoubles(dBoundary, uBoundary, knotsCount); + + std::vector> knots; + for (int i = 0; i < knotsCount; i++) { + knots.emplace_back(knotsX[i], knotsY[i]); + } + return knots; + } + + std::vector randomChoiceDoubles(double min, double max, + int size) const { + std::vector choices; + std::uniform_real_distribution dist(min, max); + for (int i = 0; i < size; i++) { + choices.push_back(dist(randomEngine)); + } + return choices; + } + + std::vector> generatePoints( + const std::vector>& knots) const { + assert(isListOfPoints(knots) && "Knots must be a valid list of points"); + int midPtsCnt = static_cast( + std::max({std::abs(fromPoint.first - toPoint.first), + std::abs(fromPoint.second - toPoint.second), 2.0})); + std::vector> controlPoints = knots; + controlPoints.insert(controlPoints.begin(), fromPoint); + controlPoints.push_back(toPoint); + return BezierCalculator::calculatePointsInCurve(midPtsCnt, controlPoints); + } + + std::vector> distortPoints( + const std::vector>& points, double distortionMean, + double distortionStDev, double distortionFrequency) const { + assert(isNumeric(distortionMean) && isNumeric(distortionStDev) && + isNumeric(distortionFrequency) && "Distortions must be numeric"); + assert(isListOfPoints(points) && "Points must be a valid list of points"); + assert(0.0 <= distortionFrequency && distortionFrequency <= 1.0 && + "distortion_frequency must be in range [0,1]"); + + std::vector> distorted; + distorted.push_back(points.front()); + + std::normal_distribution normalDist(distortionMean, + distortionStDev); + std::uniform_real_distribution uniformDist(0.0, 1.0); + + for (size_t i = 1; i < points.size() - 1; i++) { + double x = points[i][0]; + double y = points[i][1]; + double delta = 0.0; + if (uniformDist(randomEngine) < distortionFrequency) { + delta = std::round(normalDist(randomEngine)); + } + distorted.push_back({x, y + delta}); + } + distorted.push_back(points.back()); + return distorted; + } + + int32_t getMaxTime() const { + if (auto maxTime = MaskConfig::GetDouble("humanize:maxTime")) { + return static_cast(maxTime.value() * 100); + } + return 150; + } + + std::vector> tweenPoints( + const std::vector>& points) const { + assert(isListOfPoints(points) && "List of points not valid"); + + double totalLength = 0.0; + for (size_t i = 1; i < points.size(); ++i) { + double dx = points[i][0] - points[i - 1][0]; + double dy = points[i][1] - points[i - 1][1]; + totalLength += std::sqrt(dx * dx + dy * dy); + } + + // Uses a power scale to keep the speed consistent + int targetPoints = std::min( + getMaxTime(), + std::max(2, static_cast(std::pow(totalLength, 0.25) * 20))); + + std::vector> res; + for (int i = 0; i < targetPoints; i++) { + double t = static_cast(i) / (targetPoints - 1); + double easedT = easeOutQuad(t); + int index = static_cast(easedT * (points.size() - 1)); + res.push_back(points[index]); + } + return res; + } + + bool isNumeric(double val) const { return !std::isnan(val); } + + bool isListOfPoints( + const std::vector>& points) const { + for (const auto& p : points) { + if (!isNumeric(p.first) || !isNumeric(p.second)) return false; + } + return true; + } + + bool isListOfPoints(const std::vector>& points) const { + for (const auto& p : points) { + if (p.size() != 2 || !isNumeric(p[0]) || !isNumeric(p[1])) return false; + } + return true; + } + + mutable std::default_random_engine randomEngine{ + static_cast(std::time(nullptr))}; +}; \ No newline at end of file diff --git a/additions/camoucfg/moz.build b/additions/camoucfg/moz.build index c7f06bf..3fe87d2 100644 --- a/additions/camoucfg/moz.build +++ b/additions/camoucfg/moz.build @@ -9,10 +9,10 @@ with Files("**"): EXPORTS += [ "MaskConfig.hpp", + "MouseTrajectories.hpp", ] LOCAL_INCLUDES += [ - "/dom/base", "/camoucfg", ] diff --git a/additions/juggler/protocol/PageHandler.js b/additions/juggler/protocol/PageHandler.js index bab151b..3c3cd7b 100644 --- a/additions/juggler/protocol/PageHandler.js +++ b/additions/juggler/protocol/PageHandler.js @@ -1,6 +1,6 @@ /* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; @@ -81,6 +81,7 @@ class PageHandler { this._isDragging = false; this._lastMousePosition = { x: 0, y: 0 }; + this._lastTrackedPos = { x: 0, y: 0 }; this._reportedFrameIds = new Set(); this._networkEventsForUnreportedFrameIds = new Map(); @@ -500,13 +501,11 @@ class PageHandler { await helper.awaitTopic('apz-repaints-flushed'); const watcher = new EventWatcher(this._pageEventSink, types, this._pendingEventWatchers); - const promises = []; - for (const type of types) { - // This dispatches to the renderer synchronously. + const sendMouseEvent = async (eventType, eventX, eventY) => { const jugglerEventId = win.windowUtils.jugglerSendMouseEvent( - type, - x + boundingBox.left, - y + boundingBox.top, + eventType, + eventX + boundingBox.left, + eventY + boundingBox.top, button, clickCount, modifiers, @@ -519,9 +518,26 @@ class PageHandler { win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */, false /* disablePointerEvent */ ); - promises.push(watcher.ensureEvent(type, eventObject => eventObject.jugglerEventId === jugglerEventId)); + await watcher.ensureEvent(eventType, eventObject => eventObject.jugglerEventId === jugglerEventId); + }; + for (const type of types) { + if (type === 'mousemove' && ChromeUtils.camouGetBool('humanize', false)) { + let trajectory = ChromeUtils.camouGetMouseTrajectory(this._lastTrackedPos.x, this._lastTrackedPos.y, x, y); + for (let i = 2; i < trajectory.length - 2; i += 2) { + let currentX = trajectory[i]; + let currentY = trajectory[i + 1]; + // Skip movement that is out of bounds + if (currentX < 0 || currentY < 0 || currentX > boundingBox.width || currentY > boundingBox.height) { + continue; + } + await sendMouseEvent(type, currentX, currentY); + await new Promise(resolve => setTimeout(resolve, 10)); + } + } else { + // Call the function for the current event + await sendMouseEvent(type, x, y); + } } - await Promise.all(promises); await watcher.dispose(); }; @@ -579,6 +595,7 @@ class PageHandler { const watcher = new EventWatcher(this._pageEventSink, ['dragstart', 'juggler-drag-finalized'], this._pendingEventWatchers); await sendEvents(['mousemove']); + this._lastTrackedPos = { x, y }; // The order of events after 'mousemove' is sent: // 1. [dragstart] - might or might NOT be emitted diff --git a/patches/chromeutil.patch b/patches/chromeutil.patch index b689e49..5ab6fd6 100644 --- a/patches/chromeutil.patch +++ b/patches/chromeutil.patch @@ -1,16 +1,17 @@ diff --git a/dom/base/ChromeUtils.cpp b/dom/base/ChromeUtils.cpp -index 6833d2227f..0b2ea99615 100644 +index 6833d2227f..9f88bd3d34 100644 --- a/dom/base/ChromeUtils.cpp +++ b/dom/base/ChromeUtils.cpp -@@ -5,6 +5,7 @@ +@@ -5,6 +5,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #include "ChromeUtils.h" +#include "MaskConfig.hpp" ++#include "MouseTrajectories.hpp" #include "JSOracleParent.h" #include "js/CallAndConstruct.h" // JS::Call -@@ -2068,6 +2069,24 @@ bool ChromeUtils::IsDarkBackground(GlobalObject&, Element& aElement) { +@@ -2068,6 +2070,24 @@ bool ChromeUtils::IsDarkBackground(GlobalObject&, Element& aElement) { return nsNativeTheme::IsDarkBackground(f); } @@ -35,7 +36,7 @@ index 6833d2227f..0b2ea99615 100644 double ChromeUtils::DateNow(GlobalObject&) { return JS_Now() / 1000.0; } /* static */ -@@ -2094,6 +2113,39 @@ void ChromeUtils::GetAllPossibleUtilityActorNames(GlobalObject& aGlobal, +@@ -2094,6 +2114,62 @@ void ChromeUtils::GetAllPossibleUtilityActorNames(GlobalObject& aGlobal, } } @@ -61,6 +62,17 @@ index 6833d2227f..0b2ea99615 100644 +} + +/* static */ ++bool ChromeUtils::CamouGetBool(GlobalObject& aGlobal, ++ const nsAString& aVarName, ++ bool aDefaultValue) { ++ NS_ConvertUTF16toUTF8 utf8VarName(aVarName); ++ if (auto value = MaskConfig::GetBool(utf8VarName.get())) { ++ return value.value(); ++ } ++ return aDefaultValue; ++} ++ ++/* static */ +void ChromeUtils::CamouGetString(GlobalObject& aGlobal, + const nsAString& aVarName, + nsAString& aRetVal) { @@ -71,12 +83,24 @@ index 6833d2227f..0b2ea99615 100644 + aRetVal.Truncate(); + } +} ++ ++/* static */ ++void ChromeUtils::CamouGetMouseTrajectory(GlobalObject& aGlobal, long aFromX, ++ long aFromY, long aToX, long aToY, ++ nsTArray& aPoints) { ++ HumanizeMouseTrajectory trajectory(std::make_pair(aFromX, aFromY), ++ std::make_pair(aToX, aToY)); ++ std::vector flattenedPoints = trajectory.getPoints(); ++ ++ aPoints.Clear(); ++ aPoints.AppendElements(flattenedPoints.data(), flattenedPoints.size()); ++} + /* static */ bool ChromeUtils::ShouldResistFingerprinting( GlobalObject& aGlobal, JSRFPTarget aTarget, diff --git a/dom/base/ChromeUtils.h b/dom/base/ChromeUtils.h -index 0150c59670..e3d203ed1a 100644 +index 0150c59670..3a244a80e4 100644 --- a/dom/base/ChromeUtils.h +++ b/dom/base/ChromeUtils.h @@ -301,6 +301,10 @@ class ChromeUtils { @@ -90,7 +114,7 @@ index 0150c59670..e3d203ed1a 100644 static double DateNow(GlobalObject&); static void EnsureJSOracleStarted(GlobalObject&); -@@ -310,6 +314,14 @@ class ChromeUtils { +@@ -310,6 +314,21 @@ class ChromeUtils { static void GetAllPossibleUtilityActorNames(GlobalObject& aGlobal, nsTArray& aNames); @@ -99,14 +123,21 @@ index 0150c59670..e3d203ed1a 100644 + static double CamouGetDouble(GlobalObject& aGlobal, const nsAString& aVarName, + double aDefaultValue); + ++ static bool CamouGetBool(GlobalObject& aGlobal, const nsAString& aVarName, ++ bool aDefaultValue); ++ + static void CamouGetString(GlobalObject& aGlobal, const nsAString& aVarName, + nsAString& aRetVal); ++ ++ static void CamouGetMouseTrajectory(GlobalObject& aGlobal, long aFromX, ++ long aFromY, long aToX, long aToY, ++ nsTArray& aPoints); + static bool ShouldResistFingerprinting( GlobalObject& aGlobal, JSRFPTarget aTarget, const Nullable& aOverriddenFingerprintingSettings); diff --git a/dom/chrome-webidl/ChromeUtils.webidl b/dom/chrome-webidl/ChromeUtils.webidl -index bf196f039d..04e0cdcabd 100644 +index bf196f039d..e134d816b3 100644 --- a/dom/chrome-webidl/ChromeUtils.webidl +++ b/dom/chrome-webidl/ChromeUtils.webidl @@ -746,6 +746,13 @@ partial namespace ChromeUtils { @@ -123,7 +154,7 @@ index bf196f039d..04e0cdcabd 100644 /** * Starts the JSOracle process for ORB JavaScript validation, if it hasn't started already. */ -@@ -757,6 +764,21 @@ partial namespace ChromeUtils { +@@ -757,6 +764,31 @@ partial namespace ChromeUtils { [ChromeOnly] readonly attribute unsigned long aliveUtilityProcesses; @@ -138,9 +169,19 @@ index bf196f039d..04e0cdcabd 100644 + double camouGetDouble(DOMString varName, double defaultValue); + + /** ++ * Get a bool value from Camoufox MaskConfig. ++ */ ++ boolean camouGetBool(DOMString varName, boolean defaultValue); ++ ++ /** + * Get a string value from Camoufox MaskConfig. + */ + DOMString camouGetString(DOMString varName); ++ ++ /** ++ * Calculate a human-like mouse trajectory between two points. ++ */ ++ sequence camouGetMouseTrajectory(long fromX, long fromY, long toX, long toY); + /** * Get a list of all possible Utility process Actor Names ; mostly useful to diff --git a/patches/cursor-highlighter.patch b/patches/cursor-highlighter.patch new file mode 100644 index 0000000..de761d7 --- /dev/null +++ b/patches/cursor-highlighter.patch @@ -0,0 +1,36 @@ +diff --git a/browser/base/content/browser-init.js b/browser/base/content/browser-init.js +index 63c9c39741..cc7fa914c0 100644 +--- a/browser/base/content/browser-init.js ++++ b/browser/base/content/browser-init.js +@@ -292,6 +292,31 @@ var gBrowserInit = { + } + } + ++ if (ChromeUtils.camouGetBool("showcursor", true)) { ++ let cursorFollower = document.createElement("div"); ++ cursorFollower.id = "cursor-highlighter"; ++ cursorFollower.style.cssText = ` ++ position: fixed; ++ width: 10px; ++ height: 10px; ++ background-color: rgba(255,105,105,0.8); ++ border-radius: 50%; ++ pointer-events: none; ++ z-index: 2147483647; ++ transform: translate(-50%, -50%); ++ box-shadow: ++ 0 0 0 5px rgba(255,105,105,0.5), ++ 0 0 0 10px rgba(255,105,105,0.3), ++ 0 0 0 15px rgba(255,105,105,0.1); ++ `; ++ document.documentElement.appendChild(cursorFollower); ++ ++ window.addEventListener('mousemove', e => { ++ cursorFollower.style.left = `${e.clientX}px`; ++ cursorFollower.style.top = `${e.clientY}px`; ++ }); ++ } ++ + // Wait until chrome is painted before executing code not critical to making the window visible + this._boundDelayedStartup = this._delayedStartup.bind(this); + window.addEventListener("MozAfterPaint", this._boundDelayedStartup); diff --git a/settings/properties.json b/settings/properties.json index 3bfefe7..2982a6d 100644 --- a/settings/properties.json +++ b/settings/properties.json @@ -61,5 +61,8 @@ { "property": "timezone", "type": "str" }, { "property": "locale:language", "type": "str" }, { "property": "locale:region", "type": "str" }, - { "property": "locale:script", "type": "str" } + { "property": "locale:script", "type": "str" }, + { "property": "humanize", "type": "bool" }, + { "property": "humanize:maxTime", "type": "double" }, + { "property": "showcursor", "type": "bool" } ]