Ghostwire: Frida-Like Runtime Instrumentation for Browser JavaScript
Expert Network Defense Engineer
TL;DR:
- Ghostwire is runtime instrumentation for browser JavaScript, in the same category as Frida. Both observe a live function boundary instead of reading source and hoping. The target differs: Frida instruments native and mobile processes, Ghostwire instruments JavaScript inside Chrome.
- The transport is the Chrome DevTools Protocol over an OS pipe. Ghostwire launches Chrome with
--remote-debugging-pipe, so there is no debugging port to connect to and no third-party dependency to install for the core. - Hooks are debugger breakpoints, not wrappers. The live function object is never replaced, so
fn.toString()keeps returning the original source. - The interesting part is the oracle. You capture real
(input → output)pairs, write a candidate reimplementation, and let Ghostwire prove it wrong with a concrete counterexample. - A live run rejected a plausible candidate. Twelve captured pairs verified the correct function 12/12; a version missing a single
+1matched 0/13 and produced the exact failing input. - Ghostwire is a local-Chrome tool today. It does not connect to a remote WebSocket CDP endpoint, so it cannot currently drive the Scrapeless Scraping Browser session.
- Use it only where you are authorized. Start with a free Scrapeless account for ordinary browser data collection, which is a different job from runtime analysis.
Reading obfuscated JavaScript gives you a hypothesis. Running it gives you a fact. Ghostwire exists to turn the first into the second: it observes what a function actually returns at runtime, then checks your reimplementation against that ground truth and reports every input where you were wrong.
What Ghostwire Is
Ghostwire is a Python instrumentation library for JavaScript running inside Chrome. It attaches over the Chrome DevTools Protocol, sets hooks on live function objects, records arguments and return values, and compares a candidate implementation against the captured behavior.
The version used here is 0.1.0 at upstream commit a3fe4ef1e27804ab1e976e28dfa1801ee53aabf8. It requires Python 3.9 or later and MIT licensing, and the core has no third-party dependencies because the protocol runs over an OS pipe rather than a WebSocket client library.
This guide uses a synthetic local page throughout. Runtime instrumentation belongs to the same authorization category as any other security tooling: analyze systems you own or have explicit permission to test.
Ghostwire and Frida: Where the Analogy Holds
Calling Ghostwire "Frida for browser JavaScript" is a useful shorthand, and it is accurate about method rather than implementation.
| Concern | Frida | Ghostwire 0.1.0 |
|---|---|---|
| Primary target | Native processes, mobile apps, desktop binaries | JavaScript executing in Chrome |
| Attach mechanism | Injects an agent into the target process | Attaches a debugger session over CDP |
| Hook mechanism | Interceptor on native function addresses | Debugger.setBreakpointOnFunctionCall on live function objects |
| Scripting surface | JavaScript agent inside the process | Python driving the protocol from outside |
| Core dependency | Frida runtime and bindings | None; CDP over an OS pipe |
Frida's JavaScript API runs an agent inside the process it is inspecting. Ghostwire never puts code in the page. That difference is deliberate: a breakpoint on the live function object leaves the object unmodified, so the page's own fn.toString() check sees the original source.
Where the analogy holds is the working model. Both replace static reading with live observation, and both are for authorized analysis rather than production data collection.
Prerequisites
- Python 3.9 or later.
- Google Chrome or Chromium installed locally.
- A checkout of the Ghostwire repository at a pinned commit.
- Only targets you own or are authorized to analyze.
Install
The core has no packages to install, so a pinned checkout is the whole setup:
bash
git clone https://github.com/sofianeelhor/ghostwire.git
cd ghostwire
git checkout a3fe4ef1e27804ab1e976e28dfa1801ee53aabf8
Read pyproject.toml before running anything. It declares version = "0.1.0", requires-python = ">=3.9", and an empty dependencies list, with mcp>=1.0 only as an optional extra for the MCP server.
The Synthetic Target
This fixture is served from localhost and computes a deliberately simple value. It contains no credentials, no anti-bot logic, and no third-party code:
python
PAGE = b"""<!doctype html><html><body><script>
function transform(name, value) {
return btoa(name + ':' + (value * 7 + 1));
}
window.transform = transform;
let i = 0;
setInterval(function () {
window.lastResult = transform('sample' + i, 10 + i);
i += 1;
}, 120);
</script></body></html>"""
The timer matters. Ghostwire captures calls as they happen, so a boundary that is exercised repeatedly produces a corpus without any interaction.
Attach and Hook
ghostwire.attach() launches Chrome, wires the default probes, and returns an Inspector:
python
import ghostwire
with ghostwire.attach("http://localhost:8000/", headless=True) as gw:
gw.wait(1.0)
gw.hook("window.transform", capture_returns=True, label="transform")
gw.wait(1.5)
corpus = gw.corpus("transform")
print("captured pairs:", len(corpus))
capture_returns=True records the return value alongside the arguments, which is what makes the corpus usable as ground truth. The label groups those pairs for later verification.
Ready to collect ordinary page data instead of analyzing runtime behavior? Open a free Scrapeless account and use a managed browser for that job.
Verify a Candidate Against Reality
You supply a reimplementation, and Ghostwire runs it against the captured corpus in an isolated page that cannot see the real function:
python
good = "(name,value)=>btoa(name+':'+(value*7+1))"
wrong = "(name,value)=>btoa(name+':'+(value*7))"
good_result = gw.verify("window.transform", good, label="transform")
wrong_result = gw.verify("window.transform", wrong, label="transform")
The second candidate is the realistic failure: syntactically fine, semantically off by one. A model reading minified source produces this class of mistake regularly, and it survives casual review because the output still looks like valid base64.
Complete Runnable Script
The container used for verification needed two adjustments, both environmental rather than Ghostwire defects: Chrome requires --no-sandbox when running as root, and the first HTTP navigation is slow enough to exceed the stock 20-second CDP deadline.
python
import base64
import http.server
import socketserver
import sys
import threading
from pathlib import Path
HERE = Path(__file__).resolve().parent
UPSTREAM = next(
p / "upstream" for p in (HERE, *HERE.parents)
if (p / "upstream" / "ghostwire").is_dir()
)
sys.path.insert(0, str(UPSTREAM))
from ghostwire import Browser, Engine, Tracer
from ghostwire.api import Inspector
from ghostwire.crypto import CryptoLogger
from ghostwire.dataflow import DataflowTracer
from ghostwire.objects import LiveObjects
from ghostwire.oracle import Oracle
from ghostwire.origin import OriginTracer
from ghostwire.probes import NetLog, ScriptWatcher
def attach_local(url, extra_flags):
"""Same wiring as ghostwire.attach(), with container-safe Chrome flags."""
browser = Browser(headless=True, extra_flags=extra_flags)
browser.cdp.default_timeout = 90.0
engine = Engine(browser=browser)
scripts, net, tracer = ScriptWatcher(), NetLog(), Tracer()
for probe in (scripts, net, tracer):
engine.add_probe(probe)
oracle = Oracle(engine, tracer)
engine.start()
engine.navigate(url)
return Inspector(
engine, scripts, net, tracer, oracle,
OriginTracer(engine), DataflowTracer(engine),
LiveObjects(engine), CryptoLogger(engine),
)
PAGE = b"""<!doctype html><html><body><script>
function transform(name, value) {
return btoa(name + ':' + (value * 7 + 1));
}
window.transform = transform;
let i = 0;
setInterval(function () {
window.lastResult = transform('sample' + i, 10 + i);
i += 1;
}, 120);
</script></body></html>"""
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(PAGE)))
self.end_headers()
self.wfile.write(PAGE)
def log_message(self, *args):
pass
class QuietServer(socketserver.TCPServer):
allow_reuse_address = True
def handle_error(self, request, client_address):
pass
server = QuietServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
gw = attach_local(
f"http://localhost:{port}/",
["--no-sandbox", "--disable-dev-shm-usage"],
)
try:
gw.wait(1.0)
gw.hook("window.transform", capture_returns=True, label="transform")
gw.wait(1.5)
corpus = gw.corpus("transform")
good = "(name,value)=>btoa(name+':'+(value*7+1))"
wrong = "(name,value)=>btoa(name+':'+(value*7))"
good_result = gw.verify("window.transform", good, label="transform")
wrong_result = gw.verify("window.transform", wrong, label="transform")
fresh = [["alpha", 1], ["beta", 2], ["", 0]]
fresh_result = gw.verify("window.transform", good, fresh_inputs=fresh)
mismatch = wrong_result["mismatches"][0]
expected_decoded = base64.b64decode(mismatch["expected"]).decode()
got_decoded = base64.b64decode(mismatch["got"]).decode()
assert len(corpus) >= 5
assert good_result["verified"] is True
assert wrong_result["verified"] is False
assert fresh_result["verified"] is True
print("ghostwire version: 0.1.0")
print("captured pairs:", len(corpus))
print("correct candidate:", good_result["verified"],
"tested:", good_result["tested"], "matched:", good_result["matched"])
print("wrong candidate:", wrong_result["verified"],
"tested:", wrong_result["tested"], "matched:", wrong_result["matched"])
print("counterexample input:", mismatch["input"])
print("counterexample expected:", expected_decoded)
print("counterexample got:", got_decoded)
print("fresh inputs verified:", fresh_result["verified"])
print("fresh inputs tested:", fresh_result["tested"])
finally:
gw.close()
server.shutdown()
What the Run Returned
text
ghostwire version: 0.1.0
captured pairs: 12
correct candidate: True tested: 12 matched: 12
wrong candidate: False tested: 13 matched: 0
counterexample input: ['sample8', 18]
counterexample expected: sample8:127
counterexample got: sample8:126
fresh inputs verified: True
fresh inputs tested: 3
The wrong candidate matched 0 of 13, not some of them. Because the missing +1 shifts every result, the corpus rejects it everywhere. A subtler bug would produce a partial match, which is exactly the signal a corpus is good at surfacing.
The counterexample is concrete: input ['sample8', 18] should produce sample8:127 because 18 * 7 + 1 = 127, and the candidate produced sample8:126. That is a decoded, actionable diff rather than a boolean failure.
The fresh-input check verified 3 inputs with no prior corpus. Ghostwire called the live function for values it had never observed, which covers edge cases the timer never generated, including the empty string.
Honest Limits: Ghostwire and the Scraping Browser
Ghostwire cannot currently drive a Scrapeless Scraping Browser session, and the reason is architectural rather than a configuration problem.
Browser.__init__ launches a local Chrome process with --remote-debugging-pipe=JSON, duplicates two file descriptors into the child, and hands them to PipeConnection. Every protocol message flows through those descriptors. The Scraping Browser exposes a remote WebSocket endpoint instead, and Ghostwire has no WebSocket transport to speak to it.
Engine(browser=...) accepts a browser-like object, which is a genuine extension seam — this article's own runner uses it to pass container-safe Chrome flags. But making it reach a remote endpoint would require a PipeConnection-compatible WebSocket transport plus lifecycle changes, since Browser.close() assumes it owns a local process it may terminate. That work does not exist upstream, so treat a bridge as a design sketch rather than a supported path.
The practical split is clean. Use Ghostwire locally for authorized runtime analysis. Use the Scraping Browser for browser-based data collection at scale, which is what the Chrome DevTools Protocol overview covers.
Troubleshooting
Chrome exits immediately and the pipe breaks
A BrokenPipeError from cdp.py means Chrome died during launch. Running as root in a container is the usual cause; pass --no-sandbox and --disable-dev-shm-usage through extra_flags.
CDPError: timeout: Page.navigate
The navigation exceeded the connection deadline. Raise browser.cdp.default_timeout before creating the Engine. In the container used here, the first navigation to 127.0.0.1 took about 15 seconds while localhost resolved in about 0.1 seconds, so the host name alone changed the outcome.
The hook captures nothing
The function was not defined when the hook was set. Wait for the page to initialize, and confirm the expression resolves by evaluating typeof window.yourFunction first.
Verification passes but the corpus is tiny
A handful of pairs proves very little. Exercise the boundary more, or supply fresh_inputs covering edges the observed traffic never reached.
Conclusion
Function hooking is the easy half. The half that changes how you work is the oracle, which checks your reimplementation against recorded behavior and hands back the input where it broke. In this run that input was ['sample8', 18], and the candidate failed because it dropped a single +1.
Keep the scope honest. Ghostwire is a local-Chrome research instrument for systems you are authorized to analyze, and it does not currently speak to remote browser endpoints.
Start on the Scrapeless free plan for managed browser data collection, and review Scrapeless pricing when that workload grows.
FAQ
Q: Is Ghostwire actually Frida for the browser?
It occupies the same role — live runtime instrumentation instead of static reading — but the implementations differ. Frida injects an agent into a native process; Ghostwire attaches a debugger session to Chrome and never puts code in the page.
Q: Why does Ghostwire use a pipe instead of a WebSocket?
--remote-debugging-pipe avoids opening a debugging port that page JavaScript could discover, and it removes the need for a WebSocket dependency in the core.
Q: Does hooking a function change what the page sees?
Not for the usual detection checks. Hooks are set with Debugger.setBreakpointOnFunctionCall on the live object, so the object is not wrapped and fn.toString() still returns the original source.
Q: What does the verification oracle actually prove?
That your candidate matched the real function on every tested input. It is evidence bounded by coverage, not a proof of equivalence, so a passing result with 12 pairs means less than one with broad edge-case coverage.
Q: Can Ghostwire connect to the Scrapeless Scraping Browser?
No. Ghostwire 0.1.0 launches and owns a local Chrome over an OS pipe, while the Scraping Browser exposes a remote WebSocket CDP endpoint. Bridging them needs a WebSocket transport and remote lifecycle handling that upstream does not provide.
Q: Is it safe to run this against any website?
No. Runtime instrumentation should be limited to systems you own or have written authorization to test. Review the target's terms and robots directives, and get legal advice before analyzing third-party code.
At Scrapeless, we only access publicly available data while strictly complying with applicable laws, regulations, and website privacy policies. The content in this blog is for demonstration purposes only and does not involve any illegal or infringing activities. We make no guarantees and disclaim all liability for the use of information from this blog or third-party links. Before engaging in any scraping activities, consult your legal advisor and review the target website's terms of service or obtain the necessary permissions.



