· 7 min read ·

Electron Has Three Network Stacks and Only One Shows Up in DevTools

Source: lobsters

Debugging network traffic in a web app is a three-click operation. Open DevTools, go to the Network tab, reload. The tab shows everything because it sits directly in the critical path of every request the renderer makes. That workflow works in Electron too, up until the point where the app you are debugging makes its interesting requests outside of Chromium’s network stack entirely.

This is the problem a recent post on seg6.space documents and solves. The post describes intercepting network calls made through native code in an Electron app, using FFI-level hooking to capture the traffic and a proxy layer to surface it in Chrome’s Network tab. The technique is clever because it treats DevTools as an addressable display target rather than a passive observer.

Three Stacks, Three Levels of Visibility

Electron gives you Chromium and Node.js in the same process, and those are two different networking implementations with different visibility characteristics.

Renderer processes use Chromium’s network service. Every fetch call, every XMLHttpRequest, every resource load goes through Chromium’s stack, inherits the session’s proxy settings, respects certificate authorities installed at the OS level, and fires CDP Network domain events that the DevTools Network tab renders. This is the layer the tab was designed for.

The main process has access to both. Electron’s net module wraps Chromium’s URLLoader, which means main-process code using net shows up in DevTools and respects session-level proxy and cookie configuration. Node’s built-in http and https modules use libuv and bypass Chromium’s stack entirely. Traffic from Node’s http modules does not appear in the Network tab. It does not participate in Chromium’s certificate store or proxy settings. Setting HTTP_PROXY and HTTPS_PROXY environment variables will route it through a MITM proxy, and patching http.request before the app initializes can intercept it programmatically, but DevTools sees none of it.

The third layer, and the one the article focuses on, is native code. Node addons compiled with node-gyp or loaded via koffi or node-ffi-napi can open sockets using libcurl, WinHTTP, raw POSIX socket() calls, or a statically linked TLS implementation. None of those paths surface in Node’s http module. They are not affected by HTTP_PROXY. DevTools never sees them. The OS network stack is the only level where all three are visible, which is why Wireshark was historically the fallback.

What CDP Actually Is

The Chrome DevTools Protocol is a bidirectional WebSocket interface. Chromium emits events, clients receive them and can issue commands. The Network domain emits Network.requestWillBeSent when a request is dispatched, Network.responseReceived when response headers arrive, Network.dataReceived as body chunks come in, and Network.loadingFinished or Network.loadingFailed at completion. The DevTools frontend subscribes to these events and renders them into the rows you see.

The critical architectural fact is that the DevTools frontend is a subscriber, not a monitor. It has no independent visibility into socket traffic. It renders what the CDP event stream tells it. The Network tab cannot distinguish between a real Chromium request and a synthetic event constructed with the right fields and timing.

Electron exposes its CDP endpoint at a configurable port:

electron --remote-debugging-port=9222 .

The available targets are listed at http://localhost:9222/json. Each target’s webSocketDebuggerUrl is the address to connect to. Once connected, Network.enable must be sent before events begin flowing:

const CDP = require('chrome-remote-interface');

const client = await CDP({ port: 9222 });
await client.Network.enable();

client.on('Network.requestWillBeSent', ({ requestId, request }) => {
  console.log(request.method, request.url);
});

For actual interception rather than observation, Fetch.enable (which replaced the deprecated Network.setRequestInterception) pauses requests before they leave Chromium and lets you inspect or modify headers before calling Fetch.continueRequest. This covers renderer and electron.net traffic. It does not cover Node http calls or FFI-layer traffic.

The Injection Mechanism

The “hijack” in the article’s title refers to inserting synthetic CDP events into the stream that DevTools receives. The mechanism requires placing a WebSocket proxy between the actual Electron CDP endpoint and the DevTools frontend. The proxy forwards real events in both directions and additionally injects fabricated Network.* events from the interception layer.

DevTools connects to the proxy’s port instead of Electron’s port. From its perspective, the event stream is authoritative. There is no verification step that would flag a Network.requestWillBeSent event as illegitimate.

Constructing a plausible synthetic event requires consistent requestId values across the sequence and monotonically increasing timestamps in seconds since epoch (CDP uses floating-point seconds, not milliseconds):

function emitRequest(ws, { url, method, headers, body, responseStatus, responseHeaders, responseBody }) {
  const requestId = crypto.randomUUID();
  const t0 = Date.now() / 1000;

  ws.send(JSON.stringify({
    method: 'Network.requestWillBeSent',
    params: {
      requestId,
      loaderId: requestId,
      documentURL: url,
      request: { url, method, headers, postData: body },
      timestamp: t0,
      wallTime: t0,
      initiator: { type: 'script' },
      type: 'XHR',
    }
  }));

  const t1 = t0 + 0.1;
  ws.send(JSON.stringify({
    method: 'Network.responseReceived',
    params: {
      requestId,
      loaderId: requestId,
      timestamp: t1,
      type: 'XHR',
      response: {
        url,
        status: responseStatus,
        statusText: String(responseStatus),
        headers: responseHeaders,
        mimeType: responseHeaders['content-type'] || 'application/octet-stream',
      },
    }
  }));

  ws.send(JSON.stringify({
    method: 'Network.loadingFinished',
    params: { requestId, timestamp: t1, encodedDataLength: responseBody.length }
  }));
}

The response body is served when DevTools issues a Network.getResponseBody command referencing the requestId. Your proxy intercepts that command, looks up the cached body, and replies directly rather than forwarding it to Electron.

Getting the Data From Native Code

The harder problem is capturing the FFI-layer traffic in the first place.

On Linux, LD_PRELOAD is the entry point. The dynamic linker resolves symbols in preloaded libraries before system libraries, so a shared object that exports send, recv, SSL_write, or SSL_read with the correct signatures will intercept those calls from any dynamically linked code:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <string.h>

typedef ssize_t (*real_send_t)(int, const void *, size_t, int);

ssize_t send(int sockfd, const void *buf, size_t len, int flags) {
    real_send_t real_send = dlsym(RTLD_NEXT, "send");
    log_outbound(sockfd, buf, len);
    return real_send(sockfd, buf, len, flags);
}

On Windows, IAT patching modifies the Import Address Table of the loaded module to redirect calls. Microsoft Detours handles this at a higher level. macOS uses DYLD_INSERT_LIBRARIES with equivalent semantics, though SIP restrictions complicate it for hardened binaries.

Frida abstracts all of this into a cross-platform API. It attaches to the running process and lets you install interceptors on named exports without writing platform-specific hooking code:

Interceptor.attach(Module.getExportByName('libcurl.so.4', 'curl_easy_perform'), {
  onEnter(args) {
    this.curlHandle = args[0];
    // Extract URL, method, headers from CURL handle fields
  },
  onLeave(retval) {
    // Emit synthetic CDP events with captured data
    send({ type: 'curl_complete', request: this.captured });
  }
});

Both approaches fail on statically linked TLS implementations. A Rust crate that bundles BoringSSL compiled as a static library has no SSL_write symbol in the dynamic linker’s view. In that case, SSLKEYLOGFILE is the most reliable fallback: OpenSSL and NSS both write session keys to this path when the environment variable is set. Wireshark loads the key file and decrypts captured traffic. It gives raw packets rather than structured request pairs, but it works unconditionally:

export SSLKEYLOGFILE=/tmp/tls-keys.log
electron .

Why Proxy Tools Stop Working Here

Tools like mitmproxy, Charles, and Proxyman intercept at the HTTP layer and depend on the application routing traffic through a configured proxy. Setting HTTP_PROXY works for libcurl (which checks CURLOPT_PROXY and also reads the environment), for Go’s net/http (which respects the standard environment variables by default), and for any library that delegates proxy configuration to the OS. Libraries that open raw sockets, use platform APIs like WinHTTP directly without reading system proxy settings, or implement their own TLS ignore these variables entirely.

Certificate pinning adds another layer of failure. Even when proxy routing works, MITM interception requires installing a trusted CA certificate. Native code that compares the server certificate against a hardcoded hash will reject the proxy’s certificate regardless of what is in the OS trust store.

The Structural Gap

Electron’s debugging story has always been uneven. For JavaScript and renderer code, it is genuinely excellent: V8 inspector, source maps, memory profiling, and a mature DevTools surface built by Google over more than a decade. For the boundary between JavaScript and native code, the tooling is thin.

This gap has grown as Electron apps have grown more capable. VS Code ships dozens of native modules. Discord’s audio subsystem lives in native code. Password managers and VPN clients use Electron as a UI layer over low-level platform APIs. The debugging infrastructure was designed for the original use case, running web applications on the desktop, not for the category of apps Electron eventually became.

The technique in the article works because it composes three things that are each independently documented: binary-level interception with LD_PRELOAD or Frida, the CDP Network domain event schema, and a WebSocket proxy to route synthetic events into DevTools. None of those pieces are novel. The insight is recognizing that they fit together into a development-time debugging workflow that does not require new tooling or changes to the app under inspection.

For most debugging tasks the proxy-between-devtools approach is probably overkill; Wireshark plus SSLKEYLOGFILE gets you the data faster. But Wireshark gives you packets. DevTools gives you decoded headers, formatted bodies, and timing waterfall charts, displayed in the same tab where you are already debugging everything else. That difference in ergonomics matters over a long debugging session, and it justifies the engineering.

Was this interesting?