Skip to content

About

GitHub Action for end-to-end testing Sliver BOFs across operating systems and architectures

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

9 Commits

Folders and files

Repository files navigation

Sliver BOF end-to-end action

sliver-test-bof runs Beacon Object Files against real Sliver session and beacon implants. A test starts an isolated loopback Sliver server, creates a short-lived multiplayer operator profile, generates an HTTP implant, waits for its connection over gRPC, invokes the BOF, checks its output, and tears the environment down.

The repository exposes two interfaces:

  • The reusable workflow plans and runs every os/arch suite on an appropriate GitHub-hosted runner.
  • The root action runs one target in the caller's existing job, so a repository can own its build and matrix.

Use the versioned v0.1.0 release in a caller workflow. For required checks, pin the full commit SHA associated with that release. Validate the action on non-production BOFs before expanding its use. The project is available under the MIT license.

The initial reusable workflow and direct action are GitHub.com-only. They use GitHub's $/ self-repository action references; the workflow also uses exact job.workflow_repository/job.workflow_sha identity and the GitHub.com-only artifact action generations (upload-artifact v7 and download-artifact v8). They are not currently compatible with GitHub Enterprise Server.

Reusable workflow

Add a manifest at .github/sliver-bof-e2e.yml, then call the workflow:

name: BOF end-to-end

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  bof-e2e:
    uses: sliverarmory/sliver-test-bof/.github/workflows/e2e.yml@v0.1.0
    with:
      manifest: .github/sliver-bof-e2e.yml

The version tag provides a stable, easy-to-read reference. Replace it with the release commit SHA when a required check must pin the exact action code.

The planner validates the manifest, resolves the mutable Sliver selector once, and emits a dynamic matrix. Every matrix job therefore tests the same Sliver revision. Omitting the reusable workflow's sliver-version input makes sliver.version in the manifest authoritative; a non-empty workflow input overrides it. The workflow uses this allowlisted mapping:

Implant target GitHub-hosted runner
darwin/amd64 macos-15-intel
darwin/arm64 macos-15
linux/386 ubuntu-24.04; the test lifecycle runs in a full Debian i386 container through Docker/QEMU
linux/amd64 ubuntu-24.04
linux/arm64 ubuntu-24.04-arm
windows/386 windows-2022
windows/amd64 windows-2022
windows/arm64 windows-11-arm

The manifest cannot supply arbitrary runner labels. That keeps pull-request-controlled configuration away from persistent self-hosted runners.

The reusable workflow accepts an optional prepare-command for source-only BOF repositories. It builds once in the Ubuntu planner, validates and stages only the manifest-expanded .o files, then downloads those exact bytes into a private object root in every target and aggregate job. Manifests and byte-packed file arguments still resolve from the checkout, but object paths and collection globs resolve only from that isolated tree. Committed objects follow the same staging path even when the command is empty.

Source-only repositories

Pass a trusted Bash command that produces every object referenced by a test or collection across the complete manifest:

jobs:
  bof-e2e:
    uses: sliverarmory/sliver-test-bof/.github/workflows/e2e.yml@v0.1.0
    with:
      manifest: .github/sliver-bof-e2e.yml
      prepare-command: ./scripts/build-all-bofs.sh

The command runs once from the caller checkout on ubuntu-24.04, after Go setup and before manifest planning, under Bash's error and pipeline-failure handling. It receives an allowlisted build environment containing the installed tool PATH, standard home/temp/cache/compiler/proxy values, and:

Variable Value
SLIVER_BOF_E2E_PHASE plan
SLIVER_BOF_E2E_TARGET empty
SLIVER_BOF_E2E_OS empty
SLIVER_BOF_E2E_ARCH empty

Because the planner must expand collections for the whole matrix, the command must deterministically build all targets, not just the planner host. A nonzero exit fails planning. Use a checked-in script rather than embedding a large command in workflow YAML.

After planning, the controller rejects BOF object symlinks, non-portable paths, path escapes, architecture mismatches, and case-insensitive destination collisions. It copies only validated .o files into a private runner.temp tree with workspace-relative layout. SHA-pinned artifact actions distribute that tree to every native runner, the Linux/386 container route, and aggregation. Each consumer validates the isolated tree and never extracts it over the caller checkout, so stale tracked objects and checkout symlinks cannot alter the planned collection. Sources, arbitrary build output, file arguments, and credentials are not included. A manifest file argument must therefore be committed or represented by the runtime fixture facility below.

prepare-command is trusted caller code, not a sandbox. It runs as the planner's runner user with the workspace and network access. A fresh env -i subprocess explicitly withholds GitHub, Actions-runtime, OIDC, cloud, and GitHub command-file variables, so the hook cannot accidentally persist environment or PATH changes into the later token-bearing acquisition step. Callers must still avoid secrets, unreviewed pull-request code, pull_request_target, and persistent processes; same-user code can deliberately escape an environment-only boundary. The hook must clean up any processes or external state it creates. Sliver and BOF execution retains its separate credential-free environment boundary.

If a build genuinely requires a target's native host rather than a cross-toolchain on Ubuntu, use the root action in a caller-owned matrix and build immediately before invoking it.

Manifest

The v1 JSON Schema is strict: unknown fields, duplicate names, unsupported targets, empty collection globs, invalid assertions, and files outside the checked-out workspace are rejected during planning.

schema: sliver-bof-e2e/v1

sliver:
  version: master

fixtures:
  file:
    variable: FIXTURE_FILE
    contents_utf8: "sliver-bof-e2e fixture\n"
  directory:
    variable: FIXTURE_DIR
    files:
      marker.txt: "marker\n"

defaults:
  bof_executor: reflektor
  entrypoint: go
  modes: [session, beacon]
  repeat: 1
  command_timeout: 2m

suites:
  - name: windows-amd64
    target:
      os: windows
      arch: amd64

    tests:
      - name: whoami
        object: build/windows/amd64/whoami.o
        bof_executors: [reflektor, coff-loader]
        args:
          - type: wstring
            value: example
        expect:
          output:
            contains: [UserName]

      - name: loopback-probe
        object: build/windows/amd64/probe.o
        args:
          - {type: string, value: '127.0.0.1'}
          - {type: int, value: 1}
        tcp_listener:
          port_argument: 1
        expect:
          output:
            contains: [OPEN]

      - name: fixture-path
        object: build/windows/amd64/file-reader.o
        args:
          - {type: wstring, value: '${FIXTURE_FILE}'}
        expect:
          output:
            contains: [sliver-bof-e2e fixture]

      - name: legacy-process-query
        object: build/windows/amd64/process.o
        bof_executor: coff-loader
        modes: [session]
        args:
          - type: int
            value: 1234
        expect:
          output:
            contains: [Process]

  - name: linux-arm64
    target:
      os: linux
      arch: arm64

    collections:
      - name: portable-smoke
        glob: build/linux/arm64/*.o
        expect:
          success_only: true
        overrides:
          env.o:
            expect:
              output:
                contains: ['PATH=']

Manifest object and file argument paths are relative. In the reusable workflow, objects resolve from the planner-created private object root and object symlinks are rejected; file arguments resolve from the caller workspace. Direct root-action use resolves both from the caller workspace and preserves confined in-workspace object symlinks. Any symlink that resolves outside its permitted root is rejected. A collection expands its glob into one test per object; overrides are keyed by object basename.

Settings may be placed on an individual test or collection. Collection overrides replace only the fields they name; an overridden expect replaces the whole inherited expectation, and args and args_raw_base64 clear one another. The top-level defaults supports bof_executor or bof_executors, entrypoint, modes, repeat, and command_timeout.

Arguments

Arguments are packed in the declared order using Beacon's little-endian argument format:

Type Value
int or integer unsigned 32-bit integer
short unsigned 16-bit integer
string UTF-8 string
wstring UTF-16LE string
file workspace-relative file path; its bytes are packed
bytes_base64 base64-encoded bytes

Use args_raw_base64 instead of args when the caller already has a complete argument buffer. Typed and raw arguments are mutually exclusive.

Runtime fixtures

Top-level fixtures.file and fixtures.directory create deterministic resources beneath the action-owned, per-test temporary tree. Variables must match [A-Z][A-Z0-9_]{0,63} and be unique. Use an exact ${NAME} value in a string or wstring argument; partial interpolation, malformed placeholders, and unknown variables are rejected. The harness substitutes the generated absolute host path immediately before packing arguments.

Directory fixture file names use portable forward-slash relative paths. Absolute paths, . or .. traversal, Windows-invalid/control characters, drive/UNC forms, case-insensitive collisions, Windows-reserved components, and trailing dots or spaces are rejected. Directories use mode 0700 and files 0600 where POSIX permissions apply. Each session/beacon case receives fresh fixtures; repeats within that case reuse them. All resources remain inside the tracked temporary root and are removed by normal or fallback cleanup.

Fixture contents are UTF-8 and limited to 1 MiB per file, 16 MiB total, and 256 files in the directory fixture. These limits are enforced by byte length in the controller; schema maxLength remains an editor-side character-count hint.

Loopback TCP fixture

Set tcp_listener.port_argument to the zero-based index of an int argument. For each test and mode, the harness binds an ephemeral tcp4 listener to 127.0.0.1:0, replaces that argument with the selected port, accepts and closes loopback probe connections, and keeps the socket open through all repeats. The bind address and port cannot be supplied by the manifest. Linux/386 creates the listener inside the container, so its implant shares the same loopback namespace.

This fixture makes deterministic local OPEN assertions possible; it does not prevent a native BOF from making other outbound network connections.

Output assertions

expect.output supports:

  • equals
  • contains and not_contains
  • matches using Go/RE2 regular expressions
  • sha256
  • equals_base64 for exact binary output

Use expect.success_only: true when the BOF has no stable content to assert. Otherwise at least one content assertion is required. Current Sliver Reflektor responses concatenate output records into one byte buffer, so assertions apply to that complete raw buffer rather than separate stdout and stderr streams.

contains and not_contains compare each manifest string's UTF-8 byte sequence directly against that raw buffer, so unrelated non-UTF-8 bytes do not prevent literal matching. equals and matches require the complete output to be valid UTF-8; use equals_base64 or sha256 for exact binary assertions.

Executor selection

Executor selection is explicit in defaults, a test, a collection, or a collection override. Use bof_executor for one route, or bof_executors: [reflektor, coff-loader] to run the same BOF through both routes. A plural list may also contain one route. The two fields are mutually exclusive in the same settings block; a selection at a more specific level replaces the inherited selection. The existing singular field keeps its behavior. The action does not silently fall back between routes.

With both routes selected, each executor receives the same object, arguments, modes, repeats, and assertions. The report records separate results for each executor, mode, and repeat. Dual execution requires windows/386 or windows/amd64, the targets supported by the pinned Armory COFFLoader package. A target without the requested loader fails instead of being counted as compatible.

For a compatibility check, assert a recognizable BOF output value with expect.output on both routes. success_only: true can miss legacy loader errors that appear only in captured output.

  • reflektor uses Sliver's built-in BOF request (CallExtensionReq.IsBOF) and requires the connected implant to advertise the bof_v1 capability. A missing capability is a preflight failure.
  • coff-loader acquires the pinned, signed Sliver Armory COFFLoader package, registers its extension, and calls its LoadAndRun export through Sliver gRPC. The package supplies windows/386 and windows/amd64 artifacts; incompatible target/loader combinations fail before execution.

Beacon invocations wait for the matching beacon-taskresult, fetch the completed task content, and decode the final extension response. Receipt of only the initial asynchronous RPC response is not considered a successful BOF run.

Sliver selection

sliver-version accepts:

  • master
  • A full 40-character commit SHA
  • latest
  • A release tag such as v1.7.3

For both the direct action and reusable workflow, omitting sliver-version uses sliver.version from the manifest. A non-empty action or workflow input overrides the manifest. The reusable planner resolves that selection once and passes its immutable result to every target job. Release plans use an internal release:<tag>@<commit> selector so a moved tag cannot mix Sliver revisions across the matrix; manifests continue to use ordinary release tags.

latest and release tags download the exact host server asset and verify its minisign signature with Sliver's release public key. When GitHub publishes asset digests, those are checked too. master and commit selectors resolve to an immutable SHA and build the server from source with Sliver's server build tags. Source-build Go commands use action-private GOCACHE, GOMODCACHE, and GOPATH paths beneath runner.temp; they do not depend on ambient user-profile or shared Go caches. The embedded-asset helper remains on the controller's host OS/architecture with cgo disabled, while only the final server build receives explicit host GOOS/GOARCH values. This distinction is required by the Linux/386 route, where trusted amd64 tools run inside an i386 userspace and only the implant is a 32-bit target. Job-private caches are keyed by the resolved release asset identity or source SHA, host platform, toolchain, and acquisition/build recipe—not by the mutable words latest or master.

The built-in bof_v1 executor first appeared on Sliver's master branch. A release that predates it remains usable with coff-loader, but a manifest requesting reflektor will fail the capability preflight.

For direct single-target use, sliver-server-path bypasses acquisition and runs a caller-supplied server binary. Its SHA-256 is still recorded in the report.

Direct action

An action cannot create jobs or choose runners, so callers using the root action own the matrix:

jobs:
  bof-e2e:
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: windows/amd64
            runner: windows-2022
          - target: linux/arm64
            runner: ubuntu-24.04-arm
    runs-on: ${{ matrix.runner }}
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false
      - uses: actions/setup-go@v6
        with:
          go-version: 1.26.6
          cache: false
      - name: Build BOFs
        run: make bofs
      - name: Test BOFs
        uses: sliverarmory/sliver-test-bof@v0.1.0
        with:
          manifest: .github/sliver-bof-e2e.yml
          target: ${{ matrix.target }}
          sliver-version: master

The root action's outputs are results-dir and resolved-sliver. Planning and aggregation are implementation details of the reusable workflow rather than public root-action modes.

For linux/386, prefer the reusable workflow. A direct-action caller must provide an equivalent complete i386 userspace and /usr/bin/i686-linux-gnu-gcc; binfmt/QEMU registration by itself is not enough for Reflektor BOFs that resolve libc or other system imports.

Lifecycle and isolation

A target job stages its private server, client, implant, and result directories in a fresh subtree of runner.temp, separate from the checkout. It then:

  1. Acquires or builds sliver-server and records its provenance.
  2. Starts sliver-server daemon on a randomly selected loopback multiplayer port.
  3. Generates a temporary all-permissions localhost operator profile and connects over mTLS/gRPC.
  4. Starts an HTTP listener bound to loopback and generates session and/or beacon executables for the requested target.
  5. Starts a fresh implant process for each test and mode, then correlates its connection event by implant name, PID, target, transport, and event cursor.
  6. Invokes the BOF, waits for its final response, checks the configured assertions, and stops the tracked implant process group (or Windows Job Object).
  7. Stops listeners and the server and removes private state.

Go cleanup handlers own normal teardown. An always() composite step launches the idempotent fallback cleanup program with a scrubbed environment: it terminates only exact process IDs recorded in the private state file and removes only recorded temporary paths under the runner's temp area. It never uses broad process-name matching. Operator profiles and private keys are never uploaded.

The Linux/386 row has an additional container boundary: its implant runs against a real 32-bit loader, libc, compiler, and system libraries, and removing the container catches descendants that detach from the implant process group. On native Darwin and Linux rows, a BOF-created child that deliberately calls setsid, changes process group, or daemonizes can escape the action's process-group cleanup and survive until the ephemeral runner is torn down. This action is a test harness, not containment for adversarial BOFs.

Results

Every target writes:

  • report.json using sliver-bof-e2e-results/v1
  • junit.xml
  • summary.md, also appended to the GitHub job summary
  • server and per-implant logs, with bounded tails embedded in failure diagnostics
  • raw BOF output for every invocation

Reports identify the action version, resolved Sliver selector/revision and server digest, stable expanded case ID, collection (when applicable), object digest, requested executor, mode, repeat number, timing, status, and failing lifecycle stage. Reusable-workflow reports use the exact repository and job.workflow_sha; direct-action reports are exact when the action is pinned to a full SHA and otherwise identify the requested mutable ref. The reusable workflow uploads each target separately and downloads all target reports into a clean runner.temp tree in an always() aggregate job. Aggregation restores the same planner-validated object artifact and requires the exact manifest-expanded inventory for every target, case ID, mode, and repeat; rejects missing, duplicate, unexpected, metadata-mismatched, or failed cases; and requires every report to agree on the action version and resolved Sliver provenance before uploading the combined tree for seven days.

Security boundary

Generated Sliver implants and BOFs are native code executing as the runner user. Temporary directories, acquisition/runtime process separation, and sanitized subprocess environments are operational hygiene, not a sandbox. A short-lived JavaScript action builds the trusted controller and performs acquisition; it then exits. A separate shell step replaces its process image with the compiled controller under an explicit allowlisted environment, so the forwarded GitHub token, action inputs, Actions service tokens, proxies, cloud credentials, and Go build controls are absent while Sliver and BOFs execute. The Linux/386 route provides the equivalent boundary with a private token file followed by exec env -i inside the container.

This separation removes credentials explicitly forwarded to the action from the BOF execution tree. It cannot isolate malicious same-user native code from the GitHub Runner service itself or from unrelated job processes; environment scrubbing cannot make a shared operating-system account a security boundary.

Run this workflow on GitHub-hosted ephemeral runners with contents: read, no cloud/OIDC credentials, no unrelated repository secrets, and persist-credentials: false. Do not use it from pull_request_target. Do not execute unreviewed BOFs on persistent self-hosted runners. Both the multiplayer and HTTP listeners are hard-bound to loopback, but that does not make native code safe.

About

GitHub Action for end-to-end testing Sliver BOFs across operating systems and architectures

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages