Compare commits

...
Author SHA1 Message Date
moloch-- 4b12c80534 fix(shell): repair Windows input cancellation and CI 2026-08-31 08:04:48 -07:00
Joe 67c6d0d356 Merge branch 'master' into fix/shell-win 2026-08-30 10:06:50 -07:00
Joe 5e3b432b9b Merge pull request #2352 from BishopFox/feat/opfor-cna-client
feat(client): add OPFOR CNA BOF scripting
2026-08-30 10:06:32 -07:00
moloch-- fc4d9a1c36 Improvements to windows/shell handling 2026-08-30 10:06:00 -07:00
moloch-- 55b97df9f7 fix(client): repair OPFOR CI portability 2026-08-29 18:47:04 -07:00
moloch-- 227328fa87 docs: document OPFOR CNA scripting 2026-08-29 14:05:08 -07:00
moloch-- 071106d0bf test(e2e): add OPFOR native BOF coverage 2026-08-29 14:04:50 -07:00
moloch-- 8c326e6af9 feat(client): add OPFOR CNA BOF scripting 2026-08-29 14:04:27 -07:00
moloch-- 5e9ba47bf4 feat(opfor): preserve typed BOF output 2026-08-29 14:04:05 -07:00
moloch-- 0537e285fc build(deps): vendor OPFOR v0.0.1 and Reflektor v0.0.8 2026-08-29 14:03:42 -07:00
moloch-- ca685f5eed Require e2e tests for release workflow 2026-08-28 11:27:28 -07:00
Joe 902999493b Merge pull request #2351 from BishopFox/feat/wireguard-opt-in-auto-client
Make multiplayer WireGuard explicitly opt-in
2026-08-28 10:10:58 -07:00
moloch-- 14363c5d2a Make multiplayer WireGuard explicitly opt-in 2026-08-28 09:06:37 -07:00
Joe 926b7ae1a7 Merge pull request #2350 from BishopFox/feat/sgn-v0.1.0-stability
Fix SGN x64 stability and strengthen shellcode E2E coverage
2026-08-28 07:22:05 -07:00
moloch-- bf815b36ed Fix asset downloader lint findings 2026-08-27 18:04:14 -07:00
moloch-- 637853e8c2 Retry transient asset downloads safely 2026-08-27 14:48:38 -07:00
moloch-- 0c022ec722 Update SGN for x64 safe-mode stack alignment 2026-08-27 13:09:36 -07:00
moloch-- 7c83b458fa Fix SGN stability and expand shellcode E2E coverage 2026-08-27 10:39:32 -07:00
Joe 09b0540e75 Merge pull request #2349 from BishopFox/feat/armory-package-identity
Fix Armory extension updates and archive installs
2026-08-26 12:07:49 -07:00
moloch-- b4005efda8 Fix Armory PR lint failures 2026-08-26 11:03:58 -07:00
moloch-- 6d086fd7a7 Fix Armory package resolution and archive installs 2026-08-26 09:21:38 -07:00
Joe ccebd505b0 Merge pull request #2348 from BishopFox/fix/extensions-rm-external-path
Fix extensions rm deleting external directories
2026-08-25 16:21:38 -07:00
rkervella 94c5022dd0 fix(client): preserve external extension directories 2026-08-25 10:46:13 -07:00
Joe b19e383111 Merge pull request #2347 from BishopFox/feat/reflektor-default-bof-execution
Use Reflektor for default BOF execution
2026-08-25 06:56:20 -07:00
moloch-- 02ebd99c68 Fix Reflektor BOF lint failures 2026-08-24 21:30:08 -07:00
moloch-- 83d82e7161 feat: use Reflektor for default BOF execution 2026-08-24 21:13:51 -07:00
moloch-- eb5ac1b309 Only run reflektor tests on e2e 2026-08-24 16:49:00 -07:00
Joe e73618e0a5 Merge pull request #2339 from BishopFox/e2e/comprehensive
test: add comprehensive server and implant E2E coverage
2026-08-24 16:25:55 -07:00
moloch-- 0017fd2fe8 test: reduce extension callback test complexity 2026-08-24 09:53:49 -07:00
moloch-- e44111b6b4 fix: defer native extension rpc responses 2026-08-24 09:47:09 -07:00
moloch-- aa2dce510a test: run darwin shellcode e2e on macos 26 2026-08-24 06:45:15 -07:00
moloch-- 5e6acde3c7 test: surface darwin shellcode loader failures 2026-08-23 23:03:26 -07:00
moloch-- 99a8d3f859 fix: reduce xor dynamic stub complexity 2026-08-23 19:41:18 -07:00
moloch-- fc15a47b1e fix: satisfy shellcode encoder lint 2026-08-23 19:38:16 -07:00
moloch-- 8b32b6525f fix: stabilize shellcode encoders 2026-08-23 19:35:30 -07:00
moloch-- 9e0fafb785 fix: keep shellcode runner alive after return 2026-08-23 18:58:11 -07:00
moloch-- 12ebffb16c test: add shellcode execution e2e matrix 2026-08-23 18:47:39 -07:00
moloch-- b539762de7 test: cover chmod and chown on darwin 2026-08-23 15:39:14 -05:00
moloch-- e71af809d4 fix: support chmod and chown on darwin 2026-08-23 15:39:14 -05:00
moloch-- f3ffa3323b ci: fix cross-platform lint checks 2026-08-23 15:39:14 -05:00
moloch-- 778331d474 ci: export command coverage markdown 2026-08-23 15:39:14 -05:00
moloch-- 8d259577af ci: restore manual-only comprehensive trigger 2026-08-23 15:39:14 -05:00
moloch-- aa108a66a8 ci: restore comprehensive E2E matrix 2026-08-23 15:39:13 -05:00
moloch-- 44e5464950 fix Windows arm64 WireGuard reconnects 2026-08-23 15:39:13 -05:00
moloch-- 1381200b33 fix WireGuard key exchange peer readiness 2026-08-23 15:39:13 -05:00
moloch-- f81b250a04 ci: focus Windows arm64 WireGuard diagnostics 2026-08-23 15:39:13 -05:00
moloch-- 8b4ee5f5c5 test: add opt-in implant debug logging 2026-08-23 15:39:13 -05:00
moloch-- 68cb1363e8 test: preserve E2E command failure diagnostics 2026-08-23 15:39:12 -05:00
moloch-- 2344a3ae50 fix overlapping beacon check-ins 2026-08-23 15:39:12 -05:00
moloch-- 8cec5164b7 fix implant WireGuard device teardown 2026-08-23 15:39:12 -05:00
moloch-- be50c25cb2 fix WireGuard beacon result delivery receipt 2026-08-23 15:39:12 -05:00
moloch-- ee2cc83aa1 test: accept partial Windows service inventories 2026-08-23 15:39:12 -05:00
moloch-- 8e62e2dcd9 ci: temporarily run comprehensive E2E on branch pushes 2026-08-23 15:39:11 -05:00
moloch-- 1347059639 ci: add comprehensive E2E workflow 2026-08-23 15:39:11 -05:00
moloch-- 29efea561c test: add comprehensive implant command E2E suite 2026-08-23 15:39:11 -05:00
moloch-- 82deca2b0f test beacon empty check-in receive path 2026-08-23 15:39:11 -05:00
moloch-- 808b3b6408 fix beacon result delivery across reconnects 2026-08-23 15:39:11 -05:00
moloch-- eba10ebbfc fix Windows arm64 process architecture detection 2026-08-23 15:39:11 -05:00
moloch-- 13903e46a8 fix Linux arm64 memfd creation 2026-08-23 15:39:10 -05:00
moloch-- 8aaaa583bd fix Linux arm64 process architecture detection 2026-08-23 15:39:10 -05:00
moloch-- 4b428065e2 fix WireGuard key exchange response parsing 2026-08-23 15:39:10 -05:00
moloch-- 578f9a28d0 fix UDP-only netstat responses 2026-08-23 15:39:10 -05:00
moloch-- ce2f494f96 fix directory upload overwrite truncation 2026-08-23 15:39:10 -05:00
moloch-- c1c36491a6 fix cd handler error reporting 2026-08-23 15:39:09 -05:00
Joe a5c3482c6d Merge pull request #2335 from kyzzniko-lang/fix/mcp-auth-token
fix(mcp): make `mcp start` usable without the interactive confirmation
2026-08-19 14:12:23 -07:00
Joe b428274515 Merge pull request #2336 from BishopFox/reflektor-ext
Support native extensions on macOS and Linux
2026-08-19 13:24:59 -07:00
moloch-- 5dc6adae47 Pin Reflektor v0.0.6 2026-08-19 13:33:44 -05:00
moloch-- 264b00f3c7 Pin Reflektor v0.0.5 2026-08-19 12:22:06 -05:00
moloch-- ada79c8ecd Use Reflektor native loader for Unix extensions 2026-08-19 11:06:49 -05:00
moloch-- edd4c3d097 Support native extensions on macOS and Linux
Replace the Darwin universal loader with Reflektor, add Linux extension handlers and a shared Unix callback ABI, fix client registration ordering and target visibility, and extend the Reflektor integration lifecycle across supported platforms.
2026-08-19 09:36:44 -05:00
kyzzniko-lang 38f84a4f6f fix(mcp): make mcp start usable without the interactive confirmation
`mcp start` gated the whole startup behind an interactive huh confirmation
("Do you know what prompt injection is and are you an adult?"). On terminals
where those interactive forms break (#2133) the form error was discarded,
the prompt silently evaluated to false, and users were stopped with an
unrelated "not qualified" message before the server ever started: no auth
token in the output and no ~/.sliver-client/mcp.yaml, which is what #2294
reports.

- add --yes/-y to `mcp start` to skip the confirmation (the same
  flags-over-menus escape hatch recommended in #2133), and point the
  rejection message at it and at `settings autoadult`
- print the auth config path next to the token in `mcp start` and `mcp`
  status output so users can locate mcp.yaml
- surface the confirmation form error in IsUserAnAdultWithPrompt instead
  of swallowing it, so a broken terminal reports why it failed

Closes #2294
2026-08-19 19:29:47 +08:00
Joe aee0958e99 Merge pull request #2329 from BishopFox/dependabot/go_modules/charm.land/lipgloss/v2-2.0.6
chore(deps): bump charm.land/lipgloss/v2 from 2.0.4 to 2.0.6
2026-08-18 13:36:26 -07:00
Joe 23c067982a Merge pull request #2315 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/mermaid-11.16.1
chore(deps): bump mermaid from 11.15.0 to 11.16.1 in /docs/sliver-docs
2026-08-18 13:36:12 -07:00
dependabot[bot] 7e917da3ac chore(deps): bump charm.land/lipgloss/v2 from 2.0.4 to 2.0.6
Bumps [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss) from 2.0.4 to 2.0.6.
- [Release notes](https://github.com/charmbracelet/lipgloss/releases)
- [Commits](https://github.com/charmbracelet/lipgloss/compare/v2.0.4...v2.0.6)

---
updated-dependencies:
- dependency-name: charm.land/lipgloss/v2
  dependency-version: 2.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-18 03:08:02 +00:00
Joe 674b49f41f Merge pull request #2303 from BishopFox/dependabot/go_modules/github.com/jedib0t/go-pretty/v6-6.8.3
chore(deps): bump github.com/jedib0t/go-pretty/v6 from 6.7.8 to 6.8.3
2026-08-17 20:05:17 -07:00
moloch-- 4ba04df621 Only run reflektor tests on changes to implant/... 2026-08-17 20:02:29 -07:00
Joe 3274382bc7 Merge pull request #2333 from BishopFox/docs-refresh
Docs refresh
2026-08-17 19:58:50 -07:00
moloch-- 069d57b5e5 Polish docs landing and section content 2026-08-17 19:57:11 -07:00
moloch-- e023211c02 Polish docs branding and landing terminal 2026-08-17 19:35:23 -07:00
moloch-- a48006640d Fix docs scrolling and client navigation 2026-08-17 19:26:35 -07:00
moloch-- 764fd0eddf Refresh docs site design and navigation 2026-08-17 18:47:32 -07:00
dependabot[bot] 9c9943c94c chore(deps): bump github.com/jedib0t/go-pretty/v6 from 6.7.8 to 6.8.3
Bumps [github.com/jedib0t/go-pretty/v6](https://github.com/jedib0t/go-pretty) from 6.7.8 to 6.8.3.
- [Release notes](https://github.com/jedib0t/go-pretty/releases)
- [Commits](https://github.com/jedib0t/go-pretty/compare/v6.7.8...v6.8.3)

---
updated-dependencies:
- dependency-name: github.com/jedib0t/go-pretty/v6
  dependency-version: 6.8.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-18 00:10:18 +00:00
Joe 2e9b52371e Merge pull request #2332 from BishopFox/reflektor-tests
Test Sliver shared libraries with Reflektor
2026-08-17 17:07:02 -07:00
moloch-- 6d6df68972 Update Reflektor to v0.0.3
Synchronize the root and implant module graphs and vendor trees with the all-platform Go c-shared loader fixes.
2026-08-17 00:29:26 -07:00
moloch-- 7f863d3172 Fix cross-platform Reflektor integration failures
Close rendered implant files before import renames, use native GCC for Linux/386 Go shared libraries, and preserve actionable integration logs.
2026-08-17 00:28:49 -07:00
moloch-- 6ecb379bce Test Sliver shared libraries with Reflektor 2026-08-16 22:09:22 -07:00
Joe db45c4aaf9 Merge pull request #2328 from BishopFox/reflektor
Use Reflektor for implant extensions
2026-08-16 18:57:56 -07:00
moloch-- 198913d6f8 Use Reflektor for implant extensions 2026-08-15 21:54:40 -07:00
Joe cd85824185 Merge pull request #2326 from kyzzniko-lang/fix/alias-arg-passthrough
fix(alias): keep alias args intact without "--"
2026-08-15 15:45:27 -07:00
kyzzniko-lang 63797ac79c fix(alias): split splitAliasArgs up, gocyclo was at 26
golangci-lint caps cyclomatic complexity at 15. Break the token matcher,
value consumption and numeric validation into their own functions; the
main loop only dispatches now. No behaviour change, tests unchanged.
2026-08-15 13:37:23 +08:00
kyzzniko-lang 4633409623 fix(alias): keep alias args intact without "--"
Alias commands had it worse than extensions: pflag hard-errors on flags it
does not know, so a payload with its own arguments (amsi-bypass -pid 6969)
could only be invoked through cobra's "--" separator, and every alias help
text pointed to a wiki page explaining the trick (#2264).

Same treatment extensions got in #2323: alias commands now run with
DisableFlagParsing and runAliasCommand pulls the Sliver-owned flags out of
the raw slice itself (args.go). The flag table owns --process,
--process-arguments, --ppid, --save, --timeout and --help for every alias,
plus the .NET assembly set (--method, --class, --app-domain, --arch,
--in-process, --runtime, --amsi-bypass, --etw-bypass) for assembly aliases.
Everything else is handed to the payload verbatim, including glued
shorthands: "-pid" is deliberately NOT read as -p with value "id", which is
exactly the mangling behind #2264. Parsed values are written back onto the
flag set so Request()'s --timeout and the Get* readers keep working, and
"--" still forces everything after it to the payload for name collisions.

Drops the wiki workaround warning from the generated help text.

Closes #2264
2026-08-15 12:00:09 +08:00
Joe 13c2d33449 Merge pull request #2323 from kyzzniko-lang/fix/extension-bof-arg-passthrough
fix(extensions): keep BOF/extension args intact without "--"
2026-08-14 09:48:43 -07:00
kyzzniko-lang 8cf478d7b0 fix(extensions): remove unused flag-name maps flagged by golangci-lint
splitExtensionArgs matches Sliver-owned flags with literal comparisons, so the
extensionOwnedBoolFlags/extensionOwnedIntFlags package vars were never read.
golangci-lint's unused linter rejected them, failing CI on darwin/linux/windows.
Drop the maps and fold the design notes into splitExtensionArgs's doc comment.
2026-08-14 11:44:21 +08:00
Joe f55215ff93 Merge pull request #2324 from MKMithun2806/feat/profiles-generate-external-builder
Feat/profiles generate external builder
2026-08-13 11:32:29 -07:00
Ronan Kervella b5d3d0bad6 Merge branch 'master' into dependabot/npm_and_yarn/docs/sliver-docs/mermaid-11.16.1 2026-08-13 09:41:06 -07:00
Mitchaster a32d81252c Merge branch 'BishopFox:master' into feat/profiles-generate-external-builder 2026-08-13 21:44:34 +05:30
MKMithun2806 a1537535c4 test: cover external-builder flag on profiles generate
Verify the profiles generate subcommand exposes the -E/--external-builder
flag with the same semantics as generate, so the external builder path is
wired and discoverable.

Signed-off-by: MKMithun2806 <[email protected]>
2026-08-13 16:11:22 +00:00
MKMithun2806 61dd34e989 feat: use external builder for profiles generate
Mirror the generate command: when --external-builder is set, call the
existing externalBuild() helper instead of the local compile() path, and
handle ErrNoExternalBuilder / ErrNoValidBuilders with the same user-facing
messages as generate.

Signed-off-by: MKMithun2806 <[email protected]>
2026-08-13 16:09:17 +00:00
MKMithun2806 2b67cb917b feat: add --external-builder flag to profiles generate
Add the same -E/--external-builder flag already used by generate and
generate beacon so profiles generate can offload builds to an external
builder.

Signed-off-by: MKMithun2806 <[email protected]>
2026-08-13 16:08:39 +00:00
kyzzniko-lang ceead92ec5 fix(extensions): keep BOF/extension args intact without "--"
Extension commands were registered with pflag's
ParseErrorsWhitelist.UnknownFlags, which silently drops every unknown flag
together with its value. The extension's own flags (--type, --fqdn, ...) are
unknown to pflag, so they were stripped before reaching the BOF parser, and
operators had to insert cobra's "--" separator as a workaround (#2309).

Run extension commands with DisableFlagParsing and split the Sliver-owned
flags (--save, --timeout, --help) out of the raw argument slice ourselves,
then pass the rest untouched to the BOF/DLL parser. The parsed values are
written back onto the flag set so Request()'s --timeout (and any other
cmd.Flags() reader) still observes them.

Named extension flags now work with and without "--", quoted values and
positional arguments are preserved, and single/double-dash forms still reach
the BOF parser.

Closes #2309
2026-08-13 22:03:00 +08:00
Joe df9ed94635 Merge pull request #2322 from nkbeast/fix/nil-session-deref-crash
fix(server): prevent nil session dereference crash in tunnel/socks/pivot paths
2026-08-12 22:00:48 -07:00
Joe e891613f6f Merge pull request #2318 from MKMithun2806/fix/httpc2-header-probability
fix: default missing HTTPC2Header probability to 100
2026-08-12 22:00:31 -07:00
dependabot[bot] 528b04427a chore(deps): bump mermaid from 11.15.0 to 11.16.1 in /docs/sliver-docs
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.15.0 to 11.16.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/[email protected]@11.16.1)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.16.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-13 04:00:22 +00:00
Joe f60a0e91d3 Merge pull request #2317 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/dompurify-3.4.13
build(deps): bump dompurify from 3.4.12 to 3.4.13 in /docs/sliver-docs
2026-08-12 20:57:38 -07:00
Joe 2308314529 Merge pull request #2319 from BishopFox/dependabot/go_modules/gorm.io/driver/postgres-1.6.2
chore(deps): bump gorm.io/driver/postgres from 1.6.0 to 1.6.2
2026-08-12 20:57:20 -07:00
NK 04bc82bca3 fix(server): prevent nil session dereference crash in tunnel/socks/pivot paths
Sessions.Get() can return nil when an implant session is removed (e.g. the
implant dies or times out) between the RPC handler's nil check and the
tunnel creation call. Both tunnels.Create() and SocksTunnels.Create()
dereferenced the session unconditionally, and since the gRPC server has no
panic recovery interceptor, the panic took down the entire server and all
active sessions (see #1969).

Also guard the tunnel data goroutines (resend, to-implant sender) and the
pivot graph ToProtobuf() against a vanished session, logging and skipping
instead of panicking.

Adds core.ErrInvalidSessionID mapped to codes.InvalidArgument in rpcError().
2026-08-12 09:42:26 +05:30
dependabot[bot] acdfa33231 chore(deps): bump gorm.io/driver/postgres from 1.6.0 to 1.6.2
Bumps [gorm.io/driver/postgres](https://github.com/go-gorm/postgres) from 1.6.0 to 1.6.2.
- [Commits](https://github.com/go-gorm/postgres/compare/v1.6.0...v1.6.2)

---
updated-dependencies:
- dependency-name: gorm.io/driver/postgres
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-10 03:03:31 +00:00
Mitchaster a03592de81 Merge branch 'master' into fix/httpc2-header-probability 2026-08-09 22:57:55 +05:30
Joe 5cf9d088e8 Merge pull request #2316 from MKMithun2806/docs/remove-generate-stager
docs: note the generate stager command was removed
2026-08-09 09:35:03 -07:00
MKMithun2806 db3294b926 fix: default missing HTTPC2Header probability to 100
When probability is omitted from http-c2.json headers it stayed at the
protobuf zero value (0) and was never applied by the implant. Coerce any
header with a zero probability to 100 during config normalization so the
header is always sent.

Signed-off-by: MKMithun2806 <[email protected]>
2026-08-09 16:04:52 +00:00
dependabot[bot] 1847b8c24b build(deps): bump dompurify from 3.4.12 to 3.4.13 in /docs/sliver-docs
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.12 to 3.4.13.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-09 09:20:01 +00:00
MKMithun2806 79e4259081 docs: note the generate stager command was removed
The Sliver generate stager subcommand was removed in recent releases. Update
the Stagers documentation to make clear stagers must now be generated with
Metasploit's msfvenom or custom tooling, and correct the HTTP(S) stager
section heading to reflect supported schemes.
2026-08-08 17:39:12 +00:00
Joe 9ff9b55352 Merge pull request #2305 from Luppa90/fix/mtls-wg-frame-bounds
fix(implant): bound mTLS and WireGuard envelope allocation from length prefix
2026-08-05 09:42:30 -07:00
Joe 9767e24edd Merge pull request #2312 from BishopFox/dependabot/go_modules/golang.org/x/text-0.40.0
Bump golang.org/x/text from 0.37.0 to 0.40.0
2026-08-05 09:42:13 -07:00
dependabot[bot] bb572f3846 Bump golang.org/x/text from 0.37.0 to 0.40.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.37.0 to 0.40.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.37.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-03 03:03:48 +00:00
Joe 01c5b5cee4 Merge pull request #2306 from Luppa90/fix/canary-nil-connection-panic
fix(server): guard Session.ToProtobuf against nil Connection
2026-07-31 15:52:17 -07:00
Luppa 1049dc4a98 fix(lint): add package comment to wireguard test file
Satisfies the revive package-comments rule that was failing CI on all
three platforms.
2026-07-31 21:55:49 +01:00
Luppa d1b0ba13c4 fix(server): guard Session.ToProtobuf against nil Connection
When a DNS canary fires, handleCanary publishes an event containing a
Session with only the Name field set (Connection is nil). The RPC event
stream calls ToProtobuf() on that Session, which dereferences Connection
for Transport, RemoteAddress, LastCheckin, and IsDead — causing a nil
pointer panic that crashes the server.

Guard the Connection-dependent fields so ToProtobuf returns zero values
for transport metadata when Connection is nil, instead of panicking.

Fixes BishopFox/sliver#1435
2026-07-31 10:49:07 +01:00
Luppa 2d98f14149 fix(implant): bound mTLS and WireGuard envelope allocation from length prefix
Same vulnerability fixed for pivots in #2292: a corrupted or desynced
4-byte length prefix could trigger an allocation up to ~4 GiB, crashing
the implant. Cap at 512 MiB (matching the pivot transport limit) and
return a descriptive error instead of allocating.

Also fixes stale "[pivot]" log tags in both transports' zero-length
error paths.
2026-07-31 08:55:30 +01:00
Joe 19e82064b1 Merge pull request #2292 from Luppa90/fix/pivot-frame-bounds
fix(implant): bound pivot frame allocation from length prefix
2026-07-30 14:34:54 -07:00
Joe 3b72374685 Merge pull request #2291 from Luppa90/fix/log-rotation
fix(server): rotate server logs to bound disk usage
2026-07-30 14:34:36 -07:00
Luppa 20fb8c4975 fix(implant): handle Close errors in pivot tests (errcheck) 2026-07-30 11:07:01 +01:00
Luppa 68829c0a40 fix(server): handle Close error in rotation test (errcheck) 2026-07-30 10:53:46 +01:00
Joe 2c18062f4d Merge branch 'master' into fix/log-rotation 2026-07-29 17:38:56 -07:00
Joe f17ce0afbe Merge pull request #2302 from BishopFox/dependabot/go_modules/charm.land/glamour/v2-2.0.1
Bump charm.land/glamour/v2 from 2.0.0 to 2.0.1
2026-07-29 17:38:16 -07:00
dependabot[bot] 116b12885c Bump charm.land/glamour/v2 from 2.0.0 to 2.0.1
Bumps [charm.land/glamour/v2](https://github.com/charmbracelet/glamour) from 2.0.0 to 2.0.1.
- [Release notes](https://github.com/charmbracelet/glamour/releases)
- [Commits](https://github.com/charmbracelet/glamour/compare/v2.0.0...v2.0.1)

---
updated-dependencies:
- dependency-name: charm.land/glamour/v2
  dependency-version: 2.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-28 00:31:47 +00:00
Joe 3d9860db6c Merge pull request #2301 from BishopFox/dependabot/go_modules/gorm.io/gorm-1.31.2
Bump gorm.io/gorm from 1.31.0 to 1.31.2
2026-07-27 17:29:18 -07:00
dependabot[bot] 1a6579e954 Bump gorm.io/gorm from 1.31.0 to 1.31.2
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.31.0 to 1.31.2.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.31.0...v1.31.2)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-version: 1.31.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-27 03:03:55 +00:00
Joe 2659d848d7 Merge pull request #2298 from BishopFox/feat/golangci-lint
Replace Go Report Card with golangci-lint
2026-07-26 17:08:34 -07:00
moloch-- df6571f63f Fix linting without generated assets 2026-07-26 15:18:21 -07:00
moloch-- aab1470e08 Replace Go Report Card with golangci-lint 2026-07-26 14:49:42 -07:00
Joe 19d43fdc37 Merge pull request #2295 from ph0neh0me/docs/go-archive-validation
docs: add go archive validation examples
2026-07-26 14:47:25 -07:00
Joe 3b09e5ea80 Merge pull request #2297 from BishopFox/feat/dependabot-follow-up
Fix remaining Dependabot advisories
2026-07-26 14:10:38 -07:00
moloch-- b904d29b25 Update implant security dependencies 2026-07-26 08:57:27 -07:00
moloch-- 8e2b069a7d Fix remaining Dependabot advisories 2026-07-26 08:08:26 -07:00
Joe 096c418d27 Merge pull request #2296 from BishopFox/feat/dependabot-security-fixes
Fix Dependabot security alerts
2026-07-25 18:47:29 -07:00
moloch-- d9e241ccb5 Fix flaky website content test 2026-07-25 14:19:06 -07:00
moloch-- 086da95525 Fix Dependabot security alerts 2026-07-24 16:48:14 -07:00
Peter Georgiev 27ed557e55 docs: add go archive validation examples 2026-07-25 01:46:46 +03:00
Joe af7c823508 Merge pull request #2290 from ph0neh0me/feature/go-archive-output
Add Go c-archive output format
2026-07-23 17:54:34 -07:00
Joe f2a3d8ee6d Merge pull request #2293 from ecapuano/docs/update-soc-analyst-link
Docs: update stale SOC analyst blog post link
2026-07-22 21:49:00 -07:00
Eric Capuano de7215d2c0 docs: update stale SOC analyst blog link in Community Guides 2026-07-20 22:09:20 -05:00
Luppa 2d555b16d9 fix(implant): bound pivot frame allocation from length prefix
Both pivot readers (NetConnPivot and NetConnPivotClient) allocated
make([]byte, dataLength) directly from a peer's 4-byte length prefix with only
a lower-bound (<= 0) check. A corrupted or desynced prefix -- e.g. from a
framing desync during a transfer over a pivot -- can therefore be read as a
value up to ~4GiB and trigger an allocation large enough to crash the implant
(reported in #1452 with a 300KiB download over an http -> named-pipe pivot).

Add an upper bound (MaxFrameLength, 512MiB) and reject oversized frames with
ErrFrameTooLarge before allocating. The constant and error live in the pivots
package and are reused by pivotclients, which already imports it.

Adds tests covering rejection of an oversized prefix (with a timeout guard so a
regression fails fast instead of hanging) and a valid-frame round-trip.
2026-07-18 17:33:03 +01:00
Luppa 597a2571fc fix(server): rotate server logs to bound disk usage
The server log sinks (sliver.json, sliver.log, audit.json) were opened as
plain append-only file handles with no rotation, so a sustained error
condition (e.g. a hot RPC/accept error path) can grow them without bound and
exhaust the host disk, locking out the operator.

Route each sink through lumberjack. Rotation starts with safe built-in
defaults at logger init and is retuned from LogConfig after the server config
loads, mirroring the existing SetLevel pattern (the log package cannot import
server/configs -- import cycle). Configs that predate the new fields keep the
built-in defaults, so rotation is never accidentally disabled.

Adds LogConfig knobs (max_size_mb, max_backups, max_age_days, compress) and
unit tests covering the defaults, override/zero-value semantics, and real
on-disk rotation. New log files are created 0600 (more restrictive than the
previous 0644 for sliver.json/sliver.log).
2026-07-18 17:16:18 +01:00
Luppa 409b4c8fdb chore: vendor gopkg.in/natefinch/lumberjack.v2
Dependency for bounded server log rotation. Kept in a distinct commit from
the functional change per CONTRIBUTING.md ("Any changes to vendor/ should be
in a distinct commit").
2026-07-18 17:16:17 +01:00
Peter Georgiev 2e72bc96b9 Add Go c-archive output format 2026-07-18 15:09:09 +03:00
Joe fa9557348e Merge pull request #2286 from Functionhx/fix/sliver-1775-proxy-ua
fix: set ProxyConnectHeader to prevent UA leak on proxy CONNECT
2026-07-17 15:18:03 -07:00
Functionhx 4768e36074 fix: set ProxyConnectHeader to use spoofed User-Agent
Set http.Transport.ProxyConnectHeader with the generated User-Agent so
HTTPS CONNECT requests through a proxy use the spoofed UA instead of
leaking the default Go-http-client identifier.

Fixes BishopFox/sliver#1775

Signed-off-by: Yuchen Fan <[email protected]>
2026-07-17 14:00:07 +08:00
Joe e53f66de72 Merge pull request #2288 from BishopFox/feat/pr-2280-ci-fix
fix(dns): handle SplitBuffer varint boundaries and restore CI
2026-07-14 22:34:12 -07:00
moloch-- 1d33b89ec5 test: clean Go cache before generation suite 2026-07-14 19:14:37 -07:00
moloch-- 7b0f420290 Fix DNS splitting and Windows implant builds 2026-07-14 18:31:17 -07:00
moloch-- 00824d1b83 Merge remote-tracking branch 'origin/master' into feat/pr-2280-ci-fix 2026-07-14 07:50:18 -07:00
Joe 99ad05b7b7 Merge pull request #2287 from BishopFox/fix/png-decoder
Fix unbounded PNG decoding
2026-07-14 07:47:32 -07:00
moloch-- 80273462bd Fix unbounded PNG decoding 2026-07-13 22:07:42 -07:00
Luppa 2cdefc7369 fix(dns): step back on varint-boundary overshoot in SplitBuffer
The inner loop guard (subdataSpace-1) only accounts for a +2 base32
jump from a pure encoding boundary. When msg.Data length crosses a
proto3 varint boundary (e.g. 127->128 bytes), the serialized message
grows by 2 bytes, pushing the base32 encoding 3-4 chars over the
budget. This caused errMsgTooLong on specific parent domain length
parities. Add a step-back loop to correct overshoots without losing
the fast-path acceleration.

Fixes #2279
2026-06-28 08:50:31 +01:00
Ronan Kervella 80c752a422 Merge pull request #2273 from MKMithun2806/feature/configurable-keepalive
Feature/configurable keepalive
2026-06-03 12:35:42 -07:00
Mitchaster 8a8a9dc498 Merge branch 'BishopFox:master' into feature/configurable-keepalive 2026-06-03 06:14:57 +00:00
Mithun 5f8540b3c4 Standardize formatting with gofmt 2026-06-03 06:12:54 +00:00
Mithun 4eef868047 Add unit tests for rtunnels KeepAlive state management 2026-06-03 06:12:40 +00:00
Mithun 5ebfecd822 Use configurable KeepAlive in createReverseTunnelHandler 2026-06-03 06:12:26 +00:00
Mithun 9762560228 Pass KeepAlive to TrackListener in StartRportFwdListener 2026-06-03 06:12:12 +00:00
Mithun 6310c7d6a0 Store KeepAlive in rtunnels state manager 2026-06-03 06:11:58 +00:00
moloch-- fafe360672 go mod vendor 2026-05-31 14:25:51 -07:00
moloch-- d9b6f41940 Bump pge dep 2026-05-30 11:49:46 -07:00
moloch-- f1ccf0eac3 Docs site version bumps 2026-05-30 09:50:58 -07:00
Joe d2aa924bc4 Merge pull request #2258 from SAY-5/fix/install-detect-existing-minisign-2239
install: detect existing minisign before apt-get install
2026-05-07 08:01:04 -07:00
Joe 46861cd428 Merge pull request #2259 from cyberguerilla68/patch-1
Duplicate HTML Fix
2026-05-07 07:46:17 -07:00
ju li 1c1825f8f0 Duplicate HTML Fix
stageHandler function was missing a return statement at line 780, causing the server to serve duplicate HTML blocks when using the websites feature, causing things to render twice, and breaking embedded scripts like WAS stagers.

Signed-off-by: ju li <[email protected]>
2026-05-05 15:40:48 -04:00
SAY-5 a943995040 install: detect existing minisign before apt-get install
Signed-off-by: SAY-5 <[email protected]>
2026-05-05 03:48:27 -07:00
Joe f0d2a2cf24 Merge pull request #2257 from BishopFox/go/v1.26.2
Go v1.26.2
2026-05-03 17:47:00 -07:00
Joe 27ac612c82 Merge pull request #2255 from SAY-5/fix/event-broker-closed-channel-2252
core/events: don't panic on send to closed subscriber
2026-05-03 09:19:06 -07:00
moloch-- baedb563f3 Go v1.26.2 2026-05-03 09:17:04 -07:00
SAY-5 45c7914602 core/events: don't panic on send to closed subscriber
Closes #2252.
2026-05-02 01:05:02 -07:00
moloch-- 92d85cb8ff Tweak styling 2026-05-01 07:17:02 -07:00
moloch-- f5e831844a Fix various docs ui quirks in hero v3 2026-04-30 18:21:06 -07:00
moloch-- ab2fc7dc4a Initial update to heroui v3 2026-04-30 17:59:11 -07:00
Joe 5e377019e4 Merge pull request #2254 from smeukinou/fix-sliverpy
fix: safely close event broker subscription channels
2026-04-30 11:37:01 -07:00
smeukinou 88a4bebca0 fix: safely close event broker subscription channels
Moves the responsibility of closing the event subscription channel to the broker.Start() goroutine when processing an unsubscribe request. This prevents potential data races and panics caused by closing a channel concurrently while the broker might still be attempting to publish events to it.
2026-04-29 23:07:40 +02:00
Joe 2daccb5bd3 Merge pull request #2250 from BishopFox/wg/opt-in
Disable wg wrapper on multiplayer by default
2026-04-21 21:24:17 -07:00
Joe e2a009d4ac Merge pull request #2249 from Moriz82/fix/services-windows-nil-deref
fix(implant): services list panics on nil error on Windows (#1989)
2026-04-21 21:03:40 -07:00
moloch-- eda51f826a Disable wg wrapper on multiplayer by default 2026-04-21 20:51:06 -07:00
Will DeDominico f83dc42a64 fix(implant): services list panics on nil error (#1989)
servicesListHandler called err.Error() unconditionally after
service.ListServices(). On a healthy Windows host ListServices returns
nil error, causing a nil pointer dereference that crashed the implant.

serviceDetailHandler in the same file already guards correctly with
if err != nil. Apply the same pattern to servicesListHandler by
extracting response construction into a buildServicesResp helper with
a proper nil check.

Verified on Windows 11 VM: unfixed helper panics with "runtime error:
invalid memory address or nil pointer dereference" on the nil-error
path; fixed helper passes.

Fixes #1989
2026-04-20 20:13:46 -05:00
Joe 17e8c996a6 Merge pull request #2244 from Moriz82/fix/localtask-process-isolation
fix(implant): run LocalTask shellcode in forked child (#2237)
2026-04-19 08:44:56 -07:00
Will DeDominico 2b667f0e7e fix(implant): run LocalTask shellcode in forked child (#2237)
The previous implementation ran shellcode in a goroutine inside the
beacon; payloads that terminate via exit/execve killed the whole
beacon. Fork a child instead and let the parent wait for it.

Linux uses SYS_CLONE(SIGCHLD) since linux/arm64 has no SYS_FORK.
Darwin uses SYS_FORK, with the isChild check routed through the r1==1
convention that libSystem's fork() wrapper normally hides.

Post-fork the child runs only raw syscalls: runtime_BeforeFork poisons
stackguard0 and AfterForkInChild clears inherited signal handlers.
Mprotect happens in the child so the parent's page table stays
writable, inherited FDs are closed to avoid leaking C2 sockets into
exec'd payloads, and the child exits via SYS_EXIT_GROUP (Linux) or
SYS_EXIT (Darwin).

Tests under implant/sliver/taskrunner/ cover parent survival and
non-zero-exit error reporting on both Linux and Darwin.
2026-04-16 20:52:38 -05:00
Ronan Kervella 73bcf40bfd Merge pull request #2240 from BishopFox/CLD-10806/update-codeowners-replace-red-team
chore(doc): replace red-team with bf-red-team in CODEOWNERS (CLD-10806)
2026-04-10 09:02:55 -07:00
George Robbins b635adcb3c chore(doc): replace red-team with bf-red-team in CODEOWNERS (CLD-10806) 2026-04-10 10:56:01 -05:00
Joe 2b92397e07 Merge pull request #2236 from BishopFox/codex/pr2234-wg-ci
Fix WG client Windows CI regressions
2026-04-09 10:11:57 -07:00
moloch-- c23067a0e0 Wait for AI RPC event streams to subscribe 2026-04-02 09:17:33 -07:00
moloch-- 3aefc39c08 Stabilize AI RPC event stream tests 2026-04-02 08:50:19 -07:00
moloch-- abd8c83df8 Stabilize Windows WireGuard reconnects 2026-04-02 08:22:51 -07:00
Joe 6cce9426f2 Merge pull request #2235 from skoveit/fix/pkg-cache-delete-wrong-argument
fix: pkgCache.Delete(value) should be pkgCache.Delete(key)
2026-04-01 17:30:20 -07:00
skove 2e742863a1 fix: pass key instead of value to pkgcache.delete() 2026-04-01 14:47:39 +02:00
moloch-- dc13a5bb93 Add connecting animation 2026-03-31 13:33:19 -07:00
moloch-- 1f2946eb87 Revert changes to netstack 2026-03-31 09:45:40 -07:00
moloch-- 5eeda4c02b console now keeps the long-lived background WG streams on one connection 2026-03-31 09:36:05 -07:00
moloch-- 55a3386313 Improve reliabilty of multiplayer/wg setup 2026-03-30 20:02:55 -07:00
moloch-- b34589a002 Merge branch 'master' of github.com:BishopFox/sliver 2026-03-30 15:56:54 -07:00
moloch-- 4486b17b49 Tweak release trigger 2026-03-30 15:56:45 -07:00
Joe 377ee5ca7c Merge pull request #2229 from BishopFox/docs/multiplayer-wg
Update docs for wg+grpc/mtls multiplayer
2026-03-30 12:13:43 -07:00
moloch-- 4765f40854 v1.7.4 2026-03-30 11:51:15 -07:00
moloch-- e239d85821 v1.7.4 2026-03-30 11:48:34 -07:00
Joe dec419b284 Merge pull request #2232 from BishopFox/fix/linux-ver
Implement Detailed Linux Version Info
2026-03-30 09:52:39 -07:00
moloch-- 593925b4a5 Update docs for wg+grpc/mtls multiplayer 2026-03-26 17:30:33 -07:00
2358 changed files with 348710 additions and 199439 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Preserve pinned upstream source and attribution files byte-for-byte.
client/command/opfor/testdata/corpus/operatorskit/*.cna whitespace=-trailing-space,-blank-at-eof
vendor/github.com/sliverarmory/opfor/internal/regexp2/ATTRIB whitespace=-trailing-space
+1 -1
View File
@@ -1 +1 @@
* @BishopFox/red-team
* @BishopFox/bf-red-team
+28 -9
View File
@@ -2,8 +2,8 @@ name: Release
on:
push:
branches:
- master
tags:
- "v*.*.*"
permissions:
contents: write
@@ -21,17 +21,36 @@ jobs:
with:
fetch-depth: 0
- name: Find Semver Tag
- name: Validate Release Tag
id: tag
run: |
git fetch --prune --tags -f
tag=$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
git fetch --prune --tags -f origin
tag="${GITHUB_REF_NAME}"
if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag $tag is not a release semver tag."
exit 0
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/master"; then
echo "Tag $tag does not point to a commit on origin/master."
exit 0
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
e2e:
name: End-to-end test suites
needs: tag-check
if: needs.tag-check.outputs.tag != ''
uses: ./.github/workflows/comprehensive-e2e.yml
release:
name: Release
needs: tag-check
if: needs.tag-check.outputs.tag != ''
needs:
- tag-check
- e2e
if: needs.tag-check.outputs.tag != '' && needs.e2e.result == 'success'
runs-on: ubuntu-latest
environment: release
timeout-minutes: 45
@@ -41,10 +60,10 @@ jobs:
with:
fetch-depth: 0
- name: Go 1.25.8
- name: Go 1.26.6
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version: "1.26.6"
- name: Install Minisign
run: sudo apt-get update --fix-missing && sudo apt-get -y install minisign
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
steps:
# Setup go environment
- name: Go 1.25.8
- name: Go 1.26.6
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version: "1.26.6"
- name: Checkout repository
uses: actions/checkout@v6
+456
View File
@@ -0,0 +1,456 @@
name: Comprehensive e2e Tests
on:
workflow_dispatch:
workflow_call:
permissions:
contents: read
concurrency:
group: comprehensive-e2e-${{ github.ref }}
cancel-in-progress: false
jobs:
authorize:
name: Authorize invocation
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check actor repository permission
uses: actions/github-script@v9
env:
REQUESTING_ACTOR: ${{ github.triggering_actor }}
with:
script: |
const isReleaseTag = context.eventName === "push" && context.ref.startsWith("refs/tags/v");
if (isReleaseTag) {
core.info(`Authorized release test invocation for ${context.ref}`);
return;
}
const actor = process.env.REQUESTING_ACTOR || context.actor;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: actor,
});
core.info(`${actor} has repository permission ${data.permission} (${data.role_name})`);
if (data.permission !== "admin") {
core.setFailed("Comprehensive e2e Tests may only be run by repository administrators");
}
darwin:
name: comprehensive-e2e-darwin-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
goarch: amd64
server_arch: amd64
- runner: macos-15
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'darwin/%s\n' '${{ matrix.goarch }}' > e2e-results/target-darwin-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: darwin
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: darwin
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e ./test/e2e
- name: Run comprehensive E2E suite
run: >-
./sliver-comprehensive-e2e
-test.v
-test.run=^TestComprehensiveE2E$
-test.timeout=0
-repo .
-server ./sliver-server
-server-arch ${{ matrix.server_arch }}
-target-os darwin
-target-arch ${{ matrix.goarch }}
-results ./e2e-results
-transports mtls,wg,http
-implant-modes session,beacon
-beacon-interval 10s
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-darwin-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
linux-native:
name: comprehensive-e2e-linux-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
goarch: amd64
server_arch: amd64
- runner: ubuntu-24.04-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'linux/%s\n' '${{ matrix.goarch }}' > e2e-results/target-linux-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: linux
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: linux
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e ./test/e2e
- name: Run comprehensive E2E suite
run: >-
./sliver-comprehensive-e2e
-test.v
-test.run=^TestComprehensiveE2E$
-test.timeout=0
-repo .
-server ./sliver-server
-server-arch ${{ matrix.server_arch }}
-target-os linux
-target-arch ${{ matrix.goarch }}
-results ./e2e-results
-transports mtls,wg,http
-implant-modes session,beacon
-beacon-interval 10s
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-linux-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
linux-386:
name: comprehensive-e2e-linux-386
needs: authorize
runs-on: ubuntu-24.04
timeout-minutes: 360
env:
CONTAINER_NAME: sliver-comprehensive-e2e-linux-386-${{ github.run_id }}-${{ github.run_attempt }}
IMAGE_NAME: sliver-comprehensive-e2e-linux-386:${{ github.run_id }}-${{ github.run_attempt }}
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'linux/386\n' > e2e-results/target-linux-386.txt
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build linux/386 E2E image
run: |
set -euo pipefail
go_version="$(awk '$1 == "go" { print $2; exit }' go.mod)"
docker buildx build \
--load \
--platform linux/386 \
--build-arg "GO_VERSION=${go_version}" \
--file test/e2e/Dockerfile.linux-386 \
--tag "${IMAGE_NAME}" \
.
- name: Run comprehensive E2E suite
run: >-
docker run
--name "${CONTAINER_NAME}"
--platform linux/386
--volume "${PWD}/e2e-results:/results"
"${IMAGE_NAME}"
- name: Clean up linux/386 Docker resources
if: ${{ always() }}
run: |
docker rm --force "${CONTAINER_NAME}" 2>/dev/null || true
docker image rm --force "${IMAGE_NAME}" 2>/dev/null || true
docker buildx prune --all --force >/dev/null 2>&1 || true
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-linux-386
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
windows:
name: comprehensive-e2e-windows-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: windows-2022
goarch: 386
server_arch: amd64
- runner: windows-2022
goarch: amd64
server_arch: amd64
- runner: windows-11-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'windows/%s\n' '${{ matrix.goarch }}' > e2e-results/target-windows-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: windows
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server.exe ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: windows
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e.exe ./test/e2e
- name: Run comprehensive E2E suite
run: >-
./sliver-comprehensive-e2e.exe
-test.v
-test.run=^TestComprehensiveE2E$
-test.timeout=0
-repo .
-server ./sliver-server.exe
-server-arch ${{ matrix.server_arch }}
-target-os windows
-target-arch ${{ matrix.goarch }}
-results ./e2e-results
-transports mtls,wg,http
-implant-modes session,beacon
-beacon-interval 10s
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-windows-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
aggregate-coverage:
name: Aggregate comprehensive E2E coverage
needs:
- authorize
- darwin
- linux-native
- linux-386
- windows
if: ${{ always() && needs.authorize.result == 'success' }}
outputs:
command_coverage_markdown: ${{ steps.publish_command_coverage.outputs.command_coverage_markdown }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Download target coverage
uses: actions/download-artifact@v8
with:
pattern: comprehensive-e2e-target-*
path: coverage-input
merge-multiple: true
- name: Prepare aggregate artifact
run: |
mkdir -p coverage-input coverage-summary
printf 'Comprehensive E2E coverage aggregate\n' > coverage-summary/README.txt
- name: Aggregate coverage and enforce required cells
id: aggregate
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/report
-input coverage-input
-output coverage-summary
- name: Publish command coverage table
id: publish_command_coverage
if: ${{ always() }}
run: |
detailed_report="coverage-summary/coverage-summary.md"
report="coverage-summary/command-coverage.md"
if [[ ! -s "${report}" ]]; then
printf 'Command coverage report is missing or empty: %s\n' "${report}" >&2
exit 1
fi
delimiter="COMPREHENSIVE_E2E_COMMAND_COVERAGE_EOF"
if grep -Fxq "${delimiter}" "${report}"; then
printf 'Command coverage report contains the reserved output delimiter: %s\n' "${delimiter}" >&2
exit 1
fi
{
printf 'command_coverage_markdown<<%s\n' "${delimiter}"
cat "${report}"
printf '%s\n' "${delimiter}"
} >> "${GITHUB_OUTPUT}"
if [[ -f "${detailed_report}" ]]; then
cat "${detailed_report}" >> "${GITHUB_STEP_SUMMARY}"
fi
printf '\n## Command coverage by OS/architecture/transport\n\n' >> "${GITHUB_STEP_SUMMARY}"
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload aggregate coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-coverage-summary
path: coverage-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed or not-run coverage
if: ${{ steps.aggregate.outcome != 'success' }}
run: exit 1
reflektor:
name: Existing Reflektor test matrix
needs: authorize
uses: ./.github/workflows/reflektor-tests.yml
shellcode:
name: Shellcode E2E test matrix
needs: authorize
uses: ./.github/workflows/shellcode-tests.yml
+56
View File
@@ -0,0 +1,56 @@
name: golangci-lint
on:
push:
branches:
- master
pull_request:
branches:
- master
paths:
- ".golangci.yml"
- ".github/workflows/golangci-lint.yml"
- "go.mod"
- "go.sum"
- "go.work"
- "go.work.sum"
- "Makefile"
- "vendor/**"
- "**/*.go"
permissions:
contents: read
jobs:
lint:
name: golangci-lint (${{ matrix.goos }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
goos:
- linux
- darwin
- windows
env:
GOOS: ${{ matrix.goos }}
GOARCH: amd64
CGO_ENABLED: "0"
steps:
- name: Check Out Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Go 1.26.6
uses: actions/setup-go@v6
with:
go-version: "1.26.6"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
# Gate only findings introduced since the event base revision.
args: --new-from-rev=${{ github.event.pull_request.base.sha || github.event.before }} ./client/... ./server/... ./util/... ./protobuf/... ./docs ./implant/...
+214
View File
@@ -0,0 +1,214 @@
name: Reflektor Integration Test Matrix
on:
workflow_call:
permissions:
contents: read
jobs:
darwin:
name: reflektor-darwin-${{ matrix.goarch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
goarch: amd64
server_arch: amd64
- runner: macos-15
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: darwin
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: darwin
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e
-repo .
-server ./sliver-server
-target-os darwin
-target-arch ${{ matrix.goarch }}
linux-native:
name: reflektor-linux-${{ matrix.goarch }}-native
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
goarch: amd64
server_arch: amd64
- runner: ubuntu-24.04-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: linux
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: linux
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e
-repo .
-server ./sliver-server
-target-os linux
-target-arch ${{ matrix.goarch }}
linux-386:
name: reflektor-linux-386-docker
runs-on: ubuntu-24.04
timeout-minutes: 180
env:
CONTAINER_NAME: sliver-reflektor-linux-386-${{ github.run_id }}-${{ github.run_attempt }}
IMAGE_NAME: sliver-reflektor-linux-386:${{ github.run_id }}-${{ github.run_attempt }}
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build linux/386 test image
run: |
set -euo pipefail
go_version="$(awk '$1 == "go" { print $2; exit }' go.mod)"
docker buildx build \
--load \
--platform linux/386 \
--build-arg "GO_VERSION=${go_version}" \
--file test/reflektor/Dockerfile.linux-386 \
--tag "${IMAGE_NAME}" \
.
docker buildx prune --all --force
- name: Load Sliver with Reflektor
run: >-
docker run
--name "${CONTAINER_NAME}"
--platform linux/386
"${IMAGE_NAME}"
- name: Clean up linux/386 Docker resources
if: ${{ always() }}
run: |
docker rm --force "${CONTAINER_NAME}" 2>/dev/null || true
docker image rm --force "${IMAGE_NAME}" 2>/dev/null || true
docker buildx prune --all --force >/dev/null 2>&1 || true
windows:
name: reflektor-windows-${{ matrix.goarch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: windows-2022
goarch: amd64
server_arch: amd64
- runner: windows-2022
goarch: 386
server_arch: amd64
- runner: windows-11-arm
goarch: arm64
server_arch: amd64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: windows
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server.exe ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: windows
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e.exe ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e.exe
-repo .
-server ./sliver-server.exe
-target-os windows
-target-arch ${{ matrix.goarch }}
+208
View File
@@ -0,0 +1,208 @@
name: Shellcode E2E Test Matrix
on:
workflow_call:
outputs:
shellcode_coverage_markdown:
description: Shellcode E2E coverage table in Markdown
value: ${{ jobs.aggregate.outputs.shellcode_coverage_markdown }}
permissions:
contents: read
jobs:
target:
name: shellcode-e2e-${{ matrix.target_os }}-${{ matrix.target_arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: macos-26
target_os: darwin
target_arch: arm64
server_os: darwin
server_arch: arm64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: ubuntu-24.04
target_os: linux
target_arch: amd64
server_os: linux
server_arch: amd64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: ubuntu-24.04-arm
target_os: linux
target_arch: arm64
server_os: linux
server_arch: arm64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: windows-2022
target_os: windows
target_arch: "386"
server_os: windows
server_arch: amd64
server_executable: ./sliver-server.exe
test_executable: ./sliver-shellcode-e2e.exe
- runner: windows-2022
target_os: windows
target_arch: amd64
server_os: windows
server_arch: amd64
server_executable: ./sliver-server.exe
test_executable: ./sliver-shellcode-e2e.exe
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Prepare target artifact
run: |
mkdir -p shellcode-results
printf '%s/%s\n' \
'${{ matrix.target_os }}' \
'${{ matrix.target_arch }}' \
> 'shellcode-results/target-${{ matrix.target_os }}-${{ matrix.target_arch }}.txt'
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor -trimpath ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: ${{ matrix.server_os }}
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o ${{ matrix.server_executable }} ./server
- name: Compile shellcode E2E client
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.target_arch }}
GOOS: ${{ matrix.target_os }}
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o ${{ matrix.test_executable }} ./test/e2e
- name: Run shellcode generation and execution tests
run: >-
${{ matrix.test_executable }}
-test.v
-test.run=^TestShellcodeE2E$
-test.timeout=0
-repo .
-server ${{ matrix.server_executable }}
-server-arch ${{ matrix.server_arch }}
-target-os ${{ matrix.target_os }}
-target-arch ${{ matrix.target_arch }}
-results ./shellcode-results
-transports mtls,wg,http
-implant-modes session,beacon
-beacon-interval 10s
-shellcode-sgn-samples 4
- name: Upload target shellcode results
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: shellcode-e2e-target-${{ matrix.target_os }}-${{ matrix.target_arch }}
path: shellcode-results
if-no-files-found: error
overwrite: true
retention-days: 14
aggregate:
name: Aggregate shellcode E2E coverage
needs: target
if: ${{ always() }}
outputs:
shellcode_coverage_markdown: ${{ steps.publish_shellcode_coverage.outputs.shellcode_coverage_markdown }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Prepare aggregate artifact
run: |
mkdir -p shellcode-input shellcode-summary
printf 'Shellcode E2E coverage aggregate\n' > shellcode-summary/README.txt
- name: Download target shellcode results
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: shellcode-e2e-target-*
path: shellcode-input
merge-multiple: true
- name: Aggregate shellcode coverage
id: aggregate
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor -trimpath ./test/e2e/shellcode-report
-input ./shellcode-input
-output ./shellcode-summary
- name: Publish shellcode coverage table
id: publish_shellcode_coverage
if: ${{ always() }}
run: |
report="shellcode-summary/shellcode-coverage.md"
if [[ ! -s "${report}" ]]; then
printf 'Shellcode coverage report is missing or empty: %s\n' "${report}" >&2
exit 1
fi
delimiter="SHELLCODE_E2E_COVERAGE_EOF"
if grep -Fxq "${delimiter}" "${report}"; then
printf 'Shellcode coverage report contains the reserved output delimiter: %s\n' "${delimiter}" >&2
exit 1
fi
{
printf 'shellcode_coverage_markdown<<%s\n' "${delimiter}"
cat "${report}"
printf '%s\n' "${delimiter}"
} >> "${GITHUB_OUTPUT}"
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload aggregate shellcode coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: shellcode-e2e-coverage-summary
path: shellcode-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed or not-run shellcode coverage
if: ${{ always() && steps.aggregate.outcome != 'success' }}
run: exit 1
+8 -8
View File
@@ -25,10 +25,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Go 1.25.8
- name: Go 1.26.6
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version: "1.26.6"
- name: OS Packages
run: |
@@ -71,10 +71,10 @@ jobs:
runs-on: windows-latest
timeout-minutes: 180
steps:
- name: Go 1.25.8
- name: Go 1.26.6
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version: "1.26.6"
- name: Install make
run: choco install make -y --no-progress
@@ -115,10 +115,10 @@ jobs:
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Go 1.25.8
- name: Go 1.26.6
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version: "1.26.6"
- name: Check Out Code
uses: actions/checkout@v6
@@ -153,10 +153,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Go 1.25.8
- name: Go 1.26.6
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
go-version: "1.26.6"
- name: Check Out Code
uses: actions/checkout@v6
+27
View File
@@ -0,0 +1,27 @@
version: "2"
run:
timeout: 10m
modules-download-mode: vendor
build-tags:
- client
- server
- osusergo
- netgo
- go_sqlite
# Raw implant entry points are Go templates; lint their supporting packages.
- sliver_lint
linters:
default: standard
enable:
- gocyclo
- misspell
- revive
settings:
gocyclo:
min-complexity: 15
formatters:
enable:
- gofmt
+18 -1
View File
@@ -2,18 +2,31 @@
# Makefile for Sliver
#
.DEFAULT_GOAL := default
GO ?= go
ARTIFACT_SUFFIX ?=
ENV =
TAGS ?= -tags go_sqlite
CGO_ENABLED = 0
GOLANGCI_LINT ?= golangci-lint
# The tree has legacy lint findings, so gate issues introduced by this change.
GOLANGCI_LINT_BASE ?= master
GOLANGCI_LINT_ARGS ?= --new-from-merge-base=$(GOLANGCI_LINT_BASE)
GOLANGCI_LINT_PACKAGES = \
./client/... \
./server/... \
./util/... \
./protobuf/... \
./docs \
./implant/...
ifneq (,$(findstring cgo_sqlite,$(TAGS)))
CGO_ENABLED = 1
endif
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
MIN_SUPPORTED_GO_MINOR_VERSION = 25
MIN_SUPPORTED_GO_MINOR_VERSION = 26
GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MIN_SUPPORTED_GO_MAJOR_VERSION).$(MIN_SUPPORTED_GO_MINOR_VERSION)
SLIVER_PUBLIC_KEY ?= RWTZPg959v3b7tLG7VzKHRB1/QT+d3c71Uzetfa44qAoX5rH7mGoQTTR
@@ -23,6 +36,10 @@ CLIENT_ASSETS_PKG = github.com/bishopfox/sliver/client/assets
SLIVER_UPDATE_PKG = github.com/bishopfox/sliver/client/command/update
PB_COMPILERS = protoc protoc-gen-go protoc-gen-go-grpc
.PHONY: lint
lint:
$(GOLANGCI_LINT) run $(GOLANGCI_LINT_ARGS) $(GOLANGCI_LINT_PACKAGES)
ifneq ($(OS),Windows_NT)
#
+1 -1
View File
@@ -4,7 +4,7 @@ Sliver is an open source cross-platform adversary emulation/red team framework,
The server and client support MacOS, Windows, and Linux. Implants are supported on MacOS, Windows, and Linux (and possibly every Golang compiler target but we've not tested them all).
[![Release](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/BishopFox/sliver)](https://goreportcard.com/report/github.com/BishopFox/sliver) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Release](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [![golangci-lint](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
Visit [https://sliver.sh/](https://sliver.sh/) for tutorials and documentation.
+1
View File
@@ -36,6 +36,7 @@ const (
// ClientWGConfig - Optional WireGuard wrapper configuration for multiplayer.
type ClientWGConfig struct {
Enabled bool `json:"enabled,omitempty"`
ServerPubKey string `json:"server_pub_key"`
ClientPrivateKey string `json:"client_private_key"`
ClientPubKey string `json:"client_pub_key"`
+29
View File
@@ -0,0 +1,29 @@
package assets
import (
"encoding/json"
"testing"
)
func TestClientWGConfigEnabledIsPositiveOptIn(t *testing.T) {
legacyJSON := []byte(`{"wg":{"server_pub_key":"server-pub","client_private_key":"client-priv","client_ip":"100.64.0.2"}}`)
legacy := &ClientConfig{}
if err := json.Unmarshal(legacyJSON, legacy); err != nil {
t.Fatalf("unmarshal legacy config: %v", err)
}
if legacy.WG == nil {
t.Fatal("expected legacy WireGuard config")
}
if legacy.WG.Enabled {
t.Fatal("expected an absent enabled marker to remain disabled")
}
enabledJSON := []byte(`{"wg":{"enabled":true,"server_pub_key":"server-pub","client_private_key":"client-priv","client_ip":"100.64.0.2"}}`)
enabled := &ClientConfig{}
if err := json.Unmarshal(enabledJSON, enabled); err != nil {
t.Fatalf("unmarshal enabled config: %v", err)
}
if enabled.WG == nil || !enabled.WG.Enabled {
t.Fatal("expected enabled marker to opt in to WireGuard")
}
}
+3 -2
View File
@@ -52,8 +52,9 @@ func init() {
rootCmd.TraverseChildren = true
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
rootCmd.PersistentFlags().Bool(disableWGFlag, false, "connect to multiplayer directly even if the operator config includes a WireGuard wrapper")
rootCmd.PersistentFlags().Bool(requireWGFlag, false, "require the operator config's WireGuard wrapper for multiplayer connections")
rootCmd.PersistentFlags().Bool(enableWGFlag, false, "force multiplayer connections through the operator config's WireGuard wrapper")
rootCmd.PersistentFlags().Bool(disableWGFlag, false, "force multiplayer connections to use direct mTLS")
rootCmd.MarkFlagsMutuallyExclusive(enableWGFlag, disableWGFlag)
// Create the console client, without any RPC or commands bound to it yet.
// This created before anything so that multiple commands can make use of
+177
View File
@@ -0,0 +1,177 @@
package cli
import (
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
bspinner "charm.land/bubbles/v2/spinner"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
const (
minConnectSpinnerDuration = 900 * time.Millisecond
minConnectStatusDuration = 350 * time.Millisecond
)
type connectResult struct {
rpc rpcpb.SliverRPCClient
conn *grpc.ClientConn
err error
}
func connectWithSpinner(out io.Writer, target string, connect func(transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error)) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusCh := make(chan string, 8)
resultCh := make(chan connectResult, 1)
go func() {
rpc, conn, err := connect(func(status string) {
sendStatus(statusCh, status)
})
resultCh <- connectResult{rpc: rpc, conn: conn, err: err}
}()
currentStatus := ""
currentStatusSince := time.Time{}
queuedStatus := ""
lastWidth := 0
spinner := bspinner.New(bspinner.WithSpinner(bspinner.Line))
ticker := time.NewTicker(spinner.Spinner.FPS)
defer ticker.Stop()
startedAt := time.Now()
var pendingResult *connectResult
setStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" || status == currentStatus {
return
}
currentStatus = status
currentStatusSince = time.Now()
}
stageStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
if currentStatus == "" || time.Since(currentStatusSince) >= minConnectStatusDuration {
queuedStatus = ""
setStatus(status)
return
}
queuedStatus = status
}
flushQueuedStatus := func() {
if queuedStatus == "" || time.Since(currentStatusSince) < minConnectStatusDuration {
return
}
nextStatus := queuedStatus
queuedStatus = ""
setStatus(nextStatus)
}
canReturnPendingResult := func() bool {
if pendingResult == nil {
return false
}
if pendingResult.err != nil {
return true
}
if time.Since(startedAt) < minConnectSpinnerDuration {
return false
}
if queuedStatus != "" {
return false
}
if !currentStatusSince.IsZero() && time.Since(currentStatusSince) < minConnectStatusDuration {
return false
}
return true
}
render := func() {
line := fmt.Sprintf("%s %s", spinner.View(), formatConnectSpinnerMessage(target, currentStatus))
lastWidth = writeSpinnerLine(out, line, lastWidth)
}
render()
for {
select {
case status := <-statusCh:
stageStatus(status)
spinner, _ = spinner.Update(spinner.Tick())
render()
case result := <-resultCh:
pendingResult = &result
case <-ticker.C:
spinner, _ = spinner.Update(spinner.Tick())
flushQueuedStatus()
render()
if canReturnPendingResult() {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
if pendingResult != nil && pendingResult.err != nil {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
}
func sendStatus(statusCh chan string, status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
statusCh <- status
}
func formatConnectSpinnerMessage(target string, status string) string {
target = strings.TrimSpace(target)
status = strings.TrimSpace(status)
if target == "" {
if status == "" {
return "Connecting ..."
}
return fmt.Sprintf("Connecting (%s) ...", status)
}
if status == "" {
return fmt.Sprintf("Connecting to %s ...", target)
}
return fmt.Sprintf("Connecting to %s (%s) ...", target, status)
}
func writeSpinnerLine(out io.Writer, line string, lastWidth int) int {
width := utf8.RuneCountInString(line)
padding := ""
if lastWidth > width {
padding = strings.Repeat(" ", lastWidth-width)
}
fmt.Fprintf(out, "\r%s%s", line, padding)
if width > lastWidth {
return width
}
return lastWidth
}
func clearSpinnerLine(out io.Writer, lastWidth int) {
if lastWidth <= 0 {
return
}
fmt.Fprintf(out, "\r%s\r", strings.Repeat(" ", lastWidth))
}
+132
View File
@@ -0,0 +1,132 @@
package cli
import (
"bytes"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
func TestFormatConnectSpinnerMessage(t *testing.T) {
tests := []struct {
name string
target string
status string
want string
}{
{
name: "target only",
target: "127.0.0.1:31337",
want: "Connecting to 127.0.0.1:31337 ...",
},
{
name: "target with status",
target: "127.0.0.1:31337",
status: "wireguard",
want: "Connecting to 127.0.0.1:31337 (wireguard) ...",
},
{
name: "status without target",
status: "grpc/mtls",
want: "Connecting (grpc/mtls) ...",
},
{
name: "trim whitespace",
target: " 127.0.0.1:31337 ",
status: " grpc/mtls over wireguard ",
want: "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := formatConnectSpinnerMessage(test.target, test.status); got != test.want {
t.Fatalf("expected %q, got %q", test.want, got)
}
})
}
}
func TestConnectWithSpinnerOutput(t *testing.T) {
var out bytes.Buffer
spinnerDelay := 2 * (100 * time.Millisecond)
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
time.Sleep(spinnerDelay)
statusFn("grpc/mtls over wireguard")
time.Sleep(spinnerDelay)
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected wireguard status in output, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected grpc over wireguard status in output, got %q", got)
}
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames in output, got %q", got)
}
}
func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("grpc/mtls")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337 (grpc/mtls) ...") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames for fast success, got %q", got)
}
}
func TestConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
statusFn("grpc/mtls over wireguard")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected fast success output to include wireguard status, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected fast success output to include grpc over wireguard status, got %q", got)
}
if strings.Contains(got, "Connected to 127.0.0.1:31337") {
t.Fatalf("did not expect past-tense success output, got %q", got)
}
}
+11 -7
View File
@@ -20,6 +20,7 @@ package cli
import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command"
@@ -64,17 +65,20 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
return nil
}
// Don't clobber output when simply running an implant command from system shell.
if run {
fmt.Printf("Connecting to %s:%d ...\n", config.LHost, config.LPort)
}
target := fmt.Sprintf("%s:%d", config.LHost, config.LPort)
var rpc rpcpb.SliverRPCClient
var ln *grpc.ClientConn
rpc, ln, err = transport.MTLSConnect(config)
// Don't clobber output when simply running an implant command from system shell.
if run {
rpc, ln, err = connectWithSpinner(os.Stdout, target, func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
return transport.MTLSConnectWithStatus(config, statusFn)
})
} else {
rpc, ln, err = transport.MTLSConnect(config)
}
if err != nil {
fmt.Printf("Connection to server failed %s", err)
fmt.Printf("Connection to server failed %s\n", err)
return nil
}
return console.StartClient(con, rpc, ln, &console.ConnectionDetails{ConfigKey: configKey, Config: config}, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript)
+6 -6
View File
@@ -8,7 +8,7 @@ import (
)
const (
requireWGFlag = "require-wg"
enableWGFlag = "enable-wg"
disableWGFlag = "disable-wg"
)
@@ -18,7 +18,7 @@ func applyMultiplayerConnectMode(cmd *cobra.Command) error {
return nil
}
requireWG, err := cmd.Flags().GetBool(requireWGFlag)
enableWG, err := cmd.Flags().GetBool(enableWGFlag)
if err != nil {
return err
}
@@ -26,14 +26,14 @@ func applyMultiplayerConnectMode(cmd *cobra.Command) error {
if err != nil {
return err
}
if requireWG && disableWG {
return fmt.Errorf("--%s and --%s cannot be used together", requireWGFlag, disableWGFlag)
if cmd.Flags().Changed(enableWGFlag) && cmd.Flags().Changed(disableWGFlag) {
return fmt.Errorf("--%s and --%s cannot be used together", enableWGFlag, disableWGFlag)
}
mode := transport.MultiplayerConnectAuto
switch {
case requireWG:
mode = transport.MultiplayerConnectRequireWG
case enableWG:
mode = transport.MultiplayerConnectEnableWG
case disableWG:
mode = transport.MultiplayerConnectDisableWG
}
+136
View File
@@ -0,0 +1,136 @@
package cli
import (
"strings"
"testing"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
)
func TestWireGuardOverrideFlagsDefaultToFalse(t *testing.T) {
for _, name := range []string{enableWGFlag, disableWGFlag} {
flag := rootCmd.PersistentFlags().Lookup(name)
if flag == nil {
t.Fatalf("expected --%s persistent flag", name)
}
if flag.DefValue != "false" {
t.Fatalf("expected --%s to default to false, got %q", name, flag.DefValue)
}
}
}
func TestApplyMultiplayerConnectModeDefaultsToAuto(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if !transport.MultiplayerConnectUsesWireGuard(enabledWGClientConfig()) {
t.Fatal("expected automatic mode to honor an explicitly enabled WireGuard config")
}
if transport.MultiplayerConnectUsesWireGuard(legacyWGClientConfig()) {
t.Fatal("expected automatic mode to ignore a legacy WireGuard config without the opt-in marker")
}
}
func TestApplyMultiplayerConnectModeEnableWGForcesLegacyConfig(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if !transport.MultiplayerConnectUsesWireGuard(legacyWGClientConfig()) {
t.Fatal("expected --enable-wg to force a complete legacy config through WireGuard")
}
}
func TestApplyMultiplayerConnectModeDisableWGOverridesEnabledConfig(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(disableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if transport.MultiplayerConnectUsesWireGuard(enabledWGClientConfig()) {
t.Fatal("expected --disable-wg to force direct mTLS")
}
}
func TestApplyMultiplayerConnectModeRejectsConflictingOverrides(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := cmd.Flags().Set(disableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
err := applyMultiplayerConnectMode(cmd)
if err == nil {
t.Fatal("expected conflicting WireGuard overrides to fail")
}
if !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("unexpected conflict error: %v", err)
}
}
func TestApplyMultiplayerConnectModeRejectsBothOverridesWhenExplicitlyFalse(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "false"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := cmd.Flags().Set(disableWGFlag, "false"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
err := applyMultiplayerConnectMode(cmd)
if err == nil {
t.Fatal("expected explicitly combining both WireGuard override flags to fail")
}
if !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("unexpected conflict error: %v", err)
}
}
func newTransportModeTestCommand() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().Bool(enableWGFlag, false, "")
cmd.Flags().Bool(disableWGFlag, false, "")
return cmd
}
func resetMultiplayerConnectModeForTest(t *testing.T) {
t.Helper()
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
t.Cleanup(func() {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
})
}
func enabledWGClientConfig() *assets.ClientConfig {
config := legacyWGClientConfig()
config.WG.Enabled = true
return config
}
func legacyWGClientConfig() *assets.ClientConfig {
return &assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
}
}
+251
View File
@@ -0,0 +1,251 @@
package alias
/*
Sliver Implant Framework
Copyright (C) 2021 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
)
// Alias commands run with cobra's DisableFlagParsing enabled (see LoadAlias),
// the same treatment extension commands received for their arguments. Before
// that, an unknown flag such as the payload's own `-pid` was rejected outright
// by pflag ("unknown flag"), and operators had to inject cobra's "--"
// separator (documented on the wiki) to pass arguments through at all (#2264).
//
// With flag parsing disabled cobra hands Run the raw, unstripped slice, and we
// extract the Sliver-owned flags ourselves here. Everything we do not own is
// passed to the payload untouched, so all of these now work:
//
// amsi-bypass -pid 6969
// fakealias --process notepad.exe arg1 arg2
// fakealias -- --anything still-here (existing workaround, unchanged)
type ownedFlagKind int
const (
ownedBool ownedFlagKind = iota
ownedString
ownedUint32
ownedInt64
)
// ownedFlagSpec describes one Sliver-owned flag that splitAliasArgs extracts
// from the raw argument slice before the rest is handed to the alias payload.
type ownedFlagSpec struct {
long string // canonical pflag name, e.g. "process-arguments"
short string // shorthand letter, e.g. "A" ("" if the flag has none)
kind ownedFlagKind // how the value is consumed and validated
}
// baseAliasOwnedFlags are registered for every alias command.
var baseAliasOwnedFlags = []ownedFlagSpec{
{long: "process", short: "p", kind: ownedString},
{long: "process-arguments", short: "A", kind: ownedString},
{long: "ppid", short: "P", kind: ownedUint32},
{long: "save", short: "s", kind: ownedBool},
{long: "timeout", short: "t", kind: ownedInt64},
}
// assemblyAliasOwnedFlags are only owned when the alias wraps a .NET assembly;
// for every other alias they are unknown tokens and pass through to the payload.
var assemblyAliasOwnedFlags = []ownedFlagSpec{
{long: "method", short: "m", kind: ownedString},
{long: "class", short: "c", kind: ownedString},
{long: "app-domain", short: "d", kind: ownedString},
{long: "arch", short: "a", kind: ownedString},
{long: "in-process", short: "i", kind: ownedBool},
{long: "runtime", short: "r", kind: ownedString},
{long: "amsi-bypass", short: "M", kind: ownedBool},
{long: "etw-bypass", short: "E", kind: ownedBool},
}
// aliasOwnedFlagSpecs returns the flag set owned by a particular alias.
func aliasOwnedFlagSpecs(isAssembly bool) []ownedFlagSpec {
if !isAssembly {
return baseAliasOwnedFlags
}
specs := make([]ownedFlagSpec, 0, len(baseAliasOwnedFlags)+len(assemblyAliasOwnedFlags))
specs = append(specs, baseAliasOwnedFlags...)
return append(specs, assemblyAliasOwnedFlags...)
}
// ownedFlagMatch is a raw token that spelled one of the Sliver-owned flags.
type ownedFlagMatch struct {
spec ownedFlagSpec
value string // value from a "--flag=value" / "-f=value" spelling
inline bool // whether the token carried its value inline
}
// splitAliasArgs separates the Sliver-owned flags from the alias payload's own
// arguments in a raw (DisableFlagParsing) argument slice. It accepts the long
// forms --flag, --flag=value, --flag value and the shorthand forms -f, -f=value,
// -f value, mirroring pflag. Two deliberate differences from pflag:
//
// - a glued shorthand value (-pnotepad) does NOT match --process: tokens
// like the payload's own "-pid" must survive intact (#2264);
// - after a literal "--" every token is copied through verbatim, preserving
// the long-standing separator workaround.
//
// The returned map holds the raw string value of each flag that was provided;
// callers re-publish the values with applyOwnedAliasFlags.
func splitAliasArgs(raw []string, specs []ownedFlagSpec) (map[string]string, bool, []string, error) {
values := make(map[string]string)
extArgs := make([]string, 0, len(raw))
byLong, byShort := ownedFlagTables(specs)
passthrough := false
for i := 0; i < len(raw); i++ {
tok := raw[i]
// Once we have seen "--", copy everything through untouched.
if passthrough {
extArgs = append(extArgs, tok)
continue
}
if tok == "--" {
passthrough = true
continue
}
if tok == "--help" || tok == "-h" {
return values, true, extArgs, nil
}
match, ok := matchOwnedFlag(tok, byLong, byShort)
if !ok {
// Not one of ours (including glued shorthands and the payload's
// own flags): the alias owns the token, pass it through untouched.
extArgs = append(extArgs, tok)
continue
}
i = takeOwnedValue(match, raw, i, values)
}
if err := validateOwnedValues(specs, values); err != nil {
return nil, false, nil, err
}
return values, false, extArgs, nil
}
// ownedFlagTables indexes the specs by long name and shorthand.
func ownedFlagTables(specs []ownedFlagSpec) (byLong, byShort map[string]ownedFlagSpec) {
byLong = make(map[string]ownedFlagSpec, len(specs))
byShort = make(map[string]ownedFlagSpec, len(specs))
for _, spec := range specs {
byLong[spec.long] = spec
if spec.short != "" {
byShort[spec.short] = spec
}
}
return byLong, byShort
}
// matchOwnedFlag checks whether tok spells one of the Sliver-owned flags, in
// the long form (--name, --name=value) or shorthand form (-f, -f=value). A
// glued shorthand value (-pnotepad) deliberately does not match: pflag would
// read "-pid" as -p with value "id", which is exactly the mangling that broke
// #2264, so such tokens must fall through to the payload.
func matchOwnedFlag(tok string, byLong, byShort map[string]ownedFlagSpec) (ownedFlagMatch, bool) {
var match ownedFlagMatch
var name, value string
var hasValue, isShort bool
switch {
case strings.HasPrefix(tok, "--"):
name, value, hasValue = strings.Cut(tok[2:], "=")
case strings.HasPrefix(tok, "-") && len(tok) > 1:
name, value, hasValue = strings.Cut(tok[1:], "=")
isShort = len(name) == 1
default:
return match, false
}
spec, ok := byLong[name]
if isShort {
spec, ok = byShort[name]
}
if !ok {
return match, false
}
match.spec, match.value, match.inline = spec, value, hasValue
return match, true
}
// takeOwnedValue records the flag's value in values and returns the index of
// the last raw token it consumed. The "--flag value" / "-f value" spellings
// consume the following token; a bool without an inline value defaults to
// true; a value flag as the very last token is dropped and keeps its default,
// mirroring the extension behaviour.
func takeOwnedValue(match ownedFlagMatch, raw []string, i int, values map[string]string) int {
switch {
case match.spec.kind == ownedBool:
if !match.inline {
match.value = "true"
}
values[match.spec.long] = match.value
case match.inline:
values[match.spec.long] = match.value
case i+1 < len(raw):
values[match.spec.long] = raw[i+1]
return i + 1
}
return i
}
// validateOwnedValues rejects non-numeric values for the numeric flag kinds,
// the way pflag's typed values would have.
func validateOwnedValues(specs []ownedFlagSpec, values map[string]string) error {
for _, spec := range specs {
value, provided := values[spec.long]
if !provided {
continue
}
var err error
switch spec.kind {
case ownedUint32:
_, err = strconv.ParseUint(value, 10, 32)
case ownedInt64:
_, err = strconv.ParseInt(value, 10, 64)
}
if err != nil {
return fmt.Errorf("invalid value %q for --%s", value, spec.long)
}
}
return nil
}
// applyOwnedAliasFlags republishes the manually parsed Sliver-owned flag values
// onto the command's flag set. DisableFlagParsing prevents pflag from
// populating them, so readers such as runAliasCommand's Get* calls and
// ActiveTarget.Request (which derives the gRPC timeout from --timeout) would
// otherwise observe only the defaults.
func applyOwnedAliasFlags(cmd *cobra.Command, values map[string]string) error {
for name, value := range values {
flag := cmd.Flags().Lookup(name)
if flag == nil {
continue // e.g. an assembly flag on a non-assembly alias
}
if err := flag.Value.Set(value); err != nil {
return fmt.Errorf("invalid value %q for --%s: %w", value, name, err)
}
}
return nil
}
+259
View File
@@ -0,0 +1,259 @@
package alias
/*
Sliver Implant Framework
Copyright (C) 2021 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestSplitAliasArgs covers the argument passthrough regression from #2264:
// the alias payload's own flags must reach the payload intact, while the
// Sliver-owned flags are extracted and validated.
func TestSplitAliasArgs(t *testing.T) {
base := aliasOwnedFlagSpecs(false)
assembly := aliasOwnedFlagSpecs(true)
cases := []struct {
name string
specs []ownedFlagSpec
in []string
values map[string]string
help bool
extArgs []string
wantErr bool
}{
{
name: "payload flag without -- separator (#2264 regression)",
specs: base,
in: []string{"-pid", "6969"},
values: map[string]string{},
extArgs: []string{"-pid", "6969"},
},
{
name: "glued shorthand is not ours and passes through",
specs: base,
in: []string{"-pid"},
values: map[string]string{},
extArgs: []string{"-pid"},
},
{
name: "payload flag with -- separator (existing workaround)",
specs: base,
in: []string{"--", "--pid", "6969"},
values: map[string]string{},
extArgs: []string{"--pid", "6969"},
},
{
name: "owned flag after -- belongs to the payload",
specs: base,
in: []string{"--", "--save"},
values: map[string]string{},
extArgs: []string{"--save"},
},
{
name: "long flag with separate value, then payload args",
specs: base,
in: []string{"--process", "notepad.exe", "arg1", "arg2"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1", "arg2"},
},
{
name: "long flag with equals value",
specs: base,
in: []string{"--process=notepad.exe", "arg1"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1"},
},
{
name: "shorthand with separate value",
specs: base,
in: []string{"-p", "notepad.exe", "arg1"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1"},
},
{
name: "shorthand with equals value",
specs: base,
in: []string{"-p=notepad.exe"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{},
},
{
name: "bool shorthand and long false form",
specs: base,
in: []string{"-s", "--save=false", "arg1"},
values: map[string]string{"save": "false"},
extArgs: []string{"arg1"},
},
{
name: "quoted value containing spaces stays a single argument",
specs: base,
in: []string{"-A", "-kalc -kdisable", "arg1"},
values: map[string]string{"process-arguments": "-kalc -kdisable"},
extArgs: []string{"arg1"},
},
{
name: "valid uint32 and int64 values",
specs: base,
in: []string{"-P", "1234", "--timeout=90"},
values: map[string]string{"ppid": "1234", "timeout": "90"},
extArgs: []string{},
},
{
name: "invalid uint32 value errors",
specs: base,
in: []string{"--ppid", "notanumber"},
wantErr: true,
},
{
name: "invalid int64 value errors",
specs: base,
in: []string{"--timeout=x"},
wantErr: true,
},
{
name: "assembly flags are owned for assembly aliases",
specs: assembly,
in: []string{"--method", "Run", "--class", "Prog", "-i", "-M", "--arch", "x64", "payloadArg"},
values: map[string]string{
"method": "Run", "class": "Prog", "in-process": "true",
"amsi-bypass": "true", "arch": "x64",
},
extArgs: []string{"payloadArg"},
},
{
name: "assembly flags pass through for non-assembly aliases",
specs: base,
in: []string{"--method", "Run"},
values: map[string]string{},
extArgs: []string{"--method", "Run"},
},
{
name: "help flags are detected",
specs: base,
in: []string{"--help"},
help: true,
values: map[string]string{},
extArgs: []string{},
},
{
name: "short help is detected",
specs: base,
in: []string{"-h"},
help: true,
values: map[string]string{},
extArgs: []string{},
},
{
name: "value flag as last token keeps its default",
specs: base,
in: []string{"arg1", "--process"},
values: map[string]string{},
extArgs: []string{"arg1"},
},
{
name: "empty input",
specs: base,
in: nil,
values: map[string]string{},
extArgs: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
values, help, extArgs, err := splitAliasArgs(tc.in, tc.specs)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got values=%#v help=%v extArgs=%#v", values, help, extArgs)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if help != tc.help {
t.Errorf("help: got %v, want %v", help, tc.help)
}
if !reflect.DeepEqual(values, tc.values) {
t.Errorf("values: got %#v, want %#v", values, tc.values)
}
if !reflect.DeepEqual(extArgs, tc.extArgs) {
t.Errorf("extArgs: got %#v, want %#v", extArgs, tc.extArgs)
}
})
}
}
// TestApplyOwnedAliasFlags checks that manually parsed values are re-published
// on the command's flag set for every value type an alias can own.
func TestApplyOwnedAliasFlags(t *testing.T) {
cmd := &cobra.Command{}
f := pflag.NewFlagSet("alias-test", pflag.ContinueOnError)
f.StringP("process", "p", "", "host process")
f.StringP("process-arguments", "A", "", "host process arguments")
f.Uint32P("ppid", "P", 0, "parent pid")
f.BoolP("save", "s", false, "save output")
f.Int64P("timeout", "t", 60, "timeout")
f.BoolP("amsi-bypass", "M", false, "amsi bypass")
cmd.Flags().AddFlagSet(f)
err := applyOwnedAliasFlags(cmd, map[string]string{
"process": "notepad.exe",
"process-arguments": "-k foo",
"ppid": "1234",
"save": "true",
"timeout": "90",
"amsi-bypass": "true",
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
for name, want := range map[string]interface{}{
"process": "notepad.exe", "process-arguments": "-k foo",
} {
got, _ := cmd.Flags().GetString(name)
if got != want.(string) {
t.Errorf("%s: got %q, want %q", name, got, want)
}
}
if ppid, _ := cmd.Flags().GetUint32("ppid"); ppid != 1234 {
t.Errorf("ppid: got %d, want 1234", ppid)
}
if save, _ := cmd.Flags().GetBool("save"); !save {
t.Error("save: got false, want true")
}
if timeout, _ := cmd.Flags().GetInt64("timeout"); timeout != 90 {
t.Errorf("timeout: got %d, want 90", timeout)
}
if amsi, _ := cmd.Flags().GetBool("amsi-bypass"); !amsi {
t.Error("amsi-bypass: got false, want true")
}
// Unset flags keep their defaults.
if inProc := cmd.Flags().Lookup("in-process"); inProc != nil {
t.Error("in-process should not be registered on this command")
}
}
+29 -5
View File
@@ -215,8 +215,6 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
longHelp.WriteString(fmt.Sprintf("%s (%s):\t%s%s", strings.ToUpper(arg.Name), aType, optStr, arg.Desc))
}
}
longHelp.WriteString("\n\n⚠️ If you're having issues passing arguments to the alias please read:\n")
longHelp.WriteString("https://github.com/BishopFox/sliver/wiki/Aliases-&-Extensions#aliases-command-parsing")
// for each alias command, add a new app command
helpMsg := fmt.Sprintf("[%s] %s", aliasManifest.Name, aliasManifest.Help)
@@ -227,9 +225,14 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
Run: func(cmd *cobra.Command, args []string) {
runAliasCommand(cmd, con, args)
},
Args: cobra.ArbitraryArgs, // a.StringList("arguments", "arguments", grumble.Default([]string{}))
GroupID: consts.AliasHelpGroup,
Annotations: makeAliasPlatformFilters(aliasManifest),
Args: cobra.ArbitraryArgs, // a.StringList("arguments", "arguments", grumble.Default([]string{}))
// DisableFlagParsing keeps the alias payload's own flags (which are
// unknown to pflag) from being rejected or swallowed; we split the
// Sliver-owned flags out of the raw slice ourselves in
// runAliasCommand (#2264, same treatment as extensions).
DisableFlagParsing: true,
GroupID: consts.AliasHelpGroup,
Annotations: makeAliasPlatformFilters(aliasManifest),
}
if aliasManifest.IsAssembly {
@@ -333,6 +336,27 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
return
}
aliasManifest := loadedAlias.Manifest
// The command runs with DisableFlagParsing, so args is the raw, unstripped
// slice. Separate the Sliver-owned flags from the payload's own arguments
// and re-publish them on the flag set (so the Get* readers below and
// Request()'s --timeout still work), then hand only the payload arguments
// to the execution paths (#2264, same treatment as extensions in #2323).
ownedValues, helpRequested, payloadArgs, err := splitAliasArgs(args, aliasOwnedFlagSpecs(aliasManifest.IsAssembly))
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
if helpRequested {
_ = cmd.Help()
return
}
if err := applyOwnedAliasFlags(cmd, ownedValues); err != nil {
con.PrintErrorf("%s\n", err)
return
}
args = payloadArgs
binPath, err := aliasManifest.getFileForTarget(cmd.Name(), goos, goarch)
if err != nil {
con.PrintErrorf("Fail to find alias file: %s\n", err)
+1 -1
View File
@@ -164,7 +164,7 @@ func ArmoryCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
cacheEntry, ok := value.(pkgCacheEntry)
if !ok {
// Something is wrong with this entry
pkgCache.Delete(value)
pkgCache.Delete(key)
return true
}
if cacheEntry.ArmoryConfig.PublicKey != index.ArmoryConfig.PublicKey {
+34 -24
View File
@@ -301,32 +301,44 @@ func getCommandsInCache(armoryPK string) []string {
return commandNames
}
func packageMatchesInstallName(cacheEntry pkgCacheEntry, name string) bool {
if cacheEntry.Pkg.IsAlias {
return cacheEntry.Alias != nil && cacheEntry.Alias.CommandName == name
}
if cacheEntry.Extension == nil {
return false
}
if cacheEntry.Pkg.CommandName == name || cacheEntry.Extension.PackageName == name {
return true
}
for _, command := range cacheEntry.Extension.ExtCommand {
if command.CommandName == name {
return true
}
}
return false
}
func getPackagesWithCommandName(name, armoryPK, minimumVersion string) []*pkgCacheEntry {
packages := []*pkgCacheEntry{}
pkgCache.Range(func(key, value interface{}) bool {
cacheEntry := value.(pkgCacheEntry)
if cacheEntry.LastErr == nil {
if cacheEntry.Pkg.IsAlias {
if cacheEntry.Alias.CommandName == name {
if minimumVersion == "" || (minimumVersion != "" && cacheEntry.Alias.Version >= minimumVersion) {
if armoryPK == "" || (armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey == armoryPK) {
packages = append(packages, &cacheEntry)
}
}
}
} else {
for _, command := range cacheEntry.Extension.ExtCommand {
if command.CommandName == name {
if minimumVersion == "" || (minimumVersion != "" && cacheEntry.Extension.Version >= minimumVersion) {
if armoryPK == "" || (armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey == armoryPK) {
packages = append(packages, &cacheEntry)
}
break
}
}
}
}
if cacheEntry.LastErr != nil || !packageMatchesInstallName(cacheEntry, name) {
return true
}
if armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey != armoryPK {
return true
}
packageVersion := ""
if cacheEntry.Pkg.IsAlias {
packageVersion = cacheEntry.Alias.Version
} else {
packageVersion = cacheEntry.Extension.Version
}
if minimumVersion == "" || packageVersion >= minimumVersion {
packages = append(packages, &cacheEntry)
}
return true
})
@@ -732,9 +744,7 @@ func installExtensionPackage(entry *pkgCacheEntry, promptToOverwrite bool, clien
con.Printf(console.Clearln + "\r") // Clear download message
extensions.InstallFromDir(tmpFileName, promptToOverwrite, con, true)
return nil
return extensions.InstallFromDir(tmpFileName, promptToOverwrite, con, true)
}
func writeArmoryTempFile(data []byte) (string, error) {
+218
View File
@@ -0,0 +1,218 @@
package armory
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/alias"
"github.com/bishopfox/sliver/client/command/extensions"
)
func resetPackageCache(t *testing.T) {
t.Helper()
pkgCache.Range(func(key, _ any) bool {
pkgCache.Delete(key)
return true
})
t.Cleanup(func() {
pkgCache.Range(func(key, _ any) bool {
pkgCache.Delete(key)
return true
})
})
}
func testExtensionCacheEntry() pkgCacheEntry {
manifest := &extensions.ExtensionManifest{
Name: "ADCS Request (Remote)",
PackageName: "remote-adcs-package",
Version: "v0.1.5",
ArmoryName: "Default Armory",
ArmoryPK: "armory-public-key",
ExtCommand: []*extensions.ExtCommand{
{CommandName: "remote-adcs-request"},
{CommandName: "remote-adcs-request-on-behalf"},
},
}
return pkgCacheEntry{
ArmoryConfig: &assets.ArmoryConfig{PublicKey: "armory-public-key"},
Pkg: ArmoryPackage{
Name: "Remote ADCS package",
CommandName: "remote-adcs-index",
},
Extension: manifest,
ID: "remote-adcs-request-id",
}
}
func TestGetPackageForCommandAcceptsStableExtensionIdentities(t *testing.T) {
resetPackageCache(t)
entry := testExtensionCacheEntry()
pkgCache.Store(entry.ID, entry)
for _, name := range []string{
"remote-adcs-index",
"remote-adcs-request",
"remote-adcs-request-on-behalf",
"remote-adcs-package",
} {
t.Run(name, func(t *testing.T) {
found, err := getPackageForCommand(name, "armory-public-key", "v0.1.5")
if err != nil {
t.Fatalf("lookup %q failed: %v", name, err)
}
if found.ID != entry.ID {
t.Fatalf("lookup %q returned %q, want %q", name, found.ID, entry.ID)
}
})
}
if _, err := getPackageForCommand("remote-adcs-package", "other-armory", "v0.1.5"); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("wrong-armory lookup error = %v, want %v", err, ErrPackageNotFound)
}
if _, err := getPackageForCommand("remote-adcs-package", "armory-public-key", "v0.1.6"); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("newer-version lookup error = %v, want %v", err, ErrPackageNotFound)
}
for _, name := range []string{"ADCS Request (Remote)", "Remote ADCS package", "unknown-package"} {
if _, err := getPackageForCommand(name, "", ""); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("unstable identity %q lookup error = %v, want %v", name, err, ErrPackageNotFound)
}
}
}
func TestPackageMatchesInstallNameLeavesAliasesCommandOnly(t *testing.T) {
entry := pkgCacheEntry{
Pkg: ArmoryPackage{
Name: "Human Alias Name",
CommandName: "alias-command",
IsAlias: true,
},
Alias: &alias.AliasManifest{CommandName: "alias-command"},
}
if packageMatchesInstallName(entry, "Human Alias Name") {
t.Fatal("alias unexpectedly matched its display name")
}
if !packageMatchesInstallName(entry, "alias-command") {
t.Fatal("alias did not match its command name")
}
}
func TestExtensionManifestsMatch(t *testing.T) {
manifest := func(name, packageName string, commands ...string) *extensions.ExtensionManifest {
extCommands := make([]*extensions.ExtCommand, 0, len(commands))
for _, command := range commands {
extCommands = append(extCommands, &extensions.ExtCommand{CommandName: command})
}
return &extensions.ExtensionManifest{Name: name, PackageName: packageName, ExtCommand: extCommands}
}
tests := []struct {
name string
local *extensions.ExtensionManifest
latest *extensions.ExtensionManifest
want bool
}{
{
name: "v2 package identity",
local: manifest("Old Display Name", "stable-package", "old-command"),
latest: manifest("New Display Name", "stable-package", "new-command"),
want: true,
},
{
name: "different v2 packages",
local: manifest("Shared Display Name", "package-one", "shared-command"),
latest: manifest("Shared Display Name", "package-two", "shared-command"),
want: false,
},
{
name: "v2 does not downgrade to legacy identity",
local: manifest("Human Display Name", "stable-package", "shared-command"),
latest: manifest("legacy-command", "", "shared-command"),
want: false,
},
{
name: "legacy command identity",
local: manifest("legacy-command", "", "legacy-command"),
latest: manifest("Human Display Name", "stable-package", "other-command", "legacy-command"),
want: true,
},
{
name: "display names are not identity",
local: manifest("Shared Name", "", "old-command"),
latest: manifest("Shared Name", "stable-package", "new-command"),
want: false,
},
{
name: "unrelated legacy package",
local: manifest("Old Name", "", "old-command"),
latest: manifest("New Name", "stable-package", "new-command"),
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := extensionManifestsMatch(test.local, test.latest); got != test.want {
t.Fatalf("extensionManifestsMatch() = %t, want %t", got, test.want)
}
})
}
}
func TestExtensionUpdateDisplayNameResolvesPackage(t *testing.T) {
resetPackageCache(t)
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
installedManifest := []byte(`{
"name":"ADCS Request (Remote)",
"package_name":"remote-adcs-package",
"version":"v0.1.3",
"commands":[{
"command_name":"remote-adcs-request",
"help":"Request a certificate",
"files":[{"os":"windows","arch":"amd64","path":"adcs_request.x64.o"}]
}]
}`)
installDir := filepath.Join(assets.GetExtensionsDir(), "remote-adcs-request")
if err := os.MkdirAll(installDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, extensions.ManifestFileName), installedManifest, 0o600); err != nil {
t.Fatal(err)
}
entry := testExtensionCacheEntry()
pkgCache.Store(entry.ID, entry)
otherEntry := testExtensionCacheEntry()
otherEntry.ID = "other-armory-package-id"
otherEntry.ArmoryConfig = &assets.ArmoryConfig{PublicKey: "other-armory"}
otherManifest := *otherEntry.Extension
otherManifest.Version = "v9.0.0"
otherManifest.ArmoryName = "Other Armory"
otherManifest.ArmoryPK = "other-armory"
otherEntry.Extension = &otherManifest
pkgCache.Store(otherEntry.ID, otherEntry)
updates := checkForExtensionUpdates("armory-public-key")
versionInfo, ok := updates["ADCS Request (Remote)"]
if !ok {
t.Fatal("display-name update was not detected")
}
if versionInfo.OldVersion != "v0.1.3" || versionInfo.NewVersion != "v0.1.5" {
t.Fatalf("unexpected versions: %#v", versionInfo)
}
if versionInfo.PackageID != entry.ID {
t.Fatalf("update package ID = %q, want %q", versionInfo.PackageID, entry.ID)
}
found := packageCacheLookupByID(versionInfo.PackageID)
if found == nil {
t.Fatal("detected update package is missing from the cache")
}
if found.ID != entry.ID {
t.Fatalf("resolved package %q, want %q", found.ID, entry.ID)
}
}
+36 -11
View File
@@ -40,6 +40,7 @@ type VersionInformation struct {
OldVersion string
NewVersion string
ArmoryName string
PackageID string
}
type PackageType uint
@@ -124,9 +125,9 @@ func ArmoryUpdateCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
if !ok {
continue
}
updatedPackage, err := getPackageForCommand(update.Name, armoryPK, extVersionInfo.NewVersion)
if err != nil {
con.PrintErrorf("Could not get update package for extension %s: %s\n", update.Name, err)
updatedPackage := packageCacheLookupByID(extVersionInfo.PackageID)
if updatedPackage == nil {
con.PrintErrorf("Could not get update package for extension %s: %s\n", update.Name, ErrPackageNotFound)
continue
}
err = installExtensionPackage(updatedPackage, false, clientConfig, con)
@@ -173,8 +174,21 @@ func checkForAliasUpdates(armoryPK string) map[string]VersionInformation {
return results
}
func extensionManifestsMatch(localManifest, latestManifest *extensions.ExtensionManifest) bool {
if localManifest.PackageName != "" {
return latestManifest.PackageName != "" && localManifest.PackageName == latestManifest.PackageName
}
for _, localCommand := range localManifest.ExtCommand {
for _, latestCommand := range latestManifest.ExtCommand {
if localCommand.CommandName == latestCommand.CommandName {
return true
}
}
}
return false
}
func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
_, cachedExtensions := packageManifestsInCache()
// Return a map of extension name to minimum version to install
results := make(map[string]VersionInformation)
for _, extManifestPath := range assets.GetInstalledExtensionManifests() {
@@ -186,7 +200,12 @@ func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
if err != nil {
continue
}
for _, latestExt := range cachedExtensions {
pkgCache.Range(func(_, value interface{}) bool {
cacheEntry, ok := value.(pkgCacheEntry)
if !ok || cacheEntry.LastErr != nil || cacheEntry.Pkg.IsAlias || cacheEntry.Extension == nil {
return true
}
latestExt := cacheEntry.Extension
/*
We used to check if the version identifiers were different between the installed version and the
version in the armory. This worked when we had one armory, but with multiple potential armories,
@@ -194,16 +213,22 @@ func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
so we will rely on the author of the package incrementing the version identifier somehow to determine
if a package is newer.
*/
if latestExt.Name == localManifest.Name && latestExt.Version > localManifest.Version {
if extensionManifestsMatch(localManifest, latestExt) && latestExt.Version > localManifest.Version {
if latestExt.ArmoryPK == armoryPK || armoryPK == "" {
results[localManifest.Name] = VersionInformation{
OldVersion: localManifest.Version,
NewVersion: latestExt.Version,
ArmoryName: latestExt.ArmoryName,
current, found := results[localManifest.Name]
if !found || latestExt.Version > current.NewVersion ||
(latestExt.Version == current.NewVersion && cacheEntry.ID < current.PackageID) {
results[localManifest.Name] = VersionInformation{
OldVersion: localManifest.Version,
NewVersion: latestExt.Version,
ArmoryName: latestExt.ArmoryName,
PackageID: cacheEntry.ID,
}
}
}
}
}
return true
})
}
return results
+123
View File
@@ -0,0 +1,123 @@
package extensions
/*
Sliver Implant Framework
Copyright (C) 2021 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"strconv"
"strings"
"github.com/spf13/cobra"
)
// splitExtensionArgs separates Sliver-owned flags (--save, --timeout, --help)
// from the extension's own arguments in a raw argument slice.
//
// Extension commands run with cobra's DisableFlagParsing enabled (see
// ExtensionRegisterCommand). Without it, pflag parses the command line first
// and, because the extension's own flags (e.g. --type, --fqdn) are unknown to
// pflag, ParseErrorsWhitelist.UnknownFlags causes pflag to silently drop each
// unknown flag together with its value; the BOF argument parser never saw them
// unless the operator injected cobra's "--" separator (#2309). Running with
// flag parsing disabled hands us the raw, unstripped slice, so we separate the
// Sliver-owned flags ourselves here.
//
// A literal "--" stops Sliver-flag scanning; every token after it is passed
// through verbatim, preserving the long-standing workaround syntax. Unknown
// tokens (the extension's named flags, their values and positional arguments)
// are never consumed or reordered, so both forms work:
//
// delegationbof --timeout 30 --type 6 --fqdn child.htb.local
// delegationbof -- --type 6 --fqdn child.htb.local
func splitExtensionArgs(raw []string) (save bool, timeout int64, help bool, extArgs []string) {
timeout = defaultTimeout
extArgs = make([]string, 0, len(raw))
passthrough := false
for i := 0; i < len(raw); i++ {
tok := raw[i]
// Once we have seen "--", copy everything through untouched so the
// existing `ext -- --foo bar` syntax keeps working identically.
if passthrough {
extArgs = append(extArgs, tok)
continue
}
switch {
case tok == "--":
passthrough = true
case tok == "--help" || tok == "-h":
help = true
case tok == "--save" || tok == "-s":
save = true
case strings.HasPrefix(tok, "--save="):
save = parseBoolValue(tok[len("--save="):], true)
case tok == "--timeout" || tok == "-t":
// Consume the following token as the value, mirroring pflag. If no
// token follows, leave the default in place rather than erroring.
if i+1 < len(raw) {
if n, err := strconv.ParseInt(raw[i+1], 10, 64); err == nil {
timeout = n
}
i++
}
case strings.HasPrefix(tok, "--timeout="):
if n, err := strconv.ParseInt(tok[len("--timeout="):], 10, 64); err == nil {
timeout = n
}
default:
// Not a Sliver-owned flag: hand it to the extension parser untouched.
extArgs = append(extArgs, tok)
}
}
return save, timeout, help, extArgs
}
// applyOwnedFlags republishes the manually parsed Sliver-owned flag values back
// onto the command's flag set. DisableFlagParsing prevents pflag from populating
// them, so helpers that read them via cmd.Flags() (notably ActiveTarget.Request,
// which derives the gRPC timeout from --timeout) would otherwise observe only the
// defaults.
func applyOwnedFlags(cmd *cobra.Command, save bool, timeout int64) {
if flag := cmd.Flags().Lookup("timeout"); flag != nil {
_ = flag.Value.Set(strconv.FormatInt(timeout, 10))
}
if flag := cmd.Flags().Lookup("save"); flag != nil {
_ = flag.Value.Set(strconv.FormatBool(save))
}
}
// parseBoolValue parses a --flag=value bool, defaulting to defVal when the value
// is empty (the bare-flag form). It accepts the same spellings pflag does.
func parseBoolValue(v string, defVal bool) bool {
switch strings.ToLower(v) {
case "", "true", "t", "1":
return true
case "false", "f", "0":
return false
}
return defVal
}
+149
View File
@@ -0,0 +1,149 @@
package extensions
/*
Sliver Implant Framework
Copyright (C) 2021 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"reflect"
"testing"
)
// TestSplitExtensionArgs covers the argument-passthrough regression from #2309:
// the extension's own named flags must reach the BOF parser intact regardless of
// the cobra "--" separator, while Sliver-owned flags are still honoured.
func TestSplitExtensionArgs(t *testing.T) {
cases := []struct {
name string
in []string
save bool
timeout int64
help bool
extArgs []string
}{
{
name: "named extension flags without -- separator (#2309 regression)",
in: []string{"--type", "6", "--fqdn", "child.htb.local"},
timeout: defaultTimeout,
extArgs: []string{"--type", "6", "--fqdn", "child.htb.local"},
},
{
name: "named extension flags with -- separator (existing workaround)",
in: []string{"--", "--type", "6", "--fqdn", "child.htb.local"},
timeout: defaultTimeout,
extArgs: []string{"--type", "6", "--fqdn", "child.htb.local"},
},
{
name: "sliver-owned flags precede extension arguments",
in: []string{"--timeout", "30", "--save", "--type", "6"},
save: true,
timeout: 30,
extArgs: []string{"--type", "6"},
},
{
name: "quoted value containing spaces stays a single argument",
in: []string{"--name", "hello world", "--type", "6"},
timeout: defaultTimeout,
extArgs: []string{"--name", "hello world", "--type", "6"},
},
{
name: "positional extension arguments are preserved",
in: []string{"foo", "bar", "baz"},
timeout: defaultTimeout,
extArgs: []string{"foo", "bar", "baz"},
},
{
name: "single-dash and double-dash forms are preserved",
in: []string{"-type", "6", "--fqdn", "child.htb.local"},
timeout: defaultTimeout,
extArgs: []string{"-type", "6", "--fqdn", "child.htb.local"},
},
{
name: "equals form for timeout",
in: []string{"--timeout=120", "--type", "6"},
timeout: 120,
extArgs: []string{"--type", "6"},
},
{
name: "short -s save flag",
in: []string{"-s", "--type", "6"},
save: true,
timeout: defaultTimeout,
extArgs: []string{"--type", "6"},
},
{
name: "help flag is detected",
in: []string{"--help"},
help: true,
timeout: defaultTimeout,
extArgs: []string{},
},
{
name: "empty input",
in: nil,
timeout: defaultTimeout,
extArgs: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
save, timeout, help, extArgs := splitExtensionArgs(tc.in)
if save != tc.save {
t.Errorf("save: got %v, want %v", save, tc.save)
}
if timeout != tc.timeout {
t.Errorf("timeout: got %d, want %d", timeout, tc.timeout)
}
if help != tc.help {
t.Errorf("help: got %v, want %v", help, tc.help)
}
if !reflect.DeepEqual(extArgs, tc.extArgs) {
t.Errorf("extArgs: got %#v, want %#v", extArgs, tc.extArgs)
}
})
}
}
// TestParseBoolValue checks the --flag=value helper used for --save=value.
func TestParseBoolValue(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", true},
{"true", true},
{"TRUE", true},
{"1", true},
{"t", true},
{"false", false},
{"0", false},
{"no", false}, // unsupported spellings keep the default
}
for _, tc := range cases {
got := parseBoolValue(tc.in, true)
// "no" is not a pflag spelling, so it falls through to the default (true).
want := tc.want
if tc.in == "no" {
want = true
}
if got != want {
t.Errorf("parseBoolValue(%q): got %v, want %v", tc.in, got, want)
}
}
}
@@ -1,10 +1,28 @@
package extensions
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/util"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
/*
@@ -115,6 +133,101 @@ const (
}`
)
type registerExtensionTestRPC struct {
rpcpb.SliverRPCClient
register func(context.Context, *sliverpb.RegisterExtensionReq, ...grpc.CallOption) (*sliverpb.RegisterExtension, error)
}
func (f *registerExtensionTestRPC) RegisterExtension(ctx context.Context, req *sliverpb.RegisterExtensionReq, opts ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
return f.register(ctx, req, opts...)
}
func newExtensionTestConsole(t *testing.T, rpc rpcpb.SliverRPCClient, session *clientpb.Session, beacon *clientpb.Beacon) *console.SliverClient {
t.Helper()
con := console.NewConsole(false)
con.IsCLI = true
con.Rpc = rpc
con.ActiveTarget.Set(session, beacon)
return con
}
func TestRemoveExtensionByManifestNameOnlyDeletesManagedExtensions(t *testing.T) {
clientRoot := t.TempDir()
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
originalManifests := loadedManifests
originalExtensions := loadedExtensions
t.Cleanup(func() {
loadedManifests = originalManifests
loadedExtensions = originalExtensions
})
tests := []struct {
name string
extPath string
wantDeleted bool
}{
{
name: "installed extension",
extPath: filepath.Join(clientRoot, "extensions", "installed"),
wantDeleted: true,
},
{
name: "temporarily loaded extension",
extPath: t.TempDir(),
wantDeleted: false,
},
{
name: "extensions directory sibling",
extPath: filepath.Join(clientRoot, "extensions-backup"),
wantDeleted: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := os.MkdirAll(test.extPath, 0o700); err != nil {
t.Fatalf("create extension directory: %v", err)
}
markerPath := filepath.Join(test.extPath, "source-code.txt")
if err := os.WriteFile(markerPath, []byte("keep me"), 0o600); err != nil {
t.Fatalf("create marker file: %v", err)
}
manifestName := "test-extension"
commandName := "test-command"
command := &ExtCommand{CommandName: commandName}
loadedManifests = map[string]*ExtensionManifest{
manifestName: {RootPath: test.extPath, ExtCommand: []*ExtCommand{command}},
}
loadedExtensions = map[string]*ExtCommand{commandName: command}
found, err := RemoveExtensionByManifestName(manifestName, nil)
if err != nil {
t.Fatalf("remove extension: %v", err)
}
if !found {
t.Fatal("expected extension manifest to be found")
}
if _, ok := loadedManifests[manifestName]; ok {
t.Error("manifest was not unloaded")
}
if _, ok := loadedExtensions[commandName]; ok {
t.Error("command was not unloaded")
}
_, err = os.Stat(markerPath)
if test.wantDeleted && !os.IsNotExist(err) {
t.Errorf("managed extension directory was not deleted: %v", err)
}
if !test.wantDeleted && err != nil {
t.Errorf("external extension directory was modified: %v", err)
}
})
}
}
func TestParseExtensionManifest(t *testing.T) {
expectedPath := util.ResolvePath("foo/test1.dll")
@@ -189,6 +302,218 @@ func TestParseExtensionManifest(t *testing.T) {
}
func TestConvertOldManifestPreservesInit(t *testing.T) {
const initExport = "InitializeExtension"
manifest, err := ParseExtensionManifest([]byte(`{
"name": "legacy-test",
"command_name": "legacy-test",
"help": "some help",
"init": "InitializeExtension",
"files": [
{
"os": "windows",
"arch": "amd64",
"path": "foo/test.dll"
}
]
}`))
if err != nil {
t.Fatalf("Error parsing legacy extension manifest: %s", err)
}
if len(manifest.ExtCommand) != 1 {
t.Fatalf("Expected 1 extension command, got %d", len(manifest.ExtCommand))
}
if manifest.ExtCommand[0].Init != initExport {
t.Errorf("Expected init export %q, got %q", initExport, manifest.ExtCommand[0].Init)
}
}
func TestRegisterExtensionPropagatesInit(t *testing.T) {
const initExport = "InitializeExtension"
tests := []struct {
name string
session *clientpb.Session
beacon *clientpb.Beacon
wantAsync bool
wantID string
}{
{
name: "session",
session: &clientpb.Session{ID: "session-id"},
wantID: "session-id",
},
{
name: "beacon",
beacon: &clientpb.Beacon{ID: "beacon-id"},
wantAsync: true,
wantID: "beacon-id",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var gotReq *sliverpb.RegisterExtensionReq
rpc := &registerExtensionTestRPC{
register: func(_ context.Context, req *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
gotReq = req
return &sliverpb.RegisterExtension{Response: &commonpb.Response{}}, nil
},
}
con := newExtensionTestConsole(t, rpc, test.session, test.beacon)
err := registerExtension("linux", &ExtCommand{CommandName: "test", Init: initExport}, []byte("extension"), nil, con)
if err != nil {
t.Fatalf("RegisterExtension returned an error: %s", err)
}
if gotReq == nil {
t.Fatal("RegisterExtension RPC was not called")
}
if gotReq.Init != initExport {
t.Errorf("Expected init export %q, got %q", initExport, gotReq.Init)
}
if gotReq.Request.GetAsync() != test.wantAsync {
t.Errorf("Expected async=%t, got %t", test.wantAsync, gotReq.Request.GetAsync())
}
if test.wantAsync && gotReq.Request.GetBeaconID() != test.wantID {
t.Errorf("Expected beacon ID %q, got %q", test.wantID, gotReq.Request.GetBeaconID())
}
if !test.wantAsync && gotReq.Request.GetSessionID() != test.wantID {
t.Errorf("Expected session ID %q, got %q", test.wantID, gotReq.Request.GetSessionID())
}
})
}
}
func TestRegisterExtensionWaitsForBeaconRegistration(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
rpc := &registerExtensionTestRPC{
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
close(started)
<-release
return &sliverpb.RegisterExtension{Response: &commonpb.Response{Async: true}}, nil
},
}
con := newExtensionTestConsole(t, rpc, nil, &clientpb.Beacon{ID: "beacon-id"})
done := make(chan error, 1)
go func() {
done <- registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con)
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("Timed out waiting for RegisterExtension RPC")
}
select {
case err := <-done:
t.Fatalf("RegisterExtension returned before the beacon registration was enqueued: %v", err)
case <-time.After(100 * time.Millisecond):
}
close(release)
select {
case err := <-done:
if err != nil {
t.Fatalf("RegisterExtension returned an error: %s", err)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for RegisterExtension to return")
}
}
func TestRegisterExtensionReturnsBeaconRPCError(t *testing.T) {
wantErr := errors.New("register failed")
rpc := &registerExtensionTestRPC{
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
return nil, wantErr
},
}
con := newExtensionTestConsole(t, rpc, nil, &clientpb.Beacon{ID: "beacon-id"})
err := registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con)
if !errors.Is(err, wantErr) {
t.Fatalf("Expected error %q, got %v", wantErr, err)
}
}
func TestRegisterExtensionRejectsNilResponse(t *testing.T) {
rpc := &registerExtensionTestRPC{
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
return nil, nil
},
}
con := newExtensionTestConsole(t, rpc, &clientpb.Session{ID: "session-id"}, nil)
if err := registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con); err == nil {
t.Fatal("RegisterExtension accepted a nil response")
}
}
func TestExtensionCommandVisibilityUsesExactTargetPairs(t *testing.T) {
extension := &ExtCommand{Files: []*extensionFile{
{OS: "windows", Arch: "amd64"},
{OS: "linux", Arch: "amd64"},
{OS: "darwin", Arch: "arm64"},
}}
tests := []struct {
name string
session *clientpb.Session
beacon *clientpb.Beacon
wantVisible bool
}{
{
name: "cross-platform Linux session",
session: &clientpb.Session{OS: "linux", Arch: "amd64"},
wantVisible: true,
},
{
name: "cross-platform macOS beacon",
beacon: &clientpb.Beacon{OS: "darwin", Arch: "arm64"},
wantVisible: true,
},
{
name: "cross-platform Windows session",
session: &clientpb.Session{OS: "windows", Arch: "amd64"},
wantVisible: true,
},
{
name: "Linux architecture mismatch",
session: &clientpb.Session{OS: "linux", Arch: "arm64"},
wantVisible: false,
},
{
name: "macOS architecture mismatch",
beacon: &clientpb.Beacon{OS: "darwin", Arch: "amd64"},
wantVisible: false,
},
{
name: "Windows architecture mismatch",
session: &clientpb.Session{OS: "windows", Arch: "386"},
wantVisible: false,
},
{
name: "unsupported native extension target",
beacon: &clientpb.Beacon{OS: "linux", Arch: "riscv64"},
wantVisible: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
con := newExtensionTestConsole(t, nil, test.session, test.beacon)
command := &cobra.Command{Annotations: makeCommandPlatformFilters(extension)}
activeFilters := con.App.Menu(consts.ImplantMenu).ActiveFiltersFor(command)
visible := len(activeFilters) == 0
if visible != test.wantVisible {
t.Fatalf("command visibility = %t, want %t (active filters: %v)", visible, test.wantVisible, activeFilters)
}
})
}
}
func TestParseMultipleCmdManifest(t *testing.T) {
expectedPath := util.ResolvePath("ex.dll")
@@ -323,3 +648,285 @@ func TestParseExtensionManifestErrors(t *testing.T) {
}
}
}
func TestParseExtensionManifestBOFExecutor(t *testing.T) {
tests := []struct {
name string
data string
want string
}{
{
name: "multi-command manifest",
data: `{
"name":"modern-bof",
"commands":[{
"command_name":"modern-bof",
"help":"test",
"bof_executor":"reflektor",
"files":[{"os":"windows","arch":"amd64","path":"modern.o"}]
}]
}`,
want: BOFExecutorReflektor,
},
{
name: "legacy manifest",
data: `{
"name":"legacy-bof",
"command_name":"legacy-bof",
"help":"test",
"bof_executor":"coff-loader",
"depends_on":"custom-loader",
"files":[{"os":"windows","arch":"amd64","path":"legacy.o"}]
}`,
want: BOFExecutorCOFFLoader,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manifest, err := ParseExtensionManifest([]byte(test.data))
if err != nil {
t.Fatalf("ParseExtensionManifest returned an error: %s", err)
}
if got := manifest.ExtCommand[0].BOFExecutor; got != test.want {
t.Fatalf("BOFExecutor = %q, want %q", got, test.want)
}
})
}
}
func TestParseExtensionManifestRejectsInvalidBOFExecutor(t *testing.T) {
tests := []struct {
name string
executor string
dependsOn string
wantErrPart string
}{
{
name: "executor must be exact lowercase",
executor: "Reflektor",
wantErrPart: "invalid `bof_executor`",
},
{
name: "unknown executor",
executor: "other-loader",
wantErrPart: "invalid `bof_executor`",
},
{
name: "coff loader requires dependency",
executor: BOFExecutorCOFFLoader,
wantErrPart: "no `depends_on` fallback",
},
{
name: "coff loader rejects whitespace dependency",
executor: BOFExecutorCOFFLoader,
dependsOn: " \t",
wantErrPart: "no `depends_on` fallback",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manifest := &ExtensionManifest{
Name: "test",
ExtCommand: []*ExtCommand{{
CommandName: "test",
Help: "test",
BOFExecutor: test.executor,
DependsOn: test.dependsOn,
Files: []*extensionFile{{
OS: "windows", Arch: "amd64", Path: "test.o",
}},
}},
}
err := validManifest(manifest)
if err == nil || !strings.Contains(err.Error(), test.wantErrPart) {
t.Fatalf("validManifest error = %v, want error containing %q", err, test.wantErrPart)
}
})
}
}
func TestPlanExtensionExecution(t *testing.T) {
tests := []struct {
name string
ext *ExtCommand
path string
capabilities uint64
want extensionExecutionMode
wantErr bool
}{
{
name: "native artifact remains native",
ext: &ExtCommand{CommandName: "native"},
path: "native.dll",
want: extensionExecutionNative,
},
{
name: "legacy manifest keeps dependency route",
ext: &ExtCommand{CommandName: "legacy", DependsOn: "coff-loader"},
path: "legacy.o",
capabilities: sliverpb.CapabilityBOFV1,
want: extensionExecutionCOFFLoader,
},
{
name: "explicit coff loader is authoritative",
ext: &ExtCommand{CommandName: "coff", BOFExecutor: BOFExecutorCOFFLoader, DependsOn: "custom-loader"},
path: "coff.bin",
capabilities: sliverpb.CapabilityBOFV1,
want: extensionExecutionCOFFLoader,
},
{
name: "explicit Reflektor uses capable target",
ext: &ExtCommand{CommandName: "builtin", BOFExecutor: BOFExecutorReflektor, DependsOn: "coff-loader"},
path: "builtin.o",
capabilities: sliverpb.CapabilityBOFV1,
want: extensionExecutionReflektor,
},
{
name: "explicit Reflektor falls back to dependency",
ext: &ExtCommand{CommandName: "fallback", BOFExecutor: BOFExecutorReflektor, DependsOn: "coff-loader"},
path: "fallback.o",
want: extensionExecutionCOFFLoader,
},
{
name: "dependency-free BOF defaults to Reflektor",
ext: &ExtCommand{CommandName: "default"},
path: "default.o",
capabilities: sliverpb.CapabilityBOFV1,
want: extensionExecutionReflektor,
},
{
name: "uppercase object extension is a BOF",
ext: &ExtCommand{CommandName: "uppercase"},
path: "uppercase.O",
capabilities: sliverpb.CapabilityBOFV1,
want: extensionExecutionReflektor,
},
{
name: "unsupported dependency-free BOF fails closed",
ext: &ExtCommand{CommandName: "unsupported"},
path: "unsupported.o",
wantErr: true,
},
{
name: "coff loader without dependency fails closed",
ext: &ExtCommand{CommandName: "missing", BOFExecutor: BOFExecutorCOFFLoader},
path: "missing.o",
wantErr: true,
},
{
name: "invalid executor fails closed",
ext: &ExtCommand{CommandName: "invalid", BOFExecutor: "other"},
path: "invalid.o",
capabilities: sliverpb.CapabilityBOFV1,
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := planExtensionExecution(test.ext, test.path, test.capabilities)
if test.wantErr {
if err == nil {
t.Fatalf("planExtensionExecution returned mode %d, want error", got)
}
return
}
if err != nil {
t.Fatalf("planExtensionExecution returned an error: %s", err)
}
if got != test.want {
t.Fatalf("planExtensionExecution mode = %d, want %d", got, test.want)
}
})
}
}
func TestBuildCallExtensionRequestPreservesLegacyWireFields(t *testing.T) {
root := t.TempDir()
objectData := []byte("object-data")
loaderData := []byte("loader-data")
objectPath := filepath.Join(root, "object.o")
loaderPath := filepath.Join(root, "loader.dll")
if err := os.WriteFile(objectPath, objectData, 0o600); err != nil {
t.Fatalf("write object fixture: %s", err)
}
if err := os.WriteFile(loaderPath, loaderData, 0o600); err != nil {
t.Fatalf("write loader fixture: %s", err)
}
const loaderName = "custom-loader"
previousLoader, hadPreviousLoader := loadedExtensions[loaderName]
defer func() {
if hadPreviousLoader {
loadedExtensions[loaderName] = previousLoader
} else {
delete(loadedExtensions, loaderName)
}
}()
loaderManifest := &ExtensionManifest{Name: loaderName, RootPath: root}
loadedExtensions[loaderName] = &ExtCommand{
CommandName: loaderName,
Entrypoint: "RunCOFF",
Files: []*extensionFile{{
OS: "windows", Arch: "amd64", Path: filepath.Base(loaderPath),
}},
Manifest: loaderManifest,
}
legacyArgs := []byte{0x01, 0x02, 0x03, 0x04}
ext := &ExtCommand{CommandName: "test-bof", DependsOn: loaderName}
got, err := buildCallExtensionRequest("windows", "amd64", extensionExecutionCOFFLoader, ext, objectPath, "RunCOFF", legacyArgs)
if err != nil {
t.Fatalf("buildCallExtensionRequest returned an error: %s", err)
}
loaderHash := sha256.Sum256(loaderData)
want := &sliverpb.CallExtensionReq{
Name: hex.EncodeToString(loaderHash[:]),
Export: "RunCOFF",
Args: legacyArgs,
}
gotWire, err := proto.Marshal(got)
if err != nil {
t.Fatalf("marshal actual request: %s", err)
}
wantWire, err := proto.Marshal(want)
if err != nil {
t.Fatalf("marshal expected request: %s", err)
}
if !bytes.Equal(gotWire, wantWire) {
t.Fatalf("legacy request wire bytes changed:\n got: %x\nwant: %x", gotWire, wantWire)
}
}
func TestBuildCallExtensionRequestForReflektor(t *testing.T) {
root := t.TempDir()
objectData := []byte("object-data")
objectPath := filepath.Join(root, "object.o")
if err := os.WriteFile(objectPath, objectData, 0o600); err != nil {
t.Fatalf("write object fixture: %s", err)
}
typedArgs := []byte{0x05, 0x06}
got, err := buildCallExtensionRequest("windows", "amd64", extensionExecutionReflektor, &ExtCommand{CommandName: "test-bof"}, objectPath, "go", typedArgs)
if err != nil {
t.Fatalf("buildCallExtensionRequest returned an error: %s", err)
}
objectHash := sha256.Sum256(objectData)
if got.Name != hex.EncodeToString(objectHash[:]) {
t.Errorf("Name = %q, want object hash %q", got.Name, hex.EncodeToString(objectHash[:]))
}
if got.Export != "go" {
t.Errorf("Export = %q, want %q", got.Export, "go")
}
if !bytes.Equal(got.Args, typedArgs) {
t.Errorf("Args = %x, want %x", got.Args, typedArgs)
}
if !bytes.Equal(got.BOFData, objectData) {
t.Errorf("BOFData = %q, want %q", got.BOFData, objectData)
}
if !got.IsBOF {
t.Error("IsBOF = false, want true")
}
}
+29 -23
View File
@@ -40,7 +40,21 @@ func ExtensionsInstallCmd(cmd *cobra.Command, con *console.SliverClient, args []
con.PrintErrorf("Extension path '%s' does not exist", extLocalPath)
return
}
InstallFromDir(extLocalPath, true, con, strings.HasSuffix(extLocalPath, ".tar.gz"))
_ = InstallFromDir(extLocalPath, true, con, strings.HasSuffix(extLocalPath, ".tar.gz"))
}
func readExtensionManifest(extLocalPath string, isGz bool) ([]byte, error) {
if isGz {
return util.ReadFileFromTarGz(extLocalPath, fmt.Sprintf("./%s", ManifestFileName))
}
return os.ReadFile(filepath.Join(extLocalPath, ManifestFileName))
}
func extensionPackageID(manifest *ExtensionManifest) string {
if manifest.PackageName != "" {
return manifest.PackageName
}
return manifest.Name
}
// InstallFromDir installs a Sliver extension from either a local directory or gzipped archive.
@@ -54,37 +68,28 @@ func ExtensionsInstallCmd(cmd *cobra.Command, con *console.SliverClient, args []
// - con: Sliver console client for displaying status and error messages
// - isGz: Whether the source is a gzipped archive (true) or directory (false)
//
// The function will return early with error messages printed to console if:
// The function returns errors after printing them to the console if:
// - The manifest cannot be read or parsed
// - Required directories cannot be created
// - File copy operations fail
// - User declines overwrite when prompted
func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.SliverClient, isGz bool) {
var manifestData []byte
var err error
if isGz {
manifestData, err = util.ReadFileFromTarGz(extLocalPath, fmt.Sprintf("./%s", ManifestFileName))
} else {
manifestData, err = os.ReadFile(filepath.Join(extLocalPath, ManifestFileName))
}
//
// Declining an overwrite prompt cancels the installation without an error.
func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.SliverClient, isGz bool) error {
manifestData, err := readExtensionManifest(extLocalPath, isGz)
if err != nil {
con.PrintErrorf("Error reading %s: %s", ManifestFileName, err)
return
return fmt.Errorf("read %s: %w", ManifestFileName, err)
}
manifestF, err := ParseExtensionManifest(manifestData)
if err != nil {
con.PrintErrorf("Error parsing %s: %s", ManifestFileName, err)
return
return fmt.Errorf("parse %s: %w", ManifestFileName, err)
}
// Use package name if available, otherwise use extension name
// (Note, for v1 manifests this will actually be command_name)
packageID := manifestF.Name
if manifestF.PackageName != "" {
packageID = manifestF.PackageName
}
packageID := extensionPackageID(manifestF)
//create repo path
minstallPath := filepath.Join(assets.GetExtensionsDir(), filepath.Base(packageID))
@@ -94,7 +99,7 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
confirm := false
_ = forms.Confirm("Overwrite current install?", &confirm)
if !confirm {
return
return nil
}
}
forceRemoveAll(minstallPath)
@@ -103,13 +108,13 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
err = os.MkdirAll(minstallPath, 0o700)
if err != nil {
con.PrintErrorf("Error creating extension directory: %s\n", err)
return
return fmt.Errorf("create extension directory: %w", err)
}
err = os.WriteFile(filepath.Join(minstallPath, ManifestFileName), manifestData, 0o600)
if err != nil {
con.PrintErrorf("Failed to write %s: %s\n", ManifestFileName, err)
forceRemoveAll(minstallPath)
return
return fmt.Errorf("write %s: %w", ManifestFileName, err)
}
for _, manifest := range manifestF.ExtCommand {
@@ -125,7 +130,7 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
if err != nil {
con.PrintErrorf("\nError creating extension directory: %s\n", err)
forceRemoveAll(installPath)
return
return fmt.Errorf("create extension artifact directory: %w", err)
}
err = util.CopyFile(src, dst)
if err != nil {
@@ -135,11 +140,12 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
if err != nil {
con.PrintErrorf("Error installing command: %s\n", err)
forceRemoveAll(installPath)
return
return fmt.Errorf("install extension artifact: %w", err)
}
}
}
}
return nil
}
func installArtifact(extGzFilePath string, installPath string, artifactPath string) error {
+135
View File
@@ -0,0 +1,135 @@
package extensions
import (
"archive/tar"
"errors"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/console"
"github.com/klauspost/compress/gzip"
"github.com/spf13/cobra"
)
type installArchiveMember struct {
name string
data []byte
}
const installTestManifest = `{
"name": "Test Extension",
"package_name": "test-extension",
"version": "v1.0.0",
"commands": [{
"command_name": "test-command",
"help": "test command",
"files": [{"os": "windows", "arch": "amd64", "path": "foo/test.dll"}]
}]
}`
func writeInstallArchive(t *testing.T, prefix string, members ...installArchiveMember) string {
t.Helper()
archivePath := filepath.Join(t.TempDir(), "extension.tar.gz")
archive, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
gzipWriter := gzip.NewWriter(archive)
tarWriter := tar.NewWriter(gzipWriter)
for _, member := range members {
header := &tar.Header{
Name: prefix + member.name,
Mode: 0o600,
Size: int64(len(member.data)),
Typeflag: tar.TypeReg,
}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write(member.data); err != nil {
t.Fatal(err)
}
}
if err := tarWriter.Close(); err != nil {
t.Fatal(err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatal(err)
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
return archivePath
}
func testInstallConsole(t *testing.T) *console.SliverClient {
t.Helper()
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(
con, nil, nil, nil, emptyCommands, emptyCommands, false, "",
); err != nil {
t.Fatal(err)
}
return con
}
func TestInstallFromDirAcceptsPrefixedAndUnprefixedArchives(t *testing.T) {
for _, prefix := range []string{"./", ""} {
name := "unprefixed"
if prefix != "" {
name = "prefixed"
}
t.Run(name, func(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
archivePath := writeInstallArchive(t, prefix,
installArchiveMember{name: ManifestFileName, data: []byte(installTestManifest)},
installArchiveMember{name: "foo/test.dll", data: []byte("extension data")},
)
if err := InstallFromDir(archivePath, false, testInstallConsole(t), true); err != nil {
t.Fatal(err)
}
installRoot := filepath.Join(assets.GetExtensionsDir(), "test-extension")
if _, err := os.Stat(filepath.Join(installRoot, ManifestFileName)); err != nil {
t.Fatalf("installed manifest: %v", err)
}
artifact, err := os.ReadFile(filepath.Join(installRoot, "foo", "test.dll"))
if err != nil {
t.Fatalf("installed artifact: %v", err)
}
if string(artifact) != "extension data" {
t.Fatalf("artifact data = %q", artifact)
}
})
}
}
func TestInstallFromDirReturnsMissingManifest(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
archivePath := writeInstallArchive(t, "./",
installArchiveMember{name: "foo/test.dll", data: []byte("extension data")},
)
err := InstallFromDir(archivePath, false, testInstallConsole(t), true)
if !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("error = %v, want fs.ErrNotExist", err)
}
}
func TestInstallFromDirReturnsMissingArtifactAndCleansUp(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
archivePath := writeInstallArchive(t, "./",
installArchiveMember{name: ManifestFileName, data: []byte(installTestManifest)},
)
err := InstallFromDir(archivePath, false, testInstallConsole(t), true)
if !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("error = %v, want fs.ErrNotExist", err)
}
installRoot := filepath.Join(assets.GetExtensionsDir(), "test-extension")
if _, err := os.Stat(installRoot); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("install directory was not cleaned up: %v", err)
}
}
+180 -101
View File
@@ -54,6 +54,20 @@ const (
// ManifestFileName - Extension manifest file name.
ManifestFileName = "extension.json"
// BOFExecutorReflektor selects the built-in Reflektor BOF executor.
BOFExecutorReflektor = "reflektor"
// BOFExecutorCOFFLoader selects the manifest's depends_on COFF loader.
BOFExecutorCOFFLoader = "coff-loader"
)
type extensionExecutionMode uint8
const (
extensionExecutionNative extensionExecutionMode = iota
extensionExecutionCOFFLoader
extensionExecutionReflektor
)
var loadedExtensions = map[string]*ExtCommand{}
@@ -72,6 +86,7 @@ type ExtensionManifest_ struct {
Arguments []*extensionArgument `json:"arguments"`
Entrypoint string `json:"entrypoint"`
DependsOn string `json:"depends_on"`
BOFExecutor string `json:"bof_executor"`
Init string `json:"init"`
RootPath string `json:"-"`
@@ -100,6 +115,7 @@ type ExtCommand struct {
Arguments []*extensionArgument `json:"arguments"`
Entrypoint string `json:"entrypoint"`
DependsOn string `json:"depends_on"`
BOFExecutor string `json:"bof_executor"`
Init string `json:"init"`
Schema *packages.OutputSchema `json:"schema"`
@@ -218,9 +234,11 @@ func convertOldManifest(old *ExtensionManifest_) *ExtensionManifest {
{
CommandName: old.CommandName,
DependsOn: old.DependsOn,
BOFExecutor: old.BOFExecutor,
Help: old.Help,
LongHelp: old.LongHelp,
Entrypoint: old.Entrypoint,
Init: old.Init,
Files: old.Files,
Arguments: old.Arguments,
Schema: nil,
@@ -270,6 +288,15 @@ func validManifest(manifest *ExtensionManifest) error {
if extManifest.CommandName == "" {
return errors.New("missing `command_name` field in extension manifest")
}
switch extManifest.BOFExecutor {
case "", BOFExecutorReflektor:
case BOFExecutorCOFFLoader:
if strings.TrimSpace(extManifest.DependsOn) == "" {
return fmt.Errorf("command %q uses bof_executor %q but has no `depends_on` fallback", extManifest.CommandName, BOFExecutorCOFFLoader)
}
default:
return fmt.Errorf("command %q has invalid `bof_executor` %q (must be %q or %q)", extManifest.CommandName, extManifest.BOFExecutor, BOFExecutorReflektor, BOFExecutorCOFFLoader)
}
if len(extManifest.Files) == 0 {
return errors.New("missing `files` field in extension manifest")
}
@@ -370,8 +397,12 @@ func ExtensionRegisterCommand(extCmd *ExtCommand, cmd *cobra.Command, con *conso
Run: func(cmd *cobra.Command, args []string) {
runExtensionCmd(cmd, con, args)
},
GroupID: consts.ExtensionHelpGroup,
Annotations: makeCommandPlatformFilters(extCmd),
GroupID: consts.ExtensionHelpGroup,
// DisableFlagParsing keeps the extension's own flags (which are unknown to
// pflag) from being silently stripped together with their values. We split
// Sliver-owned flags out of the raw slice ourselves in runExtensionCmd (#2309).
DisableFlagParsing: true,
Annotations: makeCommandPlatformFilters(extCmd),
}
// Flags
@@ -379,7 +410,6 @@ func ExtensionRegisterCommand(extCmd *ExtCommand, cmd *cobra.Command, con *conso
f.BoolP("save", "s", false, "Save output to disk")
f.Int64P("timeout", "t", defaultTimeout, "command timeout in seconds")
extensionCmd.Flags().AddFlagSet(f)
extensionCmd.Flags().ParseErrorsWhitelist.UnknownFlags = true
// Completions
comps := carapace.Gen(extensionCmd)
@@ -468,44 +498,30 @@ func registerExtension(goos string, ext *ExtCommand, binData []byte, cmd *cobra.
//set extension name to a hash of the data to avoid loading more than one instance
bd := sha256.Sum256(binData)
name := hex.EncodeToString(bd[:])
sess, beac := con.ActiveTarget.GetInteractive()
sess, _ := con.ActiveTarget.GetInteractive()
ctrl := make(chan bool)
//first time run of an extension will require some waiting depending on the size
if sess != nil {
msg := fmt.Sprintf("Sending %s to implant ...", ext.CommandName)
con.SpinUntil(msg, ctrl)
}
//don't block if we are in beacon mode
if beac != nil && sess == nil {
go func() {
registerResp, err := con.Rpc.RegisterExtension(context.Background(), &sliverpb.RegisterExtensionReq{
Name: name,
Data: binData,
OS: goos,
Init: ext.Init,
Request: con.ActiveTarget.Request(cmd),
})
if err != nil {
con.PrintErrorf("Error registering extension: %s\n", err)
}
if registerResp.Response != nil && registerResp.Response.Err != "" {
con.PrintErrorf("Error registering extension: %s\n", errors.New(registerResp.Response.Err))
}
}()
return nil
}
//session mode (hopefully)
registerResp, err := con.Rpc.RegisterExtension(context.Background(), &sliverpb.RegisterExtensionReq{
Name: name,
Data: binData,
OS: goos,
Init: ext.Init,
Request: con.ActiveTarget.Request(cmd),
})
ctrl <- true
<-ctrl
if sess != nil {
ctrl <- true
<-ctrl
}
if err != nil {
return err
}
if registerResp == nil {
return errors.New("received empty register extension response")
}
if registerResp.Response != nil && registerResp.Response.Err != "" {
return errors.New(registerResp.Response.Err)
}
@@ -528,7 +544,102 @@ func loadDep(goos string, goarch string, depName string, cmd *cobra.Command, con
return fmt.Errorf("missing dependency %s", depName)
}
func planExtensionExecution(ext *ExtCommand, binPath string, capabilities uint64) (extensionExecutionMode, error) {
isBOF := ext.BOFExecutor != "" || strings.EqualFold(filepath.Ext(binPath), ".o")
if !isBOF {
return extensionExecutionNative, nil
}
dependsOn := strings.TrimSpace(ext.DependsOn)
switch ext.BOFExecutor {
case BOFExecutorCOFFLoader:
if dependsOn == "" {
return extensionExecutionNative, fmt.Errorf("BOF command %q selects %q but has no depends_on loader", ext.CommandName, BOFExecutorCOFFLoader)
}
return extensionExecutionCOFFLoader, nil
case "":
// Existing manifests infer the loader from depends_on. Keep that route
// even when the target supports the built-in executor.
if dependsOn != "" {
return extensionExecutionCOFFLoader, nil
}
case BOFExecutorReflektor:
default:
return extensionExecutionNative, fmt.Errorf("BOF command %q has invalid bof_executor %q", ext.CommandName, ext.BOFExecutor)
}
if capabilities&sliverpb.CapabilityBOFV1 != 0 {
return extensionExecutionReflektor, nil
}
if dependsOn != "" {
return extensionExecutionCOFFLoader, nil
}
return extensionExecutionNative, fmt.Errorf("target does not support built-in Reflektor BOF execution and command %q has no depends_on fallback", ext.CommandName)
}
func activeTargetCapabilities(session *clientpb.Session, beacon *clientpb.Beacon) uint64 {
if session != nil {
return session.Capabilities
}
if beacon != nil {
return beacon.Capabilities
}
return 0
}
func buildCallExtensionRequest(goos string, goarch string, mode extensionExecutionMode, ext *ExtCommand, binPath string, entryPoint string, extensionArgs []byte) (*sliverpb.CallExtensionReq, error) {
extData, err := os.ReadFile(binPath)
if err != nil {
return nil, err
}
bd := sha256.Sum256(extData)
name := hex.EncodeToString(bd[:])
if mode == extensionExecutionCOFFLoader {
loader, found := loadedExtensions[ext.DependsOn]
if !found {
return nil, fmt.Errorf("attempted to load non-existing extension: %s", ext.DependsOn)
}
loaderPath, err := loader.getFileForTarget(goos, goarch)
if err != nil {
return nil, fmt.Errorf("could not get file for extension %s: %w", ext.DependsOn, err)
}
loaderData, err := os.ReadFile(loaderPath)
if err != nil {
return nil, fmt.Errorf("dep read file error: %w", err)
}
if len(loaderData) == 0 {
return nil, fmt.Errorf("read empty file: %s", loaderPath)
}
bd = sha256.Sum256(loaderData)
name = hex.EncodeToString(bd[:])
}
req := &sliverpb.CallExtensionReq{
Name: name,
Export: entryPoint,
Args: extensionArgs,
}
if mode == extensionExecutionReflektor {
req.BOFData = extData
req.IsBOF = true
}
return req, nil
}
func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
// The command runs with DisableFlagParsing, so args is the raw, unstripped
// slice. Separate Sliver-owned flags from the extension's own arguments and
// re-publish them on the flag set (so Request()'s --timeout still works),
// then feed only the extension arguments to the BOF/DLL parsers (#2309).
save, timeout, helpRequested, extArgs := splitExtensionArgs(args)
if helpRequested {
_ = cmd.Help()
return
}
applyOwnedFlags(cmd, save, timeout)
args = extArgs
var (
err error
extensionArgs []byte
@@ -555,27 +666,32 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
return
}
checkCache := session != nil
if err = loadExtension(goos, goarch, checkCache, ext, cmd, con); err != nil {
con.PrintErrorf("Could not load extension: %s\n", err)
return
}
binPath, err := ext.getFileForTarget(goos, goarch)
if err != nil {
con.PrintErrorf("Failed to read extension file: %s\n", err)
return
}
isBOF := filepath.Ext(binPath) == ".o"
executionMode, err := planExtensionExecution(ext, binPath, activeTargetCapabilities(session, beacon))
if err != nil {
con.PrintErrorf("Could not execute extension: %s\n", err)
return
}
if executionMode != extensionExecutionReflektor {
checkCache := session != nil
if err = loadExtension(goos, goarch, checkCache, ext, cmd, con); err != nil {
con.PrintErrorf("Could not load extension: %s\n", err)
return
}
}
// BOFs (Beacon Object Files) are a specific kind of extensions
// that require another extension (a COFF loader) to be present.
// that either use the built-in Reflektor executor or another extension.
// BOFs also have strongly typed arguments that need to be parsed in the proper way.
// This block will pack both the BOF data and its arguments into a single buffer that
// the loader will extract and load.
if isBOF {
// Beacon Object File -- requires a COFF loader
switch executionMode {
case extensionExecutionCOFFLoader:
// Legacy COFF loaders expect the entrypoint, object, and typed argument
// buffer packed into the existing outer argument envelope.
extensionArgs, err = getBOFArgs(cmd, args, binPath, ext)
if err != nil {
con.PrintErrorf("BOF args error: %s\n", err)
@@ -583,7 +699,15 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
}
extName = ext.DependsOn
entryPoint = loadedExtensions[extName].Entrypoint // should exist at this point
} else {
case extensionExecutionReflektor:
extensionArgs, err = ParseFlagArgumentsToBuffer(cmd, args, binPath, ext)
if err != nil {
con.PrintErrorf("BOF args error: %s\n", err)
return
}
extName = ext.CommandName
entryPoint = ext.Entrypoint
default:
// Regular DLL - Just join the arguments with spaces
if len(args) > 0 {
extensionArgs = []byte(strings.Join(args, " "))
@@ -597,45 +721,15 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
ctrl := make(chan bool)
msg := fmt.Sprintf("Executing %s ...", cmd.Name())
con.SpinUntil(msg, ctrl)
extdata, err := os.ReadFile(binPath)
callExtReq, err := buildCallExtensionRequest(goos, goarch, executionMode, ext, binPath, entryPoint, extensionArgs)
if err != nil {
con.PrintErrorf("ext read file error: %s\n", err)
ctrl <- true
<-ctrl
con.PrintErrorf("Could not prepare extension call: %s\n", err)
return
}
bd := sha256.Sum256(extdata)
name := hex.EncodeToString(bd[:])
if isBOF {
//if we are using a bof, we are actually calling the coffloader extension - so get the file from dep ref and use that shasum
if extension, found := loadedExtensions[ext.DependsOn]; found {
dep, err := extension.getFileForTarget(goos, goarch)
if err != nil {
con.PrintErrorf("could not get file for extension %s", ext.DependsOn)
return
}
depdata, err := os.ReadFile(dep)
if err != nil {
con.PrintErrorf("dep read file error: %s\n", err)
return
}
if len(depdata) == 0 {
con.PrintErrorf("read empty file: %s\n", dep)
return
}
bd = sha256.Sum256(depdata)
name = hex.EncodeToString(bd[:])
} else {
// handle error
con.PrintErrorf("attempted to load non-existing extension: %s", ext.DependsOn)
return
}
}
callExtResp, err := con.Rpc.CallExtension(context.Background(), &sliverpb.CallExtensionReq{
Name: name,
Export: entryPoint,
Args: extensionArgs,
Request: con.ActiveTarget.Request(cmd),
})
callExtReq.Request = con.ActiveTarget.Request(cmd)
callExtResp, err := con.Rpc.CallExtension(context.Background(), callExtReq)
ctrl <- true
<-ctrl
if err != nil {
@@ -850,37 +944,22 @@ func makeExtensionFlagNameCompletion(extCmd *ExtCommand) carapace.Action {
}
func makeCommandPlatformFilters(extCmd *ExtCommand) map[string]string {
filtersOS := make(map[string]bool)
filtersArch := make(map[string]bool)
var all []string
// Only add filters for architectures when there OS matters.
supportedTargets := make(map[string]struct{}, len(extCmd.Files))
for _, file := range extCmd.Files {
filtersOS[file.OS] = true
if filtersOS[file.OS] {
filtersArch[file.Arch] = true
}
supportedTargets[file.OS+"/"+file.Arch] = struct{}{}
}
for os, enabled := range filtersOS {
if enabled {
all = append(all, os)
// Console command filters are hide-on-match, so annotate the command with
// every exact target pair it does not support. A separate fallback hides all
// native extensions on target pairs outside the supported loader matrix.
filters := []string{consts.NativeExtensionUnsupportedTargetFilter}
for _, target := range consts.NativeExtensionTargets() {
if _, supported := supportedTargets[target.GOOS+"/"+target.GOARCH]; !supported {
filters = append(filters, target.Filter)
}
}
for arch, enabled := range filtersArch {
if enabled {
all = append(all, arch)
}
}
if len(all) == 0 {
return map[string]string{}
}
return map[string]string{
appConsole.CommandFilterKey: strings.Join(all, ","),
appConsole.CommandFilterKey: strings.Join(filters, ","),
}
}
+12 -15
View File
@@ -23,7 +23,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/console"
@@ -100,21 +99,11 @@ func RemoveExtensionByManifestName(manifestName string, con *console.SliverClien
return true, nil
}
// If path is outside extensions directory, prompt for confirmation
if !strings.HasPrefix(extPath, assets.GetExtensionsDir()) {
confirm := false
_ = forms.Confirm(fmt.Sprintf("Remove '%s' extension directory from filesystem?", manifestName), &confirm)
if !confirm {
// Skip the forceRemoveAll but continue with the rest
delete(loadedManifests, manifestName)
for _, cmd := range man.ExtCommand {
delete(loadedExtensions, cmd.CommandName)
}
return true, nil
}
// Only installed extensions are owned by Sliver and may be deleted.
// Temporarily loaded extensions can live in arbitrary source directories.
if isWithinExtensionsDir(extPath) {
forceRemoveAll(extPath)
}
forceRemoveAll(extPath)
delete(loadedManifests, manifestName)
for _, cmd := range man.ExtCommand {
delete(loadedExtensions, cmd.CommandName)
@@ -124,6 +113,14 @@ func RemoveExtensionByManifestName(manifestName string, con *console.SliverClien
return false, nil
}
func isWithinExtensionsDir(extPath string) bool {
relPath, err := filepath.Rel(assets.GetExtensionsDir(), extPath)
if err != nil {
return false
}
return relPath != "." && filepath.IsLocal(relPath)
}
func forceRemoveAll(rootPath string) {
util.ChmodR(rootPath, 0o600, 0o700)
os.RemoveAll(rootPath)
+2 -2
View File
@@ -159,7 +159,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("save", "s", "", "directory/file to the binary to")
f.String("shellcode-encoder", "", "shellcode encoder to apply (optional; see `shellcode-encoders`)")
f.StringP("name", "n", "", "Implant name")
f.BoolP("external-builder", "E", false, "use an external builder")
})
flags.BindFlagCompletions(profilesGenerateCmd, func(comp *carapace.ActionMap) {
(*comp)["save"] = carapace.ActionFiles().Tag("directory/file to save implant")
@@ -367,7 +367,7 @@ func coreImplantFlags(name string, cmd *cobra.Command) {
f.StringP("limit-locale", "L", "", "limit execution to hosts that match this locale")
bindSpoofMetadataFlag(f)
f.StringP("format", "f", "exe", "Specifies the output formats, valid values are: 'exe', 'shared' (for dynamic libraries), 'service' (see: `psexec` for more info) and 'shellcode' (windows, darwin/arm64, linux/amd64, linux/arm64)")
f.StringP("format", "f", "exe", "Specifies the output formats, valid values are: 'exe', 'shared' (for dynamic libraries), 'archive' (for Go c-archives), 'service' (see: `psexec` for more info) and 'shellcode' (windows, darwin/arm64, linux/amd64, linux/arm64)")
// Shellcode generation options:
// - Windows: Donut
+24
View File
@@ -24,6 +24,30 @@ func TestGenerateSaveFlagCompletionRegistered(t *testing.T) {
assertFlagHasDefaultFileCompletion(t, beaconCmd, "save")
}
func TestProfilesGenerateExternalBuilderFlagRegistered(t *testing.T) {
cmds := Commands(&console.SliverClient{})
profilesCmd := commandByUse(cmds, consts.ProfilesStr)
if profilesCmd == nil {
t.Fatalf("missing %q command", consts.ProfilesStr)
}
profilesGenerateCmd := commandByUse(profilesCmd.Commands(), consts.GenerateStr)
if profilesGenerateCmd == nil {
t.Fatalf("missing %q subcommand under %q", consts.GenerateStr, consts.ProfilesStr)
}
flag := profilesGenerateCmd.Flags().Lookup("external-builder")
if flag == nil {
t.Fatalf("%q missing %q flag", profilesGenerateCmd.Use, "external-builder")
}
if flag.Shorthand != "E" {
t.Fatalf("%q %q flag shorthand mismatch: got=%q want=%q", profilesGenerateCmd.Use, "external-builder", flag.Shorthand, "E")
}
if flag.DefValue != "false" {
t.Fatalf("%q %q flag default mismatch: got=%q want=%q", profilesGenerateCmd.Use, "external-builder", flag.DefValue, "false")
}
}
func assertFlagHasDefaultFileCompletion(t *testing.T, cmd *cobra.Command, flag string) {
t.Helper()
+6
View File
@@ -87,6 +87,7 @@ var (
"linux/arm64": true,
"windows/386": true,
"windows/amd64": true,
"windows/arm64": true,
}
ErrNoExternalBuilder = errors.New("no external builders are available")
@@ -254,6 +255,8 @@ func nameOfOutputFormat(value clientpb.OutputFormat) string {
return "Service"
case clientpb.OutputFormat_SHARED_LIB:
return "Shared Library"
case clientpb.OutputFormat_GO_ARCHIVE:
return "Go Archive"
case clientpb.OutputFormat_SHELLCODE:
return "Shellcode"
default:
@@ -389,6 +392,9 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
configFormat = clientpb.OutputFormat_SHARED_LIB
isSharedLib = true
runAtLoad, _ = cmd.Flags().GetBool("run-at-load")
case "archive":
configFormat = clientpb.OutputFormat_GO_ARCHIVE
isSharedLib = true
case "shellcode":
configFormat = clientpb.OutputFormat_SHELLCODE
isShellcode = true
+1 -1
View File
@@ -197,7 +197,7 @@ func OSCompleter(con *console.SliverClient) carapace.Action {
func FormatCompleter() carapace.Action {
return carapace.ActionCallback(func(_ carapace.Context) carapace.Action {
return carapace.ActionValues([]string{
"exe", "shared", "service", "shellcode",
"exe", "shared", "archive", "service", "shellcode",
}...).Tag("implant format")
})
}
@@ -0,0 +1,13 @@
package generate
import (
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestNameOfOutputFormatGoArchive(t *testing.T) {
if got := nameOfOutputFormat(clientpb.OutputFormat_GO_ARCHIVE); got != "Go Archive" {
t.Fatalf("nameOfOutputFormat(GO_ARCHIVE) = %q, want %q", got, "Go Archive")
}
}
+22 -3
View File
@@ -19,6 +19,7 @@ package generate
*/
import (
"fmt"
"os"
"strings"
@@ -81,9 +82,27 @@ func ProfilesGenerateCmd(cmd *cobra.Command, con *console.SliverClient, args []s
profile.Config.SGNEnabled = encoder == clientpb.ShellcodeEncoder_SHIKATA_GA_NAI
}
}
_, err := compile(implantName, profile.Config, nil, save, con)
if err != nil {
return
if external, _ := cmd.Flags().GetBool("external-builder"); !external {
_, err := compile(implantName, profile.Config, nil, save, con)
if err != nil {
return
}
} else {
_, err := externalBuild(implantName, profile.Config, nil, save, con)
if err != nil {
switch err {
case ErrNoExternalBuilder:
con.PrintErrorf("There are no external builders currently connected to the server\n")
con.PrintErrorf("See 'builders' command for more information\n")
case ErrNoValidBuilders:
con.PrintErrorf("There are external builders connected to the server, but none can build the target you specified\n")
con.PrintErrorf("Invalid target %s\n", fmt.Sprintf("%s:%s/%s", profile.Config.Format, profile.Config.GOOS, profile.Config.GOARCH))
con.PrintErrorf("See 'builders' command for more information\n")
default:
con.PrintErrorf("%s\n", err)
}
return
}
}
} else {
con.PrintErrorf("No profile with name '%s'", name)
+2
View File
@@ -263,6 +263,8 @@ func populateProfileProperties(config *clientpb.ImplantConfig) map[string]string
properties["format"] = "Executable"
case clientpb.OutputFormat_SHARED_LIB:
properties["format"] = "Shared Library"
case clientpb.OutputFormat_GO_ARCHIVE:
properties["format"] = "Go Archive"
case clientpb.OutputFormat_SERVICE:
properties["format"] = "Service"
case clientpb.OutputFormat_SHELLCODE:
+27
View File
@@ -59,6 +59,7 @@ var (
consts.AIStr: aiHelp,
consts.AIConfigStr: aiConfigHelp,
consts.DocsStr: docsHelp,
consts.OpforStr: opforHelp,
consts.CatStr: catHelp,
consts.EditStr: editHelp,
consts.HexEditStr: hexEditHelp,
@@ -178,6 +179,32 @@ _=/usr/bin/env
ai
`
opforHelp = `[[.Bold]]Command:[[.Normal]] opfor [script.cna|subcommand]
[[.Bold]]About:[[.Normal]] Check, run, or persistently load OPFOR-compatible CNA scripts in the Sliver client. Loaded Beacon aliases are exposed beneath the opfor namespace and use the active session or beacon. This integration intentionally focuses on BOF-oriented scripts and a small set of terminal UI and session APIs; it is not a complete Aggressor Script client implementation.
[[.Bold]]Modes:[[.Normal]]
check compiles a CNA without executing any top-level code.
run executes top-level code once with trailing arguments in @ARGV, then discards aliases, hooks, and callbacks.
load executes top-level initialization and retains aliases, hooks, and callbacks for the lifetime of this client process. A bare CNA path is shorthand for load.
help displays beacon_command_register metadata for a loaded alias without invoking it.
Most BOF CNAs, including FirefoxDump, define an alias at top level. Running such a file only creates and immediately retires that alias; load it, select a target, and invoke the alias to execute its BOF.
The default operation timeout is 600 seconds. Put --timeout before the alias name so the host consumes it rather than passing it to the CNA command.
[[.Bold]]Examples:[[.Normal]]
opfor check ./script.cna
opfor run ./script.cna argument
opfor load ./firefoxdump.cna
opfor help firefoxdump
use <session-or-beacon>
opfor firefoxdump /all
opfor --timeout 900 firefoxdump /all
opfor list
opfor unload firefoxdump.cna
`
aiConfigHelp = `[[.Bold]]Command:[[.Normal]] ai-config
[[.Bold]]About:[[.Normal]] Open a server-side form to update the AI block in server.yaml. The form stores the default provider, model, thinking level, and provider-specific credentials such as the API key and optional base URL.
+1
View File
@@ -38,6 +38,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.String("listen", defaultConfig.ListenAddress, "listen address for the MCP server")
f.String("name", defaultConfig.ServerName, "server name for MCP initialize")
f.String("version", defaultConfig.ServerVersion, "server version for MCP initialize")
f.BoolP("yes", "y", false, "skip the prompt-injection confirmation (broken or non-interactive terminals)")
})
flags.BindFlagCompletions(startCmd, func(comp *carapace.ActionMap) {
(*comp)["transport"] = carapace.ActionValues("http", "sse")
+3
View File
@@ -32,6 +32,9 @@ func McpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if status.AuthToken != "" {
con.Printf("Auth Token: %s\n", status.AuthToken)
}
if status.AuthConfigPath != "" {
con.Printf("Auth Config: %s\n", status.AuthConfigPath)
}
if status.Running && !status.StartedAt.IsZero() {
uptime := time.Since(status.StartedAt).Truncate(time.Second)
+6 -1
View File
@@ -28,8 +28,10 @@ func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
}.WithDefaults()
msg := `Do you know what prompt injection is and are you an adult?`
if !settings.IsUserAnAdultWithPrompt(con, msg) {
skipConfirm, _ := cmd.Flags().GetBool("yes")
if !skipConfirm && !settings.IsUserAnAdultWithPrompt(con, msg) {
con.PrintErrorf("Failed to start MCP server, the user is not qualified to use feature\n")
con.PrintInfof("Use --yes to skip the confirmation prompt, or toggle it with 'settings autoadult'\n")
return
}
@@ -50,4 +52,7 @@ func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if status.AuthToken != "" {
con.PrintInfof("Auth Token: %s\n", status.AuthToken)
}
if status.AuthConfigPath != "" {
con.PrintInfof("Auth Config: %s\n", status.AuthConfigPath)
}
}
+516
View File
@@ -0,0 +1,516 @@
//go:build client
// Package opfor integrates OPFOR-compatible CNA scripting with the Sliver client.
package opfor
import (
"context"
"fmt"
"math"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
opforengine "github.com/sliverarmory/opfor"
"github.com/spf13/cobra"
)
const aliasInvocationTimeout = 10 * time.Minute
// Commands returns the OPFOR script manager for the implant menu.
func Commands(client *console.SliverClient) []*cobra.Command {
return []*cobra.Command{managementCommand(client, consts.SliverCoreHelpGroup)}
}
// ServerCommands returns the same persistent script manager in the server
// menu. CNA Beacon aliases themselves are registered only on implant roots.
func ServerCommands(client *console.SliverClient) []*cobra.Command {
return []*cobra.Command{managementCommand(client, consts.GenericHelpGroup)}
}
func managementCommand(client *console.SliverClient, groupID string) *cobra.Command {
command := &cobra.Command{
Use: consts.OpforStr + " [script.cna]",
Short: "Load and manage OPFOR CNA scripts",
Long: help.GetHelpFor([]string{consts.OpforStr}),
GroupID: groupID,
Args: cobra.ArbitraryArgs,
RunE: func(command *cobra.Command, args []string) error {
return runManagementCommand(command, client, args)
},
}
command.PersistentFlags().Int64P(
"timeout",
"t",
int64(aliasInvocationTimeout/time.Second),
"OPFOR operation timeout in seconds (place before a dynamic alias name)",
)
// The first non-flag token is a CNA alias (or convenience script path).
// Everything after it belongs to the script, including flag-like tokens.
command.Flags().SetInterspersed(false)
load := &cobra.Command{
Use: "load <script.cna>",
Short: "Execute and retain a CNA's aliases, hooks, and callbacks",
Args: cobra.ExactArgs(1),
RunE: func(command *cobra.Command, args []string) error {
return loadScriptCommand(command, client, args[0])
},
}
run := &cobra.Command{
Use: "run <script.cna> [arguments...]",
Short: "Execute a CNA once and discard its registrations",
Args: cobra.MinimumNArgs(1),
RunE: func(command *cobra.Command, args []string) error {
return runScriptCommand(command, client, args[0], args[1:])
},
}
// Tokens after the script path belong to @ARGV, including flag-like ones.
run.Flags().SetInterspersed(false)
check := &cobra.Command{
Use: "check <script.cna>",
Short: "Compile a CNA without executing it",
Args: cobra.ExactArgs(1),
RunE: func(command *cobra.Command, args []string) error {
return checkScriptCommand(command, client, args[0])
},
}
aliasHelp := &cobra.Command{
Use: "help <alias>",
Short: "Show help registered for a loaded CNA alias",
Args: cobra.ExactArgs(1),
RunE: func(command *cobra.Command, args []string) error {
return helpAliasCommand(command, client, args[0])
},
}
unload := &cobra.Command{
Use: "unload <script.cna>",
Short: "Unload a CNA script and retire its aliases",
Args: cobra.ExactArgs(1),
RunE: func(command *cobra.Command, args []string) error {
return unloadScriptCommand(command, client, args[0])
},
}
list := &cobra.Command{
Use: "list",
Short: "List loaded CNA scripts by absolute path",
Args: cobra.NoArgs,
RunE: func(command *cobra.Command, _ []string) error {
return listScriptsCommand(command, client)
},
}
command.AddCommand(check, aliasHelp, load, unload, list, run)
return command
}
func runManagementCommand(command *cobra.Command, client *console.SliverClient, args []string) error {
if _, err := configuredCommandTimeout(command); err != nil {
return err
}
if len(args) == 0 {
return command.Help()
}
if len(args) == 1 && strings.EqualFold(filepath.Ext(args[0]), ".cna") {
return loadScriptCommand(command, client, args[0])
}
manager, err := managerFor(client)
if err != nil {
return err
}
if manager.hasAlias(args[0]) {
return manager.runAlias(command, args[0], args[1:])
}
return fmt.Errorf("opfor: unknown CNA alias %q", args[0])
}
func loadScriptCommand(command *cobra.Command, client *console.SliverClient, path string) error {
manager, err := managerFor(client)
if err != nil {
return err
}
previousAliases := manager.aliasBindingCounts()
ctx, cancel, err := commandTimeoutContext(command)
if err != nil {
return err
}
defer cancel()
absolute, err := manager.Load(ctx, path)
if err != nil {
return err
}
manager.output.PrintSuccessf("Loaded CNA script %s\n", absolute)
currentAliases := manager.aliasBindingCounts()
aliases := make([]string, 0)
for _, alias := range manager.aliasNames() {
if currentAliases[alias] > previousAliases[alias] && !reservedManagementAlias(alias) {
aliases = append(aliases, alias)
}
}
if len(aliases) == 0 {
manager.output.PrintInfof("The script is retained for this client process; it registered no invokable Beacon aliases\n")
return nil
}
manager.output.PrintInfof("Registered CNA aliases:\n")
for _, alias := range aliases {
manager.output.Printf(" opfor %s\n", alias)
}
manager.output.PrintInfof("View CNA alias help:\n")
for _, alias := range aliases {
manager.output.Printf(" opfor help %s\n", alias)
}
return nil
}
func reservedManagementAlias(name string) bool {
switch name {
case "check", "help", "list", "load", "run", "unload":
return true
default:
return false
}
}
func runScriptCommand(command *cobra.Command, client *console.SliverClient, path string, arguments []string) error {
manager, err := managerFor(client)
if err != nil {
return err
}
ctx, cancel, err := commandTimeoutContext(command)
if err != nil {
return err
}
defer cancel()
_, err = manager.Run(ctx, path, arguments)
return err
}
func checkScriptCommand(command *cobra.Command, client *console.SliverClient, path string) error {
if _, err := configuredCommandTimeout(command); err != nil {
return err
}
manager, err := managerFor(client)
if err != nil {
return err
}
if _, err := manager.Check(path); err != nil {
return err
}
manager.output.Printf("%s: ok\n", path)
return nil
}
func helpAliasCommand(command *cobra.Command, client *console.SliverClient, name string) error {
if _, err := configuredCommandTimeout(command); err != nil {
return err
}
manager, err := managerFor(client)
if err != nil {
return err
}
metadata, err := manager.aliasMetadata(name)
if err != nil {
return err
}
description := strings.TrimSpace(metadata.Description)
if description == "" {
description = "CNA-defined Beacon alias"
}
manager.output.Printf("Command: opfor %s [arguments...]\n\n%s\n", name, description)
if detail := strings.TrimSpace(metadata.Detail); detail != "" {
manager.output.Printf("\n%s\n", detail)
}
return nil
}
func unloadScriptCommand(command *cobra.Command, client *console.SliverClient, path string) error {
manager, err := managerFor(client)
if err != nil {
return err
}
ctx, cancel, err := commandTimeoutContext(command)
if err != nil {
return err
}
defer cancel()
absolute, err := manager.Unload(ctx, path)
if err != nil {
return err
}
manager.output.PrintSuccessf("Unloaded CNA script %s\n", absolute)
return nil
}
func listScriptsCommand(command *cobra.Command, client *console.SliverClient) error {
if _, err := configuredCommandTimeout(command); err != nil {
return err
}
manager, err := managerFor(client)
if err != nil {
return err
}
paths := manager.Paths()
if len(paths) == 0 {
manager.output.PrintInfof("No CNA scripts are loaded\n")
return nil
}
manager.output.PrintInfof("Loaded CNA scripts:\n")
for _, path := range paths {
manager.output.Printf(" %s\n", path)
}
return nil
}
// RegisterCommands rehydrates the active CNA Beacon aliases beneath the
// implant menu's opfor namespace. Keeping script declarations namespaced avoids
// collisions with built-ins such as Sliver's own cat command.
func RegisterCommands(root *cobra.Command, client *console.SliverClient) {
if root == nil || client == nil {
return
}
manager, err := managerFor(client)
if err != nil {
client.PrintErrorf("%s\n", err)
return
}
namespace := directChild(root, consts.OpforStr)
if namespace == nil {
return
}
manager.commandMu.Lock()
manager.mu.Lock()
for previous := range manager.roots {
if previous == namespace {
continue
}
// A replaced menu root may still be executing asynchronously. Stop
// retaining it, but leave its Cobra tree intact for that execution.
delete(manager.roots, previous)
}
if manager.roots[namespace] == nil {
manager.roots[namespace] = make(map[string]*cobra.Command)
}
manager.mu.Unlock()
manager.syncRootLocked(namespace)
manager.commandMu.Unlock()
}
func (manager *Manager) syncAllRoots() {
manager.commandMu.Lock()
defer manager.commandMu.Unlock()
manager.mu.RLock()
roots := make([]*cobra.Command, 0, len(manager.roots))
for root := range manager.roots {
roots = append(roots, root)
}
manager.mu.RUnlock()
for _, root := range roots {
manager.syncRootLocked(root)
}
}
func (manager *Manager) syncRootLocked(root *cobra.Command) {
names := manager.aliasNames()
wanted := make(map[string]struct{}, len(names))
for _, name := range names {
wanted[name] = struct{}{}
}
catalog, _ := manager.runtime.SnapshotAggressorCommandCatalog(opforengine.AggressorCommandBeacon)
help := make(map[string]opforengine.AggressorCommandMetadata, len(catalog.Commands))
for _, metadata := range catalog.Commands {
help[metadata.Name] = metadata
}
manager.mu.Lock()
attached := manager.roots[root]
if attached == nil {
attached = map[string]*cobra.Command{}
manager.roots[root] = attached
}
for name, command := range attached {
if _, keep := wanted[name]; keep {
continue
}
root.RemoveCommand(command)
delete(attached, name)
}
manager.mu.Unlock()
for _, name := range names {
metadata := help[name]
manager.mu.RLock()
existing := attached[name]
manager.mu.RUnlock()
if existing != nil {
applyAliasHelp(existing, metadata)
continue
}
if directChild(root, name) != nil {
// A built-in/Armory command wins over an untrusted script declaration.
continue
}
aliasName := name
command := &cobra.Command{
Use: aliasName + " [arguments...]",
DisableFlagParsing: true,
RunE: func(command *cobra.Command, args []string) error {
return manager.runAlias(command, aliasName, args)
},
}
applyAliasHelp(command, metadata)
root.AddCommand(command)
manager.mu.Lock()
attached[aliasName] = command
manager.mu.Unlock()
}
}
func directChild(root *cobra.Command, name string) *cobra.Command {
for _, child := range root.Commands() {
if child.Name() == name {
return child
}
}
return nil
}
func applyAliasHelp(command *cobra.Command, metadata opforengine.AggressorCommandMetadata) {
command.Short = metadata.Description
if command.Short == "" {
command.Short = "CNA-defined Beacon alias"
}
command.Long = strings.TrimSpace(strings.Join([]string{metadata.Description, metadata.Detail}, "\n\n"))
if command.Long == "" {
command.Long = command.Short
}
}
func (manager *Manager) runAlias(command *cobra.Command, name string, args []string) error {
args, timeoutOverride, err := consumeAliasHostArguments(args)
if err != nil {
return err
}
ctx, cancel, err := commandTimeoutContextWithOverride(command, timeoutOverride)
if err != nil {
return err
}
defer cancel()
session, beacon := manager.client.ActiveTarget.GetInteractive()
if session == nil && beacon == nil {
return errorsNewTargetRequired(name)
}
targetID := ""
if session != nil {
targetID = session.ID
} else {
targetID = beacon.ID
}
rawInput := name
if len(args) != 0 {
rawInput += " " + strings.Join(args, " ")
}
if err := manager.invokeAlias(ctx, name, rawInput, args, targetID); err != nil {
return fmt.Errorf("OPFOR alias %s failed: %w", name, err)
}
return nil
}
func consumeAliasHostArguments(args []string) ([]string, time.Duration, error) {
remaining := args
var timeout time.Duration
for len(remaining) != 0 {
var value string
switch argument := remaining[0]; {
case argument == "--":
return remaining[1:], timeout, nil
case argument == "--timeout" || argument == "-t":
if len(remaining) < 2 {
return nil, 0, fmt.Errorf("opfor: %s requires a timeout in seconds", argument)
}
value = remaining[1]
remaining = remaining[2:]
case strings.HasPrefix(argument, "--timeout="):
value = strings.TrimPrefix(argument, "--timeout=")
remaining = remaining[1:]
case strings.HasPrefix(argument, "-t="):
value = strings.TrimPrefix(argument, "-t=")
remaining = remaining[1:]
default:
return remaining, timeout, nil
}
parsed, err := parseAliasTimeout(value)
if err != nil {
return nil, 0, err
}
timeout = parsed
}
return remaining, timeout, nil
}
func parseAliasTimeout(value string) (time.Duration, error) {
seconds, err := strconv.ParseInt(value, 10, 64)
if err != nil || seconds <= 0 || seconds > math.MaxInt64/int64(time.Second) {
return 0, fmt.Errorf("opfor: invalid timeout %q; expected positive seconds", value)
}
return time.Duration(seconds) * time.Second, nil
}
func (manager *Manager) invokeAlias(ctx context.Context, name, rawInput string, arguments []string, targetID string) error {
if targetID == "" {
return errorsNewTargetRequired(name)
}
parsedArguments := make([]string, len(arguments))
copy(parsedArguments, arguments)
_, err := manager.runtime.InvokeConsole(ctx, opforengine.ConsoleInvocation{
Kind: opforengine.BindingAlias,
Name: name,
RawInput: rawInput,
ParsedArguments: parsedArguments,
SessionID: opforengine.String(targetID),
})
return err
}
func errorsNewTargetRequired(name string) error {
return fmt.Errorf("opfor: alias %q requires an active target", name)
}
func commandTimeoutContext(command *cobra.Command) (context.Context, context.CancelFunc, error) {
return commandTimeoutContextWithOverride(command, 0)
}
func commandTimeoutContextWithOverride(command *cobra.Command, override time.Duration) (context.Context, context.CancelFunc, error) {
parent := context.Background()
if command != nil && command.Context() != nil {
parent = command.Context()
}
timeout, err := configuredCommandTimeout(command)
if err != nil {
return nil, nil, err
}
if override > 0 {
timeout = override
}
ctx, cancel := context.WithTimeout(parent, timeout)
return ctx, cancel, nil
}
func configuredCommandTimeout(command *cobra.Command) (time.Duration, error) {
if command == nil {
return aliasInvocationTimeout, nil
}
flag := command.Flag("timeout")
if flag == nil {
return aliasInvocationTimeout, nil
}
return parseAliasTimeout(flag.Value.String())
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !client
package opfor
import (
"github.com/bishopfox/sliver/client/console"
"github.com/spf13/cobra"
)
// Commands is unavailable in builds which do not include the client tag.
func Commands(_ *console.SliverClient) []*cobra.Command { return nil }
// ServerCommands is unavailable in builds which do not include the client tag.
func ServerCommands(_ *console.SliverClient) []*cobra.Command { return nil }
// RegisterCommands is a no-op outside client builds.
func RegisterCommands(_ *cobra.Command, _ *console.SliverClient) {}
+260
View File
@@ -0,0 +1,260 @@
//go:build client
package opfor
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
type corpusRPC struct {
rpcpb.SliverRPCClient
mu sync.Mutex
sessions []*clientpb.Session
calls []*sliverpb.CallExtensionReq
}
func (rpc *corpusRPC) GetSessions(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Sessions, error) {
return &clientpb.Sessions{Sessions: rpc.sessions}, nil
}
func (*corpusRPC) GetBeacons(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Beacons, error) {
return &clientpb.Beacons{}, nil
}
func (*corpusRPC) GetBeaconTaskContent(context.Context, *clientpb.BeaconTask, ...grpc.CallOption) (*clientpb.BeaconTask, error) {
return nil, fmt.Errorf("unexpected beacon task poll in session corpus test")
}
func (rpc *corpusRPC) CallExtension(_ context.Context, request *sliverpb.CallExtensionReq, _ ...grpc.CallOption) (*sliverpb.CallExtension, error) {
rpc.mu.Lock()
rpc.calls = append(rpc.calls, proto.Clone(request).(*sliverpb.CallExtensionReq))
rpc.mu.Unlock()
return &sliverpb.CallExtension{Response: &commonpb.Response{}}, nil
}
func (rpc *corpusRPC) snapshotCalls() []*sliverpb.CallExtensionReq {
rpc.mu.Lock()
defer rpc.mu.Unlock()
return append([]*sliverpb.CallExtensionReq(nil), rpc.calls...)
}
type corpusPromptUI struct{}
func (corpusPromptUI) Confirm(_ string, defaultValue bool) (bool, error) {
return defaultValue, nil
}
func (corpusPromptUI) Input(_ string, defaultValue string) (string, error) {
return defaultValue, nil
}
type corpusOutput struct{}
func (corpusOutput) Printf(string, ...any) {}
func (corpusOutput) PrintInfof(string, ...any) {}
func (corpusOutput) PrintSuccessf(string, ...any) {}
func (corpusOutput) PrintErrorf(string, ...any) {}
func newCorpusManager(t *testing.T, rpc rpcpb.SliverRPCClient) *Manager {
t.Helper()
client := console.NewConsole(false)
client.Rpc = rpc
manager, err := newManagerWithOutput(client, corpusPromptUI{}, corpusOutput{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = manager.runtime.Close(context.Background())
})
return manager
}
func materializeCorpusScript(t *testing.T, fixture, scriptName, objectPath string, object []byte) string {
t.Helper()
source, err := os.ReadFile(filepath.Join("testdata", "corpus", filepath.FromSlash(fixture)))
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
scriptPath := filepath.Join(root, scriptName)
if err := os.WriteFile(scriptPath, source, 0o600); err != nil {
t.Fatal(err)
}
resourcePath := filepath.Join(root, filepath.FromSlash(objectPath))
if err := os.MkdirAll(filepath.Dir(resourcePath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(resourcePath, object, 0o600); err != nil {
t.Fatal(err)
}
return scriptPath
}
func expectedStringArguments(value string) []byte {
payload := make([]byte, 4+len(value)+1)
payload[0] = byte(len(value) + 1)
payload[1] = byte((len(value) + 1) >> 8)
payload[2] = byte((len(value) + 1) >> 16)
payload[3] = byte((len(value) + 1) >> 24)
copy(payload[4:], value)
arguments := make([]byte, 4+len(payload))
arguments[0] = byte(len(payload))
arguments[1] = byte(len(payload) >> 8)
arguments[2] = byte(len(payload) >> 16)
arguments[3] = byte(len(payload) >> 24)
copy(arguments[4:], payload)
return arguments
}
type corpusDispatchTest struct {
name string
fixture string
scriptName string
objectPath string
alias string
arguments []string
packedString *string
objectContent []byte
}
func TestSliverArmoryCNACorpusDispatchesBOFs(t *testing.T) {
tests := []corpusDispatchTest{
{
name: "FirefoxDump all",
fixture: "firefoxdump/firefoxdump.cna",
scriptName: "firefoxdump.cna",
objectPath: "bin/firefoxdump.x64.o",
alias: "firefoxdump",
arguments: []string{"/all"},
packedString: stringPointer("/all"),
objectContent: []byte("firefoxdump-x64-object"),
},
{
name: "bof_collection cat path with spaces",
fixture: "bof_collection/cat.cna",
scriptName: "cat.cna",
objectPath: "dist/cat.x64.o",
alias: "cat",
arguments: []string{`C:\Program`, `Files\notes.txt`},
packedString: stringPointer(`C:\Program Files\notes.txt`),
objectContent: []byte("cat-x64-object"),
},
{
name: "OperatorsKit FindDotnet no arguments",
fixture: "operatorskit/finddotnet.cna",
scriptName: "finddotnet.cna",
objectPath: "finddotnet.o",
alias: "finddotnet",
objectContent: []byte("finddotnet-x64-object"),
},
{
name: "OperatorsKit FindSysmon registry",
fixture: "operatorskit/findsysmon.cna",
scriptName: "findsysmon.cna",
objectPath: "findsysmon.o",
alias: "findsysmon",
arguments: []string{"reg"},
packedString: stringPointer("reg"),
objectContent: []byte("findsysmon-x64-object"),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) { runCorpusDispatchTest(t, test) })
}
}
func runCorpusDispatchTest(t *testing.T, test corpusDispatchTest) {
t.Helper()
session := &clientpb.Session{
ID: "session-corpus",
Name: "corpus",
OS: "windows",
Arch: "amd64",
Capabilities: sliverpb.CapabilityBOFV1,
}
rpc := &corpusRPC{sessions: []*clientpb.Session{session}}
manager := newCorpusManager(t, rpc)
scriptPath := materializeCorpusScript(
t, test.fixture, test.scriptName, test.objectPath, test.objectContent,
)
loadedPath, err := manager.Load(context.Background(), scriptPath)
if err != nil {
t.Fatalf("load pinned %s fixture: %v", test.fixture, err)
}
if loadedPath != scriptPath {
t.Fatalf("loaded path = %q, want %q", loadedPath, scriptPath)
}
assertCorpusAliases(t, manager.aliasNames(), test.alias)
rawInput := strings.Join(append([]string{test.alias}, test.arguments...), " ")
if err := manager.invokeAlias(context.Background(), test.alias, rawInput, test.arguments, session.ID); err != nil {
t.Fatalf("invoke %s: %v", test.alias, err)
}
calls := rpc.snapshotCalls()
if len(calls) != 1 {
t.Fatalf("CallExtension calls = %d, want 1", len(calls))
}
assertCorpusCall(t, calls[0], test, session.ID)
if _, err := manager.Unload(context.Background(), scriptPath); err != nil {
t.Fatalf("unload %s: %v", test.alias, err)
}
if names := manager.aliasNames(); len(names) != 0 {
t.Fatalf("aliases after unload = %v, want none", names)
}
}
func assertCorpusAliases(t *testing.T, names []string, want string) {
t.Helper()
if len(names) != 1 || names[0] != want {
t.Fatalf("registered aliases = %v, want [%s]", names, want)
}
}
func assertCorpusCall(t *testing.T, call *sliverpb.CallExtensionReq, test corpusDispatchTest, sessionID string) {
t.Helper()
if !call.IsBOF || call.Export != "go" {
t.Fatalf("CallExtension BOF/export = %v/%q, want true/go", call.IsBOF, call.Export)
}
if !bytes.Equal(call.BOFData, test.objectContent) {
t.Fatalf("CallExtension BOF bytes = %q, want %q", call.BOFData, test.objectContent)
}
wantArguments := []byte{0, 0, 0, 0}
if test.packedString != nil {
wantArguments = expectedStringArguments(*test.packedString)
}
if !bytes.Equal(call.Args, wantArguments) {
t.Fatalf("CallExtension args = %x, want Reflektor framing %x", call.Args, wantArguments)
}
if call.GetRequest().GetSessionID() != sessionID || call.GetRequest().GetAsync() {
t.Fatalf("CallExtension request = %#v, want synchronous session %s", call.GetRequest(), sessionID)
}
if len(call.Name) != 64 {
t.Fatalf("CallExtension name digest length = %d, want 64", len(call.Name))
}
}
func stringPointer(value string) *string {
return &value
}
+416
View File
@@ -0,0 +1,416 @@
//go:build client
package opfor
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/bishopfox/sliver/client/console"
opforengine "github.com/sliverarmory/opfor"
"github.com/spf13/cobra"
)
// Manager owns one long-lived OPFOR runtime for a Sliver client. Keeping the
// runtime alive is important: CNA aliases, hooks, and callbacks are scoped to
// the Script returned by Runtime.Load.
type Manager struct {
client *console.SliverClient
output clientOutput
runtime *opforengine.Runtime
ui promptUI
loadMu sync.Mutex
commandMu sync.Mutex
promptMu sync.Mutex
mu sync.RWMutex
scripts map[string]*opforengine.Script
aliases map[string][]opforengine.Binding
roots map[*cobra.Command]map[string]*cobra.Command
}
type managerEntry struct {
manager *Manager
err error
}
const oneShotRuntimeCloseTimeout = 5 * time.Second
var (
clientManagersMu sync.Mutex
clientManagers = map[*console.SliverClient]managerEntry{}
)
func managerFor(client *console.SliverClient) (*Manager, error) {
if client == nil {
return nil, errors.New("opfor: Sliver client is nil")
}
clientManagersMu.Lock()
defer clientManagersMu.Unlock()
if entry, found := clientManagers[client]; found {
return entry.manager, entry.err
}
manager, err := newManager(client, formsPromptUI{})
clientManagers[client] = managerEntry{manager: manager, err: err}
return manager, err
}
func newManager(client *console.SliverClient, ui promptUI) (*Manager, error) {
return newManagerWithOutput(client, ui, client)
}
func newManagerWithOutput(client *console.SliverClient, ui promptUI, output clientOutput) (*Manager, error) {
if client == nil {
return nil, errors.New("opfor: Sliver client is nil")
}
if ui == nil {
return nil, errors.New("opfor: prompt UI is nil")
}
if output == nil {
return nil, errors.New("opfor: output sink is nil")
}
manager := &Manager{
client: client,
output: output,
ui: ui,
scripts: map[string]*opforengine.Script{},
aliases: map[string][]opforengine.Binding{},
roots: map[*cobra.Command]map[string]*cobra.Command{},
}
runtime, err := manager.newRuntime(true)
if err != nil {
return nil, fmt.Errorf("opfor: create runtime: %w", err)
}
manager.runtime = runtime
return manager, nil
}
func (manager *Manager) newRuntime(observeBindings bool) (*opforengine.Runtime, error) {
stdout := clientWriter{output: manager.output}
stderr := clientWriter{output: manager.output, stderr: true}
options := []opforengine.Option{
opforengine.WithBOFPackByteOrder(opforengine.BOFPackLittleEndian),
opforengine.WithAggressorBeaconExecutionProvider(
opforengine.AggressorBeaconExecutionProviderFunc(manager.executeBeacon),
),
opforengine.WithAggressorSessionQueryProvider(
opforengine.AggressorSessionQueryProviderFunc(manager.querySession),
),
opforengine.WithAggressorBeaconTranscriptSink(
opforengine.AggressorBeaconTranscriptSinkFunc(manager.publishTranscript),
),
opforengine.WithAggressorPromptProvider(
opforengine.AggressorPromptProviderFunc(manager.presentPrompt),
),
opforengine.WithStdout(stdout),
opforengine.WithStderr(stderr),
}
if observeBindings {
options = append(options, opforengine.WithBindingObserver(manager))
}
return opforengine.New(options...)
}
// Registered observes declarations published by loaded scripts. Only Beacon
// aliases become Sliver commands; other binding families remain available to
// OPFOR internally for hooks, events, and callback composition.
func (manager *Manager) Registered(_ context.Context, binding opforengine.Binding) error {
if binding.Kind != opforengine.BindingAlias {
return nil
}
manager.mu.Lock()
manager.aliases[binding.Name] = append(manager.aliases[binding.Name], binding)
manager.mu.Unlock()
return nil
}
// Unregistered removes the exact binding generation which retired. An older
// alias with the same name remains visible when scripts were layered.
func (manager *Manager) Unregistered(_ context.Context, binding opforengine.Binding) error {
if binding.Kind != opforengine.BindingAlias {
return nil
}
manager.mu.Lock()
bindings := manager.aliases[binding.Name]
for index := len(bindings) - 1; index >= 0; index-- {
if bindings[index].ID == binding.ID && bindings[index].Script == binding.Script {
bindings = append(bindings[:index], bindings[index+1:]...)
break
}
}
if len(bindings) == 0 {
delete(manager.aliases, binding.Name)
} else {
manager.aliases[binding.Name] = bindings
}
manager.mu.Unlock()
return nil
}
// Load compiles and executes a CNA, retaining its registrations until unload.
func (manager *Manager) Load(ctx context.Context, path string) (string, error) {
absolute, content, err := readCNAFile(path)
if err != nil {
return "", err
}
manager.loadMu.Lock()
defer manager.loadMu.Unlock()
manager.mu.RLock()
_, exists := manager.scripts[absolute]
manager.mu.RUnlock()
if exists {
return "", fmt.Errorf("opfor: script is already loaded: %s", absolute)
}
program, err := manager.runtime.Compile(opforengine.NewSource(absolute, content))
if err != nil {
return "", fmt.Errorf("opfor: compile %s: %w", absolute, err)
}
script, err := manager.runtime.Load(ctx, program)
if err != nil {
return "", fmt.Errorf("opfor: load %s: %w", absolute, err)
}
manager.mu.Lock()
manager.scripts[absolute] = script
manager.mu.Unlock()
manager.syncAllRoots()
return absolute, nil
}
// Check compiles a CNA without creating a Script or executing top-level code.
// Standalone compilation intentionally mirrors the OPFOR CLI check command and
// avoids charging the long-lived Sliver runtime's source budget.
func (manager *Manager) Check(path string) (string, error) {
absolute, _, err := compileCNAFile(path)
return absolute, err
}
// Run executes a CNA once with arguments exposed through Sleep's @ARGV. Any
// aliases, hooks, callbacks, or events registered by the script are retired
// before this method returns, matching the OPFOR CLI run command.
func (manager *Manager) Run(ctx context.Context, path string, arguments []string) (string, error) {
absolute, program, err := compileCNAFile(path)
if err != nil {
return "", err
}
values := make([]opforengine.Value, len(arguments))
for index, argument := range arguments {
values[index] = opforengine.String(argument)
}
// A one-shot runtime matches the standalone OPFOR CLI and prevents
// temporary registrations from becoming visible to the persistent Sliver
// manager. It retains all Sliver providers but deliberately omits the
// binding observer used to publish persistent aliases.
runtime, err := manager.newRuntime(false)
if err != nil {
return "", fmt.Errorf("opfor: create runtime: %w", err)
}
_, executeErr := runtime.Execute(ctx, program, values...)
closeCtx, cancel := context.WithTimeout(context.Background(), oneShotRuntimeCloseTimeout)
closeErr := runtime.Close(closeCtx)
cancel()
if executeErr != nil || closeErr != nil {
var runErr error
if executeErr != nil {
runErr = fmt.Errorf("opfor: execute %s: %w", absolute, executeErr)
}
if closeErr != nil {
runErr = errors.Join(runErr, fmt.Errorf("opfor: close runtime: %w", closeErr))
}
return "", runErr
}
return absolute, nil
}
// Unload retires a loaded CNA and all registrations owned by that script.
func (manager *Manager) Unload(ctx context.Context, path string) (string, error) {
manager.loadMu.Lock()
defer manager.loadMu.Unlock()
absolute, script, err := manager.findScript(path)
if err != nil {
return "", err
}
if err := script.Unload(ctx); err != nil {
return "", fmt.Errorf("opfor: unload %s: %w", absolute, err)
}
manager.mu.Lock()
delete(manager.scripts, absolute)
manager.mu.Unlock()
manager.syncAllRoots()
return absolute, nil
}
func (manager *Manager) findScript(path string) (string, *opforengine.Script, error) {
absolute, absoluteErr := filepath.Abs(path)
if absoluteErr == nil {
absolute = filepath.Clean(absolute)
}
manager.mu.RLock()
defer manager.mu.RUnlock()
if absoluteErr == nil {
if script := manager.scripts[absolute]; script != nil {
return absolute, script, nil
}
}
base := filepath.Base(path)
var matchedPath string
var matchedScript *opforengine.Script
for candidate, script := range manager.scripts {
if filepath.Base(candidate) != base {
continue
}
if matchedScript != nil {
return "", nil, fmt.Errorf("opfor: script name %q is ambiguous; use an absolute path", path)
}
matchedPath, matchedScript = candidate, script
}
if matchedScript == nil {
return "", nil, fmt.Errorf("opfor: script is not loaded: %s", path)
}
return matchedPath, matchedScript, nil
}
// Paths returns the sorted absolute paths of all loaded CNA scripts.
func (manager *Manager) Paths() []string {
manager.mu.RLock()
paths := make([]string, 0, len(manager.scripts))
for path := range manager.scripts {
paths = append(paths, path)
}
manager.mu.RUnlock()
sort.Strings(paths)
return paths
}
func (manager *Manager) aliasNames() []string {
manager.mu.RLock()
names := make([]string, 0, len(manager.aliases))
for name, bindings := range manager.aliases {
if len(bindings) != 0 {
names = append(names, name)
}
}
manager.mu.RUnlock()
sort.Strings(names)
return names
}
func (manager *Manager) aliasBindingCounts() map[string]int {
manager.mu.RLock()
counts := make(map[string]int, len(manager.aliases))
for name, bindings := range manager.aliases {
if len(bindings) != 0 {
counts[name] = len(bindings)
}
}
manager.mu.RUnlock()
return counts
}
func (manager *Manager) hasAlias(name string) bool {
manager.mu.RLock()
defer manager.mu.RUnlock()
return len(manager.aliases[name]) != 0
}
func (manager *Manager) aliasMetadata(name string) (opforengine.AggressorCommandMetadata, error) {
if reservedManagementAlias(name) || !manager.hasAlias(name) {
return opforengine.AggressorCommandMetadata{}, fmt.Errorf("opfor: unknown CNA alias %q", name)
}
catalog, err := manager.runtime.SnapshotAggressorCommandCatalog(opforengine.AggressorCommandBeacon)
if err != nil {
return opforengine.AggressorCommandMetadata{}, fmt.Errorf("opfor: snapshot Beacon command help: %w", err)
}
for _, metadata := range catalog.Commands {
if metadata.Name == name {
return metadata, nil
}
}
return opforengine.AggressorCommandMetadata{Name: name}, nil
}
func absoluteCNAPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", errors.New("opfor: CNA script path is empty")
}
absolute, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("opfor: resolve %s: %w", path, err)
}
absolute = filepath.Clean(absolute)
if !strings.EqualFold(filepath.Ext(absolute), ".cna") {
return "", fmt.Errorf("opfor: script must have a .cna extension: %s", absolute)
}
return absolute, nil
}
func readCNAFile(path string) (string, []byte, error) {
absolute, err := absoluteCNAPath(path)
if err != nil {
return "", nil, err
}
content, err := os.ReadFile(absolute)
if err != nil {
return "", nil, fmt.Errorf("opfor: read %s: %w", absolute, err)
}
return absolute, content, nil
}
func compileCNAFile(path string) (string, *opforengine.Program, error) {
absolute, content, err := readCNAFile(path)
if err != nil {
return "", nil, err
}
program, err := opforengine.Compile(opforengine.NewSource(absolute, content))
if err != nil {
return "", nil, fmt.Errorf("opfor: compile %s: %w", absolute, err)
}
return absolute, program, nil
}
type clientWriter struct {
output clientOutput
stderr bool
}
func (writer clientWriter) Write(data []byte) (int, error) {
if writer.output == nil || len(data) == 0 {
return len(data), nil
}
if writer.stderr {
writer.output.PrintErrorf("%s", data)
} else {
writer.output.Printf("%s", data)
}
return len(data), nil
}
type clientOutput interface {
Printf(string, ...any)
PrintInfof(string, ...any)
PrintSuccessf(string, ...any)
PrintErrorf(string, ...any)
}
var _ io.Writer = clientWriter{}
var _ opforengine.BindingObserver = (*Manager)(nil)
File diff suppressed because it is too large. Load diff
+498
View File
@@ -0,0 +1,498 @@
//go:build client
package opfor
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
opforengine "github.com/sliverarmory/opfor"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
const (
beaconTaskPollInterval = 250 * time.Millisecond
bofRequestFallback = 59 * time.Second
bofRequestDeadlineMargin = time.Second
bofOutputDefault = int32(0x00)
bofOutputError = int32(0x0d)
)
type opforRPC interface {
GetSessions(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Sessions, error)
GetBeacons(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Beacons, error)
GetBeaconTaskContent(context.Context, *clientpb.BeaconTask, ...grpc.CallOption) (*clientpb.BeaconTask, error)
CallExtension(context.Context, *sliverpb.CallExtensionReq, ...grpc.CallOption) (*sliverpb.CallExtension, error)
}
var _ opforRPC = (rpcpb.SliverRPCClient)(nil)
type resolvedTarget struct {
session *clientpb.Session
beacon *clientpb.Beacon
}
func (target resolvedTarget) id() string {
if target.session != nil {
return target.session.ID
}
if target.beacon != nil {
return target.beacon.ID
}
return ""
}
func (target resolvedTarget) capabilities() uint64 {
if target.session != nil {
return target.session.Capabilities
}
if target.beacon != nil {
return target.beacon.Capabilities
}
return 0
}
func (target resolvedTarget) arch() string {
if target.session != nil {
return target.session.Arch
}
if target.beacon != nil {
return target.beacon.Arch
}
return ""
}
func (manager *Manager) rpc() (opforRPC, error) {
if manager == nil || manager.client == nil || manager.client.Rpc == nil {
return nil, errors.New("opfor: Sliver RPC client is unavailable")
}
return manager.client.Rpc, nil
}
func (manager *Manager) resolveTarget(ctx context.Context, id string) (resolvedTarget, error) {
if strings.TrimSpace(id) == "" {
return resolvedTarget{}, errors.New("opfor: target ID is empty")
}
rpc, err := manager.rpc()
if err != nil {
return resolvedTarget{}, err
}
sessions, err := rpc.GetSessions(ctx, &commonpb.Empty{})
if err != nil {
return resolvedTarget{}, fmt.Errorf("opfor: list sessions: %w", err)
}
for _, session := range sessions.GetSessions() {
if session != nil && session.ID == id {
return resolvedTarget{session: session}, nil
}
}
beacons, err := rpc.GetBeacons(ctx, &commonpb.Empty{})
if err != nil {
return resolvedTarget{}, fmt.Errorf("opfor: list beacons: %w", err)
}
for _, beacon := range beacons.GetBeacons() {
if beacon != nil && beacon.ID == id {
return resolvedTarget{beacon: beacon}, nil
}
}
return resolvedTarget{}, fmt.Errorf("opfor: target %q was not found", id)
}
func (manager *Manager) executeBeacon(
ctx context.Context,
request opforengine.AggressorBeaconExecutionRequest,
) (opforengine.Value, error) {
targetID, call, err := manager.prepareBeaconExecution(ctx, request)
if err != nil {
return opforengine.Null(), err
}
rpc, err := manager.rpc()
if err != nil {
return opforengine.Null(), err
}
response, err := rpc.CallExtension(ctx, call)
if err != nil {
executionErr := fmt.Errorf("opfor: execute BOF on %s: %w", targetID, err)
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, "", executionErr)
}
taskID := response.GetResponse().GetTaskID()
response, err = manager.resolveCallExtensionResponse(ctx, rpc, response)
if err != nil {
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, taskID, err)
}
if taskID == "" {
taskID = response.GetResponse().GetTaskID()
}
return manager.completeBeaconExecution(ctx, request, taskID, response)
}
func (manager *Manager) prepareBeaconExecution(
ctx context.Context,
request opforengine.AggressorBeaconExecutionRequest,
) (string, *sliverpb.CallExtensionReq, error) {
if request.Kind != opforengine.AggressorBeaconInlineExecute {
return "", nil, fmt.Errorf("opfor: unsupported Beacon execution function %q", request.Name)
}
if err := ctx.Err(); err != nil {
return "", nil, err
}
targetID := request.BeaconID.String()
target, err := manager.resolveTarget(ctx, targetID)
if err != nil {
return "", nil, err
}
if _, err := beaconArchitecture(target.arch()); err != nil {
return "", nil, err
}
if target.capabilities()&sliverpb.CapabilityBOFV1 == 0 {
return "", nil, fmt.Errorf("opfor: target %q does not advertise the bof_v1 capability", targetID)
}
bofData, ok := request.Content.Bytes()
if !ok {
return "", nil, errors.New("opfor: beacon_inline_execute BOF content is not a byte string")
}
packed, ok := request.PackedArguments.Bytes()
if !ok {
packed = []byte(request.PackedArguments.String())
}
arguments, err := prefixBOFArguments(packed)
if err != nil {
return "", nil, err
}
digest := sha256.Sum256(bofData)
return targetID, &sliverpb.CallExtensionReq{
Name: hex.EncodeToString(digest[:]),
Args: arguments,
Export: request.EntryPoint.String(),
BOFData: bofData,
IsBOF: true,
// Older implants ignore this additive request bit and return legacy
// flattened Output, which bofOutputRecords continues to accept.
WantBOFOutputs: true,
Request: targetRequest(ctx, target),
}, nil
}
func (manager *Manager) completeBeaconExecution(
ctx context.Context,
request opforengine.AggressorBeaconExecutionRequest,
taskID string,
response *sliverpb.CallExtension,
) (opforengine.Value, error) {
records := bofOutputRecords(response)
var executionErr error
if response.GetResponse().GetErr() != "" {
executionErr = fmt.Errorf("opfor: BOF execution failed: %s", response.GetResponse().GetErr())
}
if request.Callback == nil {
manager.renderBOFOutputs(records)
if executionErr != nil {
return opforengine.Null(), executionErr
}
return opforengine.Null(), nil
}
for index, record := range records {
chunkNumber := index + 1
isFinal := index == len(records)-1
if err := invokeBOFOutputCallback(ctx, request, record, chunkNumber, isFinal); err != nil {
callbackErr := fmt.Errorf("opfor: BOF result callback chunk %d: %w", chunkNumber, err)
if executionErr != nil {
return opforengine.Null(), errors.Join(executionErr, callbackErr)
}
return opforengine.Null(), callbackErr
}
}
if executionErr != nil {
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, taskID, executionErr)
}
if taskID != "" {
if err := invokeBOFLifecycleCallback(ctx, request, "task_completed", taskID, nil); err != nil {
return opforengine.Null(), fmt.Errorf("opfor: BOF task_completed callback: %w", err)
}
} else if len(records) == 0 {
if err := invokeBOFLifecycleCallback(ctx, request, "success", "", nil); err != nil {
return opforengine.Null(), fmt.Errorf("opfor: BOF success callback: %w", err)
}
}
return opforengine.Null(), nil
}
// bofOutputRecords prefers the typed wire representation. Older servers and
// implants populate only Output, which remains one default-channel record.
func bofOutputRecords(response *sliverpb.CallExtension) []*sliverpb.BOFOutput {
if response == nil {
return nil
}
if records := response.GetBOFOutputs(); len(records) != 0 {
return records
}
if len(response.GetOutput()) == 0 {
return nil
}
return []*sliverpb.BOFOutput{{
Type: bofOutputDefault,
Data: response.GetOutput(),
}}
}
// The callback is deliberately multi-shot. Data records retain their exact
// byte strings and raw Cobalt output channel. chunk_num is one-based and
// is_final marks the final data record, independently of task lifecycle events.
func invokeBOFOutputCallback(
ctx context.Context,
request opforengine.AggressorBeaconExecutionRequest,
record *sliverpb.BOFOutput,
chunkNumber int,
isFinal bool,
) error {
if request.Callback == nil {
return nil
}
if record == nil {
record = &sliverpb.BOFOutput{}
}
kind := "output"
if record.GetType() == bofOutputError {
kind = "error"
}
information := opforengine.NewOrderedHash()
information.Set("type", opforengine.String(kind))
information.Set("type_id", opforengine.Int(record.GetType()))
information.Set("chunk_num", opforengine.Int(int32(chunkNumber)))
information.Set("is_final", opforengine.Bool(isFinal))
_, err := request.Callback.Invoke(
ctx,
request.BeaconID,
opforengine.BinaryString(record.GetData()),
opforengine.HashValue(information),
)
return err
}
func invokeBOFLifecycleCallback(
ctx context.Context,
request opforengine.AggressorBeaconExecutionRequest,
kind string,
taskID string,
result []byte,
) error {
if request.Callback == nil {
return nil
}
information := opforengine.NewOrderedHash()
information.Set("type", opforengine.String(kind))
if taskID != "" {
information.Set("taskid", opforengine.String(taskID))
}
_, err := request.Callback.Invoke(
ctx,
request.BeaconID,
opforengine.BinaryString(result),
opforengine.HashValue(information),
)
return err
}
func notifyBOFLifecycleError(
ctx context.Context,
request opforengine.AggressorBeaconExecutionRequest,
taskID string,
executionErr error,
) error {
if request.Callback == nil {
return executionErr
}
if ctx != nil && ctx.Err() != nil {
return executionErr
}
callbackErr := invokeBOFLifecycleCallback(
ctx, request, "error", taskID, []byte(executionErr.Error()),
)
if callbackErr != nil {
return errors.Join(executionErr, fmt.Errorf("opfor: BOF error callback: %w", callbackErr))
}
return executionErr
}
func (manager *Manager) renderBOFOutputs(records []*sliverpb.BOFOutput) {
wrote := false
endedWithNewline := true
for _, record := range records {
if record == nil || len(record.GetData()) == 0 {
continue
}
text := safeBOFText(record.GetData())
if text == "" {
continue
}
wrote = true
endedWithNewline = strings.HasSuffix(text, "\n")
if record.GetType() == bofOutputError {
manager.output.PrintErrorf("%s", text)
} else {
manager.output.Printf("%s", text)
}
}
if wrote && !endedWithNewline {
manager.output.Printf("\n")
}
}
// safeBOFText prevents untrusted native output from injecting terminal control
// sequences. Valid printable UTF-8 plus tab/newline/carriage return is retained;
// invalid bytes and other control bytes are rendered as hexadecimal escapes.
func safeBOFText(data []byte) string {
var result strings.Builder
result.Grow(len(data))
for len(data) != 0 {
r, size := utf8.DecodeRune(data)
if r == utf8.RuneError && size == 1 {
fmt.Fprintf(&result, `\x%02x`, data[0])
data = data[1:]
continue
}
if r == '\n' || r == '\r' || r == '\t' || unicode.IsPrint(r) {
result.WriteRune(r)
} else {
for _, value := range data[:size] {
fmt.Fprintf(&result, `\x%02x`, value)
}
}
data = data[size:]
}
return result.String()
}
func prefixBOFArguments(packed []byte) ([]byte, error) {
if uint64(len(packed)) > math.MaxUint32 {
return nil, errors.New("opfor: BOF argument buffer exceeds uint32 length")
}
arguments := make([]byte, 4+len(packed))
binary.LittleEndian.PutUint32(arguments, uint32(len(packed)))
copy(arguments[4:], packed)
return arguments, nil
}
func targetRequest(ctx context.Context, target resolvedTarget) *commonpb.Request {
request := &commonpb.Request{Timeout: int64(bofRequestTimeout(ctx))}
if target.session != nil {
request.SessionID = target.session.ID
request.Async = false
} else if target.beacon != nil {
request.BeaconID = target.beacon.ID
request.Async = true
}
return request
}
func bofRequestTimeout(ctx context.Context) time.Duration {
if ctx == nil {
return bofRequestFallback
}
deadline, ok := ctx.Deadline()
if !ok {
return bofRequestFallback
}
remaining := time.Until(deadline)
if remaining <= 0 {
return time.Nanosecond
}
if remaining > bofRequestDeadlineMargin {
remaining -= bofRequestDeadlineMargin
}
return remaining
}
func (manager *Manager) resolveCallExtensionResponse(
ctx context.Context,
rpc opforRPC,
response *sliverpb.CallExtension,
) (*sliverpb.CallExtension, error) {
if response == nil {
return nil, errors.New("opfor: BOF execution returned an empty response")
}
if response.GetResponse().GetErr() != "" || !response.GetResponse().GetAsync() {
return response, nil
}
taskID := response.GetResponse().GetTaskID()
if taskID == "" {
return nil, errors.New("opfor: asynchronous BOF execution returned no task ID")
}
ticker := time.NewTicker(beaconTaskPollInterval)
defer ticker.Stop()
for {
task, err := rpc.GetBeaconTaskContent(ctx, &clientpb.BeaconTask{ID: taskID})
if err != nil {
return nil, fmt.Errorf("opfor: fetch beacon task %s: %w", taskID, err)
}
state := strings.ToLower(task.GetState())
switch state {
case "completed":
if len(task.Response) == 0 {
return nil, fmt.Errorf("opfor: beacon task %s returned an empty response", taskID)
}
completed := &sliverpb.CallExtension{}
if err := proto.Unmarshal(task.Response, completed); err != nil {
return nil, fmt.Errorf("opfor: decode beacon task %s response: %w", taskID, err)
}
if completed.Response == nil {
completed.Response = &commonpb.Response{}
}
if completed.Response.TaskID == "" {
completed.Response.TaskID = taskID
}
return completed, nil
case "failed", "canceled", "cancelled":
return nil, fmt.Errorf("opfor: beacon task %s %s", taskID, state)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
}
}
func (manager *Manager) publishTranscript(
_ context.Context,
record opforengine.AggressorBeaconTranscriptRecord,
) error {
target := record.BeaconID.String()
text := record.Text.String()
prefix := ""
if target != "" {
prefix = "[" + target + "] "
}
switch record.Kind {
case opforengine.AggressorBeaconTranscriptError,
opforengine.AggressorBeaconTranscriptJobError:
manager.output.PrintErrorf("%s%s\n", prefix, text)
case opforengine.AggressorBeaconTranscriptTask,
opforengine.AggressorBeaconTranscriptTaskCompleted:
manager.output.PrintInfof("%s%s\n", prefix, text)
default:
manager.output.Printf("%s%s\n", prefix, text)
}
return nil
}
+218
View File
@@ -0,0 +1,218 @@
//go:build client
package opfor
import (
"context"
"fmt"
"strings"
"github.com/bishopfox/sliver/protobuf/commonpb"
opforengine "github.com/sliverarmory/opfor"
)
func (manager *Manager) querySession(
ctx context.Context,
query opforengine.AggressorSessionQuery,
) (opforengine.Value, error) {
if err := ctx.Err(); err != nil {
return opforengine.Null(), err
}
switch query.Kind {
case opforengine.AggressorSessionQueryBeacons,
opforengine.AggressorSessionQueryBeaconIDs:
return manager.queryAllSessions(ctx, query.Kind)
default:
return manager.querySingleSession(ctx, query)
}
}
func (manager *Manager) queryAllSessions(
ctx context.Context,
kind opforengine.AggressorSessionQueryKind,
) (opforengine.Value, error) {
targets, err := manager.allTargets(ctx)
if err != nil {
return opforengine.Null(), err
}
values := make([]opforengine.Value, 0, len(targets))
for _, target := range targets {
if kind == opforengine.AggressorSessionQueryBeaconIDs {
values = append(values, opforengine.String(target.id()))
} else {
values = append(values, opforengine.HashValue(targetMetadata(target)))
}
}
return opforengine.ArrayValue(opforengine.NewArray(values...)), nil
}
func (manager *Manager) querySingleSession(
ctx context.Context,
query opforengine.AggressorSessionQuery,
) (opforengine.Value, error) {
target, err := manager.resolveTarget(ctx, query.SessionID.String())
if err != nil {
return opforengine.Null(), err
}
metadata := targetMetadata(target)
switch query.Kind {
case opforengine.AggressorSessionQueryBeaconArchitecture,
opforengine.AggressorSessionQueryBeaconData,
opforengine.AggressorSessionQueryBeaconInfo,
opforengine.AggressorSessionQueryIs64:
return querySessionMetadata(target, metadata, query)
case opforengine.AggressorSessionQueryIsActive,
opforengine.AggressorSessionQueryIsAdmin,
opforengine.AggressorSessionQueryIsBeacon,
opforengine.AggressorSessionQueryIsSSH:
return querySessionStatus(target, query)
default:
return opforengine.Null(), fmt.Errorf("opfor: unsupported session query %q", query.Name)
}
}
func querySessionMetadata(
target resolvedTarget,
metadata *opforengine.Hash,
query opforengine.AggressorSessionQuery,
) (opforengine.Value, error) {
switch query.Kind {
case opforengine.AggressorSessionQueryBeaconArchitecture:
architecture, err := beaconArchitecture(target.arch())
if err != nil {
return opforengine.Null(), err
}
return opforengine.String(architecture), nil
case opforengine.AggressorSessionQueryBeaconData:
return opforengine.HashValue(metadata), nil
case opforengine.AggressorSessionQueryBeaconInfo:
value, found := metadata.Get(strings.ToLower(query.Key.String()))
if !found {
return opforengine.Null(), nil
}
return value, nil
case opforengine.AggressorSessionQueryIs64:
architecture, err := beaconArchitecture(target.arch())
if err != nil {
return opforengine.Null(), err
}
return opforengine.Bool(architecture == "x64"), nil
default:
return opforengine.Null(), fmt.Errorf("opfor: unsupported session metadata query %q", query.Name)
}
}
func querySessionStatus(
target resolvedTarget,
query opforengine.AggressorSessionQuery,
) (opforengine.Value, error) {
switch query.Kind {
case opforengine.AggressorSessionQueryIsActive:
if target.session != nil {
return opforengine.Bool(!target.session.IsDead), nil
}
return opforengine.Bool(target.beacon != nil && !target.beacon.IsDead), nil
case opforengine.AggressorSessionQueryIsAdmin:
integrity := strings.ToLower(targetIntegrity(target))
return opforengine.Bool(strings.Contains(integrity, "high") || strings.Contains(integrity, "system") || strings.Contains(integrity, "admin")), nil
case opforengine.AggressorSessionQueryIsBeacon:
return opforengine.Bool(target.beacon != nil), nil
case opforengine.AggressorSessionQueryIsSSH:
return opforengine.Bool(false), nil
default:
return opforengine.Null(), fmt.Errorf("opfor: unsupported session status query %q", query.Name)
}
}
func targetIntegrity(target resolvedTarget) string {
if target.session != nil {
return target.session.Integrity
}
if target.beacon != nil {
return target.beacon.Integrity
}
return ""
}
func (manager *Manager) allTargets(ctx context.Context) ([]resolvedTarget, error) {
rpc, err := manager.rpc()
if err != nil {
return nil, err
}
sessions, err := rpc.GetSessions(ctx, &commonpb.Empty{})
if err != nil {
return nil, fmt.Errorf("opfor: list sessions: %w", err)
}
beacons, err := rpc.GetBeacons(ctx, &commonpb.Empty{})
if err != nil {
return nil, fmt.Errorf("opfor: list beacons: %w", err)
}
targets := make([]resolvedTarget, 0, len(sessions.GetSessions())+len(beacons.GetBeacons()))
for _, session := range sessions.GetSessions() {
if session != nil {
targets = append(targets, resolvedTarget{session: session})
}
}
for _, beacon := range beacons.GetBeacons() {
if beacon != nil {
targets = append(targets, resolvedTarget{beacon: beacon})
}
}
return targets, nil
}
func beaconArchitecture(arch string) (string, error) {
switch strings.ToLower(arch) {
case "amd64", "x64", "x86_64":
return "x64", nil
case "386", "x86", "i386":
return "x86", nil
default:
return "", fmt.Errorf("opfor: unsupported BOF target architecture %q", arch)
}
}
func targetMetadata(target resolvedTarget) *opforengine.Hash {
metadata := opforengine.NewOrderedHash()
set := func(key string, value opforengine.Value) { metadata.Set(key, value) }
if target.session != nil {
appendTargetMetadata(set, target.session.ID, target.session.Name, target.session.Hostname,
target.session.Username, target.session.OS, target.session.Arch, target.session.Transport,
target.session.RemoteAddress, target.session.Filename, target.session.Version,
target.session.Integrity, target.session.PID, !target.session.IsDead, false)
} else if target.beacon != nil {
appendTargetMetadata(set, target.beacon.ID, target.beacon.Name, target.beacon.Hostname,
target.beacon.Username, target.beacon.OS, target.beacon.Arch, target.beacon.Transport,
target.beacon.RemoteAddress, target.beacon.Filename, target.beacon.Version,
target.beacon.Integrity, target.beacon.PID, !target.beacon.IsDead, true)
}
return metadata
}
func appendTargetMetadata(
set func(string, opforengine.Value),
id, name, hostname, username, osName, arch, transport, remoteAddress, filename, version, integrity string,
pid int32,
active, beacon bool,
) {
architecture, err := beaconArchitecture(arch)
if err != nil {
architecture = strings.ToLower(arch)
}
set("id", opforengine.String(id))
set("name", opforengine.String(name))
set("computer", opforengine.String(hostname))
set("host", opforengine.String(hostname))
set("user", opforengine.String(username))
set("os", opforengine.String(osName))
set("arch", opforengine.String(architecture))
set("transport", opforengine.String(transport))
set("external", opforengine.String(remoteAddress))
set("process", opforengine.String(filename))
set("version", opforengine.String(version))
set("integrity", opforengine.String(integrity))
set("pid", opforengine.Int(pid))
set("active", opforengine.Bool(active))
set("beacon", opforengine.Bool(beacon))
}
+11
View File
@@ -0,0 +1,11 @@
# SliverArmory CNA corpus
These unmodified CNA scripts are pinned regression fixtures for the OPFOR client integration. The tests pair them with inert object bytes and verify script loading, command registration, resource resolution, BOF argument packing, and Sliver dispatch; they do not execute the embedded BOF code locally.
| Fixture | Upstream revision | License |
| --- | --- | --- |
| `firefoxdump/firefoxdump.cna` | [`sliverarmory/firefoxdump@19bec6f`](https://github.com/sliverarmory/firefoxdump/tree/19bec6fb2def510b299430955fb2be79c1f51820) | GPL-3.0-only; see the Sliver repository root `LICENSE` |
| `bof_collection/cat.cna` | [`sliverarmory/bof_collection@d937d25`](https://github.com/sliverarmory/bof_collection/tree/d937d25a164f40192e289b67e9c550d2bd4c369a) | Apache-2.0; copied `LICENSE` and `NOTICE` are retained here |
| `operatorskit/finddotnet.cna` and `findsysmon.cna` | [`sliverarmory/OperatorsKit@66368f4`](https://github.com/sliverarmory/OperatorsKit/tree/66368f4738528d26cc1ccc6d9a3c93d44d63edc1) | MIT; copied `LICENSE` is retained here |
The object files are intentionally not vendored. Live BOF execution belongs in a safety-gated Windows integration environment, while these fixtures keep the client test suite deterministic and platform independent.
@@ -1,4 +1,3 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@@ -0,0 +1,26 @@
bof_collection
Copyright 2026 Pim Beune
This product includes a modified copy of beacon.h derived from the
Cobalt-Strike/bof_template project, which is licensed under the Apache License,
Version 2.0. The local copy has been updated from the upstream template and is
distributed under the same license.
Armory packaging and integration changes were made by Sliver Armory
contributors.
Research and reference acknowledgements retained from the source project:
- Outflank: Remote Registry Protocol information-disclosure techniques
(https://www.youtube.com/watch?v=MxDq552Di3Y) and remote named-pipe
enumeration research
(https://outflank.nl/blog/2023/10/19/listing-remote-named-pipes/).
- Julian Peña: CVE-2019-16098 exploitation write-up referenced by the driver
proof of concept
(https://julian-pena.com/2025-09-29-exploiting-drivers-1/).
- Eric Esquivel: ntoskrnl function-offset technique acknowledged in the
CVE-2019-16098 source.
These research acknowledgements identify referenced techniques and materials;
they do not assert that the named researchers authored this collection's code
or that they endorse this package.
@@ -0,0 +1,32 @@
# author: Pim Beune
beacon_command_register(
"cat", "Prints content of a file",
"INFO: Prints content of a file.\n\n" .
"FEATURES: UTF-8 and UTF-16 (LE/BE) compatible, with or without BOM\n" .
" Refuses files larger than 128 KB\n" .
" Sanitizes output: strips control characters and invalid\n" .
" byte sequences (replaced with '?'), keeps tabs/newlines\n" .
" Paths do not need to be quoted or backslash-escaped\n\n" .
"EXAMPLES: cat C:\\Users\\User\\Desktop\\flag.txt\n" .
" cat C:\\Program Files\\Something\\notes.txt\n" .
" cat \\\\server\\share\\folder\\file.txt");
alias cat {
$bid = $1;
$file = join(" ", sublist(@_, 1));
if ($file eq "") {
berror($bid, "Please specify a file path.");
return;
}
# Read in the right BOF file
$handle = openf(script_resource("dist/cat." . barch($bid) . ".o"));
$data = readb($handle, -1);
closef($handle);
# Pack our arguments
$args = bof_pack($bid, "z", $file);
beacon_inline_execute($bid, $data, "go", $args);
}
@@ -0,0 +1,52 @@
# Firefox Dump BOF - Extract Firefox passwords and cookies
# Standalone version for Cobalt Strike
beacon_command_register(
"firefoxdump",
"Extract Firefox secrets (cookies, passwords) from Firefox profile.",
"INFO:\n" .
"Extracts browser secrets from Firefox browser.\n" .
"This BOF reads Firefox profile files (key4.db, logins.json, cookies.sqlite).\n\n" .
"SUPPORTED DATA:\n" .
" - Saved passwords (from logins.json - decrypted using NSS)\n" .
" - Cookies (from cookies.sqlite)\n\n" .
"USAGE:\n" .
" firefoxdump - Extract passwords only (default)\n" .
" firefoxdump /all - Extract passwords and cookies\n" .
" firefoxdump /cookies - Extract cookies only\n\n" .
"OUTPUT:\n" .
"Displays extracted passwords (cleartext) and/or cookies.\n" .
"Passwords are decrypted using NSS libraries from Firefox installation.\n\n" .
"NOTE:\n" .
"Requires access to the user's Firefox profile directory.\n" .
"Works best when run from the same user context as Firefox.\n" .
"Requires Firefox to be installed (for NSS libraries).\n"
);
alias firefoxdump {
local('$bid $barch $handle $bof $option $data');
$bid = $1;
$barch = barch($bid);
# Extract option from command line
if (size(@_) > 1) {
$option = @_[1];
} else {
$option = "";
}
# Read the BOF file
$handle = openf(script_resource("bin/firefoxdump." . $barch . ".o"));
$bof = readb($handle, -1);
closef($handle);
if (strlen($bof) == 0) {
berror($bid, "Could not read firefoxdump BOF file");
return;
}
# Pack option for BOF
$data = bof_pack($bid, "z", $option);
btask($bid, "Extracting Firefox secrets");
beacon_inline_execute($bid, $bof, "go", $data);
}
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 awgh
Copyright (c) 2023 REDMED-X
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,22 @@
# author REDMED-X
beacon_command_register(
"finddotnet", "Find processes that most likely have .NET loaded.",
"INFO:\nFind processes that most likely have .NET loaded by searching for the section name: \BaseNamedObjects\Cor_Private_IPCBlock(_v4)_<ProcessId>\n\n" .
"USAGE:\nfinddotnet\n\n");
alias finddotnet {
$bid = $1;
# Read in the right BOF file
$handle = openf(script_resource("finddotnet.o"));
$data = readb($handle, -1);
closef($handle);
blog($bid, "Tasked to search for processes that have .NET loaded..");
beacon_inline_execute($bid, $data, "go", $null);
}
@@ -0,0 +1,32 @@
# author REDMED-X
beacon_command_register(
"findsysmon", "Verify if Sysmon is running.\n",
"INFO:\nVerify if Sysmon is running. This can be done by checking the registry or by enumerating Minifilter drivers and search for one that is associated with Sysmon.\n\nOPTIONS:\n[reg]: search the registry to check if Sysmon is present on the system and return the Sysmon service PID if active.\n[driver]: list all the Minifilter drivers on the system to check manually (requires elevated privileges).\n\n" .
"USAGE:\nfindsysmon <reg | driver>\n\n");
alias findsysmon {
$bid = $1;
$action = $2;
if ($action eq "reg" || $action eq "driver") {
}
else {
berror($bid, "Please specify one of the following enumeration options: reg | driver\n");
return;
}
# Read in the right BOF file
$handle = openf(script_resource("findsysmon.o"));
$data = readb($handle, -1);
closef($handle);
# Pack our arguments
$arg_data = bof_pack($bid, "z", $action);
blog($bid, "Tasked to find Sysmon..");
beacon_inline_execute($bid, $data, "go", $arg_data);
}
+112
View File
@@ -0,0 +1,112 @@
//go:build client
package opfor
import (
"context"
"fmt"
"strings"
"github.com/bishopfox/sliver/client/forms"
opforengine "github.com/sliverarmory/opfor"
)
// promptUI is deliberately small and injectable. The production adapter uses
// Sliver's existing Bubble Tea/Huh forms; tests can exercise callback behavior
// without taking over the terminal.
type promptUI interface {
Confirm(title string, defaultValue bool) (bool, error)
Input(title, defaultValue string) (string, error)
}
type formsPromptUI struct{}
func (formsPromptUI) Confirm(title string, defaultValue bool) (bool, error) {
value := defaultValue
err := forms.Confirm(title, &value)
return value, err
}
func (formsPromptUI) Input(title, defaultValue string) (string, error) {
value := defaultValue
err := forms.Input(title, &value)
return value, err
}
func (manager *Manager) presentPrompt(
ctx context.Context,
presentation opforengine.AggressorPromptPresentation,
responder opforengine.AggressorPromptResponder,
) error {
if responder == nil {
return errorsPromptResponderNil()
}
if err := ctx.Err(); err != nil {
return err
}
switch presentation.Kind {
case opforengine.AggressorPromptConfirm:
title := joinPromptTitle(presentation.Title, presentation.Text)
answer, err := manager.confirmPrompt(title, false)
if err != nil {
return err
}
if !answer {
return responder.Dismiss()
}
_, err = responder.Accept(ctx)
return err
case opforengine.AggressorPromptText,
opforengine.AggressorPromptDirectoryOpen,
opforengine.AggressorPromptFileOpen,
opforengine.AggressorPromptFileSave:
title := presentation.Text
if title == "" {
title = presentation.Title
}
if title == "" {
title = presentation.Name
}
defaultValue := ""
if presentation.HasDefault {
defaultValue = presentation.Default.String()
}
answer, err := manager.inputPrompt(title, defaultValue)
if err != nil {
return err
}
_, err = responder.Accept(ctx, opforengine.String(answer))
return err
default:
return fmt.Errorf("opfor: unsupported prompt kind %q", presentation.Kind)
}
}
func (manager *Manager) confirmPrompt(title string, defaultValue bool) (bool, error) {
manager.promptMu.Lock()
defer manager.promptMu.Unlock()
return manager.ui.Confirm(title, defaultValue)
}
func (manager *Manager) inputPrompt(title, defaultValue string) (string, error) {
manager.promptMu.Lock()
defer manager.promptMu.Unlock()
return manager.ui.Input(title, defaultValue)
}
func joinPromptTitle(parts ...string) string {
nonempty := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
nonempty = append(nonempty, part)
}
}
return strings.Join(nonempty, ": ")
}
func errorsPromptResponderNil() error {
return fmt.Errorf("opfor: prompt responder is nil")
}
+2
View File
@@ -45,6 +45,7 @@ import (
"github.com/bishopfox/sliver/client/command/mcp"
"github.com/bishopfox/sliver/client/command/monitor"
"github.com/bishopfox/sliver/client/command/operators"
"github.com/bishopfox/sliver/client/command/opfor"
"github.com/bishopfox/sliver/client/command/reaction"
"github.com/bishopfox/sliver/client/command/serverctx"
"github.com/bishopfox/sliver/client/command/sessions"
@@ -101,6 +102,7 @@ func ServerCommands(con *client.SliverClient, serverCmds func() []*cobra.Command
bind(consts.GenericHelpGroup,
exit.Command,
ai.ServerCommands,
opfor.ServerCommands,
docscmd.ServerCommands,
serverctx.Commands,
licenses.Commands,
+6 -1
View File
@@ -50,7 +50,12 @@ func IsUserAnAdultWithPrompt(con *console.SliverClient, prompt string) bool {
return true
}
confirm := false
_ = forms.Confirm(prompt, &confirm)
if err := forms.Confirm(prompt, &confirm); err != nil {
// The interactive form is known to fail on some terminals (#2133);
// surface the error instead of silently treating it as a rejection.
con.PrintErrorf("Confirmation prompt failed: %s\n", err)
return false
}
return confirm
}
+32
View File
@@ -0,0 +1,32 @@
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
package shell
import (
"io"
"os"
)
// This fallback preserves the legacy blocking behavior on unsupported client
// platforms. Unix and Windows clients use cancellable implementations.
type cancellableShellInput struct {
reader io.Reader
}
func newCancellableShellInput(_ *os.File, reader io.Reader) (*cancellableShellInput, error) {
return &cancellableShellInput{reader: reader}, nil
}
func (s *cancellableShellInput) Read(data []byte, done <-chan struct{}) (int, bool, error) {
select {
case <-done:
return 0, true, nil
default:
}
n, err := s.reader.Read(data)
return n, false, err
}
func (s *cancellableShellInput) Close() error {
return nil
}
+129
View File
@@ -0,0 +1,129 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || windows
package shell
import (
"errors"
"io"
"os"
"testing"
"time"
)
func closeShellInputTestFile(t *testing.T, file *os.File) {
t.Helper()
if err := file.Close(); err != nil {
t.Errorf("close %s: %v", file.Name(), err)
}
}
func TestCancellableShellInputWakesOnTunnelClose(t *testing.T) {
readFile, writeFile, err := os.Pipe()
if err != nil {
t.Fatalf("create stdin pipe: %v", err)
}
t.Cleanup(func() { closeShellInputTestFile(t, readFile) })
t.Cleanup(func() { closeShellInputTestFile(t, writeFile) })
input, err := newCancellableShellInput(readFile, newFilterReader(readFile))
if err != nil {
t.Fatalf("prepare cancellable stdin: %v", err)
}
t.Cleanup(func() {
if err := input.Close(); err != nil {
t.Errorf("close cancellable stdin: %v", err)
}
})
done := make(chan struct{})
type readResult struct {
n int
err error
tunnelClosed bool
}
readDone := make(chan readResult, 1)
go func() {
n, tunnelClosed, err := input.Read(make([]byte, 16), done)
readDone <- readResult{n: n, err: err, tunnelClosed: tunnelClosed}
}()
select {
case result := <-readDone:
t.Fatalf("stdin Read returned before data or tunnel close: %+v", result)
case <-time.After(50 * time.Millisecond):
}
close(done)
select {
case result := <-readDone:
if result.n != 0 || result.err != nil || !result.tunnelClosed {
t.Fatalf("stdin Read after tunnel close = %+v, want n=0 err=nil tunnelClosed=true", result)
}
case <-time.After(time.Second):
t.Fatal("stdin Read did not wake when the tunnel closed")
}
}
func TestCancellableShellInputReportsPipeEOF(t *testing.T) {
readFile, writeFile, err := os.Pipe()
if err != nil {
t.Fatalf("create stdin pipe: %v", err)
}
t.Cleanup(func() { closeShellInputTestFile(t, readFile) })
if err := writeFile.Close(); err != nil {
t.Fatalf("close stdin pipe writer: %v", err)
}
input, err := newCancellableShellInput(readFile, newFilterReader(readFile))
if err != nil {
t.Fatalf("prepare cancellable stdin: %v", err)
}
t.Cleanup(func() {
if err := input.Close(); err != nil {
t.Errorf("close cancellable stdin: %v", err)
}
})
n, tunnelClosed, err := input.Read(make([]byte, 16), make(chan struct{}))
if n != 0 || tunnelClosed || !errors.Is(err, io.EOF) {
t.Fatalf("stdin Read after pipe EOF = n=%d tunnelClosed=%v err=%v, want n=0 tunnelClosed=false err=EOF", n, tunnelClosed, err)
}
}
func TestCancellableShellInputReadsRegularFile(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "shell-stdin-*")
if err != nil {
t.Fatalf("create stdin file: %v", err)
}
t.Cleanup(func() { closeShellInputTestFile(t, file) })
const inputData = "whoami\n"
if _, err := file.WriteString(inputData); err != nil {
t.Fatalf("write stdin file: %v", err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind stdin file: %v", err)
}
input, err := newCancellableShellInput(file, newFilterReader(file))
if err != nil {
t.Fatalf("prepare cancellable stdin: %v", err)
}
t.Cleanup(func() {
if err := input.Close(); err != nil {
t.Errorf("close cancellable stdin: %v", err)
}
})
data := make([]byte, len(inputData))
n, tunnelClosed, err := input.Read(data, make(chan struct{}))
if err != nil {
t.Fatalf("read stdin file: %v", err)
}
if tunnelClosed {
t.Fatal("stdin file read reported a closed tunnel")
}
if got := string(data[:n]); got != inputData {
t.Fatalf("stdin file data = %q, want %q", got, inputData)
}
}
+80
View File
@@ -0,0 +1,80 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package shell
import (
"errors"
"io"
"os"
"sync"
"golang.org/x/sys/unix"
)
const shellInputPollMilliseconds = 100
type cancellableShellInput struct {
reader io.Reader
fd uintptr
originalFlags int
flagsChanged bool
restoreOnce sync.Once
restoreErr error
}
func newCancellableShellInput(file *os.File, reader io.Reader) (*cancellableShellInput, error) {
flags, err := unix.FcntlInt(file.Fd(), unix.F_GETFL, 0)
if err != nil {
return nil, err
}
input := &cancellableShellInput{
reader: reader,
fd: file.Fd(),
originalFlags: flags,
}
if flags&unix.O_NONBLOCK == 0 {
if _, err := unix.FcntlInt(file.Fd(), unix.F_SETFL, flags|unix.O_NONBLOCK); err != nil {
return nil, err
}
input.flagsChanged = true
}
return input, nil
}
func (s *cancellableShellInput) Read(data []byte, done <-chan struct{}) (int, bool, error) {
pollFDs := []unix.PollFd{{Fd: int32(s.fd), Events: unix.POLLIN}}
for {
select {
case <-done:
return 0, true, nil
default:
}
ready, err := unix.Poll(pollFDs, shellInputPollMilliseconds)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
return 0, false, err
}
if ready == 0 {
continue
}
n, err := s.reader.Read(data)
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
continue
}
return n, false, err
}
}
func (s *cancellableShellInput) Close() error {
s.restoreOnce.Do(func() {
if s.flagsChanged {
_, s.restoreErr = unix.FcntlInt(s.fd, unix.F_SETFL, s.originalFlags)
}
})
return s.restoreErr
}
+66
View File
@@ -0,0 +1,66 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package shell
import (
"os"
"testing"
"time"
"golang.org/x/sys/unix"
)
func TestCancellableShellInputRestoresFileFlags(t *testing.T) {
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("create input pipe: %v", err)
}
t.Cleanup(func() { closeShellInputTestFile(t, reader) })
t.Cleanup(func() { closeShellInputTestFile(t, writer) })
originalFlags, err := unix.FcntlInt(reader.Fd(), unix.F_GETFL, 0)
if err != nil {
t.Fatalf("read original input flags: %v", err)
}
input, err := newCancellableShellInput(reader, reader)
if err != nil {
t.Fatalf("prepare cancellable input: %v", err)
}
type readResult struct {
err error
closed bool
}
done := make(chan struct{})
result := make(chan readResult, 1)
go func() {
_, closed, err := input.Read(make([]byte, 32), done)
result <- readResult{err: err, closed: closed}
}()
select {
case got := <-result:
t.Fatalf("input read returned before data or close: %+v", got)
case <-time.After(50 * time.Millisecond):
}
close(done)
select {
case got := <-result:
if got.err != nil || !got.closed {
t.Fatalf("input read after close = %+v, want closed without error", got)
}
case <-time.After(time.Second):
t.Fatal("input read did not wake after tunnel close")
}
if err := input.Close(); err != nil {
t.Fatalf("restore input flags: %v", err)
}
restoredFlags, err := unix.FcntlInt(reader.Fd(), unix.F_GETFL, 0)
if err != nil {
t.Fatalf("read restored input flags: %v", err)
}
if restoredFlags != originalFlags {
t.Fatalf("restored input flags = %#x, want %#x", restoredFlags, originalFlags)
}
}
+204
View File
@@ -0,0 +1,204 @@
//go:build windows
package shell
import (
"errors"
"fmt"
"io"
"os"
"runtime"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
const shellInputWaitMilliseconds = 100
var (
cancelSynchronousIO = windows.NewLazySystemDLL("kernel32.dll").NewProc("CancelSynchronousIo")
peekNamedPipe = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekNamedPipe")
)
type cancellableShellInput struct {
reader io.Reader
handle windows.Handle
isConsole bool
isPipe bool
}
func newCancellableShellInput(file *os.File, reader io.Reader) (*cancellableShellInput, error) {
handle := windows.Handle(file.Fd())
fileType, err := windows.GetFileType(handle)
if err != nil {
return nil, err
}
var consoleMode uint32
return &cancellableShellInput{
reader: reader,
handle: handle,
isConsole: windows.GetConsoleMode(handle, &consoleMode) == nil,
isPipe: fileType == windows.FILE_TYPE_PIPE,
}, nil
}
func (s *cancellableShellInput) Read(data []byte, done <-chan struct{}) (int, bool, error) {
select {
case <-done:
return 0, true, nil
default:
}
if s.isPipe {
return s.readPipe(data, done)
}
if !s.isConsole {
n, err := s.reader.Read(data)
return n, false, err
}
for {
select {
case <-done:
return 0, true, nil
default:
}
event, err := windows.WaitForSingleObject(s.handle, shellInputWaitMilliseconds)
if err != nil {
return 0, false, err
}
switch event {
case windows.WAIT_OBJECT_0:
select {
case <-done:
return 0, true, nil
default:
}
return s.readConsole(data, done)
case uint32(windows.WAIT_TIMEOUT):
continue
default:
return 0, false, fmt.Errorf("unexpected stdin wait result 0x%x", event)
}
}
}
// WaitForSingleObject does not reliably indicate that a pipe read will
// complete. PeekNamedPipe lets redirected stdin remain cancellable without
// starting an overlapped os.File.Read that CancelSynchronousIo cannot stop.
func (s *cancellableShellInput) readPipe(data []byte, done <-chan struct{}) (int, bool, error) {
ticker := time.NewTicker(shellInputWaitMilliseconds * time.Millisecond)
defer ticker.Stop()
for {
available, err := pipeBytesAvailable(s.handle)
if errors.Is(err, windows.ERROR_BROKEN_PIPE) ||
errors.Is(err, windows.ERROR_NO_DATA) ||
errors.Is(err, windows.ERROR_PIPE_NOT_CONNECTED) {
return 0, false, io.EOF
}
if err != nil {
return 0, false, err
}
if available > 0 {
n, readErr := s.reader.Read(data)
return n, false, readErr
}
select {
case <-done:
return 0, true, nil
case <-ticker.C:
}
}
}
func pipeBytesAvailable(handle windows.Handle) (uint32, error) {
var available uint32
result, _, err := peekNamedPipe.Call(
uintptr(handle),
0,
0,
0,
uintptr(unsafe.Pointer(&available)),
0,
)
if result == 0 {
return 0, err
}
return available, nil
}
// A console input handle can be signaled by window, focus, mouse, or key-up
// events that do not make ReadFile return. Keep the synchronous read on a
// known OS thread so tunnel shutdown can cancel that exact read without
// leaving a background stdin reader behind to steal input from the REPL.
func (s *cancellableShellInput) readConsole(data []byte, done <-chan struct{}) (int, bool, error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
thread, err := windows.OpenThread(windows.THREAD_TERMINATE, false, windows.GetCurrentThreadId())
if err != nil {
return 0, false, err
}
defer func() {
_ = windows.CloseHandle(thread)
}()
readFinished := make(chan struct{})
cancelFinished := make(chan error, 1)
go func() {
select {
case <-done:
cancelFinished <- cancelThreadSynchronousIO(thread, readFinished)
case <-readFinished:
cancelFinished <- nil
}
}()
n, readErr := s.reader.Read(data)
close(readFinished)
cancelErr := <-cancelFinished
if cancelErr != nil {
return n, false, cancelErr
}
select {
case <-done:
if n == 0 && (readErr == nil || errors.Is(readErr, windows.ERROR_OPERATION_ABORTED)) {
return 0, true, nil
}
default:
}
return n, false, readErr
}
func cancelThreadSynchronousIO(thread windows.Handle, readFinished <-chan struct{}) error {
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for {
result, _, err := cancelSynchronousIO.Call(uintptr(thread))
if result != 0 {
return nil
}
if !errors.Is(err, windows.ERROR_NOT_FOUND) {
return err
}
// The close signal can race just ahead of the synchronous read. Retry
// ERROR_NOT_FOUND until the read starts or finishes so no console reader
// is left behind to steal input from the REPL.
select {
case <-readFinished:
return nil
case <-ticker.C:
}
}
}
func (s *cancellableShellInput) Close() error {
return nil
}
+199 -95
View File
@@ -8,59 +8,43 @@ import (
"os"
"strings"
"sync"
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/core"
)
const (
shellEscapeByte = byte(0x1d) // Ctrl-]
stdinBufSize = 32 * 1024
stdinQueueDepth = 128
finalInputDeliveryTimeout = 2 * time.Second
)
type shellInputChunk struct {
data []byte
delivered chan bool
}
type shellLineState struct {
lineBuf []byte
inEscSeq bool
caseInsensitive bool
}
// runAttachedIO forwards local stdin to a shell tunnel while attached to the shell.
// It returns:
// - detached=true when user hit escape (Ctrl-])
// - closeRequested=true when user requested shell close (EOF/exit/logout)
func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient) (detached bool, closeRequested bool) {
func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient, remoteOS string) (detached bool, closeRequested bool) {
// We can't pipe stdin directly into the tunnel: if the gRPC tunnel send path blocks
// (network hang, server gone, etc.) tunnel.Write can block forever and "lock" the
// client in shell mode. Instead, buffer stdin locally and look for a local escape key.
const (
shellEscapeByte = byte(0x1d) // Ctrl-]
stdinBufSize = 32 * 1024
stdinQueueDepth = 128
)
stdinQueue := make(chan []byte, stdinQueueDepth)
stdinQueue := make(chan shellInputChunk, stdinQueueDepth)
stopWriter := make(chan struct{})
var writerWG sync.WaitGroup
writerWG.Add(1)
go func() {
defer writerWG.Done()
defer func() {
// Tunnel teardown can race with writes (server-side close, disconnect, etc.).
// Don't let a send-on-closed-channel panic bring down the whole client.
if r := recover(); r != nil {
log.Printf("Shell tunnel writer stopped: %v", r)
}
}()
for {
select {
case <-stopWriter:
return
case data, ok := <-stdinQueue:
if !ok {
return
}
if len(data) == 0 {
continue
}
select {
case <-stopWriter:
return
case tunnel.Send <- data:
}
}
}
}()
go runShellInputWriter(tunnel, stdinQueue, stopWriter, &writerWG)
defer func() {
close(stopWriter)
@@ -68,68 +52,33 @@ func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient) (detached b
writerWG.Wait()
}()
stdin := newFilterReader(os.Stdin)
stdin, err := newCancellableShellInput(os.Stdin, newFilterReader(os.Stdin))
if err != nil {
con.PrintErrorf("Failed to prepare shell input: %s\n", err)
return false, true
}
defer func() {
if err := stdin.Close(); err != nil {
log.Printf("Failed to restore shell input state: %v", err)
}
}()
buf := make([]byte, stdinBufSize)
lineBuf := make([]byte, 0, 128)
inEscSeq := false
lineState := shellLineState{
lineBuf: make([]byte, 0, 128),
caseInsensitive: remoteOS == "windows",
}
log.Printf("Reading from stdin (escape=Ctrl-]) ...")
for {
n, err := stdin.Read(buf)
n, tunnelClosed, err := stdin.Read(buf, tunnel.Done())
if tunnelClosed {
closeRequested = true
break
}
if n > 0 {
data := buf[:n]
if i := bytes.IndexByte(data, shellEscapeByte); i >= 0 {
if i > 0 {
// Best-effort: don't block stdin if the tunnel is wedged.
dataCopy := append([]byte(nil), data[:i]...)
select {
case stdinQueue <- dataCopy:
default:
}
}
detached = true
break
}
exitRequested := false
for _, b := range data {
if inEscSeq {
if b >= 0x40 && b <= 0x7e {
inEscSeq = false
}
continue
}
switch b {
case '\r', '\n':
line := strings.TrimSpace(string(lineBuf))
if line == "exit" || line == "logout" {
exitRequested = true
}
lineBuf = lineBuf[:0]
case 0x1b:
inEscSeq = true
case 0x08, 0x7f:
if len(lineBuf) > 0 {
lineBuf = lineBuf[:len(lineBuf)-1]
}
default:
// Keep printable bytes only; ignore control sequences (arrows, etc.).
if b >= 0x20 && b <= 0x7e {
lineBuf = append(lineBuf, b)
}
}
}
dataCopy := append([]byte(nil), data...)
select {
case stdinQueue <- dataCopy:
default:
// Drop input if the tunnel send path is blocked; still allow escape.
}
if exitRequested {
closeRequested = true
detached, closeRequested = forwardShellInput(buf[:n], &lineState, stdinQueue, tunnel.Done())
if detached || closeRequested {
break
}
}
@@ -146,3 +95,158 @@ func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient) (detached b
bufio.NewWriter(os.Stdout).Flush()
return detached, closeRequested
}
func runShellInputWriter(tunnel *core.TunnelIO, stdinQueue <-chan shellInputChunk, stop <-chan struct{}, writerWG *sync.WaitGroup) {
defer writerWG.Done()
defer recoverShellInputWriter()
for {
select {
case <-stop:
return
case <-tunnel.Done():
return
case chunk, ok := <-stdinQueue:
if !ok || !deliverShellInputChunk(tunnel, chunk, stop) {
return
}
}
}
}
func recoverShellInputWriter() {
// Tunnel teardown can race with writes (server-side close, disconnect, etc.).
// Don't let a send-on-closed-channel panic bring down the whole client.
if r := recover(); r != nil {
log.Printf("Shell tunnel writer stopped: %v", r)
}
}
func deliverShellInputChunk(tunnel *core.TunnelIO, chunk shellInputChunk, stop <-chan struct{}) bool {
if len(chunk.data) == 0 {
notifyShellInputDelivery(chunk.delivered, true)
return true
}
delivered := false
select {
case <-stop:
case <-tunnel.Done():
case tunnel.Send <- chunk.data:
delivered = true
}
notifyShellInputDelivery(chunk.delivered, delivered)
return delivered
}
func notifyShellInputDelivery(delivery chan<- bool, delivered bool) {
if delivery != nil {
delivery <- delivered
}
}
func forwardShellInput(data []byte, lineState *shellLineState, stdinQueue chan<- shellInputChunk, tunnelDone <-chan struct{}) (detached bool, closeRequested bool) {
if i := bytes.IndexByte(data, shellEscapeByte); i >= 0 {
if i > 0 {
queueShellInputBestEffort(stdinQueue, data[:i])
}
return true, false
}
exitRequested := lineState.exitRequested(data)
if exitRequested {
deliverFinalShellInput(stdinQueue, data, tunnelDone)
return false, true
}
queueShellInputBestEffort(stdinQueue, data)
return false, false
}
func queueShellInputBestEffort(stdinQueue chan<- shellInputChunk, data []byte) {
dataCopy := append([]byte(nil), data...)
select {
case stdinQueue <- shellInputChunk{data: dataCopy}:
default:
// Drop input if the tunnel send path is blocked; still allow escape.
}
}
func deliverFinalShellInput(stdinQueue chan<- shellInputChunk, data []byte, tunnelDone <-chan struct{}) {
delivered := make(chan bool, 1)
chunk := shellInputChunk{
data: append([]byte(nil), data...),
delivered: delivered,
}
queueTimer := time.NewTimer(finalInputDeliveryTimeout)
queued := false
select {
case stdinQueue <- chunk:
queued = true
case <-tunnelDone:
case <-queueTimer.C:
}
queueTimer.Stop()
if !queued {
return
}
deliveryTimer := time.NewTimer(finalInputDeliveryTimeout)
select {
case <-delivered:
case <-tunnelDone:
case <-deliveryTimer.C:
}
deliveryTimer.Stop()
}
func (s *shellLineState) exitRequested(data []byte) bool {
exitRequested := false
for _, b := range data {
if s.inEscSeq {
if b >= 0x40 && b <= 0x7e {
s.inEscSeq = false
}
continue
}
switch b {
case '\r', '\n':
if isShellExitCommand(string(s.lineBuf), s.caseInsensitive) {
exitRequested = true
}
s.lineBuf = s.lineBuf[:0]
case 0x1b:
s.inEscSeq = true
case 0x08, 0x7f:
if len(s.lineBuf) > 0 {
s.lineBuf = s.lineBuf[:len(s.lineBuf)-1]
}
default:
// Keep printable bytes only; ignore control sequences (arrows, etc.).
if b >= 0x20 && b <= 0x7e {
s.lineBuf = append(s.lineBuf, b)
}
}
}
return exitRequested
}
func isShellExitCommand(line string, caseInsensitive bool) bool {
fields := strings.Fields(line)
if len(fields) == 0 {
return false
}
command := fields[0]
if caseInsensitive {
command = strings.ToLower(command)
}
switch command {
case "exit", "logout":
return true
default:
return false
}
}
+153
View File
@@ -0,0 +1,153 @@
package shell
import (
"bytes"
"os"
"testing"
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/core"
)
func TestIsShellExitCommand(t *testing.T) {
tests := []struct {
name string
line string
windows bool
want bool
}{
{name: "exit", line: "exit", want: true},
{name: "unix uppercase is not exit", line: " EXIT ", want: false},
{name: "windows case insensitive exit", line: " EXIT ", windows: true, want: true},
{name: "exit status", line: "exit 42", want: true},
{name: "cmd exit arguments", line: "Exit /B 1", windows: true, want: true},
{name: "logout", line: "logout", want: true},
{name: "windows logout arguments", line: "LoGoUt --force", windows: true, want: true},
{name: "empty", line: " ", want: false},
{name: "exit prefix", line: "exiting", want: false},
{name: "logout prefix", line: "logout-now", want: false},
{name: "exit later in line", line: "echo exit", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isShellExitCommand(tt.line, tt.windows); got != tt.want {
t.Fatalf("isShellExitCommand(%q, %v) = %v, want %v", tt.line, tt.windows, got, tt.want)
}
})
}
}
func TestForwardShellInputDetachesAtEscape(t *testing.T) {
stdinQueue := make(chan shellInputChunk, 1)
tunnelDone := make(chan struct{})
lineState := &shellLineState{}
detached, closeRequested := forwardShellInput(
[]byte("whoami\x1dignored"),
lineState,
stdinQueue,
tunnelDone,
)
if !detached || closeRequested {
t.Fatalf("forwardShellInput result = detached %v, close requested %v", detached, closeRequested)
}
select {
case chunk := <-stdinQueue:
if !bytes.Equal(chunk.data, []byte("whoami")) {
t.Fatalf("queued input = %q, want only data before escape", chunk.data)
}
default:
t.Fatal("input before escape was not queued")
}
}
func TestShellLineStatePersistsAcrossChunksAndBackspace(t *testing.T) {
lineState := &shellLineState{}
if lineState.exitRequested([]byte("exiz")) {
t.Fatal("partial input unexpectedly requested exit")
}
if lineState.exitRequested([]byte{'\b'}) {
t.Fatal("backspace unexpectedly requested exit")
}
if !lineState.exitRequested([]byte("t\n")) {
t.Fatal("corrected exit command split across chunks did not request exit")
}
if lineState.exitRequested([]byte("echo still attached\n")) {
t.Fatal("line state was not reset after the exit command newline")
}
}
func TestRunAttachedIODeliversExitBeforeReturning(t *testing.T) {
readFile, writeFile, err := os.Pipe()
if err != nil {
t.Fatalf("create stdin pipe: %v", err)
}
t.Cleanup(func() {
closeTestResource(t, "stdin reader", readFile.Close)
})
t.Cleanup(func() {
closeTestResource(t, "stdin writer", writeFile.Close)
})
originalStdin := os.Stdin
os.Stdin = readFile
defer func() { os.Stdin = originalStdin }()
tunnel := core.NewTunnelIO(91, "session")
if err := tunnel.Open(); err != nil {
t.Fatalf("open tunnel: %v", err)
}
t.Cleanup(func() {
closeTestResource(t, "tunnel", tunnel.Close)
})
con := &console.SliverClient{}
received := make(chan []byte, 1)
go func() {
select {
case data := <-tunnel.Send:
received <- append([]byte(nil), data...)
case <-tunnel.Done():
}
}()
type ioResult struct {
detached bool
closed bool
}
result := make(chan ioResult, 1)
go func() {
detached, closed := runAttachedIO(tunnel, con, "linux")
result <- ioResult{detached: detached, closed: closed}
}()
if _, err := writeFile.Write([]byte("exit\n")); err != nil {
t.Fatalf("write test stdin: %v", err)
}
select {
case data := <-received:
if !bytes.Equal(data, []byte("exit\n")) {
t.Fatalf("delivered input = %q, want exit command", data)
}
case <-time.After(time.Second):
t.Fatal("exit command was not delivered before attached I/O returned")
}
select {
case got := <-result:
if got.detached || !got.closed {
t.Fatalf("runAttachedIO result = %+v, want close requested", got)
}
case <-time.After(time.Second):
t.Fatal("runAttachedIO did not return after delivering exit")
}
}
func closeTestResource(t *testing.T, name string, closeFn func() error) {
t.Helper()
if err := closeFn(); err != nil {
t.Errorf("close test %s: %v", name, err)
}
}
+29 -8
View File
@@ -37,8 +37,10 @@ import (
)
const (
darwin = "darwin"
linux = "linux"
darwin = "darwin"
linux = "linux"
shellBindTimeout = 5 * time.Second
shellCloseTimeout = 5 * time.Second
)
type shellResult int
@@ -262,12 +264,12 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
if existing == nil {
// Create an RPC tunnel, then start it before binding the shell to the newly created tunnel.
ctxTunnel, cancelTunnel := context.WithCancel(context.Background())
defer cancelTunnel()
ctxTunnel, cancelTunnel := con.GrpcContext(cmd)
rpcTunnel, err := con.Rpc.CreateTunnel(ctxTunnel, &sliverpb.Tunnel{
SessionID: session.ID,
})
cancelTunnel()
if err != nil {
con.PrintErrorf("%s\n", err)
return shellAttachFailed
@@ -276,6 +278,20 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
// Start() takes an RPC tunnel and creates a local Reader/Writer tunnel object.
tunnel = core.GetTunnels().Start(rpcTunnel.TunnelID, rpcTunnel.SessionID)
bindTimer := time.NewTimer(shellBindTimeout)
select {
case <-tunnel.Bound():
bindTimer.Stop()
case <-tunnel.Done():
bindTimer.Stop()
con.PrintErrorf("Shell tunnel closed before it was bound\n")
go backgroundCloseShell(con, tunnel.ID, session.ID)
return shellAttachFailed
case <-bindTimer.C:
con.PrintErrorf("Timed out binding shell tunnel\n")
go backgroundCloseShell(con, tunnel.ID, session.ID)
return shellAttachFailed
}
var rows uint32
var cols uint32
@@ -290,7 +306,8 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
}
}
shell, err := con.Rpc.Shell(context.Background(), &sliverpb.ShellReq{
ctxShell, cancelShell := con.GrpcContext(cmd)
shell, err := con.Rpc.Shell(ctxShell, &sliverpb.ShellReq{
Request: con.ActiveTarget.Request(cmd),
Path: shellPath,
EnablePTY: !noPty,
@@ -298,9 +315,10 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
Cols: cols,
TunnelID: tunnel.ID,
})
cancelShell()
if err != nil {
con.PrintErrorf("%s\n", err)
core.GetTunnels().Close(tunnel.ID)
go backgroundCloseShell(con, tunnel.ID, session.ID)
return shellAttachFailed
}
if shell.Response != nil && shell.Response.Err != "" {
@@ -363,7 +381,7 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
}()
}
detached, _ := runAttachedIO(tunnel, con)
detached, _ := runAttachedIO(tunnel, con, session.OS)
if detached {
managed.SetOutput(io.Discard)
managed.setState(shellStateDetached)
@@ -393,7 +411,9 @@ func closeShellTunnel(con *console.SliverClient, tunnelID uint64, sessionID stri
return nil
}
_, err := con.Rpc.CloseTunnel(context.Background(), &sliverpb.Tunnel{
ctx, cancel := context.WithTimeout(context.Background(), shellCloseTimeout)
defer cancel()
_, err := con.Rpc.CloseTunnel(ctx, &sliverpb.Tunnel{
TunnelID: tunnelID,
SessionID: sessionID,
})
@@ -413,6 +433,7 @@ func requestRemoteShellExit(tunnel *core.TunnelIO) {
}
}()
select {
case <-tunnel.Done():
case tunnel.Send <- append([]byte(nil), data...):
default:
}
+41
View File
@@ -0,0 +1,41 @@
package shell
import (
"context"
"testing"
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"google.golang.org/grpc"
)
type closeTunnelTestRPC struct {
rpcpb.SliverRPCClient
deadline time.Time
}
func (r *closeTunnelTestRPC) CloseTunnel(ctx context.Context, _ *sliverpb.Tunnel, _ ...grpc.CallOption) (*commonpb.Empty, error) {
deadline, ok := ctx.Deadline()
if ok {
r.deadline = deadline
}
return &commonpb.Empty{}, nil
}
func TestCloseShellTunnelUsesBoundedContext(t *testing.T) {
rpc := &closeTunnelTestRPC{}
con := &console.SliverClient{Rpc: rpc}
if err := closeShellTunnel(con, 99, "session"); err != nil {
t.Fatalf("close shell tunnel: %v", err)
}
if rpc.deadline.IsZero() {
t.Fatal("CloseTunnel RPC context has no deadline")
}
if remaining := time.Until(rpc.deadline); remaining <= 0 || remaining > shellCloseTimeout {
t.Fatalf("CloseTunnel deadline is %v away, want within (0, %v]", remaining, shellCloseTimeout)
}
}
+5
View File
@@ -38,6 +38,7 @@ import (
"github.com/bishopfox/sliver/client/command/info"
"github.com/bishopfox/sliver/client/command/kill"
"github.com/bishopfox/sliver/client/command/network"
"github.com/bishopfox/sliver/client/command/opfor"
"github.com/bishopfox/sliver/client/command/pivots"
"github.com/bishopfox/sliver/client/command/portfwd"
"github.com/bishopfox/sliver/client/command/privilege"
@@ -81,6 +82,7 @@ func SliverCommands(con *client.SliverClient) console.Commands {
// [ Core ]
bind(consts.SliverCoreHelpGroup,
ai.Commands,
opfor.Commands,
docscmd.Commands,
reconfig.Commands,
// sessions.Commands,
@@ -173,6 +175,9 @@ func SliverCommands(con *client.SliverClient) console.Commands {
}
}
// Rehydrate CNA-defined Beacon aliases from the persistent OPFOR runtime.
opfor.RegisterCommands(sliver, con)
// [ Post-command declaration setup ]----------------------------------------
// Everything below this line should preferably not be any command binding
+90 -17
View File
@@ -46,6 +46,9 @@ func (con *SliverClient) SetConnection(rpc rpcpb.SliverRPCClient, grpcConn *grpc
con.Rpc = rpc
con.grpcConn = grpcConn
con.connDetails = details
con.backgroundRPC = nil
con.backgroundConn = nil
con.backgroundDedicated = false
if con.Rpc == nil || con.grpcConn == nil {
return nil
@@ -58,37 +61,61 @@ func (con *SliverClient) SetConnection(rpc rpcpb.SliverRPCClient, grpcConn *grpc
con.BeaconTaskCallbacksMutex.Unlock()
con.ActiveTarget.Set(nil, nil)
if useDedicatedCommandConnection(details) {
con.backgroundDedicated = true
commandRPC, commandConn, err := transport.MTLSConnect(details.Config)
if err != nil {
log.Printf("Dedicated command WireGuard connection unavailable, async console streams disabled to preserve command reliability: %v", err)
} else {
con.backgroundRPC = con.Rpc
con.backgroundConn = con.grpcConn
con.Rpc = commandRPC
con.grpcConn = commandConn
}
}
ctx, cancel := context.WithCancel(context.Background())
con.connCancel = cancel
wg := &sync.WaitGroup{}
con.connWg = wg
wg.Add(1)
go func(wg *sync.WaitGroup) {
defer wg.Done()
con.startEventLoop(ctx)
}(wg)
backgroundRPC := con.backgroundRPCClientLocked()
if backgroundRPC != nil {
wg.Add(1)
go func(wg *sync.WaitGroup, rpc rpcpb.SliverRPCClient) {
defer wg.Done()
con.startEventLoop(ctx, rpc)
}(wg, backgroundRPC)
wg.Add(1)
go func(wg *sync.WaitGroup) {
defer wg.Done()
if err := core.TunnelLoop(ctx, con.Rpc); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("TunnelLoop error: %v", err)
}
}(wg)
wg.Add(1)
go func(wg *sync.WaitGroup, rpc rpcpb.SliverRPCClient) {
defer wg.Done()
if err := core.TunnelLoop(ctx, rpc); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("TunnelLoop error: %v", err)
}
}(wg, backgroundRPC)
}
wg.Add(1)
go func(wg *sync.WaitGroup, conn *grpc.ClientConn) {
defer wg.Done()
con.monitorConnectionLost(ctx, conn)
}(wg, con.grpcConn)
if !con.backgroundDedicated || con.backgroundConn == nil {
wg.Add(1)
go func(wg *sync.WaitGroup, conn *grpc.ClientConn) {
defer wg.Done()
con.monitorConnectionLost(ctx, conn)
}(wg, con.grpcConn)
}
con.refreshRemoteLogStreamsLocked()
return nil
}
func useDedicatedCommandConnection(details *ConnectionDetails) bool {
return details != nil &&
details.Config != nil &&
transport.MultiplayerConnectUsesWireGuard(details.Config)
}
// CloseConnection stops background loops and closes the current gRPC connection.
func (con *SliverClient) CloseConnection() error {
con.connMu.Lock()
@@ -127,13 +154,59 @@ func (con *SliverClient) detachConnectionLocked() {
_ = transport.CloseGRPCConnection(con.grpcConn)
con.grpcConn = nil
}
if con.backgroundConn != nil {
_ = transport.CloseGRPCConnection(con.backgroundConn)
con.backgroundConn = nil
}
con.Rpc = nil
con.backgroundRPC = nil
con.backgroundDedicated = false
con.connDetails = nil
// Tear down any singleton network tooling that was tied to the previous server.
core.ResetClientState()
}
func (con *SliverClient) backgroundRPCClientLocked() rpcpb.SliverRPCClient {
if con.backgroundRPC != nil {
return con.backgroundRPC
}
if con.backgroundDedicated {
return nil
}
return con.Rpc
}
func (con *SliverClient) refreshDedicatedCommandConnectionHook(args []string) ([]string, error) {
if err := con.refreshDedicatedCommandConnection(); err != nil {
return args, err
}
return args, nil
}
func (con *SliverClient) refreshDedicatedCommandConnection() error {
con.connMu.Lock()
defer con.connMu.Unlock()
if !con.backgroundDedicated || con.backgroundConn == nil || con.connDetails == nil || con.connDetails.Config == nil {
return nil
}
rpc, conn, err := transport.MTLSConnect(con.connDetails.Config)
if err != nil {
return fmt.Errorf("refresh command connection: %w", err)
}
oldConn := con.grpcConn
con.Rpc = rpc
con.grpcConn = conn
if oldConn != nil {
_ = transport.CloseGRPCConnection(oldConn)
}
return nil
}
func (con *SliverClient) monitorConnectionLost(ctx context.Context, conn *grpc.ClientConn) {
if conn == nil {
return
+62
View File
@@ -0,0 +1,62 @@
package console
import (
"testing"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/transport"
)
func TestDedicatedCommandConnectionUsesEffectiveWireGuardMode(t *testing.T) {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
t.Cleanup(func() {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
})
legacyDetails := &ConnectionDetails{Config: completeWireGuardClientConfig(false)}
if useDedicatedCommandConnection(legacyDetails) {
t.Fatal("expected auto mode to keep an unmarked legacy config on direct mTLS")
}
enabledDetails := &ConnectionDetails{Config: completeWireGuardClientConfig(true)}
if !useDedicatedCommandConnection(enabledDetails) {
t.Fatal("expected auto mode to use a dedicated connection for an explicitly enabled config")
}
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectEnableWG)
if !useDedicatedCommandConnection(legacyDetails) {
t.Fatal("expected force-enable mode to use a dedicated connection with a complete legacy config")
}
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectDisableWG)
if useDedicatedCommandConnection(enabledDetails) {
t.Fatal("expected force-disable mode to keep an enabled config on direct mTLS")
}
}
func TestDedicatedCommandConnectionRejectsIncompleteEnabledConfig(t *testing.T) {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
t.Cleanup(func() {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
})
details := &ConnectionDetails{
Config: &assets.ClientConfig{
WG: &assets.ClientWGConfig{Enabled: true},
},
}
if useDedicatedCommandConnection(details) {
t.Fatal("expected an incomplete enabled config not to select a dedicated connection")
}
}
func completeWireGuardClientConfig(enabled bool) *assets.ClientConfig {
return &assets.ClientConfig{
WG: &assets.ClientWGConfig{
Enabled: enabled,
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
}
}
+12 -2
View File
@@ -95,6 +95,9 @@ type SliverClient struct {
connMu sync.Mutex
grpcConn *grpc.ClientConn
backgroundRPC rpcpb.SliverRPCClient
backgroundConn *grpc.ClientConn
backgroundDedicated bool
connDetails *ConnectionDetails
connCancel context.CancelFunc
connWg *sync.WaitGroup
@@ -326,6 +329,7 @@ func (con *SliverClient) applyConnectionHooksOnce() {
con.App.PreReadlineHooks = append(con.App.PreReadlineHooks, con.syncOutputHook)
con.App.PostCmdRunHooks = append(con.App.PostCmdRunHooks, con.syncOutputHook)
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, con.refreshDedicatedCommandConnectionHook)
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, con.allowServerRootCommands)
if shell := con.App.Shell(); shell != nil && shell.Completer != nil {
baseCompleter := shell.Completer
@@ -336,8 +340,12 @@ func (con *SliverClient) applyConnectionHooksOnce() {
}
}
func (con *SliverClient) startEventLoop(ctx context.Context) {
eventStream, err := con.Rpc.Events(ctx, &commonpb.Empty{})
func (con *SliverClient) startEventLoop(ctx context.Context, rpc rpcpb.SliverRPCClient) {
if rpc == nil {
return
}
eventStream, err := rpc.Events(ctx, &commonpb.Empty{})
if err != nil {
fmt.Printf("%s%s\n", Warn, err)
return
@@ -1139,6 +1147,7 @@ func (con *SliverClient) ExposeCommands() {
case con.ActiveTarget.session != nil:
session := con.ActiveTarget.session
filters = append(filters, consts.BeaconCmdsFilter)
filters = append(filters, consts.NativeExtensionTargetFilter(session.OS, session.Arch))
// Operating system
if session.OS != "windows" {
@@ -1153,6 +1162,7 @@ func (con *SliverClient) ExposeCommands() {
case con.ActiveTarget.beacon != nil:
beacon := con.ActiveTarget.beacon
filters = append(filters, consts.SessionCmdsFilter)
filters = append(filters, consts.NativeExtensionTargetFilter(beacon.OS, beacon.Arch))
// Operating system
if beacon.OS != "windows" {
+15 -4
View File
@@ -107,7 +107,8 @@ func (con *SliverClient) refreshRemoteLogStreamsLocked() {
if con.jsonRemoteWriter == nil && con.asciicastRemoteWriter == nil {
return
}
if con.Rpc == nil {
rpc := con.backgroundRPCClientLocked()
if rpc == nil {
con.setRemoteLogStreamsLocked(nil, nil)
return
}
@@ -116,7 +117,7 @@ func (con *SliverClient) refreshRemoteLogStreamsLocked() {
var asciicastStream *ConsoleClientLogger
if con.jsonRemoteWriter != nil {
s, err := con.ClientLogStream("json")
s, err := con.clientLogStream(rpc, "json")
if err != nil {
log.Printf("Could not get client json log stream: %s", err)
} else {
@@ -124,7 +125,7 @@ func (con *SliverClient) refreshRemoteLogStreamsLocked() {
}
}
if con.asciicastRemoteWriter != nil {
s, err := con.ClientLogStream("asciicast")
s, err := con.clientLogStream(rpc, "asciicast")
if err != nil {
log.Printf("Could not get client asciicast log stream: %s", err)
} else {
@@ -166,7 +167,17 @@ func (con *SliverClient) setRemoteLogStreamsLocked(jsonStream, asciicastStream *
// ClientLogStream requires a log stream name, used to save the logs
// going through this stream in a specific log subdirectory/file.
func (con *SliverClient) ClientLogStream(name string) (*ConsoleClientLogger, error) {
stream, err := con.Rpc.ClientLog(context.Background())
con.connMu.Lock()
rpc := con.backgroundRPCClientLocked()
con.connMu.Unlock()
if rpc == nil {
return nil, fmt.Errorf("no RPC connection available for client log stream %q", name)
}
return con.clientLogStream(rpc, name)
}
func (con *SliverClient) clientLogStream(rpc rpcpb.SliverRPCClient, name string) (*ConsoleClientLogger, error) {
stream, err := rpc.ClientLog(context.Background())
if err != nil {
return nil, err
}
+46
View File
@@ -231,6 +231,7 @@ const (
AIStr = "ai"
AIConfigStr = "ai-config"
DocsStr = "docs"
OpforStr = "opfor"
CatStr = "cat"
EditStr = "edit"
HexEditStr = "hex-edit"
@@ -363,8 +364,53 @@ const (
BeaconCmdsFilter = "beacon"
WindowsCmdsFilter = "windows"
WireguardCmdsFilter = "wireguard"
NativeExtensionUnsupportedTargetFilter = "native-extension:unsupported"
NativeExtensionDarwinAMD64Filter = "native-extension:darwin/amd64"
NativeExtensionDarwinARM64Filter = "native-extension:darwin/arm64"
NativeExtensionLinux386Filter = "native-extension:linux/386"
NativeExtensionLinuxAMD64Filter = "native-extension:linux/amd64"
NativeExtensionLinuxARM64Filter = "native-extension:linux/arm64"
NativeExtensionWindows386Filter = "native-extension:windows/386"
NativeExtensionWindowsAMD64Filter = "native-extension:windows/amd64"
NativeExtensionWindowsARM64Filter = "native-extension:windows/arm64"
)
// NativeExtensionTarget identifies a GOOS/GOARCH pair supported by native
// implant extensions and the command filter associated with that exact pair.
type NativeExtensionTarget struct {
GOOS string
GOARCH string
Filter string
}
var nativeExtensionTargets = [8]NativeExtensionTarget{
{GOOS: "darwin", GOARCH: "amd64", Filter: NativeExtensionDarwinAMD64Filter},
{GOOS: "darwin", GOARCH: "arm64", Filter: NativeExtensionDarwinARM64Filter},
{GOOS: "linux", GOARCH: "386", Filter: NativeExtensionLinux386Filter},
{GOOS: "linux", GOARCH: "amd64", Filter: NativeExtensionLinuxAMD64Filter},
{GOOS: "linux", GOARCH: "arm64", Filter: NativeExtensionLinuxARM64Filter},
{GOOS: "windows", GOARCH: "386", Filter: NativeExtensionWindows386Filter},
{GOOS: "windows", GOARCH: "amd64", Filter: NativeExtensionWindowsAMD64Filter},
{GOOS: "windows", GOARCH: "arm64", Filter: NativeExtensionWindowsARM64Filter},
}
// NativeExtensionTargets returns the supported native extension target matrix.
func NativeExtensionTargets() [8]NativeExtensionTarget {
return nativeExtensionTargets
}
// NativeExtensionTargetFilter returns the filter for an exact supported target.
// Unsupported pairs share a filter that hides all native extension commands.
func NativeExtensionTargetFilter(goos string, goarch string) string {
for _, target := range nativeExtensionTargets {
if target.GOOS == goos && target.GOARCH == goarch {
return target.Filter
}
}
return NativeExtensionUnsupportedTargetFilter
}
// Creds (needed here to avoid recursive imports).
const (
UserColonHashNewlineFormat = "user:hash" // username:hash\n
+11 -2
View File
@@ -41,8 +41,9 @@ func TunnelLoop(ctx context.Context, rpc rpcpb.SliverRPCClient) error {
return err
}
GetTunnels().SetStream(stream)
defer GetTunnels().SetStream(nil)
tunnels := GetTunnels()
streamGeneration := tunnels.SetStream(stream)
defer tunnels.CloseStream(streamGeneration)
for {
log.Printf("Waiting for TunnelData ...")
@@ -65,6 +66,14 @@ func TunnelLoop(ctx context.Context, rpc rpcpb.SliverRPCClient) error {
tunnel := GetTunnels().Get(incoming.TunnelID)
if tunnel != nil {
if !incoming.Closed && len(incoming.GetData()) == 0 {
// The server uses an empty frame to acknowledge that the client
// stream is bound to a newly created tunnel. It is control
// traffic, not data for the tunnel reader.
tunnel.markBound()
log.Printf("Received bind acknowledgement for tunnel %d", incoming.TunnelID)
continue
}
data := incoming.GetData()
log.Printf("This is data on tunnel %d: %s", tunnel.ID, data)
+87 -26
View File
@@ -26,6 +26,10 @@ import (
"sync"
)
const tunnelRecvBufferSize = 16
var errClosedTunnel = errors.New("closed tunnel")
// TunnelIO - Duplex data tunnel, compatible with both io.ReadWriter
type TunnelIO struct {
ID uint64
@@ -34,8 +38,12 @@ type TunnelIO struct {
Send chan []byte
Recv chan []byte
isOpen bool
mutex *sync.RWMutex
done chan struct{}
closeOnce sync.Once
bound chan struct{}
boundOnce sync.Once
isOpen bool
mutex *sync.RWMutex
}
// NewTunnelIO - Single entry point for creating instance of new TunnelIO
@@ -46,9 +54,13 @@ func NewTunnelIO(tunnelID uint64, sessionID string) *TunnelIO {
ID: tunnelID,
SessionID: sessionID,
Send: make(chan []byte),
Recv: make(chan []byte),
isOpen: false,
mutex: &sync.RWMutex{},
// A tunnel can receive its first frames before the command-specific
// reader is running. Keep that short startup burst from blocking the
// single client TunnelLoop while retaining bounded backpressure.
Recv: make(chan []byte, tunnelRecvBufferSize),
done: make(chan struct{}),
bound: make(chan struct{}),
mutex: &sync.RWMutex{},
}
}
@@ -66,33 +78,58 @@ func (tun *TunnelIO) Write(data []byte) (int, error) {
log.Printf("Write %d bytes", n)
log.Printf("This bytes is: %s", dataCopy)
tun.Send <- dataCopy
select {
case <-tun.done:
return 0, io.EOF
default:
}
return n, nil
select {
case tun.Send <- dataCopy:
return n, nil
case <-tun.done:
return 0, io.EOF
}
}
// Read - Reader method for interface
func (tun *TunnelIO) Read(data []byte) (int, error) {
recvData, ok := <-tun.Recv
if !ok {
// Prefer already queued output over the close signal so callers can drain
// the final frames before observing EOF.
select {
case recvData := <-tun.Recv:
return tun.copyRecvData(data, recvData), nil
default:
}
select {
case recvData := <-tun.Recv:
return tun.copyRecvData(data, recvData), nil
case <-tun.done:
log.Printf("Warning: Read on closed tunnel %d", tun.ID)
return 0, io.EOF
}
}
func (tun *TunnelIO) copyRecvData(data []byte, recvData []byte) int {
var buff bytes.Buffer
log.Printf("Read %d bytes", len(recvData))
buff.Write(recvData)
n := copy(data, buff.Bytes())
return n, nil
return copy(data, buff.Bytes())
}
// Close - Close tunnel IO operations
func (tun *TunnelIO) Close() error {
tun.mutex.Lock()
defer tun.mutex.Unlock()
wasOpen := tun.isOpen
tun.isOpen = false
tun.closeOnce.Do(func() {
close(tun.done)
})
tun.mutex.Unlock()
if !tun.isOpen {
if !wasOpen {
log.Printf("Warning: Close on closed tunnel %d", tun.ID)
// I guess we can ignore it and don't return any error
@@ -101,14 +138,26 @@ func (tun *TunnelIO) Close() error {
log.Printf("Close tunnel %d", tun.ID)
tun.isOpen = false
close(tun.Recv)
close(tun.Send)
return nil
}
// Done is closed when the tunnel is closed. The public data channels remain
// open so concurrent producers cannot panic by racing a channel close.
func (tun *TunnelIO) Done() <-chan struct{} {
return tun.done
}
// Bound is closed after the server acknowledges the tunnel's streaming bind.
func (tun *TunnelIO) Bound() <-chan struct{} {
return tun.bound
}
func (tun *TunnelIO) markBound() {
tun.boundOnce.Do(func() {
close(tun.bound)
})
}
func (tun *TunnelIO) IsOpen() bool {
tun.mutex.RLock()
defer tun.mutex.RUnlock()
@@ -123,6 +172,11 @@ func (tun *TunnelIO) Open() error {
if tun.isOpen {
return errors.New("tunnel relady in open state")
}
select {
case <-tun.done:
return errClosedTunnel
default:
}
log.Printf("Open tunnel %d", tun.ID)
@@ -131,16 +185,23 @@ func (tun *TunnelIO) Open() error {
return nil
}
// RecvData - safe way to send data to internal Recv channel. Blocking.
// RecvData - safely queues data on the bounded internal Recv channel.
// It blocks only when that startup buffer is full.
func (tun *TunnelIO) RecvData(data []byte) error {
tun.mutex.Lock()
defer tun.mutex.Unlock()
if !tun.isOpen {
return errors.New("closed tunnel")
if !tun.IsOpen() {
return errClosedTunnel
}
tun.Recv <- data
select {
case <-tun.done:
return errClosedTunnel
default:
}
return nil
select {
case tun.Recv <- data:
return nil
case <-tun.done:
return errClosedTunnel
}
}
+205
View File
@@ -0,0 +1,205 @@
package core
import (
"bytes"
"errors"
"io"
"testing"
"time"
)
func TestTunnelIOBuffersDataBeforeReaderStarts(t *testing.T) {
tunnel := NewTunnelIO(1, "session")
if err := tunnel.Open(); err != nil {
t.Fatalf("open tunnel: %v", err)
}
t.Cleanup(func() {
_ = tunnel.Close()
})
if got := cap(tunnel.Recv); got != tunnelRecvBufferSize {
t.Fatalf("receive buffer capacity = %d, want %d", got, tunnelRecvBufferSize)
}
payload := []byte("early shell output")
recvDone := make(chan error, 1)
go func() {
recvDone <- tunnel.RecvData(payload)
}()
select {
case err := <-recvDone:
if err != nil {
t.Fatalf("queue data before reader startup: %v", err)
}
case <-time.After(time.Second):
t.Fatal("RecvData blocked before the tunnel reader started")
}
select {
case got := <-tunnel.Recv:
if !bytes.Equal(got, payload) {
t.Fatalf("queued payload = %q, want %q", got, payload)
}
default:
t.Fatal("early payload was not queued")
}
}
func TestTunnelIOCloseUnblocksFullReceiveBuffer(t *testing.T) {
tunnel := NewTunnelIO(2, "session")
if err := tunnel.Open(); err != nil {
t.Fatalf("open tunnel: %v", err)
}
for i := 0; i < tunnelRecvBufferSize; i++ {
recvDone := make(chan error, 1)
go func() {
recvDone <- tunnel.RecvData([]byte{byte(i)})
}()
select {
case err := <-recvDone:
if err != nil {
t.Fatalf("fill receive buffer frame %d: %v", i, err)
}
case <-time.After(time.Second):
t.Fatalf("frame %d blocked before receive buffer was full", i)
}
}
blockedRecv := make(chan error, 1)
go func() {
blockedRecv <- tunnel.RecvData([]byte("seventeenth frame"))
}()
select {
case err := <-blockedRecv:
t.Fatalf("frame beyond bounded receive capacity returned before close: %v", err)
case <-time.After(50 * time.Millisecond):
}
closeDone := make(chan error, 1)
go func() {
closeDone <- tunnel.Close()
}()
select {
case err := <-closeDone:
if err != nil {
t.Fatalf("close full tunnel: %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close blocked behind RecvData on a full receive buffer")
}
select {
case err := <-blockedRecv:
if !errors.Is(err, errClosedTunnel) {
t.Fatalf("blocked RecvData error = %v, want %v", err, errClosedTunnel)
}
case <-time.After(time.Second):
t.Fatal("blocked RecvData did not wake when tunnel closed")
}
}
func TestTunnelIOCloseUnblocksRead(t *testing.T) {
tunnel := NewTunnelIO(3, "session")
if err := tunnel.Open(); err != nil {
t.Fatalf("open tunnel: %v", err)
}
type readResult struct {
n int
err error
}
readDone := make(chan readResult, 1)
go func() {
n, err := tunnel.Read(make([]byte, 32))
readDone <- readResult{n: n, err: err}
}()
if err := tunnel.Close(); err != nil {
t.Fatalf("close tunnel: %v", err)
}
select {
case result := <-readDone:
if result.n != 0 || !errors.Is(result.err, io.EOF) {
t.Fatalf("Read after close = (%d, %v), want (0, EOF)", result.n, result.err)
}
case <-time.After(time.Second):
t.Fatal("blocked Read did not wake when tunnel closed")
}
}
func TestTunnelIOCloseUnblocksWrite(t *testing.T) {
tunnel := NewTunnelIO(4, "session")
if err := tunnel.Open(); err != nil {
t.Fatalf("open tunnel: %v", err)
}
type writeResult struct {
n int
err error
}
writeDone := make(chan writeResult, 1)
go func() {
n, err := tunnel.Write([]byte("blocked write"))
writeDone <- writeResult{n: n, err: err}
}()
select {
case result := <-writeDone:
t.Fatalf("Write returned before close without a consumer: (%d, %v)", result.n, result.err)
case <-time.After(50 * time.Millisecond):
}
if err := tunnel.Close(); err != nil {
t.Fatalf("close tunnel: %v", err)
}
select {
case result := <-writeDone:
if result.n != 0 || !errors.Is(result.err, io.EOF) {
t.Fatalf("Write after close = (%d, %v), want (0, EOF)", result.n, result.err)
}
case <-time.After(time.Second):
t.Fatal("blocked Write did not wake when tunnel closed")
}
}
func TestTunnelIOReadDrainsQueuedDataBeforeEOF(t *testing.T) {
tunnel := NewTunnelIO(5, "session")
if err := tunnel.Open(); err != nil {
t.Fatalf("open tunnel: %v", err)
}
payload := []byte("trailing output")
if err := tunnel.RecvData(payload); err != nil {
t.Fatalf("queue trailing output: %v", err)
}
if err := tunnel.Close(); err != nil {
t.Fatalf("close tunnel: %v", err)
}
buf := make([]byte, 32)
n, err := tunnel.Read(buf)
if err != nil || !bytes.Equal(buf[:n], payload) {
t.Fatalf("first Read after close = (%q, %v), want (%q, nil)", buf[:n], err, payload)
}
n, err = tunnel.Read(buf)
if n != 0 || !errors.Is(err, io.EOF) {
t.Fatalf("second Read after close = (%d, %v), want (0, EOF)", n, err)
}
}
func TestTunnelIOCloseBeforeOpenIsTerminal(t *testing.T) {
tunnel := NewTunnelIO(6, "session")
if err := tunnel.Close(); err != nil {
t.Fatalf("close tunnel before open: %v", err)
}
select {
case <-tunnel.Done():
default:
t.Fatal("Close before Open did not close Done")
}
if err := tunnel.Open(); !errors.Is(err, errClosedTunnel) {
t.Fatalf("Open after Close error = %v, want %v", err, errClosedTunnel)
}
if tunnel.IsOpen() {
t.Fatal("tunnel reopened after terminal Close")
}
}
+235
View File
@@ -0,0 +1,235 @@
package core
import (
"bytes"
"context"
"errors"
"io"
"testing"
"time"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"google.golang.org/grpc"
)
type tunnelLoopTestRPC struct {
rpcpb.SliverRPCClient
stream *tunnelLoopTestStream
opened chan struct{}
}
func (r *tunnelLoopTestRPC) TunnelData(ctx context.Context, _ ...grpc.CallOption) (grpc.BidiStreamingClient[sliverpb.TunnelData, sliverpb.TunnelData], error) {
r.stream.ctx = ctx
close(r.opened)
return r.stream, nil
}
type tunnelLoopTestStream struct {
grpc.BidiStreamingClient[sliverpb.TunnelData, sliverpb.TunnelData]
ctx context.Context
incoming chan *sliverpb.TunnelData
sent chan *sliverpb.TunnelData
recvCalls chan struct{}
recvErr chan error
sendErr error
}
func (s *tunnelLoopTestStream) Send(data *sliverpb.TunnelData) error {
if s.sendErr != nil {
return s.sendErr
}
s.sent <- data
return nil
}
func TestTunnelBindSendFailureClosesTunnel(t *testing.T) {
tunnels := GetTunnels()
tunnels.Reset()
stream := &tunnelLoopTestStream{
incoming: make(chan *sliverpb.TunnelData),
sent: make(chan *sliverpb.TunnelData, 1),
recvCalls: make(chan struct{}, 2),
sendErr: errors.New("test bind send failure"),
}
rpc := &tunnelLoopTestRPC{stream: stream, opened: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
loopDone := make(chan error, 1)
go func() { loopDone <- TunnelLoop(ctx, rpc) }()
t.Cleanup(func() {
cancel()
select {
case <-loopDone:
case <-time.After(time.Second):
t.Error("TunnelLoop did not stop after cancellation")
}
tunnels.Reset()
})
waitForTestSignal(t, rpc.opened, "TunnelLoop stream startup")
waitForTestSignal(t, stream.recvCalls, "initial TunnelData receive")
tunnel := tunnels.Start(9, "session")
select {
case <-tunnel.Done():
case <-time.After(time.Second):
t.Fatal("tunnel remained open after its bind frame failed to send")
}
if got := tunnels.Get(tunnel.ID); got != nil {
t.Fatal("failed bind tunnel remained registered")
}
}
func (s *tunnelLoopTestStream) Recv() (*sliverpb.TunnelData, error) {
s.recvCalls <- struct{}{}
select {
case data := <-s.incoming:
return data, nil
case err := <-s.recvErr:
return nil, err
case <-s.ctx.Done():
return nil, s.ctx.Err()
}
}
func TestTunnelLoopFiltersEmptyBindAcknowledgement(t *testing.T) {
tunnels := GetTunnels()
tunnels.Reset()
stream := &tunnelLoopTestStream{
incoming: make(chan *sliverpb.TunnelData, 2),
sent: make(chan *sliverpb.TunnelData, 1),
recvCalls: make(chan struct{}, 4),
}
rpc := &tunnelLoopTestRPC{
stream: stream,
opened: make(chan struct{}),
}
ctx, cancel := context.WithCancel(context.Background())
loopDone := make(chan error, 1)
go func() {
loopDone <- TunnelLoop(ctx, rpc)
}()
t.Cleanup(func() {
cancel()
select {
case <-loopDone:
case <-time.After(time.Second):
t.Error("TunnelLoop did not stop after cancellation")
}
tunnels.Reset()
})
waitForTestSignal(t, rpc.opened, "TunnelLoop stream startup")
waitForTestSignal(t, stream.recvCalls, "initial TunnelData receive")
tunnel := tunnels.Start(7, "session")
select {
case bind := <-stream.sent:
if bind.TunnelID != tunnel.ID || len(bind.Data) != 0 {
t.Fatalf("bind frame = %+v, want empty data for tunnel %d", bind, tunnel.ID)
}
case <-time.After(time.Second):
t.Fatal("client did not send the tunnel bind frame")
}
stream.incoming <- &sliverpb.TunnelData{
TunnelID: tunnel.ID,
SessionID: tunnel.SessionID,
}
waitForTestSignal(t, stream.recvCalls, "receive after bind acknowledgement")
select {
case <-tunnel.Bound():
default:
t.Fatal("bind acknowledgement did not mark the tunnel bound")
}
select {
case data := <-tunnel.Recv:
t.Fatalf("bind acknowledgement leaked to the tunnel reader: %q", data)
default:
}
payload := []byte("shell prompt")
stream.incoming <- &sliverpb.TunnelData{
TunnelID: tunnel.ID,
SessionID: tunnel.SessionID,
Data: payload,
}
waitForTestSignal(t, stream.recvCalls, "receive after shell payload")
select {
case got := <-tunnel.Recv:
if !bytes.Equal(got, payload) {
t.Fatalf("tunnel payload = %q, want %q", got, payload)
}
default:
t.Fatal("non-empty tunnel payload was not delivered")
}
stream.incoming <- &sliverpb.TunnelData{
TunnelID: tunnel.ID,
SessionID: tunnel.SessionID,
Closed: true,
}
waitForTestSignal(t, stream.recvCalls, "receive after tunnel close")
if got := tunnels.Get(tunnel.ID); got != nil {
t.Fatal("empty close frame was filtered as a bind acknowledgement")
}
select {
case tunnel.Send <- []byte("after close"):
t.Fatal("tunnel sender still consumed data after Close")
case <-time.After(50 * time.Millisecond):
}
}
func TestTunnelLoopClosesActiveTunnelsOnStreamEOF(t *testing.T) {
tunnels := GetTunnels()
tunnels.Reset()
t.Cleanup(tunnels.Reset)
stream := &tunnelLoopTestStream{
incoming: make(chan *sliverpb.TunnelData),
sent: make(chan *sliverpb.TunnelData, 1),
recvCalls: make(chan struct{}, 4),
recvErr: make(chan error, 1),
}
rpc := &tunnelLoopTestRPC{stream: stream, opened: make(chan struct{})}
loopDone := make(chan error, 1)
go func() {
loopDone <- TunnelLoop(context.Background(), rpc)
}()
waitForTestSignal(t, rpc.opened, "TunnelLoop stream startup")
waitForTestSignal(t, stream.recvCalls, "initial TunnelData receive")
tunnel := tunnels.Start(8, "session")
select {
case <-stream.sent:
case <-time.After(time.Second):
t.Fatal("client did not send the tunnel bind frame")
}
stream.recvErr <- io.EOF
select {
case err := <-loopDone:
if err != nil {
t.Fatalf("TunnelLoop EOF result = %v, want nil", err)
}
case <-time.After(time.Second):
t.Fatal("TunnelLoop did not return after stream EOF")
}
select {
case <-tunnel.Done():
case <-time.After(time.Second):
t.Fatal("active tunnel did not close after stream EOF")
}
if got := tunnels.Get(tunnel.ID); got != nil {
t.Fatal("active tunnel remained registered after stream EOF")
}
}
func waitForTestSignal(t *testing.T, signal <-chan struct{}, description string) {
t.Helper()
select {
case <-signal:
case <-time.After(time.Second):
t.Fatalf("timed out waiting for %s", description)
}
}
+55 -19
View File
@@ -55,19 +55,35 @@ func GetTunnels() *tunnels {
// Holds the tunnels locally so we can map incoming data
// messages to the tunnel
type tunnels struct {
tunnels *map[uint64]*TunnelIO
mutex *sync.RWMutex
streamMutex *sync.Mutex
stream rpcpb.SliverRPC_TunnelDataClient
tunnels *map[uint64]*TunnelIO
mutex *sync.RWMutex
streamMutex *sync.Mutex
stream rpcpb.SliverRPC_TunnelDataClient
streamGeneration uint64
}
func (t *tunnels) SetStream(stream rpcpb.SliverRPC_TunnelDataClient) {
func (t *tunnels) SetStream(stream rpcpb.SliverRPC_TunnelDataClient) uint64 {
t.streamMutex.Lock()
defer t.streamMutex.Unlock()
log.Printf("Set stream")
t.streamGeneration++
t.stream = stream
return t.streamGeneration
}
// CloseStream clears one specific stream generation and closes the tunnels it
// owned. A stale loop cannot clear tunnels created by a replacement stream.
func (t *tunnels) CloseStream(generation uint64) {
t.streamMutex.Lock()
defer t.streamMutex.Unlock()
if generation != t.streamGeneration {
return
}
t.stream = nil
t.closeAll()
}
// Get - Get a tunnel
@@ -105,27 +121,39 @@ func (t *tunnels) Start(tunnelID uint64, sessionID string) *TunnelIO {
(*t.tunnels)[tunnelID] = tunnel
go func(tunnel *TunnelIO) {
tunnel.Open()
if err := tunnel.Open(); err != nil {
log.Printf("Failed to open tunnel %d: %v", tunnelID, err)
return
}
log.Printf("Tunnel now is open, %d", tunnelID)
for data := range tunnel.Send {
log.Printf("Send %d bytes on tunnel %d", len(data), tunnel.ID)
for {
select {
case <-tunnel.Done():
log.Printf("Tunnel send loop stopped. %d", tunnelID)
return
case data := <-tunnel.Send:
log.Printf("Send %d bytes on tunnel %d", len(data), tunnel.ID)
err := t.send(&sliverpb.TunnelData{
TunnelID: tunnel.ID,
SessionID: tunnel.SessionID,
Data: data,
})
err := t.send(&sliverpb.TunnelData{
TunnelID: tunnel.ID,
SessionID: tunnel.SessionID,
Data: data,
})
if err != nil {
log.Printf("Error sending, %s", err)
if err != nil {
log.Printf("Error sending, %s", err)
t.Close(tunnel.ID)
return
}
}
}
log.Printf("Tunnel Send channel looks closed now. %d", tunnelID)
}(tunnel)
tunnel.Send <- make([]byte, 0) // Send "zero" message to bind client to tunnel
select {
case tunnel.Send <- make([]byte, 0): // Send "zero" message to bind client to tunnel
case <-tunnel.Done():
}
return tunnel
}
@@ -164,8 +192,16 @@ func (t *tunnels) CloseForSession(sessionID string) {
// Reset closes all open tunnels and clears the underlying storage/stream.
// This is used when switching servers inside a single client process.
func (t *tunnels) Reset() {
t.SetStream(nil)
t.streamMutex.Lock()
defer t.streamMutex.Unlock()
t.streamGeneration++
t.stream = nil
t.closeAll()
}
// closeAll runs with streamMutex held so a replacement stream cannot publish
// new tunnels until the previous generation has been fully torn down.
func (t *tunnels) closeAll() {
t.mutex.Lock()
defer t.mutex.Unlock()
+1
View File
@@ -79,6 +79,7 @@ func GenerateProfilesNewForm() (*GenerateProfilesNewFormResult, error) {
options := []huh.Option[string]{
huh.NewOption("Executable", "exe"),
huh.NewOption("Shared library", "shared"),
huh.NewOption("Go archive", "archive"),
}
if result.OS == "windows" {
options = append(options,
@@ -84,6 +84,7 @@ func GenerateProfilesNewBeaconForm() (*GenerateProfilesNewBeaconFormResult, erro
options := []huh.Option[string]{
huh.NewOption("Executable", "exe"),
huh.NewOption("Shared library", "shared"),
huh.NewOption("Go archive", "archive"),
}
if result.OS == "windows" {
options = append(options,
+2
View File
@@ -22,6 +22,7 @@ type targetOptionBindings struct {
var formatOptionOrder = []formatOption{
{format: clientpb.OutputFormat_EXECUTABLE, label: "Executable", value: "exe"},
{format: clientpb.OutputFormat_SHARED_LIB, label: "Shared library", value: "shared"},
{format: clientpb.OutputFormat_GO_ARCHIVE, label: "Go archive", value: "archive"},
{format: clientpb.OutputFormat_SERVICE, label: "Service", value: "service"},
{format: clientpb.OutputFormat_SHELLCODE, label: "Shellcode", value: "shellcode"},
}
@@ -59,6 +60,7 @@ func commonFormatOptions(goos string) []huh.Option[string] {
options := []huh.Option[string]{
huh.NewOption("Executable", "exe"),
huh.NewOption("Shared library", "shared"),
huh.NewOption("Go archive", "archive"),
}
if goos == "windows" {
options = append(options,
+14 -2
View File
@@ -11,9 +11,21 @@ import (
type MultiplayerConnectMode int
const (
// MultiplayerConnectAuto follows the explicit WireGuard opt-in recorded in
// the operator config.
MultiplayerConnectAuto MultiplayerConnectMode = iota
// MultiplayerConnectEnableWG forces WireGuard for complete configs,
// including legacy configs that predate the explicit opt-in marker.
MultiplayerConnectEnableWG
// MultiplayerConnectDisableWG forces direct multiplayer mTLS.
MultiplayerConnectDisableWG
MultiplayerConnectRequireWG
// MultiplayerConnectDirect is retained as a source-compatible alias for
// callers that explicitly select direct multiplayer mTLS.
MultiplayerConnectDirect = MultiplayerConnectDisableWG
// MultiplayerConnectRequireWG is retained for source compatibility.
// Deprecated: use MultiplayerConnectEnableWG.
MultiplayerConnectRequireWG = MultiplayerConnectEnableWG
)
type connectionCloser interface {
@@ -65,9 +77,9 @@ func CloseGRPCConnection(conn *grpc.ClientConn) error {
}
var errs []error
errs = append(errs, conn.Close())
if closer := unregisterConnCloser(conn); closer != nil {
errs = append(errs, closer.Close())
}
errs = append(errs, conn.Close())
return errors.Join(errs...)
}
+40 -7
View File
@@ -58,6 +58,15 @@ const (
multiplayerDialWireGuard
)
const (
connectStatusGRPCMTLS = "grpc/mtls"
connectStatusWireGuard = "wireguard"
connectStatusGRPCMTLSOverWireGuard = "grpc/mtls over wireguard"
)
// ConnectStatusFn receives human-readable connection phase updates.
type ConnectStatusFn func(string)
// Return value is mapped to request headers.
func (t TokenAuth) GetRequestMetadata(ctx context.Context, in ...string) (map[string]string, error) {
return map[string]string{
@@ -71,6 +80,12 @@ func (TokenAuth) RequireTransportSecurity() bool {
// MTLSConnect - Connect to the sliver server
func MTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
return MTLSConnectWithStatus(config, nil)
}
// MTLSConnectWithStatus connects to the sliver server and optionally reports
// transport phase updates, such as WireGuard setup and gRPC dialing.
func MTLSConnectWithStatus(config *assets.ClientConfig, statusFn ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
strategy, err := selectMultiplayerDialStrategy(config)
if err != nil {
return nil, nil, err
@@ -78,9 +93,9 @@ func MTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.Clie
switch strategy {
case multiplayerDialWireGuard:
return wireGuardMTLSConnect(config)
return wireGuardMTLSConnect(config, statusFn)
default:
return directMTLSConnect(config)
return directMTLSConnect(config, statusFn)
}
}
@@ -89,26 +104,38 @@ func selectMultiplayerDialStrategy(config *assets.ClientConfig) (multiplayerDial
return multiplayerDialDirect, errors.New("client config is required")
}
switch getMultiplayerConnectMode() {
mode := getMultiplayerConnectMode()
switch mode {
case MultiplayerConnectDisableWG:
return multiplayerDialDirect, nil
case MultiplayerConnectRequireWG:
case MultiplayerConnectEnableWG:
if err := validateWireGuardConfig(config); err != nil {
return multiplayerDialDirect, err
}
return multiplayerDialWireGuard, nil
default:
if config.WG == nil {
case MultiplayerConnectAuto:
if config.WG == nil || !config.WG.Enabled {
return multiplayerDialDirect, nil
}
if err := validateWireGuardConfig(config); err != nil {
return multiplayerDialDirect, err
}
return multiplayerDialWireGuard, nil
default:
return multiplayerDialDirect, fmt.Errorf("invalid multiplayer connect mode %d", mode)
}
}
func directMTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
// MultiplayerConnectUsesWireGuard reports whether the current mode and
// operator config select the WireGuard wrapper. Invalid WireGuard configs
// report false; the initial multiplayer dial returns the validation error.
func MultiplayerConnectUsesWireGuard(config *assets.ClientConfig) bool {
strategy, err := selectMultiplayerDialStrategy(config)
return err == nil && strategy == multiplayerDialWireGuard
}
func directMTLSConnect(config *assets.ClientConfig, statusFn ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
notifyConnectStatus(statusFn, connectStatusGRPCMTLS)
options, err := newMTLSDialOptions(config)
if err != nil {
return nil, nil, err
@@ -116,6 +143,12 @@ func directMTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grp
return dialRPCClient(fmt.Sprintf("%s:%d", config.LHost, config.LPort), options, nil)
}
func notifyConnectStatus(statusFn ConnectStatusFn, status string) {
if statusFn != nil {
statusFn(status)
}
}
func newMTLSDialOptions(config *assets.ClientConfig) ([]grpc.DialOption, error) {
tlsConfig, err := getTLSConfig(config.CACertificate, config.Certificate, config.PrivateKey)
if err != nil {
+108 -23
View File
@@ -5,9 +5,18 @@ import (
"testing"
"github.com/bishopfox/sliver/client/assets"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
)
func TestSelectMultiplayerDialStrategyLegacyConfigUsesDirectMTLS(t *testing.T) {
func TestMultiplayerConnectModeDefaultsToAuto(t *testing.T) {
if mode := getMultiplayerConnectMode(); mode != MultiplayerConnectAuto {
t.Fatalf("expected auto to be the default multiplayer mode, got %v", mode)
}
}
func TestSelectMultiplayerDialStrategyAutoWithoutWGConfigUsesDirectMTLS(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
@@ -19,8 +28,47 @@ func TestSelectMultiplayerDialStrategyLegacyConfigUsesDirectMTLS(t *testing.T) {
}
}
func TestSelectMultiplayerDialStrategyRequireWGRejectsMissingWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectRequireWG)
func TestSelectMultiplayerDialStrategyAutoLegacyWGConfigUsesDirectMTLS(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyAutoEnabledWGConfigRequiresCompleteConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
Enabled: true,
ServerPubKey: "server-pub",
ClientIP: "100.64.0.2",
},
})
if !errors.Is(err, ErrIncompleteWireGuardConfig) {
t.Fatalf("expected incomplete WG config error, got %v", err)
}
}
func TestSelectMultiplayerDialStrategyAutoEnabledWGConfigUsesWireGuard(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialWireGuard {
t.Fatalf("expected WireGuard strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyEnableWGRejectsMissingWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
if !errors.Is(err, ErrMissingWireGuardConfig) {
@@ -28,8 +76,8 @@ func TestSelectMultiplayerDialStrategyRequireWGRejectsMissingWGConfig(t *testing
}
}
func TestSelectMultiplayerDialStrategyRejectsIncompleteWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
func TestSelectMultiplayerDialStrategyEnableWGRejectsIncompleteWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
@@ -42,16 +90,10 @@ func TestSelectMultiplayerDialStrategyRejectsIncompleteWGConfig(t *testing.T) {
}
}
func TestSelectMultiplayerDialStrategyUsesWireGuardWhenConfigComplete(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
func TestSelectMultiplayerDialStrategyEnableWGUsesCompleteLegacyConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
})
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
@@ -60,21 +102,38 @@ func TestSelectMultiplayerDialStrategyUsesWireGuardWhenConfigComplete(t *testing
}
}
func TestSelectMultiplayerDialStrategyDisableWGOverridesCompleteConfig(t *testing.T) {
func TestSelectMultiplayerDialStrategyDisableWGOverridesEnabledConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectDisableWG)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
})
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct strategy when WG is disabled, got %v", strategy)
t.Fatalf("expected direct strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyRejectsInvalidMode(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectMode(255))
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
if strategy != multiplayerDialDirect {
t.Fatalf("expected invalid mode to fall back to direct strategy, got %v", strategy)
}
if err == nil || err.Error() != "invalid multiplayer connect mode 255" {
t.Fatalf("expected invalid mode error, got %v", err)
}
}
func completeWGClientConfig(enabled bool) *assets.ClientConfig {
return &assets.ClientConfig{
WG: &assets.ClientWGConfig{
Enabled: enabled,
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
}
}
@@ -87,3 +146,29 @@ func setTestMultiplayerConnectMode(t *testing.T, mode MultiplayerConnectMode) {
SetMultiplayerConnectMode(previous)
})
}
func TestCloseGRPCConnectionClosesConnBeforeTransportCloser(t *testing.T) {
conn, err := grpc.NewClient("passthrough:///sliver-test", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("create grpc client: %v", err)
}
var stateSeen connectivity.State
registerConnCloser(conn, testConnectionCloser(func() error {
stateSeen = conn.GetState()
return nil
}))
if err := CloseGRPCConnection(conn); err != nil {
t.Fatalf("close grpc connection: %v", err)
}
if stateSeen != connectivity.Shutdown {
t.Fatalf("expected transport closer to run after grpc shutdown, got state %v", stateSeen)
}
}
type testConnectionCloser func() error
func (fn testConnectionCloser) Close() error {
return fn()
}
+145 -10
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/protobuf/rpcpb"
@@ -23,11 +24,17 @@ const (
multiplayerWireGuardDefaultServerIP = "100.65.0.1"
multiplayerWireGuardMTU = 1420
multiplayerWireGuardKeepalive = 25
multiplayerWireGuardDialTimeout = 30 * time.Second
multiplayerWireGuardRetryDelay = 250 * time.Millisecond
)
var (
ErrMissingWireGuardConfig = errors.New("operator config has no wg block")
ErrIncompleteWireGuardConfig = errors.New("operator config has incomplete wg block")
multiplayerWireGuardIdleTimeout = 5 * time.Second
wireGuardTunnelCacheMu sync.Mutex
wireGuardTunnelCache = map[string]*cachedWireGuardTunnel{}
)
type wireGuardTunnel struct {
@@ -37,6 +44,19 @@ type wireGuardTunnel struct {
closeOnce sync.Once
}
type cachedWireGuardTunnel struct {
tunnel *wireGuardTunnel
target string
timer *time.Timer
}
type idleWireGuardTunnelCloser struct {
key string
tunnel *wireGuardTunnel
target string
}
func (t *wireGuardTunnel) Close() error {
if t == nil {
return nil
@@ -44,6 +64,7 @@ func (t *wireGuardTunnel) Close() error {
t.closeOnce.Do(func() {
if t.dev != nil {
t.dev.Close()
<-t.dev.Wait()
}
})
return nil
@@ -56,22 +77,136 @@ func (t *wireGuardTunnel) DialContext(ctx context.Context, address string) (net.
return t.net.DialContext(ctx, "tcp", address)
}
func wireGuardMTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
func (c *idleWireGuardTunnelCloser) Close() error {
if c == nil || c.key == "" || c.tunnel == nil {
return nil
}
cacheIdleWireGuardTunnel(c.key, c.tunnel, c.target)
return nil
}
func wireGuardMTLSConnect(config *assets.ClientConfig, statusFn ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
deadline := time.Now().Add(multiplayerWireGuardDialTimeout)
var lastErr error
attempts := 0
for {
attempts++
notifyConnectStatus(statusFn, connectStatusWireGuard)
cacheKey, tunnel, target, err := acquireWireGuardTunnel(config)
if err != nil {
return nil, nil, err
}
options, err := newMTLSDialOptions(config)
if err != nil {
_ = tunnel.Close()
return nil, nil, err
}
options = append(options, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
return tunnel.DialContext(ctx, addr)
}))
notifyConnectStatus(statusFn, connectStatusGRPCMTLSOverWireGuard)
rpcClient, conn, err := dialRPCClient(target, options, nil)
if err == nil {
registerConnCloser(conn, &idleWireGuardTunnelCloser{
key: cacheKey,
tunnel: tunnel,
target: target,
})
return rpcClient, conn, nil
}
_ = tunnel.Close()
lastErr = err
if !errors.Is(err, context.DeadlineExceeded) || !time.Now().Before(deadline) {
break
}
time.Sleep(multiplayerWireGuardRetryDelay)
}
if attempts > 1 {
return nil, nil, fmt.Errorf("wireguard multiplayer connect failed after %d attempts: %w", attempts, lastErr)
}
return nil, nil, lastErr
}
func acquireWireGuardTunnel(config *assets.ClientConfig) (string, *wireGuardTunnel, string, error) {
key, err := wireGuardTunnelCacheKey(config)
if err != nil {
return "", nil, "", err
}
wireGuardTunnelCacheMu.Lock()
if cached := wireGuardTunnelCache[key]; cached != nil && cached.tunnel != nil {
delete(wireGuardTunnelCache, key)
if cached.timer != nil {
cached.timer.Stop()
cached.timer = nil
}
tunnel := cached.tunnel
target := cached.target
wireGuardTunnelCacheMu.Unlock()
return key, tunnel, target, nil
}
wireGuardTunnelCacheMu.Unlock()
tunnel, target, err := newWireGuardTunnel(config)
if err != nil {
return nil, nil, err
return "", nil, "", err
}
return key, tunnel, target, nil
}
options, err := newMTLSDialOptions(config)
if err != nil {
_ = tunnel.Close()
return nil, nil, err
func cacheIdleWireGuardTunnel(key string, tunnel *wireGuardTunnel, target string) {
if key == "" || tunnel == nil {
return
}
options = append(options, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
return tunnel.DialContext(ctx, addr)
}))
wireGuardTunnelCacheMu.Lock()
previous := wireGuardTunnelCache[key]
cached := &cachedWireGuardTunnel{
tunnel: tunnel,
target: target,
}
wireGuardTunnelCache[key] = cached
if previous != nil && previous.timer != nil {
previous.timer.Stop()
}
cached.timer = time.AfterFunc(multiplayerWireGuardIdleTimeout, func() {
wireGuardTunnelCacheMu.Lock()
current := wireGuardTunnelCache[key]
if current != cached || current == nil {
wireGuardTunnelCacheMu.Unlock()
return
}
delete(wireGuardTunnelCache, key)
current.timer = nil
tunnel := current.tunnel
wireGuardTunnelCacheMu.Unlock()
if tunnel != nil {
_ = tunnel.Close()
}
})
wireGuardTunnelCacheMu.Unlock()
if previous != nil && previous.tunnel != nil {
_ = previous.tunnel.Close()
}
}
return dialRPCClient(target, options, tunnel)
func wireGuardTunnelCacheKey(config *assets.ClientConfig) (string, error) {
if err := validateWireGuardConfig(config); err != nil {
return "", err
}
return strings.Join([]string{
strings.TrimSpace(config.LHost),
strconv.Itoa(config.LPort),
strings.TrimSpace(config.WG.ServerPubKey),
strings.TrimSpace(config.WG.ClientPrivateKey),
strings.TrimSpace(config.WG.ClientIP),
strings.TrimSpace(config.WG.ServerIP),
}, "\x00"), nil
}
func newWireGuardTunnel(config *assets.ClientConfig) (*wireGuardTunnel, string, error) {
+44 -3
View File
@@ -38,6 +38,11 @@ type transportTun struct {
type transportNet transportTun
const (
transportTCPReceiveBufferMax = 8 << 20
transportTCPSendBufferMax = 6 << 20
)
func createTransportNetTUN(localAddresses []netip.Addr, mtu int) (tun.Device, *transportNet, error) {
n, err := rand.Int(rand.Reader, big.NewInt(0xFFFFFFFF))
if err != nil {
@@ -60,9 +65,8 @@ func createTransportNetTUN(localAddresses []netip.Addr, mtu int) (tun.Device, *t
mtu: mtu,
}
sackEnabledOpt := tcpip.TCPSACKEnabled(true)
if tcpipErr := dev.stack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); tcpipErr != nil {
return nil, nil, fmt.Errorf("could not enable TCP SACK: %v", tcpipErr)
if err := configureTransportTCPStack(dev.stack); err != nil {
return nil, nil, err
}
dev.notifyHandle = dev.ep.AddNotify(dev)
@@ -102,6 +106,43 @@ func createTransportNetTUN(localAddresses []netip.Addr, mtu int) (tun.Device, *t
return dev, (*transportNet)(dev), nil
}
func configureTransportTCPStack(ipstack *stack.Stack) error {
sackEnabledOpt := tcpip.TCPSACKEnabled(true)
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); tcpipErr != nil {
return fmt.Errorf("could not enable TCP SACK: %v", tcpipErr)
}
tcpRecoveryOpt := tcpip.TCPRecovery(0)
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpRecoveryOpt); tcpipErr != nil {
return fmt.Errorf("could not disable TCP RACK: %v", tcpipErr)
}
renoOpt := tcpip.CongestionControlOption("reno")
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &renoOpt); tcpipErr != nil {
return fmt.Errorf("could not set TCP congestion control to reno: %v", tcpipErr)
}
tcpRXBufOpt := tcpip.TCPReceiveBufferSizeRangeOption{
Min: tcp.MinBufferSize,
Default: tcp.DefaultSendBufferSize,
Max: transportTCPReceiveBufferMax,
}
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpRXBufOpt); tcpipErr != nil {
return fmt.Errorf("could not set TCP RX buffer size: %v", tcpipErr)
}
tcpTXBufOpt := tcpip.TCPSendBufferSizeRangeOption{
Min: tcp.MinBufferSize,
Default: tcp.DefaultReceiveBufferSize,
Max: transportTCPSendBufferMax,
}
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpTXBufOpt); tcpipErr != nil {
return fmt.Errorf("could not set TCP TX buffer size: %v", tcpipErr)
}
return nil
}
func (tun *transportTun) Name() (string, error) {
return "go", nil
}
+86
View File
@@ -0,0 +1,86 @@
package transport
import (
"testing"
"time"
"github.com/bishopfox/sliver/client/assets"
)
func TestWireGuardTunnelCacheKeyIncludesConfigMaterial(t *testing.T) {
config := &assets.ClientConfig{
LHost: "127.0.0.1",
LPort: 31337,
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.65.0.2",
ServerIP: "100.65.0.1",
},
}
key1, err := wireGuardTunnelCacheKey(config)
if err != nil {
t.Fatalf("cache key: %v", err)
}
config.WG.ClientPrivateKey = "other-client-priv"
key2, err := wireGuardTunnelCacheKey(config)
if err != nil {
t.Fatalf("cache key after config change: %v", err)
}
if key1 == key2 {
t.Fatal("expected cache key to change when wireguard config changes")
}
}
func TestCacheIdleWireGuardTunnelRemovesIdleTunnel(t *testing.T) {
resetWireGuardTunnelCacheForTest(t)
previousIdleTimeout := multiplayerWireGuardIdleTimeout
multiplayerWireGuardIdleTimeout = 10 * time.Millisecond
t.Cleanup(func() {
multiplayerWireGuardIdleTimeout = previousIdleTimeout
})
const cacheKey = "test-cache-key"
cacheIdleWireGuardTunnel(cacheKey, &wireGuardTunnel{}, "100.65.0.1:31337")
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
wireGuardTunnelCacheMu.Lock()
_, exists := wireGuardTunnelCache[cacheKey]
wireGuardTunnelCacheMu.Unlock()
if !exists {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("expected idle tunnel cache entry to be removed")
}
func resetWireGuardTunnelCacheForTest(t *testing.T) {
t.Helper()
wireGuardTunnelCacheMu.Lock()
for key, shared := range wireGuardTunnelCache {
if shared != nil && shared.timer != nil {
shared.timer.Stop()
}
delete(wireGuardTunnelCache, key)
}
wireGuardTunnelCacheMu.Unlock()
t.Cleanup(func() {
wireGuardTunnelCacheMu.Lock()
for key, shared := range wireGuardTunnelCache {
if shared != nil && shared.timer != nil {
shared.timer.Stop()
}
delete(wireGuardTunnelCache, key)
}
wireGuardTunnelCacheMu.Unlock()
})
}
+71 -2
View File
@@ -3,6 +3,8 @@ import React from "react";
type AsciinemaPlayerProps = {
src: string;
className?: string;
hideControls?: boolean;
cols?: string;
rows?: string;
@@ -18,10 +20,67 @@ type AsciinemaPlayerProps = {
fontSize?: string;
};
function AsciinemaPlayer({ src, ...asciinemaOptions }: AsciinemaPlayerProps) {
function AsciinemaPlayer({
src,
className,
hideControls,
cols,
rows,
autoPlay,
preload,
loop,
startAt,
speed,
idleTimeLimit,
theme,
poster,
fit,
fontSize,
}: AsciinemaPlayerProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(false);
const [player, setPlayer] =
React.useState<typeof import("asciinema-player")>();
const asciinemaOptions = React.useMemo(
() => ({
autoPlay: prefersReducedMotion ? false : autoPlay,
cols,
controls: hideControls ? false : undefined,
fit,
fontSize,
idleTimeLimit,
loop: prefersReducedMotion ? false : loop,
poster,
preload,
rows,
speed,
startAt,
theme,
}),
[
autoPlay,
cols,
fit,
fontSize,
hideControls,
idleTimeLimit,
loop,
poster,
preload,
prefersReducedMotion,
rows,
speed,
startAt,
theme,
],
);
React.useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const syncPreference = () => setPrefersReducedMotion(mediaQuery.matches);
syncPreference();
mediaQuery.addEventListener("change", syncPreference);
return () => mediaQuery.removeEventListener("change", syncPreference);
}, []);
React.useEffect(() => {
import("asciinema-player").then((p) => {
setPlayer(p);
@@ -35,7 +94,17 @@ function AsciinemaPlayer({ src, ...asciinemaOptions }: AsciinemaPlayerProps) {
};
}, [src, player, asciinemaOptions]);
return <div ref={ref} />;
return (
<div
ref={ref}
className={[
className,
hideControls ? "sliver-asciinema-controls-hidden" : undefined,
]
.filter(Boolean)
.join(" ")}
/>
);
}
export default AsciinemaPlayer;
+2 -5
View File
@@ -60,11 +60,8 @@ const CodeViewer = (props: CodeViewerProps) => {
props.script.source_code
);
const [fontSize, setFontSize] = React.useState(props.fontSize || 13);
const editorContainerClassName = React.useMemo(() => {
return theme === Themes.DARK
? "col-span-12 mt-4 rounded-2xl overflow-hidden"
: "col-span-12 mt-4 rounded-2xl overflow-hidden border border-gray-300";
}, [theme]);
const editorContainerClassName =
"col-span-12 mt-4 overflow-hidden rounded-2xl border border-separator bg-surface-secondary";
return (
<div className={editorContainerClassName}>
@@ -3,8 +3,9 @@ import React from "react";
const LoadingState: React.FC = () => {
return (
<div className="flex min-h-screen w-full items-center justify-center">
<Spinner label="Loading ..." labelColor="foreground" />
<div className="flex min-h-[55vh] w-full items-center justify-center gap-3 text-muted">
<Spinner aria-label="Loading" />
<span className="text-sm">Loading documentation…</span>
</div>
);
};
+53 -43
View File
@@ -27,6 +27,7 @@ import Youtube from "./youtube";
export type MarkdownProps = {
markdown: string;
demoteTopLevelHeading?: boolean;
};
type MarkdownAsciiCast = {
@@ -121,12 +122,12 @@ const slugify = (value: string) => {
};
const headingClassNames: Record<HeadingLevel, string> = {
1: "mt-12 text-3xl font-semibold tracking-tight text-slate-900 first:mt-0 dark:text-slate-100",
2: "mt-10 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-100",
3: "mt-8 text-xl font-semibold tracking-tight text-slate-900 dark:text-slate-100",
4: "mt-6 text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100",
5: "mt-6 text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100",
6: "mt-6 text-base font-semibold uppercase tracking-wide text-slate-700 dark:text-slate-300",
1: "mt-12 text-3xl font-semibold tracking-tight text-foreground first:mt-0",
2: "mt-12 text-2xl font-semibold tracking-tight text-foreground",
3: "mt-9 text-xl font-semibold tracking-tight text-foreground",
4: "mt-8 text-lg font-semibold tracking-tight text-foreground",
5: "mt-7 text-base font-semibold tracking-tight text-foreground",
6: "mt-7 text-base font-semibold tracking-tight text-foreground",
};
const MarkdownViewer = (props: MarkdownProps) => {
@@ -218,7 +219,9 @@ const MarkdownViewer = (props: MarkdownProps) => {
}
const anchor = anchorRef.current;
const HeadingTag = `h${level}`;
const renderedLevel =
level === 1 && props.demoteTopLevelHeading ? 2 : level;
const HeadingTag = `h${renderedLevel}`;
return createElement(
HeadingTag,
@@ -226,7 +229,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
...rest,
id: anchor || undefined,
className: mergeClassNames(
headingClassNames[level],
headingClassNames[renderedLevel],
"scroll-mt-[70px]",
className
),
@@ -236,8 +239,8 @@ const MarkdownViewer = (props: MarkdownProps) => {
{anchor && (
<a
href={`#${anchor}`}
aria-label="Copy link to section"
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-slate-400 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 hover:text-primary dark:text-slate-500"
aria-label={`Link to ${textContent}`}
className="-my-2 inline-flex size-10 items-center justify-center rounded-xl text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-secondary hover:text-accent"
>
<svg
aria-hidden="true"
@@ -283,12 +286,12 @@ const MarkdownViewer = (props: MarkdownProps) => {
h5: createHeadingComponent(5),
h6: createHeadingComponent(6),
};
}, [getAnchor]);
}, [getAnchor, props.demoteTopLevelHeading]);
const proseClassName = mergeClassNames(
"markdown-body prose max-w-none leading-7",
theme === Themes.DARK ? "dark:prose-invert prose-slate" : "prose-slate",
"prose-pre:bg-slate-950/90 prose-pre:text-slate-100 prose-code:font-mono prose-img:rounded-xl"
"prose-code:font-mono prose-img:rounded-2xl"
);
return (
@@ -307,7 +310,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
{...rest}
href={href}
className={mergeClassNames(
"font-medium text-primary transition-colors duration-150 hover:text-primary-dark",
"font-medium text-accent underline-offset-4 hover:underline",
className
)}
onClick={(e) => {
@@ -342,7 +345,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
}
const anchorClassName = mergeClassNames(
"font-medium text-primary transition-colors duration-150 underline-offset-4 hover:text-primary-dark",
"font-medium text-accent underline-offset-4 hover:underline",
className
);
@@ -376,7 +379,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<p
{...rest}
className={mergeClassNames(
"my-6 text-base leading-7 text-slate-700 dark:text-slate-300",
"my-6 text-base leading-7 text-foreground/80",
className
)}
>
@@ -391,7 +394,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<ul
{...rest}
className={mergeClassNames(
"my-6 list-disc space-y-2 pl-6 text-slate-700 marker:text-primary dark:text-slate-300",
"my-6 list-disc space-y-2 pl-6 text-foreground/80 marker:text-accent",
className
)}
>
@@ -406,7 +409,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<ol
{...rest}
className={mergeClassNames(
"my-6 list-decimal space-y-2 pl-6 text-slate-700 marker:text-primary dark:text-slate-300",
"my-6 list-decimal space-y-2 pl-6 text-foreground/80 marker:text-accent",
className
)}
>
@@ -421,7 +424,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<li
{...rest}
className={mergeClassNames(
"leading-6 text-slate-700 dark:text-slate-300",
"leading-6 text-foreground/80",
className
)}
>
@@ -436,7 +439,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<blockquote
{...rest}
className={mergeClassNames(
"my-8 border-l-4 border-slate-200 bg-slate-50 px-6 py-4 text-base italic text-slate-700 shadow-sm dark:border-slate-700 dark:bg-slate-900/50 dark:text-slate-300",
"my-8 rounded-r-2xl border-l-4 border-accent/40 bg-surface-secondary px-6 py-4 text-base italic text-foreground/80",
className
)}
>
@@ -451,7 +454,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<hr
{...rest}
className={mergeClassNames(
"my-12 border-t border-slate-200 dark:border-slate-800",
"my-12 border-t border-separator",
className
)}
/>
@@ -461,11 +464,11 @@ const MarkdownViewer = (props: MarkdownProps) => {
table(tableProps) {
const { className, children, ...rest } = tableProps;
return (
<div className="my-8 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950/40">
<div className="sliver-scrollbar my-8 overflow-x-auto rounded-2xl border border-separator bg-surface">
<table
{...rest}
className={mergeClassNames(
"w-full min-w-max divide-y divide-slate-200 text-left text-sm dark:divide-slate-800",
"w-full min-w-max divide-y divide-separator text-left text-sm",
className
)}
>
@@ -481,7 +484,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<thead
{...rest}
className={mergeClassNames(
"bg-slate-50 text-sm font-semibold uppercase tracking-wide text-slate-600 dark:bg-slate-900/60 dark:text-slate-300",
"bg-surface-secondary text-sm font-semibold text-muted",
className
)}
>
@@ -496,7 +499,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<tbody
{...rest}
className={mergeClassNames(
"divide-y divide-slate-200 dark:divide-slate-800",
"divide-y divide-separator",
className
)}
>
@@ -511,7 +514,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<tr
{...rest}
className={mergeClassNames(
"transition-colors hover:bg-slate-50 dark:hover:bg-slate-900/60",
"bg-transparent",
className
)}
>
@@ -526,7 +529,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<th
{...rest}
className={mergeClassNames(
"px-4 py-3 text-left text-sm font-semibold text-slate-600 dark:text-slate-200",
"px-4 py-3 text-left text-sm font-semibold text-foreground",
className
)}
>
@@ -541,7 +544,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<td
{...rest}
className={mergeClassNames(
"px-4 py-3 align-top text-sm text-slate-700 dark:text-slate-300",
"px-4 py-3 align-top text-sm text-foreground/80",
className
)}
>
@@ -556,7 +559,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<strong
{...rest}
className={mergeClassNames(
"font-semibold text-slate-900 dark:text-slate-100",
"font-semibold text-foreground",
className
)}
>
@@ -571,7 +574,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<em
{...rest}
className={mergeClassNames(
"text-slate-700 dark:text-slate-300",
"text-foreground/80",
className
)}
>
@@ -605,7 +608,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<pre
{...rest}
className={mergeClassNames(
"my-6 overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-[13px] leading-6 text-slate-900 shadow-inner dark:border-slate-800 dark:bg-slate-950/90 dark:text-slate-100",
"sliver-scrollbar my-6 overflow-x-auto rounded-2xl border border-separator bg-surface-secondary p-4 text-[13px] leading-6 text-foreground",
className
)}
>
@@ -625,7 +628,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
width={1200}
height={720}
className={mergeClassNames(
"my-8 w-full rounded-xl border border-slate-200 object-contain shadow-sm dark:border-slate-800",
"my-8 w-full rounded-2xl border border-separator/70 object-contain",
className
)}
/>
@@ -655,7 +658,11 @@ const MarkdownViewer = (props: MarkdownProps) => {
if (normalizedLang === "youtube") {
const embedId = sourceCode || "";
return <Youtube embedId={embedId.trim()} />;
return (
<div className="not-prose my-8 overflow-hidden rounded-2xl">
<Youtube embedId={embedId.trim()} />
</div>
);
}
if (normalizedLang === "asciinema") {
@@ -668,15 +675,18 @@ const MarkdownViewer = (props: MarkdownProps) => {
return <></>;
}
return (
<AsciinemaPlayer
src={srcUrl.toString()}
rows={asciiCast.rows || "18"}
cols={asciiCast.cols || "75"}
idleTimeLimit={asciiCast.idleTimeLimit || 2}
preload={true}
autoPlay={true}
loop={true}
/>
<div className="sliver-terminal-frame not-prose my-8 w-full max-w-full overflow-x-auto rounded-2xl bg-[#111315]">
<AsciinemaPlayer
className="min-w-[42rem]"
src={srcUrl.toString()}
rows={asciiCast.rows || "18"}
cols={asciiCast.cols || "75"}
idleTimeLimit={asciiCast.idleTimeLimit || 2}
preload={true}
autoPlay={true}
loop={true}
/>
</div>
);
}
@@ -694,7 +704,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
<code
{...rest}
className={mergeClassNames(
"rounded-md bg-slate-100 px-1.5 py-0.5 font-mono text-[13px] text-slate-700 dark:bg-slate-800/80 dark:text-slate-200",
"rounded-md bg-surface-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground",
className
)}
>
@@ -752,7 +762,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
const formattedSourceCode = sourceCode.replace(/\n$/, "");
const preWrapperClassName = mergeClassNames(
"not-prose mt-4 overflow-x-auto rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-[13px] leading-6 text-slate-900 shadow-sm dark:border-slate-800 dark:bg-slate-950/90 dark:text-slate-100",
"sliver-scrollbar not-prose mt-4 overflow-x-auto rounded-2xl border border-separator bg-surface-secondary px-4 py-4 text-[13px] leading-6 text-foreground",
className
);
+1 -1
View File
@@ -115,7 +115,7 @@ const Mermaid = ({ diagram, minHeight }: MermaidProps) => {
if (error) {
return (
<div className="not-prose my-6 text-sm text-rose-700 dark:text-rose-300">
<div className="not-prose my-6 rounded-2xl bg-danger-soft p-4 text-sm text-danger-soft-foreground">
Failed to render Mermaid diagram: {error}
</div>
);
+262 -255
View File
@@ -1,298 +1,305 @@
"use client";
import { SliversIcon } from "@/components/icons/slivers";
import { useSearchContext } from "@/util/search-context";
import { Themes } from "@/util/themes";
import { faDiscord, faGithub } from "@fortawesome/free-brands-svg-icons";
import {
faBars,
faHome,
faMoon,
faSearch,
faSun,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
Input,
Link,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
Tooltip,
} from "@heroui/react";
import { Button, Drawer, SearchField, Tooltip } from "@heroui/react";
import { useTheme } from "next-themes";
import NextLink from "next/link";
import { useRouter } from "next/router";
import React from "react";
export type TopNavbarProps = {};
const routes = [
{ href: "/docs", label: "Docs" },
{ href: "/tutorials", label: "Tutorials" },
{ href: "/talks", label: "Talks" },
];
export default function TopNavbar(props: TopNavbarProps) {
const discordUrl =
"https://discord.com/channels/791066041198968873/1339996286514106409";
const githubUrl = "https://github.com/BishopFox/sliver";
export default function TopNavbar() {
const router = useRouter();
const search = useSearchContext();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const lightDarkModeIcon = React.useMemo(() => {
if (!mounted) {
return faMoon;
}
return theme === Themes.DARK ? faSun : faMoon;
}, [mounted, theme]);
const [query, setQuery] = React.useState("");
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
const activeTheme = theme || Themes.DARK;
const isDarkTheme = activeTheme === Themes.DARK;
const lightDarkModeIcon = isDarkTheme ? faSun : faMoon;
const themeToggleLabel = isDarkTheme
? "Switch to light mode"
: "Switch to dark mode";
const toggleTheme = React.useCallback(() => {
setTheme(isDarkTheme ? Themes.LIGHT : Themes.DARK);
}, [isDarkTheme, setTheme]);
React.useEffect(() => {
setIsMobileMenuOpen(false);
}, [router.pathname]);
const desktopBreakpoint = window.matchMedia("(min-width: 768px)");
const closeMobileMenu = (event: MediaQueryListEvent) => {
if (event.matches) {
setIsMobileMenuOpen(false);
}
};
const handleSearchSubmit = React.useCallback(() => {
if (query.trim().length === 0) {
return;
}
router.push({ pathname: "/search/", query: { search: query } });
setQuery("");
}, [query, router]);
desktopBreakpoint.addEventListener("change", closeMobileMenu);
return () =>
desktopBreakpoint.removeEventListener("change", closeMobileMenu);
}, []);
const renderSearchInput = (wrapperClassName?: string) => (
<div className={wrapperClassName}>
<Tooltip content="Press enter to search" isOpen={query.length > 0}>
<Input
size="sm"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onClear={() => setQuery("")}
startContent={<FontAwesomeIcon icon={faSearch} />}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearchSubmit();
}
}}
/>
</Tooltip>
</div>
const handleSearchSubmit = React.useCallback(
(value = query) => {
const searchQuery = value.trim();
if (searchQuery.length === 0) {
return;
}
void router.push({
pathname: "/search/",
query: { search: searchQuery },
});
setQuery("");
setIsMobileMenuOpen(false);
},
[query, router],
);
const renderTooltip = React.useCallback(
(content: string, children: React.ReactNode) => (
<Tooltip delay={0}>
{children}
<Tooltip.Content>{content}</Tooltip.Content>
</Tooltip>
),
[],
);
const renderSearchInput = (className?: string) => (
<SearchField
aria-label="Search documentation"
className={className}
fullWidth
value={query}
variant="secondary"
onChange={setQuery}
onSubmit={handleSearchSubmit}
>
<SearchField.Group>
<SearchField.SearchIcon>
<FontAwesomeIcon icon={faSearch} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="Search documentation…" />
{query.length > 0 ? (
<SearchField.ClearButton aria-label="Clear search" />
) : null}
</SearchField.Group>
</SearchField>
);
const renderDesktopRoute = (href: string, label: string) => {
const isActive = router.pathname.startsWith(href);
return (
<li key={href}>
<NextLink
href={href}
aria-current={isActive ? "page" : undefined}
className={`inline-flex h-10 items-center justify-center rounded-xl px-4 text-sm font-medium no-underline outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
isActive
? "bg-surface-secondary text-foreground shadow-surface"
: "text-muted hover:bg-surface-secondary hover:text-foreground"
}`}
>
{label}
</NextLink>
</li>
);
};
const renderMobileRoute = (href: string, label: string) => {
const isActive =
href === "/"
? router.pathname === "/"
: router.pathname.startsWith(href);
return (
<NextLink
key={href}
href={href}
aria-current={isActive ? "page" : undefined}
className={`inline-flex min-h-10 w-full items-center rounded-xl px-3 py-2 text-sm font-medium no-underline outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
isActive
? "bg-surface-secondary text-foreground shadow-surface"
: "text-muted hover:bg-surface-secondary hover:text-foreground"
}`}
onClick={() => setIsMobileMenuOpen(false)}
>
{label}
</NextLink>
);
};
return (
<>
<Navbar
isBordered
maxWidth="full"
classNames={{
item: [
"flex",
"relative",
"h-full",
"items-center",
"data-[active=true]:after:content-['']",
"data-[active=true]:after:absolute",
"data-[active=true]:after:bottom-0",
"data-[active=true]:after:left-0",
"data-[active=true]:after:right-0",
"data-[active=true]:after:h-[2px]",
"data-[active=true]:after:rounded-[2px]",
"data-[active=true]:after:bg-primary",
],
}}
>
<NavbarBrand>
<SliversIcon />
<span className="hidden sm:block font-bold text-inherit">
&nbsp; Sliver C2
</span>
</NavbarBrand>
<NavbarContent className="hidden md:flex">
<NavbarItem isActive={router.pathname === "/"}>
<Button
variant="light"
color={router.pathname === "/" ? "primary" : "default"}
as={Link}
onPress={() => router.push("/")}
<nav
aria-label="Primary navigation"
className="sticky top-0 z-40 h-16 border-b border-separator bg-background/85 backdrop-blur-xl"
>
<div className="mx-auto flex h-16 w-full max-w-[1440px] items-center gap-3 px-4 sm:px-6 lg:px-8">
<NextLink
href="/"
aria-label="Sliver documentation home"
className="-ml-2 inline-flex h-10 shrink-0 items-center gap-2 rounded-xl px-2 text-sm font-semibold text-foreground no-underline outline-none hover:bg-surface-secondary focus-visible:ring-2 focus-visible:ring-accent/40"
>
<FontAwesomeIcon fixedWidth icon={faHome} />
</Button>
</NavbarItem>
<span className="flex size-8 items-center justify-center rounded-xl bg-accent text-accent-foreground">
<SliversIcon height={18} />
</span>
<span className="hidden items-baseline gap-1.5 sm:flex">
<span>Sliver C2</span>
</span>
</NextLink>
<NavbarItem isActive={router.pathname.startsWith("/tutorials")}>
<Button
variant="light"
color={router.pathname === "/tutorials" ? "primary" : "default"}
as={Link}
onPress={() => router.push("/tutorials")}
>
Tutorials
</Button>
</NavbarItem>
<ul className="hidden items-center gap-1 md:flex">
{routes.map((route) =>
renderDesktopRoute(route.href, route.label),
)}
</ul>
<NavbarItem isActive={router.pathname.startsWith("/talks")}>
<Button
variant="light"
color={router.pathname === "/talks" ? "primary" : "default"}
as={Link}
onPress={() => router.push("/talks")}
>
Talks
</Button>
</NavbarItem>
<div className="ml-auto hidden items-center gap-1 md:flex">
{renderSearchInput("mr-2 w-56 xl:w-72")}
<NavbarItem isActive={router.pathname.startsWith("/docs")}>
<Button
variant="light"
color={router.pathname === "/docs" ? "primary" : "default"}
as={Link}
onPress={() => router.push("/docs")}
>
Docs
</Button>
</NavbarItem>
</NavbarContent>
{renderTooltip(
themeToggleLabel,
<Button
isIconOnly
aria-label={themeToggleLabel}
variant="ghost"
onPress={toggleTheme}
>
<FontAwesomeIcon icon={lightDarkModeIcon} />
</Button>,
)}
<NavbarContent as="div" justify="end" className="hidden md:flex">
{renderSearchInput("w-64")}
<Tooltip
content={theme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode"}
>
<Button
variant="ghost"
onPress={() => {
setTheme(theme === Themes.DARK ? Themes.LIGHT : Themes.DARK);
}}
isDisabled={!mounted}
>
<FontAwesomeIcon icon={lightDarkModeIcon} />
</Button>
</Tooltip>
{renderTooltip(
"Join Discord",
<a
href={discordUrl}
target="_blank"
rel="noreferrer"
aria-label="Join Discord"
className="inline-flex size-10 items-center justify-center rounded-xl text-muted no-underline outline-none hover:bg-surface-secondary hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent/40"
>
<FontAwesomeIcon icon={faDiscord} />
</a>,
)}
<Tooltip content="Join Discord">
<Button
variant="ghost"
onPress={() => {
window.open(
"https://discord.com/channels/791066041198968873/1339996286514106409",
"_blank",
);
}}
>
<FontAwesomeIcon icon={faDiscord} />
</Button>
</Tooltip>
{renderTooltip(
"View on GitHub",
<a
href={githubUrl}
target="_blank"
rel="noreferrer"
aria-label="View on GitHub"
className="inline-flex size-10 items-center justify-center rounded-xl text-muted no-underline outline-none hover:bg-surface-secondary hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent/40"
>
<FontAwesomeIcon icon={faGithub} />
</a>,
)}
</div>
<Tooltip content="View on GitHub">
<Button
variant="ghost"
onPress={() => {
window.open("https://github.com/BishopFox/sliver", "_blank");
}}
>
<FontAwesomeIcon icon={faGithub} />
</Button>
</Tooltip>
</NavbarContent>
<div className="ml-auto flex items-center gap-1 md:hidden">
{renderTooltip(
themeToggleLabel,
<Button
isIconOnly
aria-label={themeToggleLabel}
variant="ghost"
onPress={toggleTheme}
>
<FontAwesomeIcon icon={lightDarkModeIcon} />
</Button>,
)}
<div className="flex items-center gap-2 md:hidden">
<Tooltip
content={theme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode"}
>
<Button
variant="ghost"
onPress={() => {
setTheme(theme === Themes.DARK ? Themes.LIGHT : Themes.DARK);
}}
isDisabled={!mounted}
>
<FontAwesomeIcon icon={lightDarkModeIcon} />
</Button>
</Tooltip>
<Tooltip content={isMobileMenuOpen ? "Close menu" : "Open menu"}>
<Button
variant="ghost"
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
onPress={() => setIsMobileMenuOpen((current) => !current)}
>
<FontAwesomeIcon icon={isMobileMenuOpen ? faXmark : faBars} />
</Button>
</Tooltip>
</div>
</Navbar>
{isMobileMenuOpen ? (
<div className="md:hidden border-b border-default-200 dark:border-default-100/40 bg-content1 px-4 pb-4 shadow-sm">
<div className="mt-3">{renderSearchInput("w-full")}</div>
<div className="mt-4 flex flex-col gap-2">
<Button
variant={router.pathname === "/" ? "solid" : "light"}
color={router.pathname === "/" ? "primary" : "default"}
onPress={() => {
router.push("/");
setIsMobileMenuOpen(false);
}}
startContent={<FontAwesomeIcon fixedWidth icon={faHome} />}
>
Home
</Button>
<Button
variant={router.pathname.startsWith("/tutorials") ? "solid" : "light"}
color={router.pathname.startsWith("/tutorials") ? "primary" : "default"}
onPress={() => {
router.push("/tutorials");
setIsMobileMenuOpen(false);
}}
>
Tutorials
</Button>
<Button
variant={router.pathname.startsWith("/talks") ? "solid" : "light"}
color={router.pathname.startsWith("/talks") ? "primary" : "default"}
onPress={() => {
router.push("/talks");
setIsMobileMenuOpen(false);
}}
>
Talks
</Button>
<Button
variant={router.pathname.startsWith("/docs") ? "solid" : "light"}
color={router.pathname.startsWith("/docs") ? "primary" : "default"}
onPress={() => {
router.push("/docs");
setIsMobileMenuOpen(false);
}}
>
Docs
</Button>
<Button
variant="light"
onPress={() => {
window.open(
"https://discord.com/channels/791066041198968873/1339996286514106409",
"_blank",
);
}}
startContent={<FontAwesomeIcon icon={faDiscord} />}
>
Discord
</Button>
<Button
variant="light"
onPress={() => {
window.open("https://github.com/BishopFox/sliver", "_blank");
}}
startContent={<FontAwesomeIcon icon={faGithub} />}
>
GitHub
</Button>
{renderTooltip(
isMobileMenuOpen ? "Close menu" : "Open menu",
<Button
isIconOnly
aria-controls="mobile-navigation"
aria-expanded={isMobileMenuOpen}
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
variant="ghost"
onPress={() =>
setIsMobileMenuOpen((current) => !current)
}
>
<FontAwesomeIcon icon={isMobileMenuOpen ? faXmark : faBars} />
</Button>,
)}
</div>
</div>
) : null}
</nav>
<Drawer.Backdrop
isOpen={isMobileMenuOpen}
variant="blur"
onOpenChange={setIsMobileMenuOpen}
>
<Drawer.Content placement="right">
<Drawer.Dialog
id="mobile-navigation"
className="w-[min(24rem,calc(100vw-1.5rem))]"
>
<Drawer.CloseTrigger />
<Drawer.Header>
<Drawer.Heading>Navigate Sliver Docs</Drawer.Heading>
</Drawer.Header>
<Drawer.Body className="flex flex-col gap-5 pt-2">
{renderSearchInput("w-full")}
<nav
aria-label="Mobile navigation"
className="flex flex-col gap-1"
>
{renderMobileRoute("/", "Home")}
{routes.map((route) =>
renderMobileRoute(route.href, route.label),
)}
</nav>
</Drawer.Body>
<Drawer.Footer className="grid grid-cols-2 gap-2">
<a
href={discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-separator px-4 text-sm font-medium text-foreground no-underline outline-none hover:bg-surface-secondary focus-visible:ring-2 focus-visible:ring-accent/40"
>
<FontAwesomeIcon icon={faDiscord} />
Discord
</a>
<a
href={githubUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-separator px-4 text-sm font-medium text-foreground no-underline outline-none hover:bg-surface-secondary focus-visible:ring-2 focus-visible:ring-accent/40"
>
<FontAwesomeIcon icon={faGithub} />
GitHub
</a>
</Drawer.Footer>
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</>
);
}
+35 -40
View File
@@ -1,9 +1,8 @@
import { Button, Card, CardFooter, CardHeader } from "@heroui/react";
import { Button, Card } from "@heroui/react";
import AsciinemaPlayer from "./asciinema";
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import styles from "./TutorialCard.module.css";
export type TutorialCardCardProps = {
name: string;
@@ -13,6 +12,7 @@ export type TutorialCardCardProps = {
rows?: string;
cols?: string;
idleTimeLimit?: number;
hideControls?: boolean;
italicDescription?: boolean;
onPress?: () => void;
@@ -22,53 +22,48 @@ export type TutorialCardCardProps = {
export default function TutorialCard(props: TutorialCardCardProps) {
return (
<Card isFooterBlurred className="relative z-0">
<CardHeader className="absolute z-10 top-1 flex-col items-end">
<p className="text-md text-white/70 uppercase font-bold">
{props.name}
</p>
</CardHeader>
<div className={styles["hide-control-bar"]}>
<Card className="relative h-full min-w-0 gap-0 overflow-hidden bg-black p-0 shadow-surface">
<div className="sliver-terminal-frame relative w-full max-w-full overflow-x-auto bg-[#111315]">
<AsciinemaPlayer
className="min-w-[36rem]"
src={props.asciiCast}
rows={props.rows ? props.rows : "18"}
cols={props.cols ? props.cols : "75"}
idleTimeLimit={props.idleTimeLimit ? props.idleTimeLimit : 2}
hideControls={props.hideControls}
rows={props.rows || "18"}
cols={props.cols || "75"}
idleTimeLimit={props.idleTimeLimit || 2}
preload={true}
autoPlay={true}
loop={true}
/>
<div className="pointer-events-none absolute inset-x-3 top-3 z-10 flex justify-end">
<h3 className="max-w-[80%] rounded-xl border border-white/15 bg-black/55 px-3 py-2 text-right text-sm font-semibold text-white/85 shadow-lg backdrop-blur-xl">
{props.name}
</h3>
</div>
</div>
<CardFooter className="absolute bottom-0 z-10 bg-black/40 border-t-1 border-default-600 dark:border-default-100">
<div className="flex flex-row w-full items-center">
<p
className={
props.italicDescription
? "text-xs text-white/80 italic"
: "text-xs text-white/80"
}
>
{props.description}
</p>
<Card.Footer className="absolute inset-x-3 bottom-10 z-10 flex-row items-center gap-3 rounded-2xl border border-white/15 bg-black/55 px-3 py-2.5 text-white shadow-xl backdrop-blur-xl">
<p
className={`min-w-0 flex-1 text-sm leading-5 text-white/70 ${
props.italicDescription ? "italic" : ""
}`}
>
{props.description}
</p>
{props.showButton ? (
<Button
variant="ghost"
color="warning"
size="sm"
onPress={props.onPress}
className="ml-auto"
>
{props.buttonText ? props.buttonText : "Read Tutorial"}{" "}
<FontAwesomeIcon icon={faChevronRight} />
</Button>
) : (
<></>
)}
</div>
</CardFooter>
{props.showButton ? (
<Button
className="shrink-0 text-white hover:bg-white/10"
size="sm"
variant="ghost"
onPress={props.onPress}
>
{props.buttonText || "Read tutorial"}
<FontAwesomeIcon icon={faChevronRight} />
</Button>
) : null}
</Card.Footer>
</Card>
);
}
+1
View File
@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
trailingSlash: true,
reactStrictMode: true,
images: {
unoptimized: true,
Loaded 100 of 2358 files, more files were not shown because too many files have changed in this diff. Show more