Compare commits

..
127 Commits
Author SHA1 Message Date
moloch-- d95da61556 Improve go linker flag handling 2026-09-04 06:57:44 -07:00
moloch-- 5d5661947c Update Garble v1.27.1 checksums 2026-09-03 17:13:50 -07:00
moloch-- aad8ca5d4e Merge branch 'master' into obf-cfo
# Conflicts:
#	server/db/models/beacon.go
#	server/db/models/implant.go
#	server/gogo/go.go
#	server/handlers/beacons.go
2026-09-03 16:53:58 -07:00
moloch-- 84bb2397c7 Merge pull request #2357 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/browserslist-4.28.8 2026-09-03 16:26:41 -07:00
moloch-- 503d040885 Merge pull request #2355 from BishopFox/dependabot/go_modules/github.com/alecthomas/chroma/v2-2.27.0 2026-09-03 16:26:37 -07:00
moloch-- 801452eeff Merge pull request #2341 from BishopFox/dependabot/go_modules/google.golang.org/protobuf-1.36.12 2026-09-03 16:26:03 -07:00
moloch-- f6c7d73014 Merge pull request #2343 from BishopFox/dependabot/go_modules/golang.org/x/crypto-0.55.0 2026-09-03 16:25:12 -07:00
moloch-- 228757f656 Merge pull request #2358 from BishopFox/dependabot/go_modules/google.golang.org/grpc-1.83.1 2026-09-03 16:24:09 -07:00
dependabot[bot] 738010e990 build(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.1
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.1 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-03 22:59:35 +00:00
dependabot[bot] 9dcf9b2d25 build(deps): bump golang.org/x/crypto from 0.53.0 to 0.55.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.53.0 to 0.55.0.
- [Commits](https://github.com/golang/crypto/compare/v0.53.0...v0.55.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-03 22:59:31 +00:00
dependabot[bot] 32ab020dbd build(deps): bump google.golang.org/protobuf from 1.36.11 to 1.36.12
Bumps google.golang.org/protobuf from 1.36.11 to 1.36.12.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-version: 1.36.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-03 22:59:17 +00:00
Joe 6fcaf970b1 Merge pull request #2346 from BishopFox/feat/go-1.27-server-client-build
Go v1.27
2026-09-03 15:56:40 -07:00
moloch-- 47c83bbaa0 Adjust width of session/beacons cmd output depending on terminal width 2026-09-03 15:56:05 -07:00
moloch-- 7d76febbc9 test: synchronize reverse port forward cleanup 2026-09-03 14:04:02 -07:00
moloch-- f4c182427f refactor: adopt Go 1.27 implant APIs 2026-09-03 13:41:11 -07:00
moloch-- 836e33ade7 Merge remote-tracking branch 'origin/master' into feat/go-1.27-server-client-build
# Conflicts:
#	test/e2e/Dockerfile.linux-386
#	test/reflektor/Dockerfile.linux-386
2026-09-03 11:03:51 -07:00
Joe 0aa7e5bf96 Merge pull request #2360 from BishopFox/feat/remove-386-e2e
ci: remove 386 targets from E2E matrices
2026-09-03 08:58:13 -07:00
moloch-- b46df4b05e ci: remove 386 targets from E2E matrices 2026-09-03 07:22:30 -07:00
Joe cbc9200649 Merge pull request #2359 from BishopFox/feat/portfwd-socks5-e2e
feat: harden portfwd and SOCKS5 tunnels
2026-09-02 22:09:53 -07:00
moloch-- a0bf009c06 refactor tunnel lifecycle bookkeeping 2026-09-02 19:00:28 -07:00
moloch-- 46f516fa3a feat: harden portfwd and SOCKS5 tunnels
Add negotiated bounded flow control and exact-generation tunnel lifecycle handling.

Preserve legacy implant compatibility, serialize HTTP tunnel responses, and add manual local/Proxmox E2E plus fuzz coverage for real traffic and RDP.
2026-09-02 15:09:29 -07:00
moloch-- 1f82c38e11 Fix Go 1.27 Linux shellcode generation 2026-09-02 11:51:54 -07:00
moloch-- 6ac84ffc11 Merge remote-tracking branch 'origin/master' into feat/go-1.27-server-client-build
# Conflicts:
#	client/command/shell/ioloop.go
#	server/handlers/pivot.go
#	server/rpc/rpc-extensions_test.go
2026-09-02 10:20:06 -07:00
moloch-- 2638b5f384 Update Go and Garble to 1.27.1 2026-09-02 09:54:14 -07:00
dependabot[bot] cf19741b9d chore(deps-dev): bump browserslist in /docs/sliver-docs
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.2 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-01 23:23:11 +00:00
Joe b2c0979b63 Merge pull request #2356 from BishopFox/feat/rportfwd-authorization
Rework reverse port forwarding lifecycle and E2E coverage
2026-09-01 16:21:51 -07:00
moloch-- ea5e1412b2 fix: pass cross-platform lint checks 2026-09-01 13:19:51 -07:00
moloch-- bd948b8ab1 test: preserve pinned OPFOR corpus bytes 2026-09-01 11:01:04 -07:00
moloch-- 937a74d597 test: make e2e process cleanup idempotent 2026-09-01 09:20:24 -07:00
moloch-- e747187dda ci: fix reverse port forward workflow context 2026-09-01 09:05:15 -07:00
moloch-- ec992dea76 fix: complete reverse port forward hardening 2026-09-01 09:03:30 -07:00
moloch-- 544c872881 test: integrate reverse port forwarding into e2e 2026-08-31 12:40:48 -07:00
moloch-- 4701b895b7 fix: harden reverse port forward authorization 2026-08-31 12:22:36 -07:00
Joe 18c56ec3c5 Merge pull request #2353 from BishopFox/fix/shell-win
Improvements to windows/shell handling
2026-08-31 09:57:48 -07:00
moloch-- 4b12c80534 fix(shell): repair Windows input cancellation and CI 2026-08-31 08:04:48 -07:00
dependabot[bot] a8c6d5e0a3 chore(deps): bump github.com/alecthomas/chroma/v2 from 2.23.1 to 2.27.0
Bumps [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) from 2.23.1 to 2.27.0.
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.23.1...v2.27.0)

---
updated-dependencies:
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-version: 2.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-31 03:03:42 +00: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
moloch-- f447a170c6 Close UUID test database on Windows 2026-08-26 14:00:49 -07:00
moloch-- d565592773 Fix Go 1.27 CI validation 2026-08-26 13:37:58 -07:00
moloch-- c21da4dceb Modernize host code for Go 1.27 2026-08-26 13:13:50 -07:00
moloch-- b08e2ff5c0 Merge branch 'master' into feat/go-1.27-server-client-build 2026-08-26 12:32:17 -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
Ronan Kervella 55b0f2bce3 Merge branch 'master' into obf-cfo 2026-08-25 10:14:11 -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-- c481a4f5db Tweaks for go v1.27 2026-08-24 17:31:01 -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
moloch-- 73a567550d Fix control-flow CI validation
Handle newly introduced lint findings, make Windows default builds produce executable names, and verify pinned Garble extraction before the native control-flow probe.
2026-08-18 13:13:35 -07:00
moloch-- 9be2cd8b34 Add versioned Garble control-flow obfuscation
Carry the balanced-v1 policy through clients, profiles, builders, and persisted build snapshots. Verify pinned Garble assets, fail closed on unsupported runtimes, and cover local/external build lifecycle and native control-flow behavior.
2026-08-18 09:39:51 -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
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
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
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
1661 changed files with 359063 additions and 29307 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Preserve pinned upstream source and attribution files byte-for-byte.
client/command/opfor/testdata/corpus/** -text
client/command/opfor/testdata/corpus/operatorskit/*.cna whitespace=-trailing-space,-blank-at-eof
vendor/github.com/sliverarmory/opfor/internal/regexp2/ATTRIB whitespace=-trailing-space
+12 -4
View File
@@ -39,10 +39,18 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
release:
name: Release
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
- e2e
if: needs.tag-check.outputs.tag != '' && needs.e2e.result == 'success'
runs-on: ubuntu-latest
environment: release
timeout-minutes: 45
@@ -52,10 +60,10 @@ jobs:
with:
fetch-depth: 0
- name: Go 1.26.6
- name: Go 1.27.1
uses: actions/setup-go@v6
with:
go-version: "1.26.6"
go-version: "1.27.1"
- 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.26.6
- name: Go 1.27.1
uses: actions/setup-go@v6
with:
go-version: "1.26.6"
go-version: "1.27.1"
- name: Checkout repository
uses: actions/checkout@v6
+551
View File
@@ -0,0 +1,551 @@
name: Comprehensive e2e Tests
on:
workflow_dispatch:
inputs:
test_scope:
description: Test scope to run across the complete platform matrix
required: true
default: comprehensive
type: choice
options:
- comprehensive
- rportfwd
workflow_call:
inputs:
test_scope:
description: Test scope to run across the complete platform matrix
required: false
default: comprehensive
type: string
permissions:
contents: read
env:
SLIVER_E2E_SCOPE: ${{ inputs.test_scope || 'comprehensive' }}
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 }}
REQUESTED_SCOPE: ${{ inputs.test_scope || 'comprehensive' }}
with:
script: |
const testScope = process.env.REQUESTED_SCOPE || "comprehensive";
if (!new Set(["comprehensive", "rportfwd"]).has(testScope)) {
core.setFailed(`Unsupported E2E test scope: ${JSON.stringify(testScope)}`);
return;
}
core.info(`Validated E2E test scope ${testScope}`);
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");
}
rportfwd-security-regression:
name: Reverse-port-forward malicious protocol regression
needs: authorize
runs-on: ubuntu-24.04
timeout-minutes: 30
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 Sliver build assets
env:
SLIVER_ROOT_DIR: ${{ runner.temp }}/sliver-rportfwd-security
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Test malicious destination poisoning and cross-session isolation
env:
SLIVER_ROOT_DIR: ${{ runner.temp }}/sliver-rportfwd-security
run: >-
go test -buildvcs=false -mod=vendor
-tags=server,osusergo,netgo,go_sqlite,sliver_e2e
./server/c2
-run '^TestMTLSYamux_ReversePortForwardAuthorizationCannotBePoisonedOrReplayed$'
-count=1 -v
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 selected E2E scope
run: |
set -o pipefail
./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 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os darwin
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- 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 selected E2E scope
run: |
set -o pipefail
./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 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os linux
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- 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
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: 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 selected E2E scope
run: |
set -o pipefail
./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 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os windows
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- 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-rportfwd:
name: Aggregate reverse-port-forward E2E coverage
needs:
- authorize
- rportfwd-security-regression
- darwin
- linux-native
- windows
if: ${{ always() && needs.authorize.result == 'success' }}
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
id: download
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: comprehensive-e2e-target-*
path: rportfwd-coverage-input
- name: Prepare aggregate artifact
if: ${{ always() }}
run: |
mkdir -p rportfwd-coverage-input rportfwd-coverage-summary
printf 'Reverse-port-forward E2E coverage aggregate\n' > rportfwd-coverage-summary/README.txt
- name: Aggregate coverage and enforce all 162 required cells
id: aggregate
if: ${{ always() }}
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input rportfwd-coverage-input
-output rportfwd-coverage-summary
- name: Publish reverse-port-forward coverage table
if: ${{ always() }}
run: |
report="rportfwd-coverage-summary/rportfwd-coverage.md"
if [[ -s "${report}" ]]; then
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
else
printf 'Reverse-port-forward coverage report is missing or empty: %s\n' "${report}" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload aggregate reverse-port-forward coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-rportfwd-coverage-summary
path: rportfwd-coverage-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed prerequisites, missing, malformed, or unavailable coverage
if: ${{ needs.rportfwd-security-regression.result != 'success' || needs.darwin.result != 'success' || needs.linux-native.result != 'success' || needs.windows.result != 'success' || steps.download.outcome != 'success' || steps.aggregate.outcome != 'success' }}
run: exit 1
aggregate-coverage:
name: Aggregate comprehensive E2E coverage
needs:
- authorize
- rportfwd-security-regression
- darwin
- linux-native
- windows
if: ${{ always() && needs.authorize.result == 'success' && inputs.test_scope != 'rportfwd' }}
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 prerequisites or failed or not-run coverage
if: ${{ needs.rportfwd-security-regression.result != 'success' || needs.darwin.result != 'success' || needs.linux-native.result != 'success' || needs.windows.result != 'success' || steps.aggregate.outcome != 'success' }}
run: exit 1
reflektor:
name: Existing Reflektor test matrix
needs: authorize
if: ${{ inputs.test_scope != 'rportfwd' }}
uses: ./.github/workflows/reflektor-tests.yml
shellcode:
name: Shellcode E2E test matrix
needs: authorize
if: ${{ inputs.test_scope != 'rportfwd' }}
uses: ./.github/workflows/shellcode-tests.yml
+7 -7
View File
@@ -20,7 +20,6 @@ on:
permissions:
contents: read
pull-requests: read
jobs:
lint:
@@ -41,16 +40,17 @@ jobs:
steps:
- name: Check Out Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Go 1.26.6
- name: Go 1.27.1
uses: actions/setup-go@v6
with:
go-version: "1.26.6"
go-version: "1.27.1"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
# Keep legacy findings visible without making them blockers.
only-new-issues: true
args: ./client/... ./server/... ./util/... ./protobuf/... ./docs ./implant/...
version: v2.13.1
# 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/...
+164
View File
@@ -0,0 +1,164 @@
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 }}
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-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 }}
+201
View File
@@ -0,0 +1,201 @@
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: 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
+10 -8
View File
@@ -6,6 +6,8 @@ on:
paths:
- "go.mod"
- "go.sum"
- ".gitattributes"
- "client/command/opfor/testdata/corpus/**"
- ".github/workflows/unit-tests.yml"
- "Makefile"
- "**/*.go"
@@ -25,10 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Go 1.26.6
- name: Go 1.27.1
uses: actions/setup-go@v6
with:
go-version: "1.26.6"
go-version: "1.27.1"
- name: OS Packages
run: |
@@ -71,10 +73,10 @@ jobs:
runs-on: windows-latest
timeout-minutes: 180
steps:
- name: Go 1.26.6
- name: Go 1.27.1
uses: actions/setup-go@v6
with:
go-version: "1.26.6"
go-version: "1.27.1"
- name: Install make
run: choco install make -y --no-progress
@@ -115,10 +117,10 @@ jobs:
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Go 1.26.6
- name: Go 1.27.1
uses: actions/setup-go@v6
with:
go-version: "1.26.6"
go-version: "1.27.1"
- name: Check Out Code
uses: actions/checkout@v6
@@ -153,10 +155,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Go 1.26.6
- name: Go 1.27.1
uses: actions/setup-go@v6
with:
go-version: "1.26.6"
go-version: "1.27.1"
- name: Check Out Code
uses: actions/checkout@v6
+23 -15
View File
@@ -26,7 +26,7 @@ ifneq (,$(findstring cgo_sqlite,$(TAGS)))
endif
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
MIN_SUPPORTED_GO_MINOR_VERSION = 26
MIN_SUPPORTED_GO_MINOR_VERSION = 27
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
@@ -35,6 +35,7 @@ ARMORY_REPO_URL ?= https://api.github.com/repos/sliverarmory/armory/releases
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
ASSET_SOURCES = $(filter-out %_test.go,$(wildcard util/assets/*.go util/cmd/assets/*.go))
.PHONY: lint
lint:
@@ -170,8 +171,8 @@ pb:
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
.PHONY: debug
debug: clean .downloaded_assets validate-go-version
$(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor $(TAGS),server $(LDFLAGS_DEBUG) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor $(TAGS),client $(LDFLAGS_DEBUG) -o sliver-client$(ARTIFACT_SUFFIX) ./client
@@ -199,9 +200,9 @@ clean-all: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
.downloaded_assets: $(ASSET_SOURCES)
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
#
@@ -221,6 +222,13 @@ LDFLAGS = -ldflags "-s -w -X $(SLIVER_UPDATE_PKG).SliverPublicKey=$(SLIVER_PUBLI
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
COMMA := ,
DEFAULT_EXECUTABLE_EXT := .exe
ifneq ($(strip $(GOOS)),)
ifneq ($(strip $(GOOS)),windows)
DEFAULT_EXECUTABLE_EXT :=
endif
endif
ifeq ($(MAKECMDGOALS), linux)
LDFLAGS = -ldflags "-s -w -extldflags '-static' -X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
@@ -235,10 +243,10 @@ $(call windows_exec,$(ENV) GOOS=$(1) GOARCH=$(2) CGO_ENABLED=$(3),"$(GO)" build
endef
.PHONY: default
default: clean validate-go-version
$(call windows_exec,$(ENV) GOOS= GOARCH=,"$(MAKE)" GOOS= GOARCH= .downloaded_assets)
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
default: clean validate-go-version
$(call windows_exec,$(ENV) GOOS= GOARCH=,"$(MAKE)" GOOS= GOARCH= .downloaded_assets)
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX)$(DEFAULT_EXECUTABLE_EXT))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX)$(DEFAULT_EXECUTABLE_EXT))
.PHONY: client
client: clean .downloaded_assets validate-go-version
@@ -299,8 +307,8 @@ pb: validate-pb-compilers
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
.PHONY: debug
debug: clean .downloaded_assets validate-go-version
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor,server,$(LDFLAGS_DEBUG),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor,client,$(LDFLAGS_DEBUG),sliver-client$(ARTIFACT_SUFFIX))
@@ -324,8 +332,8 @@ clean-all: clean
clean:
-del /Q /F sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe 2>NUL
.downloaded_assets:
$(call windows_exec,$(ENV),"$(GO)" run -mod=vendor ./util/cmd/assets)
@type NUL > .downloaded_assets
.downloaded_assets: $(ASSET_SOURCES)
$(call windows_exec,$(ENV),"$(GO)" run -mod=vendor ./util/cmd/assets)
@type NUL > .downloaded_assets
endif
+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 -1
View File
@@ -52,7 +52,9 @@ func init() {
rootCmd.TraverseChildren = true
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
rootCmd.PersistentFlags().Bool(enableWGFlag, false, "connect to multiplayer through the operator config's WireGuard wrapper")
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
+13
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/bishopfox/sliver/client/transport"
@@ -52,6 +53,10 @@ func TestFormatConnectSpinnerMessage(t *testing.T) {
}
func TestConnectWithSpinnerOutput(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerOutput)
}
func testConnectWithSpinnerOutput(t *testing.T) {
var out bytes.Buffer
spinnerDelay := 2 * (100 * time.Millisecond)
@@ -85,6 +90,10 @@ func TestConnectWithSpinnerOutput(t *testing.T) {
}
func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerFastSuccessStillShowsMultipleFrames)
}
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) {
@@ -108,6 +117,10 @@ func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
}
func TestConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerFastSuccessShowsEachStatus)
}
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) {
+17 -4
View File
@@ -1,17 +1,20 @@
package cli
import (
"fmt"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
)
const (
enableWGFlag = "enable-wg"
enableWGFlag = "enable-wg"
disableWGFlag = "disable-wg"
)
func applyMultiplayerConnectMode(cmd *cobra.Command) error {
if cmd == nil {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectDirect)
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
return nil
}
@@ -19,10 +22,20 @@ func applyMultiplayerConnectMode(cmd *cobra.Command) error {
if err != nil {
return err
}
disableWG, err := cmd.Flags().GetBool(disableWGFlag)
if err != nil {
return err
}
if cmd.Flags().Changed(enableWGFlag) && cmd.Flags().Changed(disableWGFlag) {
return fmt.Errorf("--%s and --%s cannot be used together", enableWGFlag, disableWGFlag)
}
mode := transport.MultiplayerConnectDirect
if enableWG {
mode := transport.MultiplayerConnectAuto
switch {
case enableWG:
mode = transport.MultiplayerConnectEnableWG
case disableWG:
mode = transport.MultiplayerConnectDisableWG
}
transport.SetMultiplayerConnectMode(mode)
return nil
+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",
},
}
}
+5 -20
View File
@@ -487,10 +487,7 @@ func (m *aiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if conversationContainsUserPrompt(m.currentConversation, m.pendingPrompt) {
m.pendingPrompt = ""
}
m.selectedConversation = conversationIndexByID(m.conversations, selectedConversationID(msg.conversation, msg.selectedID))
if m.selectedConversation < 0 {
m.selectedConversation = 0
}
m.selectedConversation = max(conversationIndexByID(m.conversations, selectedConversationID(msg.conversation, msg.selectedID)), 0)
m.invalidateTranscriptCache()
if strings.TrimSpace(msg.status) != "" {
m.status = msg.status
@@ -3273,10 +3270,7 @@ func (m *aiModel) removeConversation(conversationID string) {
}
m.conversations = filtered
next := conversationIndexByID(m.conversations, m.selectedConversationID())
if next < 0 {
next = 0
}
next := max(conversationIndexByID(m.conversations, m.selectedConversationID()), 0)
m.selectedConversation = next
}
@@ -3683,10 +3677,7 @@ func (m *aiModel) applyConversationEvent(event *clientpb.AIConversationEvent) {
}
if m.currentConversation != nil {
m.selectedConversation = conversationIndexByID(m.conversations, m.currentConversation.GetID())
if m.selectedConversation < 0 {
m.selectedConversation = 0
}
m.selectedConversation = max(conversationIndexByID(m.conversations, m.currentConversation.GetID()), 0)
}
m.invalidateTranscriptCache()
}
@@ -5579,10 +5570,7 @@ func truncateToolCallContentLines(content string, maxLines int) (string, int) {
}
truncated := strings.Join(lines[:maxLines], "\n")
hiddenBytes := len(content) - len(truncated)
if hiddenBytes < 0 {
hiddenBytes = 0
}
hiddenBytes := max(len(content)-len(truncated), 0)
return truncated, hiddenBytes
}
@@ -5828,10 +5816,7 @@ func inputWindow(input []rune, cursor, width int) ([]rune, int) {
start := 0
if len(input) > width {
start = cursor - width + 1
if start < 0 {
start = 0
}
start = max(cursor-width+1, 0)
if start > len(input)-width {
start = len(input) - width
}
+36 -26
View File
@@ -22,6 +22,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
@@ -37,7 +38,6 @@ import (
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/util"
"github.com/bishopfox/sliver/util/minisign"
)
@@ -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
})
@@ -336,7 +348,7 @@ func getPackagesWithCommandName(name, armoryPK, minimumVersion string) []*pkgCac
func getPackageIDFromUser(name string, options map[string]string) string {
selectedPackageKey := ""
optionKeys := util.Keys(options)
optionKeys := slices.Collect(maps.Keys(options))
slices.Sort(optionKeys)
// Add a cancel option
optionKeys = append(optionKeys, doNotInstallOption)
@@ -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)
}
}
+40 -14
View File
@@ -21,7 +21,9 @@ package armory
import (
"errors"
"fmt"
"maps"
"os"
"slices"
"sort"
"github.com/jedib0t/go-pretty/v6/table"
@@ -33,13 +35,13 @@ import (
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/util"
)
type VersionInformation struct {
OldVersion string
NewVersion string
ArmoryName string
PackageID string
}
type PackageType uint
@@ -124,9 +126,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 +175,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 +201,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 +214,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
@@ -219,8 +245,8 @@ func sortUpdateIdentifiers(aliasUpdates, extensionUpdates map[string]VersionInfo
result := []UpdateIdentifier{}
aliasNames := util.Keys(aliasUpdates)
extensionNames := util.Keys(extensionUpdates)
aliasNames := slices.Collect(maps.Keys(aliasUpdates))
extensionNames := slices.Collect(maps.Keys(extensionUpdates))
for _, name := range aliasNames {
result = append(result, UpdateIdentifier{
Type: AliasPackage,
+143 -87
View File
@@ -30,6 +30,7 @@ import (
"github.com/bishopfox/sliver/client/command/kill"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
@@ -121,50 +122,30 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
if err != nil {
width = 999
}
return renderBeaconsAtWidth(beacons, filter, filterRegex, con, width, time.Now())
}
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
wideTermWidth := con.Settings.SmallTermWidth < width
// renderBeaconsAtWidth uses one reference time for all candidate layouts,
// preventing a second boundary from changing the table width while it is fitted.
func renderBeaconsAtWidth(beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, width int, now time.Time) table.Writer {
windowsBeaconInList := false
for _, beacon := range beacons {
if beacon.OS == "windows" {
windowsBeaconInList = true
break
}
}
if wideTermWidth {
if windowsBeaconInList {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Integrity",
"Operating System",
"Locale",
"Last Check-in",
"Next Check-in",
})
} else {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Operating System",
"Locale",
"Last Check-in",
"Next Check-in",
})
}
} else {
return targettable.Fit(width, windowsBeaconInList, func(layout targettable.Layout) table.Writer {
return buildBeaconTable(beacons, filter, filterRegex, con, layout, windowsBeaconInList, now)
})
}
func buildBeaconTable(beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, layout targettable.Layout, windowsBeaconInList bool, now time.Time) table.Writer {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
includeIntegrity := windowsBeaconInList && layout.IncludeIntegrity
if layout.Compact {
tw.AppendHeader(table.Row{
"ID",
"Name",
@@ -175,22 +156,84 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
"Last Check-in",
"Next Check-in",
})
} else {
header := table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
}
if includeIntegrity {
header = append(header, "Integrity")
}
header = append(header, "Operating System")
if layout.IncludeLocale {
header = append(header, "Locale")
}
header = append(header, "Last Check-in", "Next Check-in")
tw.AppendHeader(header)
}
activeBeaconID := ""
if con.ActiveTarget != nil {
if activeBeacon := con.ActiveTarget.GetBeacon(); activeBeacon != nil {
activeBeaconID = activeBeacon.ID
}
}
for _, beacon := range beacons {
integrity := beacon.Integrity
if integrity == "" {
integrity = "-"
}
username := strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")
lastCheckin := time.Unix(beacon.LastCheckin, 0)
nextCheckin := time.Unix(beacon.NextCheckin, 0)
canonicalEntries := []string{
strings.Split(beacon.ID, "-")[0],
beacon.Name,
fmt.Sprintf("%d/%d", beacon.TasksCountCompleted, beacon.TasksCount),
beacon.Transport,
beacon.RemoteAddress,
beacon.Hostname,
username,
fmt.Sprintf("%s (%d)", beacon.Filename, beacon.PID),
integrity,
fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch),
beacon.Locale,
formatBeaconDateDelta(lastCheckin, now, true, false),
formatBeaconDateDelta(nextCheckin, now, true, false),
}
if !beaconMatchesFilter(canonicalEntries, filter, filterRegex) {
continue
}
style := console.StyleNormal
activeBeacon := con.ActiveTarget.GetBeacon()
if activeBeacon != nil && activeBeacon.ID == beacon.ID {
if activeBeaconID == beacon.ID {
style = console.StyleGreen
}
if beacon.Integrity == "" {
beacon.Integrity = "-"
}
// We need a slice of strings so we can apply filters
var rowEntries []string
if wideTermWidth {
if layout.Compact {
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
style.Render(beacon.Transport),
style.Render(beacon.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
formatBeaconDateDelta(lastCheckin, now, false, false),
formatBeaconDateDelta(nextCheckin, now, false, true),
}
} else {
processName := beacon.Filename
if layout.ProcessBasename {
processName = targettable.ProcessName(processName)
}
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
@@ -198,56 +241,69 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
style.Render(beacon.Transport),
style.Render(beacon.RemoteAddress),
style.Render(beacon.Hostname),
style.Render(strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
style.Render(fmt.Sprintf("%s (%d)", beacon.Filename, beacon.PID)),
style.Render(username),
style.Render(fmt.Sprintf("%s (%d)", processName, beacon.PID)),
}
if windowsBeaconInList {
rowEntries = append(rowEntries, style.Render(beacon.Integrity))
if includeIntegrity {
rowEntries = append(rowEntries, style.Render(integrity))
}
rowEntries = append(rowEntries, []string{
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
style.Render(beacon.Locale),
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
}...)
} else {
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
style.Render(beacon.Transport),
style.Render(beacon.Hostname),
style.Render(strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
rowEntries = append(rowEntries, style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)))
if layout.IncludeLocale {
rowEntries = append(rowEntries, style.Render(beacon.Locale))
}
rowEntries = append(rowEntries,
formatBeaconDateDelta(lastCheckin, now, true, false),
formatBeaconDateDelta(nextCheckin, now, true, true),
)
}
// Build the row struct
row := table.Row{}
for _, entry := range rowEntries {
row = append(row, entry)
}
// Apply filters if any
if filter == "" && filterRegex == nil {
tw.AppendRow(row)
} else {
for _, rowEntry := range rowEntries {
if filter != "" {
if strings.Contains(rowEntry, filter) {
tw.AppendRow(row)
break
}
}
if filterRegex != nil {
if filterRegex.MatchString(rowEntry) {
tw.AppendRow(row)
break
}
}
}
}
tw.AppendRow(row)
}
return tw
}
func beaconMatchesFilter(entries []string, filter string, filterRegex *regexp.Regexp) bool {
if filter == "" && filterRegex == nil {
return true
}
for _, entry := range entries {
if filter != "" && strings.Contains(entry, filter) {
return true
}
if filterRegex != nil && filterRegex.MatchString(entry) {
return true
}
}
return false
}
func formatBeaconDateDelta(timestamp time.Time, now time.Time, includeDate bool, color bool) string {
date := timestamp.Format(time.UnixDate)
interval := ""
if timestamp.Before(now) {
delta := now.Sub(timestamp).Round(time.Second)
if includeDate {
interval = fmt.Sprintf("%s (%s ago)", date, delta)
} else {
interval = delta.String()
}
if color {
interval = console.StyleBoldRed.Render(interval)
}
} else {
delta := timestamp.Sub(now).Round(time.Second)
if includeDate {
interval = fmt.Sprintf("%s (in %s)", date, delta)
} else {
interval = delta.String()
}
if color {
interval = console.StyleBoldGreen.Render(interval)
}
}
return interval
}
+257
View File
@@ -0,0 +1,257 @@
package beacons
import (
"regexp"
"strings"
"testing"
"time"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
var beaconTableTestNow = time.Date(2026, time.September, 3, 21, 25, 41, 0, time.UTC)
func TestRenderBeaconsUsesFullProcessPathWhenItFits(t *testing.T) {
beacon := testWindowsBeacon()
layout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
width := beaconCandidateWidth(t, []*clientpb.Beacon{beacon}, layout, true)
rendered := renderBeaconTableAtWidth(t, []*clientpb.Beacon{beacon}, "", nil, width)
if !strings.Contains(rendered, beacon.Filename+" (2020)") {
t.Fatalf("full process path missing from table:\n%s", rendered)
}
if !strings.Contains(rendered, "(36s ago)") || !strings.Contains(rendered, "(in 24s)") {
t.Fatalf("wide check-in timestamps changed unexpectedly:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, width)
}
func TestRenderBeaconsUsesPortableProcessBasenameWhenRequired(t *testing.T) {
tests := []struct {
name string
beacon *clientpb.Beacon
hasIntegrity bool
wantProcess string
}{
{
name: "windows path",
beacon: testWindowsBeacon(),
hasIntegrity: true,
wantProcess: "winterfell.exe (2020)",
},
{
name: "posix path",
beacon: testLinuxBeacon(),
hasIntegrity: false,
wantProcess: "winterfell-agent (2020)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
beacons := []*clientpb.Beacon{tt.beacon}
fullLayout := targettable.Layout{
IncludeIntegrity: tt.hasIntegrity,
IncludeLocale: true,
}
basenameLayout := fullLayout
basenameLayout.ProcessBasename = true
fullWidth := beaconCandidateWidth(t, beacons, fullLayout, tt.hasIntegrity)
basenameWidth := beaconCandidateWidth(t, beacons, basenameLayout, tt.hasIntegrity)
if basenameWidth >= fullWidth {
t.Fatalf("basename candidate width %d is not narrower than full-path width %d", basenameWidth, fullWidth)
}
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, basenameWidth)
if !strings.Contains(rendered, tt.wantProcess) {
t.Fatalf("process basename missing from table:\n%s", rendered)
}
if strings.Contains(rendered, tt.beacon.Filename) {
t.Fatalf("full process path remained in basename layout:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, basenameWidth)
})
}
}
func TestRenderBeaconsOmitsIntegrityBeforeLocale(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
withAll := targettable.Layout{
ProcessBasename: true,
IncludeIntegrity: true,
IncludeLocale: true,
}
withoutIntegrity := withAll
withoutIntegrity.IncludeIntegrity = false
withoutLocale := withoutIntegrity
withoutLocale.IncludeLocale = false
allWidth := beaconCandidateWidth(t, beacons, withAll, true)
withoutIntegrityWidth := beaconCandidateWidth(t, beacons, withoutIntegrity, true)
withoutLocaleWidth := beaconCandidateWidth(t, beacons, withoutLocale, true)
if !(withoutLocaleWidth < withoutIntegrityWidth && withoutIntegrityWidth < allWidth) {
t.Fatalf("candidate widths did not shrink in omission order: all=%d no-integrity=%d no-locale=%d", allWidth, withoutIntegrityWidth, withoutLocaleWidth)
}
withoutIntegrityRendered := renderBeaconTableAtWidth(t, beacons, "", nil, withoutIntegrityWidth)
assertHeaderPresence(t, withoutIntegrityRendered, "Integrity", false)
assertHeaderPresence(t, withoutIntegrityRendered, "Locale", true)
assertBeaconTableFits(t, withoutIntegrityRendered, withoutIntegrityWidth)
withoutLocaleRendered := renderBeaconTableAtWidth(t, beacons, "", nil, withoutLocaleWidth)
assertHeaderPresence(t, withoutLocaleRendered, "Integrity", false)
assertHeaderPresence(t, withoutLocaleRendered, "Locale", false)
assertBeaconTableFits(t, withoutLocaleRendered, withoutLocaleWidth)
}
func TestRenderBeaconsDoesNotAddIntegrityForNonWindowsTargets(t *testing.T) {
beacon := testLinuxBeacon()
beacons := []*clientpb.Beacon{beacon}
layout := targettable.Layout{IncludeLocale: true}
width := beaconCandidateWidth(t, beacons, layout, false)
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, width)
assertHeaderPresence(t, rendered, "Integrity", false)
assertHeaderPresence(t, rendered, "Locale", true)
if !strings.Contains(rendered, beacon.Filename+" (2020)") {
t.Fatalf("non-Windows full process path missing from table:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, width)
}
func TestRenderBeaconsFiltersCanonicalFullRows(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
basenameLayout := targettable.Layout{
ProcessBasename: true,
IncludeIntegrity: true,
IncludeLocale: true,
}
basenameWidth := beaconCandidateWidth(t, beacons, basenameLayout, true)
withoutLocale := basenameLayout
withoutLocale.IncludeIntegrity = false
withoutLocale.IncludeLocale = false
withoutLocaleWidth := beaconCandidateWidth(t, beacons, withoutLocale, true)
t.Run("full process path survives basename projection", func(t *testing.T) {
rendered := renderBeaconTableAtWidth(t, beacons, `AppData\Local\Temp`, nil, basenameWidth)
if !strings.Contains(rendered, beacon.Name) {
t.Fatalf("filter matching canonical process path removed beacon:\n%s", rendered)
}
if strings.Contains(rendered, `AppData\Local\Temp`) {
t.Fatalf("basename layout unexpectedly displayed filtered directory:\n%s", rendered)
}
})
t.Run("hidden integrity survives column projection", func(t *testing.T) {
rendered := renderBeaconTableAtWidth(t, beacons, "", regexp.MustCompile(`^High$`), withoutLocaleWidth)
if !strings.Contains(rendered, beacon.Name) {
t.Fatalf("filter matching hidden integrity removed beacon:\n%s", rendered)
}
assertHeaderPresence(t, rendered, "Integrity", false)
})
}
func TestRenderBeaconsDoesNotMutateEmptyIntegrity(t *testing.T) {
beacon := testWindowsBeacon()
beacon.Integrity = ""
layout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
width := beaconCandidateWidth(t, []*clientpb.Beacon{beacon}, layout, true)
_ = renderBeaconTableAtWidth(t, []*clientpb.Beacon{beacon}, "", nil, width)
if beacon.Integrity != "" {
t.Fatalf("beacon integrity mutated to %q", beacon.Integrity)
}
}
func TestRenderBeaconsCompactFallbackPreservesLegacyColumnsAndTimes(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
compactWidth := beaconCandidateWidth(t, beacons, targettable.Layout{Compact: true}, true)
withoutOptionalWidth := beaconCandidateWidth(t, beacons, targettable.Layout{ProcessBasename: true}, true)
if compactWidth >= withoutOptionalWidth {
t.Fatalf("test fixture does not require compact fallback: compact=%d detailed=%d", compactWidth, withoutOptionalWidth)
}
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, compactWidth)
for _, omitted := range []string{"Tasks", "Remote Address", "Process (PID)", "Integrity", "Locale"} {
assertHeaderPresence(t, rendered, omitted, false)
}
if !strings.Contains(rendered, "36s") || !strings.Contains(rendered, "24s") {
t.Fatalf("compact relative check-in times missing:\n%s", rendered)
}
if strings.Contains(rendered, "36s ago") || strings.Contains(rendered, "in 24s") {
t.Fatalf("compact fallback did not preserve legacy timestamp format:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, compactWidth)
}
func testWindowsBeacon() *clientpb.Beacon {
return &clientpb.Beacon{
ID: "0c9f6b27-1111-2222-3333-444444444444",
Name: "winterfell-session",
TasksCount: 3,
TasksCountCompleted: 2,
Transport: "mtls",
RemoteAddress: "10.13.37.11:49760",
Hostname: "winterfell",
Username: `winterfell\vagrant`,
Filename: `C:\Users\vagrant\AppData\Local\Temp\sliver-range\winterfell.exe`,
PID: 2020,
Integrity: "High",
OS: "windows",
Arch: "amd64",
Locale: "en-US",
LastCheckin: beaconTableTestNow.Add(-36 * time.Second).Unix(),
NextCheckin: beaconTableTestNow.Add(24 * time.Second).Unix(),
}
}
func testLinuxBeacon() *clientpb.Beacon {
beacon := testWindowsBeacon()
beacon.OS = "linux"
beacon.Hostname = "winterfell-linux"
beacon.Username = "vagrant"
beacon.Filename = "/opt/sliver/temporary/build/output/winterfell-agent"
beacon.Integrity = ""
return beacon
}
func testBeaconConsole() *console.SliverClient {
return &console.SliverClient{
Settings: &assets.ClientSettings{TableStyle: "SliverDefault"},
}
}
func beaconCandidateWidth(t *testing.T, beacons []*clientpb.Beacon, layout targettable.Layout, hasIntegrity bool) int {
t.Helper()
tw := buildBeaconTable(beacons, "", nil, testBeaconConsole(), layout, hasIntegrity, beaconTableTestNow)
return text.LongestLineLen(tw.Render())
}
func renderBeaconTableAtWidth(t *testing.T, beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, width int) string {
t.Helper()
tw := renderBeaconsAtWidth(beacons, filter, filterRegex, testBeaconConsole(), width, beaconTableTestNow)
return text.StripEscape(tw.Render())
}
func assertBeaconTableFits(t *testing.T, rendered string, width int) {
t.Helper()
if got := text.LongestLineLen(rendered); got > width {
t.Fatalf("rendered table width %d exceeds terminal width %d:\n%s", got, width, rendered)
}
}
func assertHeaderPresence(t *testing.T, rendered string, header string, want bool) {
t.Helper()
headerLine := strings.SplitN(rendered, "\n", 2)[0]
if got := strings.Contains(strings.ToLower(headerLine), strings.ToLower(header)); got != want {
t.Fatalf("header %q presence = %v, want %v; header line: %s", header, got, want, headerLine)
}
}
+2 -2
View File
@@ -145,8 +145,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+2 -1
View File
@@ -49,7 +49,7 @@ func PrintBuilders(externalBuilders []*clientpb.Builder, con *console.SliverClie
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.AppendHeader(table.Row{
"Name", "Operator", "Templates", "Platform", "Compiler Targets",
"Name", "Operator", "Templates", "Platform", "Capabilities", "Compiler Targets",
})
for _, builder := range externalBuilders {
@@ -63,6 +63,7 @@ func PrintBuilders(externalBuilders []*clientpb.Builder, con *console.SliverClie
builder.OperatorName,
strings.Join(builder.Templates, ", "),
fmt.Sprintf("%s/%s", builder.GOOS, builder.GOARCH),
strings.Join(builder.Capabilities, ", "),
strings.Join(targets, "\n"),
}
tw.AppendRow(table.Row(row))
+1 -1
View File
@@ -28,11 +28,11 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/settings"
+2 -2
View File
@@ -84,8 +84,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+9 -1
View File
@@ -228,6 +228,14 @@ func startCursedChromeProcess(isEdge bool, session *clientpb.Session, cmd *cobra
DialTimeout: 30 * time.Second,
}
tcpProxy.AddRoute(bindAddr, channelProxy)
if err := tcpProxy.Start(); err != nil {
channelProxy.Stop()
_ = tcpProxy.Close()
return nil, errors.Join(
fmt.Errorf("start port forward listener %s: %w", bindAddr, err),
terminateStartedCursedProcess(con, session.ID, chromeExec.GetPid()),
)
}
portFwd := core.Portfwds.Add(tcpProxy, channelProxy)
curse := &core.CursedProcess{
@@ -241,7 +249,7 @@ func startCursedChromeProcess(isEdge bool, session *clientpb.Session, cmd *cobra
}
core.CursedProcesses.Store(bindPort, curse)
go func() {
err := tcpProxy.Run()
err := tcpProxy.Wait()
if err != nil {
log.Printf("Proxy error %s", err)
}
+1 -1
View File
@@ -80,7 +80,7 @@ func selectDebugTarget(targets []overlord.ChromeDebugTarget, con *console.Sliver
if selected == "" {
return nil
}
selectedID := strings.Split(selected, " ")[0]
selectedID, _, _ := strings.Cut(selected, " ")
selectedTarget := id2target[selectedID]
return &selectedTarget
}
+10 -1
View File
@@ -20,6 +20,7 @@ package cursed
import (
"context"
"errors"
"fmt"
"log"
"path"
@@ -190,6 +191,14 @@ func startCursedElectronProcess(electronExe string, session *clientpb.Session, c
DialTimeout: 30 * time.Second,
}
tcpProxy.AddRoute(bindAddr, channelProxy)
if err := tcpProxy.Start(); err != nil {
channelProxy.Stop()
_ = tcpProxy.Close()
return nil, errors.Join(
fmt.Errorf("start port forward listener %s: %w", bindAddr, err),
terminateStartedCursedProcess(con, session.ID, electronExec.GetPid()),
)
}
portFwd := core.Portfwds.Add(tcpProxy, channelProxy)
curse := &core.CursedProcess{
@@ -202,7 +211,7 @@ func startCursedElectronProcess(electronExe string, session *clientpb.Session, c
}
core.CursedProcesses.Store(bindPort, curse)
go func() {
err := tcpProxy.Run()
err := tcpProxy.Wait()
if err != nil {
log.Printf("Proxy error %s", err)
}
+27
View File
@@ -20,20 +20,47 @@ package cursed
import (
"bytes"
"context"
"errors"
"fmt"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/core"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/util"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
const cursedStartCleanupTimeout = 30 * time.Second
func terminateStartedCursedProcess(con *console.SliverClient, sessionID string, pid uint32) error {
const maxTerminatePID = uint32(1<<31 - 1)
if con == nil || con.Rpc == nil || sessionID == "" || pid == 0 || pid > maxTerminatePID {
return errors.New("cannot terminate incompletely tracked cursed process")
}
ctx, cancel := context.WithTimeout(context.Background(), cursedStartCleanupTimeout)
defer cancel()
response, err := con.Rpc.Terminate(ctx, &sliverpb.TerminateReq{
Request: &commonpb.Request{SessionID: sessionID},
Pid: int32(pid),
})
if err != nil {
return fmt.Errorf("terminate started cursed process %d: %w", pid, err)
}
if response.GetResponse().GetErr() != "" {
return fmt.Errorf("terminate started cursed process %d: %s", pid, response.GetResponse().GetErr())
}
return nil
}
// CursedChromeCmd - Execute a .NET assembly in-memory.
func CursedCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
// Collect existing curses from core
+5 -21
View File
@@ -396,10 +396,7 @@ func (m *editorModel) renderLine(index int) string {
return ""
}
prefixWidth := m.lineNumberWidth()
contentWidth := totalWidth - prefixWidth
if contentWidth < 0 {
contentWidth = 0
}
contentWidth := max(totalWidth-prefixWidth, 0)
prefix := m.linePrefix(index, prefixWidth)
if index >= len(m.lines) {
@@ -508,10 +505,7 @@ func (m *editorModel) lineNumberWidth() int {
if !m.showLineNumbers {
return 0
}
total := len(m.lines)
if total < 1 {
total = 1
}
total := max(len(m.lines), 1)
return len(fmt.Sprintf("%d", total)) + 1
}
@@ -559,10 +553,7 @@ func (m *editorModel) ensureCursorVisible() {
m.top = 0
}
width := m.textWidth() - m.lineNumberWidth()
if width < 1 {
width = 1
}
width := max(m.textWidth()-m.lineNumberWidth(), 1)
if m.col < m.left {
m.left = m.col
} else if m.col >= m.left+width {
@@ -1125,20 +1116,13 @@ func lastIndexOfRunes(haystack, needle []rune, end int) int {
return -1
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func baseName(path string) string {
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx != -1 {
return clean[idx+1:]
if _, after, ok := strings.CutLast(clean, "/"); ok {
return after
}
return path
}
+5 -3
View File
@@ -234,10 +234,12 @@ func parseEnvPairs(envPairs []string) (map[string]string, error) {
func determineCommandName(command string) string {
commandName := strings.ReplaceAll(command, "\\", "/")
commandName = commandName[strings.LastIndex(commandName, "/")+1:]
if _, after, ok := strings.CutLast(commandName, "/"); ok {
commandName = after
}
if strings.Contains(commandName, ".") {
commandName = commandName[:strings.LastIndex(commandName, ".")]
if before, _, ok := strings.CutLast(commandName, "."); ok {
commandName = before
}
return commandName
+28
View File
@@ -59,6 +59,19 @@ func MigrateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
config = con.GetActiveBeaconConfig()
implantName = beacon.Name
}
if config == nil {
con.PrintErrorf("Failed to derive active implant config.\n")
return
}
builds, err := con.Rpc.ImplantBuilds(context.Background(), &commonpb.Empty{})
if err != nil {
con.PrintErrorf("Failed to retrieve originating implant build %q: %v\n", implantName, err)
return
}
if !applyMigrateObfuscationPolicy(config, implantName, builds) {
con.PrintWarnf("Originating implant build %q was not found; continuing with reconstructed legacy obfuscation settings.\n", implantName)
}
targetArch := config.GetGOARCH()
if session != nil && session.Arch != "" {
@@ -134,6 +147,21 @@ func MigrateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
}
}
func applyMigrateObfuscationPolicy(config *clientpb.ImplantConfig, implantName string, builds *clientpb.ImplantBuilds) bool {
if config == nil || builds == nil {
return false
}
originatingConfig, ok := builds.GetConfigs()[implantName]
if !ok || originatingConfig == nil {
return false
}
config.Debug = originatingConfig.Debug
config.ObfuscateSymbols = originatingConfig.ObfuscateSymbols
config.ControlFlow = originatingConfig.ControlFlow
return true
}
func normalizeShellcodeEncoderName(name string) string {
normalized := strings.ToLower(strings.TrimSpace(name))
normalized = strings.ReplaceAll(normalized, "-", "_")
@@ -0,0 +1,53 @@
package exec
import (
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestApplyMigrateObfuscationPolicy(t *testing.T) {
config := &clientpb.ImplantConfig{
Debug: true,
ObfuscateSymbols: false,
ControlFlow: clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED,
}
builds := &clientpb.ImplantBuilds{Configs: map[string]*clientpb.ImplantConfig{
"source-build": {
Debug: false,
ObfuscateSymbols: true,
ControlFlow: clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1,
},
}}
if !applyMigrateObfuscationPolicy(config, "source-build", builds) {
t.Fatal("applyMigrateObfuscationPolicy() = false, want true")
}
if config.Debug {
t.Fatal("Debug = true, want originating build value false")
}
if !config.ObfuscateSymbols {
t.Fatal("ObfuscateSymbols = false, want originating build value true")
}
if config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1 {
t.Fatalf("ControlFlow = %s, want %s", config.ControlFlow, clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1)
}
}
func TestApplyMigrateObfuscationPolicyMissingBuildPreservesLegacyConfig(t *testing.T) {
config := &clientpb.ImplantConfig{
Debug: true,
ObfuscateSymbols: false,
ControlFlow: clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED,
}
if applyMigrateObfuscationPolicy(
config,
"missing-build",
&clientpb.ImplantBuilds{Configs: map[string]*clientpb.ImplantConfig{}},
) {
t.Fatal("applyMigrateObfuscationPolicy() = true for missing build, want false")
}
if !config.Debug || config.ObfuscateSymbols || config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED {
t.Fatalf("missing build changed legacy config: %+v", config)
}
}
+5 -1
View File
@@ -90,7 +90,11 @@ func PsExecCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
con.PrintErrorf("No profile found for name %s\n", profile)
return
}
serviceBinary, _ = generate.GetSliverBinary(implantProfile, con)
serviceBinary, err = generate.GetSliverBinary(implantProfile, con)
if err != nil {
con.PrintErrorf("Failed to generate service binary: %s\n", err)
return
}
} else {
// use a custom exe instead of generating a new Sliver
fileBytes, err := os.ReadFile(customExe)
+2 -3
View File
@@ -21,6 +21,7 @@ package extensions
import (
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
@@ -129,9 +130,7 @@ func getInstalledManifests() map[string]*ExtensionManifest {
// Name field.
func getTemporarilyLoadedManifests() map[string]*ExtensionManifest {
tempManifests := map[string]*ExtensionManifest{}
for name, manifest := range loadedManifests {
tempManifests[name] = manifest
}
maps.Copy(tempManifests, loadedManifests)
return tempManifests
}
@@ -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)
}
}
+162 -98
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")
}
@@ -471,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)
}
@@ -531,6 +544,89 @@ 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
@@ -570,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)
@@ -598,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, " "))
@@ -612,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 {
@@ -865,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)
+1
View File
@@ -326,6 +326,7 @@ func coreImplantFlags(name string, cmd *cobra.Command) {
f.StringP("debug-file", "O", "", "path to debug output")
f.BoolP("evasion", "e", false, "enable evasion features (e.g. overwrite user space hooks)")
f.BoolP("skip-symbols", "l", false, "skip symbol obfuscation")
f.String("control-flow", "off", "control-flow obfuscation policy (off, balanced-v1)")
f.String("shellcode-encoder", "", "shellcode encoder to apply (optional; see `shellcode-encoders`)")
f.StringP("exports", "v", "StartW,VoidFunc,DllInstall,DllRegisterServer,DllUnregisterServer", "comma separated list of exports to include in the binary")
f.StringP("canary", "c", "", "canary domain(s)")
@@ -0,0 +1,87 @@
package generate
import (
"testing"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestParseControlFlowPolicy(t *testing.T) {
tests := []struct {
name string
value string
want clientpb.ControlFlowPolicy
wantErr bool
}{
{name: "off", value: "off", want: clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED},
{name: "balanced", value: "balanced-v1", want: clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1},
{name: "case and whitespace", value: " BALANCED-V1 ", want: clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1},
{name: "unknown", value: "aggressive", wantErr: true},
{name: "empty", value: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseControlFlowPolicy(tt.value)
if tt.wantErr {
if err == nil {
t.Fatal("expected an error")
}
return
}
if err != nil {
t.Fatalf("parseControlFlowPolicy() error = %v", err)
}
if got != tt.want {
t.Fatalf("parseControlFlowPolicy() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidateControlFlowFlags(t *testing.T) {
disabled := clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED
balanced := clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1
if err := validateControlFlowFlags(disabled, true, true); err != nil {
t.Fatalf("disabled control flow should not constrain other flags: %v", err)
}
if err := validateControlFlowFlags(balanced, false, false); err != nil {
t.Fatalf("balanced control flow with symbol obfuscation should be valid: %v", err)
}
if err := validateControlFlowFlags(balanced, true, false); err == nil {
t.Fatal("expected --debug conflict")
}
if err := validateControlFlowFlags(balanced, false, true); err == nil {
t.Fatal("expected --skip-symbols conflict")
}
}
func TestApplyGenerateBeaconFormSetsControlFlow(t *testing.T) {
generateCmd := commandByUse(Commands(&console.SliverClient{}), consts.GenerateStr)
if generateCmd == nil {
t.Fatalf("missing %q command", consts.GenerateStr)
}
beaconCmd := commandByUse(generateCmd.Commands(), consts.BeaconStr)
if beaconCmd == nil {
t.Fatalf("missing %q subcommand", consts.BeaconStr)
}
err := applyGenerateBeaconForm(beaconCmd, &forms.GenerateBeaconFormResult{
OS: "darwin",
Arch: "arm64",
Format: "exe",
C2Type: "mtls",
C2Value: "127.0.0.1:31337",
ControlFlow: "balanced-v1",
})
if err != nil {
t.Fatalf("applyGenerateBeaconForm() error = %v", err)
}
if got, err := beaconCmd.Flags().GetString("control-flow"); err != nil || got != "balanced-v1" {
t.Fatalf("control-flow flag = %q, %v; want balanced-v1", got, err)
}
}
+7 -2
View File
@@ -57,9 +57,11 @@ func GenerateBeaconCmd(cmd *cobra.Command, con *console.SliverClient, args []str
save, _ = os.Getwd()
}
if external, _ := cmd.Flags().GetBool("external-builder"); !external {
compile(name, config, spoofMetadata, save, con)
_, _ = compile(name, config, spoofMetadata, save, con)
} else {
externalBuild(name, config, spoofMetadata, save, con)
if _, err := externalBuild(name, config, spoofMetadata, save, con); err != nil {
con.PrintErrorf("%s\n", err)
}
}
}
@@ -113,6 +115,9 @@ func applyGenerateBeaconForm(cmd *cobra.Command, result *forms.GenerateBeaconFor
if err := cmd.Flags().Set("format", result.Format); err != nil {
return err
}
if err := cmd.Flags().Set("control-flow", result.ControlFlow); err != nil {
return err
}
name := strings.TrimSpace(result.Name)
if name != "" {
if err := cmd.Flags().Set("name", name); err != nil {
+130 -10
View File
@@ -74,7 +74,9 @@ const (
)
const (
crossCompilerInfoURL = "https://github.com/BishopFox/sliver/wiki/Cross-Compiling-Implants"
crossCompilerInfoURL = "https://github.com/BishopFox/sliver/wiki/Cross-Compiling-Implants"
controlFlowCapability = "garble.control-flow/balanced-v1"
localControlFlowCapability = "garble.control-flow.local/balanced-v1"
)
var (
@@ -87,11 +89,17 @@ var (
"linux/arm64": true,
"windows/386": true,
"windows/amd64": true,
"windows/arm64": true,
}
// ErrNoExternalBuilder indicates that no external builders are registered.
ErrNoExternalBuilder = errors.New("no external builders are available")
ErrNoValidBuilders = errors.New("no valid external builders for target")
ErrNoSelection = errors.New("no selection")
// ErrNoValidBuilders indicates that no external builder supports the target.
ErrNoValidBuilders = errors.New("no valid external builders for target")
// ErrNoControlFlowBuilder indicates that no target-compatible builder supports control-flow obfuscation.
ErrNoControlFlowBuilder = errors.New("no target-compatible external builder supports control-flow obfuscation")
// ErrNoSelection indicates that the user did not select an external builder.
ErrNoSelection = errors.New("no selection")
)
const (
@@ -99,6 +107,38 @@ const (
imageFileDLLCharacteristic = 0x2000
)
func parseControlFlowPolicy(value string) (clientpb.ControlFlowPolicy, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "off":
return clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED, nil
case "balanced-v1":
return clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1, nil
default:
return clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED, fmt.Errorf("invalid control-flow policy %q (expected off or balanced-v1)", value)
}
}
func validateControlFlowFlags(policy clientpb.ControlFlowPolicy, debug, skipSymbols bool) error {
if policy == clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED {
return nil
}
if debug || skipSymbols {
return errors.New("--control-flow=balanced-v1 requires symbol obfuscation and cannot be combined with --debug or --skip-symbols")
}
return nil
}
func controlFlowPolicyName(policy clientpb.ControlFlowPolicy) string {
switch policy {
case clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED:
return "disabled"
case clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1:
return "balanced-v1"
default:
return fmt.Sprintf("unknown (%d)", policy)
}
}
// GenerateCmd - The main command used to generate implant binaries
func GenerateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if shouldRunGenerateForm(cmd, con, args) {
@@ -171,6 +211,9 @@ func applyGenerateForm(cmd *cobra.Command, result *forms.GenerateFormResult) err
if err := cmd.Flags().Set("format", result.Format); err != nil {
return err
}
if err := cmd.Flags().Set("control-flow", result.ControlFlow); err != nil {
return err
}
name := strings.TrimSpace(result.Name)
if name != "" {
if err := cmd.Flags().Set("name", name); err != nil {
@@ -328,12 +371,18 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
}
c2s = append(c2s, tcpPivotC2...)
var symbolObfuscation bool
if debug, _ := cmd.Flags().GetBool("debug"); debug {
symbolObfuscation = false
} else {
symbolObfuscation, _ = cmd.Flags().GetBool("skip-symbols")
symbolObfuscation = !symbolObfuscation
debug, _ := cmd.Flags().GetBool("debug")
skipSymbols, _ := cmd.Flags().GetBool("skip-symbols")
symbolObfuscation := !debug && !skipSymbols
controlFlowValue, _ := cmd.Flags().GetString("control-flow")
controlFlow, err := parseControlFlowPolicy(controlFlowValue)
if err != nil {
con.PrintErrorf("%s\n", err)
return "", nil
}
if err := validateControlFlowFlags(controlFlow, debug, skipSymbols); err != nil {
con.PrintErrorf("%s\n", err)
return "", nil
}
if len(mtlsC2) == 0 && len(wgC2) == 0 && len(httpC2) == 0 && len(dnsC2) == 0 && len(namedPipeC2) == 0 && len(tcpPivotC2) == 0 {
@@ -352,7 +401,6 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
}
}
debug, _ := cmd.Flags().GetBool("debug")
evasion, _ := cmd.Flags().GetBool("evasion")
templateName, _ := cmd.Flags().GetString("template")
@@ -515,6 +563,7 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
SGNEnabled: sgnEnabled,
ShellcodeEncoder: shellcodeEncoder,
ObfuscateSymbols: symbolObfuscation,
ControlFlow: controlFlow,
C2: c2s,
CanaryDomains: canaryDomains,
TemplateName: templateName,
@@ -1642,6 +1691,10 @@ func outputSavePath(save string, defaultName string, config *clientpb.ImplantCon
}
func externalBuild(name string, config *clientpb.ImplantConfig, spoofMetadata *clientpb.SpoofMetadataConfig, save string, con *console.SliverClient) (*commonpb.File, error) {
if err := ensureServerSupportsControlFlow(config, con); err != nil {
return nil, err
}
potentialBuilders, err := findExternalBuilders(config, con)
if err != nil {
return nil, err
@@ -1669,6 +1722,9 @@ func externalBuild(name string, config *clientpb.ImplantConfig, spoofMetadata *c
} else if !config.Debug {
con.PrintErrorf("Symbol obfuscation is %s\n", console.StyleBold.Render("disabled"))
}
if config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED {
con.PrintInfof("Control-flow obfuscation policy: %s\n", console.StyleBold.Render(controlFlowPolicyName(config.ControlFlow)))
}
start := time.Now()
listenerID, listener := con.CreateEventListener()
@@ -1689,6 +1745,14 @@ func externalBuild(name string, config *clientpb.ImplantConfig, spoofMetadata *c
con.PrintErrorf("%s\n", err)
return nil, err
}
if config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED &&
(externalImplantConfig == nil || externalImplantConfig.Config == nil || externalImplantConfig.Config.ControlFlow != config.ControlFlow) {
return nil, fmt.Errorf(
"server returned control-flow policy %s for requested policy %s",
controlFlowPolicyName(externalImplantConfig.GetConfig().GetControlFlow()),
controlFlowPolicyName(config.ControlFlow),
)
}
con.Printf("done\n")
msgF := "Waiting for external builder to acknowledge build (template: %s) ... %s"
@@ -1817,6 +1881,11 @@ func externalBuild(name string, config *clientpb.ImplantConfig, spoofMetadata *c
}
func compile(name string, config *clientpb.ImplantConfig, spoofMetadata *clientpb.SpoofMetadataConfig, save string, con *console.SliverClient) (*commonpb.File, error) {
if err := ensureServerCanBuildControlFlow(config, con); err != nil {
con.PrintErrorf("%s\n", err)
return nil, err
}
if config.IsBeacon {
interval := time.Duration(config.BeaconInterval)
con.PrintInfof("Generating new %s/%s beacon implant binary (%v)\n", config.GOOS, config.GOARCH, interval)
@@ -1828,6 +1897,9 @@ func compile(name string, config *clientpb.ImplantConfig, spoofMetadata *clientp
} else if !config.Debug {
con.PrintErrorf("Symbol obfuscation is %s\n", console.StyleBold.Render("disabled"))
}
if config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED {
con.PrintInfof("Control-flow obfuscation policy: %s\n", console.StyleBold.Render(controlFlowPolicyName(config.ControlFlow)))
}
start := time.Now()
ctrl := make(chan bool)
@@ -2008,9 +2080,14 @@ func findExternalBuilders(config *clientpb.ImplantConfig, con *console.SliverCli
}
validBuilders := []*clientpb.Builder{}
targetCompatibleBuilder := false
for _, builder := range builders.Builders {
for _, target := range builder.Targets {
if target.GOOS == config.GOOS && target.GOARCH == config.GOARCH && config.Format == target.Format {
targetCompatibleBuilder = true
if config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED && !hasCapability(builder.Capabilities, controlFlowCapability) {
break
}
validBuilders = append(validBuilders, builder)
break
}
@@ -2018,12 +2095,55 @@ func findExternalBuilders(config *clientpb.ImplantConfig, con *console.SliverCli
}
if len(validBuilders) < 1 {
if targetCompatibleBuilder && config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED {
return []*clientpb.Builder{}, ErrNoControlFlowBuilder
}
return []*clientpb.Builder{}, ErrNoValidBuilders
}
return validBuilders, nil
}
func ensureServerSupportsControlFlow(config *clientpb.ImplantConfig, con *console.SliverClient) error {
if config == nil || config.ControlFlow == clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED {
return nil
}
compiler, err := con.Rpc.GetCompiler(context.Background(), &commonpb.Empty{})
if err != nil {
return fmt.Errorf("failed to query server control-flow capability: %w", err)
}
if !hasCapability(compiler.Capabilities, controlFlowCapability) {
return fmt.Errorf("server does not support %s", controlFlowCapability)
}
return nil
}
func ensureServerCanBuildControlFlow(config *clientpb.ImplantConfig, con *console.SliverClient) error {
if config == nil || config.ControlFlow == clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED {
return nil
}
compiler, err := con.Rpc.GetCompiler(context.Background(), &commonpb.Empty{})
if err != nil {
return fmt.Errorf("failed to query server control-flow capability: %w", err)
}
if !hasCapability(compiler.Capabilities, controlFlowCapability) {
return fmt.Errorf("server does not support %s", controlFlowCapability)
}
if !hasCapability(compiler.Capabilities, localControlFlowCapability) {
return fmt.Errorf("server local compiler does not provide %s", localControlFlowCapability)
}
return nil
}
func hasCapability(capabilities []string, required string) bool {
for _, capability := range capabilities {
if capability == required {
return true
}
}
return false
}
func selectExternalBuilder(builders []*clientpb.Builder, _ *console.SliverClient) (*clientpb.Builder, error) {
choices := []string{}
for _, builder := range builders {
+27 -2
View File
@@ -58,7 +58,7 @@ func ProfilesNewCmd(cmd *cobra.Command, con *console.SliverClient, args []string
Name: name,
Config: config,
}
resp, err := con.Rpc.SaveImplantProfile(context.Background(), profile)
resp, err := saveImplantProfileWithControlFlowCheck(con, profile)
if err != nil {
con.PrintErrorf("%s\n", err)
} else {
@@ -106,7 +106,7 @@ func ProfilesNewBeaconCmd(cmd *cobra.Command, con *console.SliverClient, args []
Name: name,
Config: config,
}
resp, err := con.Rpc.SaveImplantProfile(context.Background(), profile)
resp, err := saveImplantProfileWithControlFlowCheck(con, profile)
if err != nil {
con.PrintErrorf("%s\n", err)
} else {
@@ -114,6 +114,25 @@ func ProfilesNewBeaconCmd(cmd *cobra.Command, con *console.SliverClient, args []
}
}
func saveImplantProfileWithControlFlowCheck(con *console.SliverClient, profile *clientpb.ImplantProfile) (*clientpb.ImplantProfile, error) {
if profile == nil || profile.Config == nil {
return nil, errors.New("implant profile config cannot be nil")
}
if err := ensureServerSupportsControlFlow(profile.Config, con); err != nil {
return nil, err
}
resp, err := con.Rpc.SaveImplantProfile(context.Background(), profile)
if err != nil {
return nil, err
}
if profile.Config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED &&
(resp == nil || resp.Config == nil || resp.Config.ControlFlow != profile.Config.ControlFlow) {
return nil, errors.New("server did not preserve the requested control-flow policy")
}
return resp, nil
}
func shouldRunGenerateProfilesNewForm(cmd *cobra.Command, con *console.SliverClient, args []string) bool {
if con == nil || con.IsCLI {
return false
@@ -134,6 +153,9 @@ func applyGenerateProfilesNewForm(cmd *cobra.Command, result *forms.GenerateProf
if err := cmd.Flags().Set("format", result.Format); err != nil {
return "", err
}
if err := cmd.Flags().Set("control-flow", result.ControlFlow); err != nil {
return "", err
}
profileName := strings.TrimSpace(result.ProfileName)
if profileName == "" {
@@ -200,6 +222,9 @@ func applyGenerateProfilesNewBeaconForm(cmd *cobra.Command, result *forms.Genera
if err := cmd.Flags().Set("format", result.Format); err != nil {
return "", err
}
if err := cmd.Flags().Set("control-flow", result.ControlFlow); err != nil {
return "", err
}
profileName := strings.TrimSpace(result.ProfileName)
if profileName == "" {
+7
View File
@@ -60,6 +60,7 @@ func PrintProfiles(profiles []*clientpb.ImplantProfile, con *console.SliverClien
"Debug",
"Format",
"Obfuscation",
"Control Flow",
"Limitations",
"C2 Profile",
// "Nonce",
@@ -91,6 +92,7 @@ func PrintProfiles(profiles []*clientpb.ImplantProfile, con *console.SliverClien
fmt.Sprintf("%v", config.Debug),
fmt.Sprintf("%v", config.Format),
obfuscation,
controlFlowPolicyName(config.ControlFlow),
getLimitsString(config),
config.HTTPC2ConfigName,
// profile.ImplantID,
@@ -164,6 +166,7 @@ func populateProfileProperties(config *clientpb.ImplantConfig) map[string]string
} else {
properties["obsymbols"] = "disabled"
}
properties["obcontrolflow"] = controlFlowPolicyName(config.ControlFlow)
properties["shellcodeencoder"] = "n/a"
if config.Format == clientpb.OutputFormat_SHELLCODE {
@@ -323,6 +326,10 @@ func PrintProfileInfo(name string, con *console.SliverClient) {
"Obfuscation of symbols is",
properties["obsymbols"],
})
tw.AppendRow(table.Row{
"Control-flow obfuscation is",
properties["obcontrolflow"],
})
tw.AppendRow(table.Row{
"Shellcode encoder is",
properties["shellcodeencoder"],
+4 -3
View File
@@ -22,6 +22,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
@@ -33,12 +34,12 @@ import (
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/util"
"github.com/gofrs/uuid"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"google.golang.org/protobuf/proto"
uuid "uuid"
)
// TrafficEncodersCmd - Generate traffic encoders command implementation.
@@ -67,7 +68,7 @@ func DisplayTrafficEncoders(encoderMap *clientpb.TrafficEncoderMap, con *console
})
allIDs := []uint64{}
for _, encoder := range encoderMap.Encoders {
isDuplicate := util.Contains(allIDs, encoder.ID)
isDuplicate := slices.Contains(allIDs, encoder.ID)
allIDs = append(allIDs, encoder.ID)
name := encoder.Wasm.Name
if isDuplicate {
@@ -94,7 +95,7 @@ func TrafficEncodersAddCmd(cmd *cobra.Command, con *console.SliverClient, args [
}
skipTests, _ := cmd.Flags().GetBool("skip-tests")
testID := uuid.Must(uuid.NewV4()).String()
testID := uuid.NewV4().String()
trafficEncoder := &clientpb.TrafficEncoder{
Wasm: &commonpb.File{
Name: filepath.Base(args[0]),
+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.
+3 -6
View File
@@ -540,10 +540,7 @@ func (m *editorModel) ensureCursorVisible() {
if m.top < 0 {
m.top = 0
}
maxTop := m.totalLines() - height
if maxTop < 0 {
maxTop = 0
}
maxTop := max(m.totalLines()-height, 0)
if m.top > maxTop {
m.top = maxTop
}
@@ -725,8 +722,8 @@ func baseName(path string) string {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx != -1 {
return clean[idx+1:]
if _, after, ok := strings.CutLast(clean, "/"); ok {
return after
}
return path
}
+1 -4
View File
@@ -56,10 +56,7 @@ func HexEditCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
return
}
fetchSize := maxSize + 1
if fetchSize < maxSize {
fetchSize = maxSize
}
fetchSize := max(maxSize+1, maxSize)
ctx, cancel := con.GrpcContext(cmd)
defer cancel()
+2 -2
View File
@@ -88,8 +88,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+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) {}
+299
View File
@@ -0,0 +1,299 @@
//go:build client
package opfor
import (
"bytes"
"context"
"crypto/sha256"
"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
}
func TestSliverArmoryCNACorpusPinnedHashes(t *testing.T) {
fixtures := []struct {
path string
sha256 string
}{
{
path: "bof_collection/cat.cna",
sha256: "94c7bcaae209a6355dcc8c126019f6e19a681173680955166b8c30cf97fc66f7",
},
{
path: "firefoxdump/firefoxdump.cna",
sha256: "c8c9ef28675d16dd1c8786f055c08ce2096eb0fedb16fad68b78f22c81b1bbde",
},
{
path: "operatorskit/finddotnet.cna",
sha256: "897dbee453ed504be7e9ace1229e7070f131a9351fb09a723629dd8e0337aa03",
},
{
path: "operatorskit/findsysmon.cna",
sha256: "1579dcca9e1c0ab7b20a646ff2db7705e05468c067a8ed6b3c35b553f10458d7",
},
}
for _, fixture := range fixtures {
fixture := fixture
t.Run(fixture.path, func(t *testing.T) {
content, err := os.ReadFile(filepath.Join("testdata", "corpus", filepath.FromSlash(fixture.path)))
if err != nil {
t.Fatalf("read pinned corpus fixture: %v", err)
}
digest := fmt.Sprintf("%x", sha256.Sum256(content))
if digest != fixture.sha256 {
t.Fatalf("SHA-256 = %s, want %s", digest, fixture.sha256)
}
})
}
}
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.
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -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")
}
+15 -9
View File
@@ -21,7 +21,6 @@ package portfwd
import (
"fmt"
"log"
"net"
"regexp"
"time"
@@ -50,14 +49,10 @@ func PortfwdAddCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
con.PrintErrorf("Must specify a remote target host:port\n")
return
}
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr)
if err != nil {
con.PrintErrorf("Failed to parse remote target %s\n", err)
if err := validatePortfwdRemoteAddress(remoteAddr); err != nil {
con.PrintErrorf("Failed to parse remote target: %s\n", err)
return
}
if remotePort == "3389" {
con.PrintWarnf("RDP is generally broken over tunneled portfwds, we recommend using WireGuard portfwds\n")
}
bindAddr, _ := cmd.Flags().GetString("bind")
if bindAddr == "" {
con.PrintErrorf("Must specify a bind target host:port (e.g. 127.0.0.1:8000)")
@@ -88,14 +83,25 @@ func PortfwdAddCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
DialTimeout: 30 * time.Second,
}
tcpProxy.AddRoute(bindAddr, channelProxy)
if err := tcpProxy.Start(); err != nil {
channelProxy.Stop()
_ = tcpProxy.Close()
con.PrintErrorf("Failed to start port forward listener: %s\n", err)
return
}
core.Portfwds.Add(tcpProxy, channelProxy)
go func() {
err := tcpProxy.Run()
err := tcpProxy.Wait()
if err != nil {
log.Printf("Proxy error %s", err)
}
}()
con.PrintInfof("Port forwarding %s -> %s:%s\n", bindAddr, remoteHost, remotePort)
con.PrintInfof("Port forwarding %s -> %s\n", bindAddr, remoteAddr)
}
func validatePortfwdRemoteAddress(remoteAddr string) error {
_, _, err := (&core.ChannelProxy{RemoteAddr: remoteAddr}).ValidatedHostPort()
return err
}
@@ -0,0 +1,38 @@
package portfwd
import "testing"
func TestValidatePortfwdRemoteAddress(t *testing.T) {
tests := []struct {
name string
address string
valid bool
}{
{name: "hostname RDP", address: "windows.example:3389", valid: true},
{name: "IPv4", address: "192.0.2.10:443", valid: true},
{name: "IPv6", address: "[2001:db8::10]:8443", valid: true},
{name: "missing port", address: "target.example", valid: false},
{name: "empty port", address: "target.example:", valid: false},
{name: "empty host", address: ":3389", valid: false},
{name: "zero port", address: "target.example:0", valid: false},
{name: "out of range port", address: "target.example:65536", valid: false},
{name: "service name", address: "target.example:https", valid: false},
{name: "unbracketed IPv6", address: "2001:db8::10:8443", valid: false},
{name: "hostname with space", address: "bad host:80", valid: false},
{name: "hostname with tab", address: "bad\thost:80", valid: false},
{name: "hostname with newline", address: "bad\nhost:80", valid: false},
{name: "IPv6 zone with space", address: "[fe80::1%bad zone]:80", valid: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validatePortfwdRemoteAddress(test.address)
if test.valid && err != nil {
t.Fatalf("validatePortfwdRemoteAddress(%q): %v", test.address, err)
}
if !test.valid && err == nil {
t.Fatalf("validatePortfwdRemoteAddress(%q) returned nil", test.address)
}
})
}
}
+24
View File
@@ -28,6 +28,7 @@ import (
"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/sliverpb"
)
@@ -53,6 +54,14 @@ func GetSystemCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
con.PrintErrorf("Failed to derive active session config.\n")
return
}
builds, err := con.Rpc.ImplantBuilds(context.Background(), &commonpb.Empty{})
if err != nil {
con.PrintErrorf("Failed to retrieve originating implant build %q: %v\n", session.Name, err)
return
}
if !applyGetSystemObfuscationPolicy(config, session.Name, builds) {
con.PrintWarnf("Originating implant build %q was not found; continuing with reconstructed legacy obfuscation settings.\n", session.Name)
}
/* If the HTTP C2 Config name is not defined, then put in the default value
This will have no effect on implants that do not use HTTP C2
@@ -94,6 +103,21 @@ func GetSystemCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
}
}
func applyGetSystemObfuscationPolicy(config *clientpb.ImplantConfig, implantName string, builds *clientpb.ImplantBuilds) bool {
if config == nil || builds == nil {
return false
}
originatingConfig, ok := builds.GetConfigs()[implantName]
if !ok || originatingConfig == nil {
return false
}
config.Debug = originatingConfig.Debug
config.ObfuscateSymbols = originatingConfig.ObfuscateSymbols
config.ControlFlow = originatingConfig.ControlFlow
return true
}
// PrintGetSystem - Print the results of get system
func PrintGetSystem(getsystemResp *sliverpb.GetSystem, con *console.SliverClient) {
if getsystemResp.Response != nil && getsystemResp.Response.GetErr() != "" {
@@ -0,0 +1,35 @@
package privilege
import (
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestApplyGetSystemObfuscationPolicy(t *testing.T) {
config := &clientpb.ImplantConfig{Debug: true}
builds := &clientpb.ImplantBuilds{Configs: map[string]*clientpb.ImplantConfig{
"source-build": {
Debug: false,
ObfuscateSymbols: true,
ControlFlow: clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1,
},
}}
if !applyGetSystemObfuscationPolicy(config, "source-build", builds) {
t.Fatal("applyGetSystemObfuscationPolicy() = false, want true")
}
if config.Debug || !config.ObfuscateSymbols || config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_BALANCED_V1 {
t.Fatalf("obfuscation policy = %+v, want originating build values", config)
}
}
func TestApplyGetSystemObfuscationPolicyMissingBuildPreservesLegacyConfig(t *testing.T) {
config := &clientpb.ImplantConfig{Debug: true}
if applyGetSystemObfuscationPolicy(config, "missing-build", &clientpb.ImplantBuilds{}) {
t.Fatal("applyGetSystemObfuscationPolicy() = true for missing build, want false")
}
if !config.Debug || config.ObfuscateSymbols || config.ControlFlow != clientpb.ControlFlowPolicy_CONTROL_FLOW_DISABLED {
t.Fatalf("missing build changed legacy config: %+v", config)
}
}
+2 -8
View File
@@ -58,17 +58,11 @@ func RegCreateKeyCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
if strings.Contains(regPath, "/") {
regPath = strings.ReplaceAll(regPath, "/", "\\")
}
pathBaseIdx := strings.LastIndex(regPath, `\`)
if pathBaseIdx < 0 {
finalPath, key, ok := strings.CutLast(regPath, `\`)
if !ok {
con.PrintErrorf("invalid path: %s", regPath)
return
}
if len(regPath) < pathBaseIdx+1 {
con.PrintErrorf("invalid path: %s", regPath)
return
}
finalPath := regPath[:pathBaseIdx]
key := regPath[pathBaseIdx+1:]
createKey, err := con.Rpc.RegistryCreateKey(context.Background(), &sliverpb.RegistryCreateKeyReq{
Hive: hive,
+2 -8
View File
@@ -58,17 +58,11 @@ func RegDeleteKeyCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
if strings.Contains(regPath, "/") {
regPath = strings.ReplaceAll(regPath, "/", "\\")
}
pathBaseIdx := strings.LastIndex(regPath, `\`)
if pathBaseIdx < 0 {
finalPath, key, ok := strings.CutLast(regPath, `\`)
if !ok {
con.PrintErrorf("invalid path: %s", regPath)
return
}
if len(regPath) < pathBaseIdx+1 {
con.PrintErrorf("invalid path: %s", regPath)
return
}
finalPath := regPath[:pathBaseIdx]
key := regPath[pathBaseIdx+1:]
deleteKey, err := con.Rpc.RegistryDeleteKey(context.Background(), &sliverpb.RegistryDeleteKeyReq{
Hive: hive,
+3 -8
View File
@@ -111,17 +111,12 @@ func RegReadCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if strings.Contains(regPath, "/") {
regPath = strings.ReplaceAll(regPath, "/", "\\")
}
pathBaseIdx := strings.LastIndex(regPath, `\`)
if pathBaseIdx < 0 {
var ok bool
finalPath, key, ok = strings.CutLast(regPath, `\`)
if !ok {
con.PrintErrorf("invalid path: %s", regPath)
return
}
if len(regPath) < pathBaseIdx+1 {
con.PrintErrorf("invalid path: %s", regPath)
return
}
finalPath = regPath[:pathBaseIdx]
key = regPath[pathBaseIdx+1:]
regRead, err := con.Rpc.RegistryRead(context.Background(), &sliverpb.RegistryReadReq{
Hive: hive,
+2 -8
View File
@@ -76,17 +76,11 @@ func RegWriteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if strings.Contains(regPath, "/") {
regPath = strings.ReplaceAll(regPath, "/", "\\")
}
pathBaseIdx := strings.LastIndex(regPath, `\`)
if pathBaseIdx < 0 {
finalPath, key, ok := strings.CutLast(regPath, `\`)
if !ok {
con.PrintErrorf("invalid path: %s", regPath)
return
}
if len(regPath) < pathBaseIdx+1 {
con.PrintErrorf("invalid path: %s", regPath)
return
}
finalPath := regPath[:pathBaseIdx]
key := regPath[pathBaseIdx+1:]
switch valType {
case sliverpb.RegistryTypeBinary:
var (
+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,
+2 -2
View File
@@ -125,8 +125,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+124 -85
View File
@@ -27,6 +27,7 @@ import (
"github.com/bishopfox/sliver/client/command/kill"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
@@ -131,51 +132,29 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
if err != nil {
width = 999
}
tw := renderSessions(sessions, filter, filterRegex, con, width, time.Now())
con.Printf("%s\n", tw.Render())
}
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
wideTermWidth := con.Settings.SmallTermWidth < width
windowsSessionInList := false
func renderSessions(sessions map[string]*clientpb.Session, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, width int, now time.Time) table.Writer {
hasIntegrity := false
for _, session := range sessions {
if session.OS == "windows" {
windowsSessionInList = true
hasIntegrity = true
break
}
}
if wideTermWidth {
if windowsSessionInList {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Integrity",
"Operating System",
"Locale",
"Last Message",
"Health",
})
} else {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Operating System",
"Locale",
"Last Message",
"Health",
})
}
return targettable.Fit(width, hasIntegrity, func(layout targettable.Layout) table.Writer {
return buildSessionsTable(sessions, filter, filterRegex, con, layout, hasIntegrity, now)
})
}
} else {
func buildSessionsTable(sessions map[string]*clientpb.Session, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, layout targettable.Layout, hasIntegrity bool, now time.Time) table.Writer {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
if layout.Compact {
tw.AppendHeader(table.Row{
"ID",
"Transport",
@@ -185,31 +164,98 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
"Operating System",
"Health",
})
} else {
header := table.Row{
"ID",
"Name",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
}
if hasIntegrity && layout.IncludeIntegrity {
header = append(header, "Integrity")
}
header = append(header, "Operating System")
if layout.IncludeLocale {
header = append(header, "Locale")
}
header = append(header, "Last Message", "Health")
tw.AppendHeader(header)
}
tw.SortBy([]table.SortBy{
{Name: "ID", Mode: table.Asc},
})
for _, session := range sessions {
style := console.StyleNormal
if con.ActiveTarget.GetSession() != nil && con.ActiveTarget.GetSession().ID == session.ID {
style = console.StyleGreen
activeSessionID := ""
if con.ActiveTarget != nil {
if activeSession := con.ActiveTarget.GetSession(); activeSession != nil {
activeSessionID = activeSession.ID
}
var SessionHealth string
}
for _, session := range sessions {
username := strings.TrimPrefix(session.Username, session.Hostname+"\\") // For non-AD Windows users
lastMessage := formatRelativeTime(time.Unix(session.LastCheckin, 0), now)
health := "[ALIVE]"
if session.IsDead {
SessionHealth = console.StyleBoldRed.Render("[DEAD]")
} else {
SessionHealth = console.StyleBoldGreen.Render("[ALIVE]")
health = "[DEAD]"
}
burned := ""
if session.Burned {
burned = "🔥"
}
username := strings.TrimPrefix(session.Username, session.Hostname+"\\") // For non-AD Windows users
canonicalEntries := []string{
ShortSessionID(session.ID),
session.Name,
session.Transport,
session.RemoteAddress,
session.Hostname,
username,
fmt.Sprintf("%s (%d)", session.Filename, session.PID),
}
if hasIntegrity {
canonicalEntries = append(canonicalEntries, session.Integrity)
}
canonicalEntries = append(canonicalEntries,
fmt.Sprintf("%s/%s", session.OS, session.Arch),
session.Locale,
lastMessage,
burned+health,
)
if !matchesSessionFilter(canonicalEntries, filter, filterRegex) {
continue
}
style := console.StyleNormal
if activeSessionID == session.ID {
style = console.StyleGreen
}
var sessionHealth string
if session.IsDead {
sessionHealth = console.StyleBoldRed.Render("[DEAD]")
} else {
sessionHealth = console.StyleBoldGreen.Render("[ALIVE]")
}
var rowEntries []string
if wideTermWidth {
if layout.Compact {
rowEntries = []string{
style.Render(ShortSessionID(session.ID)),
style.Render(session.Transport),
style.Render(session.RemoteAddress),
style.Render(session.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
burned + sessionHealth,
}
} else {
processName := session.Filename
if layout.ProcessBasename {
processName = targettable.ProcessName(processName)
}
rowEntries = []string{
style.Render(ShortSessionID(session.ID)),
style.Render(session.Name),
@@ -217,57 +263,50 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
style.Render(session.RemoteAddress),
style.Render(session.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s (%d)", session.Filename, session.PID)),
style.Render(fmt.Sprintf("%s (%d)", processName, session.PID)),
}
if windowsSessionInList {
if hasIntegrity && layout.IncludeIntegrity {
rowEntries = append(rowEntries, style.Render(session.Integrity))
}
rowEntries = append(rowEntries, []string{
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
style.Render(session.Locale),
con.FormatDateDelta(time.Unix(session.LastCheckin, 0), wideTermWidth, false),
burned + SessionHealth,
}...)
} else {
rowEntries = []string{
style.Render(ShortSessionID(session.ID)),
style.Render(session.Transport),
style.Render(session.RemoteAddress),
style.Render(session.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
burned + SessionHealth,
rowEntries = append(rowEntries, style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)))
if layout.IncludeLocale {
rowEntries = append(rowEntries, style.Render(session.Locale))
}
rowEntries = append(rowEntries, lastMessage, burned+sessionHealth)
}
// Build the row struct
row := table.Row{}
for _, entry := range rowEntries {
row = append(row, entry)
}
// Apply filters if any
if filter == "" && filterRegex == nil {
tw.AppendRow(row)
} else {
for _, rowEntry := range rowEntries {
if filter != "" {
if strings.Contains(rowEntry, filter) {
tw.AppendRow(row)
break
}
}
if filterRegex != nil {
if filterRegex.MatchString(rowEntry) {
tw.AppendRow(row)
break
}
}
}
}
tw.AppendRow(row)
}
con.Printf("%s\n", tw.Render())
return tw
}
func matchesSessionFilter(entries []string, filter string, filterRegex *regexp.Regexp) bool {
if filter == "" && filterRegex == nil {
return true
}
for _, entry := range entries {
if filter != "" && strings.Contains(entry, filter) {
return true
}
if filterRegex != nil && filterRegex.MatchString(entry) {
return true
}
}
return false
}
func formatRelativeTime(t time.Time, now time.Time) string {
if t.After(now) {
return fmt.Sprintf("in %s", t.Sub(now).Round(time.Second))
}
return fmt.Sprintf("%s ago", now.Sub(t).Round(time.Second))
}
// ShortSessionID - Shorten the session ID.
+276
View File
@@ -0,0 +1,276 @@
package sessions
import (
"regexp"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
)
var sessionsTestNow = time.Date(2026, time.September, 3, 14, 25, 41, 0, time.UTC)
func TestRenderSessionsKeepsFullProcessPathWhenItFits(t *testing.T) {
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\sliver.exe`)
sessions := sessionMap(session)
con := sessionsTestConsole()
hasIntegrity := true
fullLayout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
width := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, fullLayout, hasIntegrity, sessionsTestNow))
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, width, sessionsTestNow))
if !strings.Contains(rendered, session.Filename+" (456)") {
t.Fatalf("full process path was not retained:\n%s", rendered)
}
assertSessionTableFits(t, rendered, width)
}
func TestRenderSessionsUsesTargetProcessBasenameBeforeOmittingColumns(t *testing.T) {
tests := []struct {
name string
operatingSys string
filename string
basename string
hasIntegrity bool
}{
{
name: "windows path",
operatingSys: "windows",
filename: `C:\Users\vagrant\AppData\Local\Temp\a-very-long-directory-name\sliver.exe`,
basename: "sliver.exe",
hasIntegrity: true,
},
{
name: "POSIX path",
operatingSys: "linux",
filename: "/opt/sliver/a-very-long-directory-name/implant/sliver",
basename: "sliver",
hasIntegrity: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
session := sessionsTestSession(test.operatingSys, test.filename)
sessions := sessionMap(session)
con := sessionsTestConsole()
fullLayout := targettable.Layout{IncludeIntegrity: test.hasIntegrity, IncludeLocale: true}
basenameLayout := targettable.Layout{ProcessBasename: true, IncludeIntegrity: test.hasIntegrity, IncludeLocale: true}
fullWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, fullLayout, test.hasIntegrity, sessionsTestNow))
basenameWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, basenameLayout, test.hasIntegrity, sessionsTestNow))
if basenameWidth >= fullWidth {
t.Fatalf("test fixture does not make basename table narrower: basename=%d full=%d", basenameWidth, fullWidth)
}
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, basenameWidth, sessionsTestNow))
if !strings.Contains(rendered, test.basename+" (456)") {
t.Fatalf("process basename was not rendered:\n%s", rendered)
}
if strings.Contains(rendered, test.filename) {
t.Fatalf("full process path was rendered after basename fallback:\n%s", rendered)
}
if !strings.Contains(rendered, "Locale") {
t.Fatalf("Locale was omitted before it was needed:\n%s", rendered)
}
if test.hasIntegrity && !strings.Contains(rendered, "Integrity") {
t.Fatalf("Integrity was omitted before it was needed:\n%s", rendered)
}
assertSessionTableFits(t, rendered, basenameWidth)
})
}
}
func TestRenderSessionsOmitsIntegrityBeforeLocale(t *testing.T) {
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\a-very-long-directory-name\sliver.exe`)
session.Integrity = "System-With-An-Extremely-Long-Integrity-Label"
session.Locale = "extraordinarily-long-locale"
sessions := sessionMap(session)
con := sessionsTestConsole()
hasIntegrity := true
withoutIntegrity := targettable.Layout{ProcessBasename: true, IncludeLocale: true}
withoutIntegrityOrLocale := targettable.Layout{ProcessBasename: true}
withoutIntegrityWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, withoutIntegrity, hasIntegrity, sessionsTestNow))
withoutIntegrityOrLocaleWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, withoutIntegrityOrLocale, hasIntegrity, sessionsTestNow))
if withoutIntegrityOrLocaleWidth >= withoutIntegrityWidth {
t.Fatalf("test fixture does not make Locale omission narrower: without Locale=%d with Locale=%d", withoutIntegrityOrLocaleWidth, withoutIntegrityWidth)
}
t.Run("Integrity omitted first", func(t *testing.T) {
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, withoutIntegrityWidth, sessionsTestNow))
if strings.Contains(rendered, "Integrity") || strings.Contains(rendered, session.Integrity) {
t.Fatalf("Integrity column was retained:\n%s", rendered)
}
if !strings.Contains(rendered, "Locale") || !strings.Contains(rendered, session.Locale) {
t.Fatalf("Locale should remain after Integrity is omitted:\n%s", rendered)
}
if !strings.Contains(rendered, "Process (PID)") {
t.Fatalf("renderer unexpectedly used the compact fallback:\n%s", rendered)
}
assertSessionTableFits(t, rendered, withoutIntegrityWidth)
})
t.Run("Locale omitted second", func(t *testing.T) {
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, withoutIntegrityOrLocaleWidth, sessionsTestNow))
if strings.Contains(rendered, "Integrity") || strings.Contains(rendered, "Locale") {
t.Fatalf("optional columns were retained:\n%s", rendered)
}
if !strings.Contains(rendered, "Process (PID)") {
t.Fatalf("renderer unexpectedly used the compact fallback:\n%s", rendered)
}
assertSessionTableFits(t, rendered, withoutIntegrityOrLocaleWidth)
})
}
func TestRenderSessionsUsesDirectionalRelativeLastMessage(t *testing.T) {
tests := []struct {
name string
lastCheckin time.Time
want string
}{
{name: "past", lastCheckin: sessionsTestNow.Add(-36 * time.Second), want: "36s ago"},
{name: "future clock skew", lastCheckin: sessionsTestNow.Add(5 * time.Second), want: "in 5s"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
session := sessionsTestSession("linux", "/opt/sliver/sliver")
session.LastCheckin = test.lastCheckin.Unix()
rendered := renderedSessionTable(renderSessions(sessionMap(session), "", nil, sessionsTestConsole(), 10_000, sessionsTestNow))
if !strings.Contains(rendered, test.want) {
t.Fatalf("relative Last Message %q was not rendered:\n%s", test.want, rendered)
}
if strings.Contains(rendered, "2026") || strings.Contains(rendered, test.lastCheckin.Format(time.UnixDate)) {
t.Fatalf("Last Message unexpectedly contains a calendar date:\n%s", rendered)
}
})
}
}
func TestRenderSessionsOmitsIntegrityForNonWindowsSessions(t *testing.T) {
session := sessionsTestSession("linux", "/opt/sliver/sliver")
rendered := renderedSessionTable(renderSessions(sessionMap(session), "", nil, sessionsTestConsole(), 10_000, sessionsTestNow))
if strings.Contains(rendered, "Integrity") {
t.Fatalf("non-Windows sessions table contains an Integrity column:\n%s", rendered)
}
if !strings.Contains(rendered, "Locale") {
t.Fatalf("wide non-Windows sessions table is missing Locale:\n%s", rendered)
}
}
func TestRenderSessionsCompactFallbackPreservesLegacyColumns(t *testing.T) {
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\sliver.exe`)
sessions := sessionMap(session)
con := sessionsTestConsole()
compactWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, targettable.Layout{Compact: true}, true, sessionsTestNow))
withoutOptionalWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, targettable.Layout{ProcessBasename: true}, true, sessionsTestNow))
if compactWidth >= withoutOptionalWidth {
t.Fatalf("test fixture does not require compact fallback: compact=%d detailed=%d", compactWidth, withoutOptionalWidth)
}
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, compactWidth, sessionsTestNow))
header := strings.SplitN(rendered, "\n", 2)[0]
for _, retained := range []string{"ID", "Transport", "Remote Address", "Hostname", "Username", "Operating System", "Health"} {
if !strings.Contains(header, retained) {
t.Fatalf("compact fallback is missing legacy column %q:\n%s", retained, rendered)
}
}
for _, omitted := range []string{"Process (PID)", "Integrity", "Locale", "Last Message"} {
if strings.Contains(header, omitted) {
t.Fatalf("compact fallback unexpectedly contains column %q:\n%s", omitted, rendered)
}
}
if strings.Contains(rendered, session.Name) {
t.Fatalf("compact fallback unexpectedly contains the session name:\n%s", rendered)
}
assertSessionTableFits(t, rendered, compactWidth)
}
func TestRenderSessionsFiltersCanonicalValuesHiddenByLayout(t *testing.T) {
session := sessionsTestSession("windows", `C:\hidden-directory-token\sliver.exe`)
session.Integrity = "hidden-integrity-token"
session.Locale = "hidden-locale-token"
sessions := sessionMap(session)
con := sessionsTestConsole()
layout := targettable.Layout{ProcessBasename: true}
width := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, layout, true, sessionsTestNow))
tests := []struct {
name string
filter string
filterRegex *regexp.Regexp
hiddenValue string
}{
{name: "substring in shortened path", filter: "hidden-directory-token", hiddenValue: "hidden-directory-token"},
{name: "regexp in omitted columns", filterRegex: regexp.MustCompile(`hidden-(?:integrity|locale)-token`), hiddenValue: "hidden-integrity-token"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rendered := renderedSessionTable(renderSessions(sessions, test.filter, test.filterRegex, con, width, sessionsTestNow))
if !strings.Contains(rendered, ShortSessionID(session.ID)) {
t.Fatalf("session matching a canonical value was filtered out:\n%s", rendered)
}
if strings.Contains(rendered, test.hiddenValue) {
t.Fatalf("hidden canonical value leaked into rendered layout:\n%s", rendered)
}
if !strings.Contains(rendered, "Process (PID)") || strings.Contains(rendered, "Locale") || strings.Contains(rendered, "Integrity") {
t.Fatalf("test did not exercise the shortened, omitted-column layout:\n%s", rendered)
}
assertSessionTableFits(t, rendered, width)
})
}
}
func sessionsTestSession(operatingSystem string, filename string) *clientpb.Session {
return &clientpb.Session{
ID: "01234567-89ab-cdef-0123-456789abcdef",
Name: "winterfell-session",
Transport: "mtls",
RemoteAddress: "10.13.37.10:61839",
Hostname: "winterfell",
Username: `winterfell\vagrant`,
Filename: filename,
PID: 456,
Integrity: "High",
OS: operatingSystem,
Arch: "amd64",
Locale: "en-US",
LastCheckin: sessionsTestNow.Add(-36 * time.Second).Unix(),
}
}
func sessionsTestConsole() *console.SliverClient {
return &console.SliverClient{
Settings: &assets.ClientSettings{TableStyle: settings.SliverDefault.Name},
}
}
func sessionMap(sessions ...*clientpb.Session) map[string]*clientpb.Session {
result := make(map[string]*clientpb.Session, len(sessions))
for _, session := range sessions {
result[session.ID] = session
}
return result
}
func renderedSessionTable(writer table.Writer) string {
return text.StripEscape(writer.Render())
}
func renderedSessionTableWidth(writer table.Writer) int {
return text.LongestLineLen(writer.Render())
}
func assertSessionTableFits(t *testing.T, rendered string, width int) {
t.Helper()
if got := text.LongestLineLen(rendered); got > width {
t.Fatalf("rendered table width %d exceeds terminal width %d:\n%s", got, width, rendered)
}
}
+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
}
+200 -96
View File
@@ -8,59 +8,44 @@ 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:
}
}
}
}()
writerWG.Go(func() {
runShellInputWriter(tunnel, stdinQueue, stopWriter)
})
defer func() {
close(stopWriter)
@@ -68,68 +53,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 +96,157 @@ 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{}) {
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)
}
}
+2 -4
View File
@@ -73,13 +73,11 @@ func (s *managedShell) SetOutput(w io.Writer) {
}
func (s *managedShell) startReader(remove func()) {
s.readerWG.Add(1)
go func() {
defer s.readerWG.Done()
s.readerWG.Go(func() {
_, err := io.Copy(s.output, s.tunnel)
log.Printf("Shell tunnel reader (id=%d tunnel=%d) exited: %v", s.ID, s.TunnelID, err)
remove()
}()
})
}
// swapWriter is an io.Writer whose underlying destination can be swapped at runtime.
+47 -18
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
@@ -201,7 +203,7 @@ func ShellKillCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
// Best-effort graceful request before closing the tunnel.
requestRemoteShellExit(sh.Tunnel())
if err := closeShellTunnel(con, sh.TunnelID, sh.SessionID); err != nil {
if err := closeShellTunnel(con, sh.Tunnel()); err != nil {
con.PrintWarnf("Error closing shell tunnel %d: %s\n", sh.TunnelID, err)
}
@@ -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)
return shellAttachFailed
case <-bindTimer.C:
con.PrintErrorf("Timed out binding shell tunnel\n")
go backgroundCloseShell(con, tunnel)
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,14 +315,15 @@ 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)
return shellAttachFailed
}
if shell.Response != nil && shell.Response.Err != "" {
con.PrintErrorf("Error: %s\n", shell.Response.Err)
go backgroundCloseShell(con, tunnel.ID, session.ID)
go backgroundCloseShell(con, tunnel)
return shellAttachFailed
}
@@ -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)
@@ -375,27 +393,37 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
shells.Remove(managed.ID)
// "exit" should return immediately; tunnel close/cleanup is best-effort in the background.
go backgroundCloseShell(con, tunnel.ID, session.ID)
go backgroundCloseShell(con, tunnel)
return shellExited
}
func backgroundCloseShell(con *console.SliverClient, tunnelID uint64, sessionID string) {
err := closeShellTunnel(con, tunnelID, sessionID)
func backgroundCloseShell(con *console.SliverClient, tunnel *core.TunnelIO) {
if tunnel == nil {
return
}
err := closeShellTunnel(con, tunnel)
if err != nil {
log.Printf("Background close tunnel %d failed: %v", tunnelID, err)
log.Printf("Background close tunnel %d failed: %v", tunnel.ID, err)
}
}
func closeShellTunnel(con *console.SliverClient, tunnelID uint64, sessionID string) error {
core.GetTunnels().Close(tunnelID)
func closeShellTunnel(con *console.SliverClient, tunnel *core.TunnelIO) error {
if tunnel == nil {
return nil
}
if !core.GetTunnels().CloseIf(tunnel) {
return nil
}
if con == nil || con.Rpc == nil {
return nil
}
_, err := con.Rpc.CloseTunnel(context.Background(), &sliverpb.Tunnel{
TunnelID: tunnelID,
SessionID: sessionID,
ctx, cancel := context.WithTimeout(context.Background(), shellCloseTimeout)
defer cancel()
_, err := con.Rpc.CloseTunnel(ctx, &sliverpb.Tunnel{
TunnelID: tunnel.ID,
SessionID: tunnel.SessionID,
})
return err
}
@@ -413,6 +441,7 @@ func requestRemoteShellExit(tunnel *core.TunnelIO) {
}
}()
select {
case <-tunnel.Done():
case tunnel.Send <- append([]byte(nil), data...):
default:
}
+104
View File
@@ -0,0 +1,104 @@
package shell
import (
"context"
"testing"
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/core"
"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
calls int
}
type closeShellTestStream struct {
grpc.BidiStreamingClient[sliverpb.TunnelData, sliverpb.TunnelData]
sent chan *sliverpb.TunnelData
}
func (stream *closeShellTestStream) Send(frame *sliverpb.TunnelData) error {
stream.sent <- frame
return nil
}
func (r *closeTunnelTestRPC) CloseTunnel(ctx context.Context, _ *sliverpb.Tunnel, _ ...grpc.CallOption) (*commonpb.Empty, error) {
r.calls++
deadline, ok := ctx.Deadline()
if ok {
r.deadline = deadline
}
return &commonpb.Empty{}, nil
}
func TestCloseShellTunnelUsesBoundedContext(t *testing.T) {
tunnels := core.GetTunnels()
tunnels.Reset()
t.Cleanup(tunnels.Reset)
stream := &closeShellTestStream{sent: make(chan *sliverpb.TunnelData, 1)}
tunnels.SetStream(stream)
tunnel := tunnels.Start(99, "session")
waitForShellBindFrame(t, stream.sent)
rpc := &closeTunnelTestRPC{}
con := &console.SliverClient{Rpc: rpc}
if err := closeShellTunnel(con, tunnel); err != nil {
t.Fatalf("close shell tunnel: %v", err)
}
if rpc.calls != 1 {
t.Fatalf("CloseTunnel RPC calls = %d, want 1", rpc.calls)
}
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)
}
}
func TestCloseShellTunnelDoesNotCloseReplacementGeneration(t *testing.T) {
tunnels := core.GetTunnels()
tunnels.Reset()
t.Cleanup(tunnels.Reset)
stream := &closeShellTestStream{sent: make(chan *sliverpb.TunnelData, 2)}
tunnels.SetStream(stream)
old := tunnels.Start(100, "old-session")
waitForShellBindFrame(t, stream.sent)
replacement := tunnels.Start(100, "new-session")
waitForShellBindFrame(t, stream.sent)
rpc := &closeTunnelTestRPC{}
con := &console.SliverClient{Rpc: rpc}
if err := closeShellTunnel(con, old); err != nil {
t.Fatalf("close stale shell tunnel: %v", err)
}
if rpc.calls != 0 {
t.Fatalf("stale shell cleanup sent %d numeric CloseTunnel RPCs, want 0", rpc.calls)
}
if current := tunnels.Get(100); current != replacement {
t.Fatal("stale shell cleanup removed the replacement tunnel")
}
select {
case <-replacement.Done():
t.Fatal("stale shell cleanup closed the replacement tunnel")
default:
}
}
func waitForShellBindFrame(t *testing.T, frames <-chan *sliverpb.TunnelData) {
t.Helper()
select {
case <-frames:
case <-time.After(time.Second):
t.Fatal("timed out waiting for shell tunnel bind frame")
}
}
+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
+65
View File
@@ -0,0 +1,65 @@
// Package targettable contains shared layout helpers for the sessions and
// beacons tables.
package targettable
import (
"path"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
)
// Layout describes one rendering candidate for a target table.
type Layout struct {
ProcessBasename bool
IncludeIntegrity bool
IncludeLocale bool
Compact bool
}
// Fit returns the first table that fits within width. Layouts are tried from
// most to least detailed: full process path, process basename, no Integrity,
// no Locale, and finally the legacy compact layout bounded to width. When no
// Integrity column exists, that omission step is skipped. A non-positive width
// is treated as unknown and keeps the full layout.
func Fit(width int, hasIntegrity bool, build func(Layout) table.Writer) table.Writer {
layout := Layout{
IncludeIntegrity: hasIntegrity,
IncludeLocale: true,
}
if candidate := build(layout); width <= 0 || text.LongestLineLen(candidate.Render()) <= width {
return candidate
}
layout.ProcessBasename = true
if candidate := build(layout); text.LongestLineLen(candidate.Render()) <= width {
return candidate
}
if hasIntegrity {
layout.IncludeIntegrity = false
if candidate := build(layout); text.LongestLineLen(candidate.Render()) <= width {
return candidate
}
}
layout.IncludeLocale = false
if candidate := build(layout); text.LongestLineLen(candidate.Render()) <= width {
return candidate
}
compact := build(Layout{Compact: true})
compact.Style().Size.WidthMax = width
return compact
}
// ProcessName returns the final path component of a target process path.
// Target paths can use a different separator than the client host, so Windows
// separators are normalized before applying path.Base.
func ProcessName(processPath string) string {
if processPath == "" {
return ""
}
return path.Base(strings.ReplaceAll(processPath, `\`, "/"))
}
@@ -0,0 +1,215 @@
package targettable
import (
"strings"
"testing"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
)
func TestFitSelectsLayoutsInOrder(t *testing.T) {
tests := []struct {
name string
width int
hasIntegrity bool
wantWidth int
wantCalls []Layout
}{
{
name: "full path exact fit",
width: 50,
hasIntegrity: true,
wantWidth: 50,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
},
},
{
name: "process basename",
width: 40,
hasIntegrity: true,
wantWidth: 40,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeIntegrity: true, IncludeLocale: true},
},
},
{
name: "omit integrity",
width: 30,
hasIntegrity: true,
wantWidth: 30,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeLocale: true},
},
},
{
name: "omit locale",
width: 20,
hasIntegrity: true,
wantWidth: 20,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeLocale: true},
{ProcessBasename: true},
},
},
{
name: "compact fallback",
width: 19,
hasIntegrity: true,
wantWidth: 10,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeLocale: true},
{ProcessBasename: true},
{Compact: true},
},
},
{
name: "skip absent integrity",
width: 20,
hasIntegrity: false,
wantWidth: 20,
wantCalls: []Layout{
{IncludeLocale: true},
{ProcessBasename: true, IncludeLocale: true},
{ProcessBasename: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls := []Layout{}
got := Fit(tt.width, tt.hasIntegrity, func(layout Layout) table.Writer {
calls = append(calls, layout)
return tableWithWidth(layoutWidth(layout))
})
if gotWidth := text.LongestLineLen(got.Render()); gotWidth != tt.wantWidth {
t.Fatalf("selected table width = %d, want %d", gotWidth, tt.wantWidth)
}
if len(calls) != len(tt.wantCalls) {
t.Fatalf("build calls = %#v, want %#v", calls, tt.wantCalls)
}
for index := range calls {
if calls[index] != tt.wantCalls[index] {
t.Fatalf("build call %d = %#v, want %#v", index, calls[index], tt.wantCalls[index])
}
}
})
}
}
func TestFitUsesANSIVisibleWidthAndAcceptsExactFit(t *testing.T) {
const colored = "\x1b[31m12345\x1b[0m"
buildCalls := 0
build := func(Layout) table.Writer {
buildCalls++
tw := table.NewWriter()
tw.SetStyle(singleCellStyle())
tw.AppendRow(table.Row{colored})
return tw
}
exactWidth := text.LongestLineLen(build(Layout{}).Render())
buildCalls = 0
got := Fit(exactWidth, true, build)
if buildCalls != 1 {
t.Fatalf("build calls = %d, want 1 for an ANSI-aware exact fit", buildCalls)
}
if gotWidth := text.LongestLineLen(got.Render()); gotWidth != exactWidth {
t.Fatalf("selected table width = %d, want %d", gotWidth, exactWidth)
}
}
func TestFitBoundsOversizedCompactFallback(t *testing.T) {
const width = 20
got := Fit(width, true, func(layout Layout) table.Writer {
if layout.Compact {
return tableWithWidth(40)
}
return tableWithWidth(50)
})
if gotWidth := text.LongestLineLen(got.Render()); gotWidth > width {
t.Fatalf("compact table width = %d, want at most %d", gotWidth, width)
}
}
func TestFitTreatsUnknownWidthAsUnlimited(t *testing.T) {
buildCalls := 0
got := Fit(0, true, func(layout Layout) table.Writer {
buildCalls++
return tableWithWidth(layoutWidth(layout))
})
if buildCalls != 1 {
t.Fatalf("build calls = %d, want 1 for an unknown terminal width", buildCalls)
}
if gotWidth := text.LongestLineLen(got.Render()); gotWidth != 50 {
t.Fatalf("selected table width = %d, want full-width table of 50", gotWidth)
}
}
func TestProcessName(t *testing.T) {
tests := []struct {
name string
path string
want string
}{
{name: "windows", path: `C:\Users\vagrant\AppData\Local\Temp\sliver.exe`, want: "sliver.exe"},
{name: "UNC", path: `\\server\share\sliver.exe`, want: "sliver.exe"},
{name: "POSIX", path: "/usr/local/bin/sliver", want: "sliver"},
{name: "mixed separators", path: `C:\Temp/subdir\sliver.exe`, want: "sliver.exe"},
{name: "base name", path: "sliver.exe", want: "sliver.exe"},
{name: "empty", path: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ProcessName(tt.path); got != tt.want {
t.Fatalf("ProcessName(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func layoutWidth(layout Layout) int {
switch {
case layout.Compact:
return 10
case !layout.ProcessBasename:
return 50
case layout.IncludeIntegrity:
return 40
case layout.IncludeLocale:
return 30
default:
return 20
}
}
func tableWithWidth(width int) table.Writer {
tw := table.NewWriter()
tw.SetStyle(singleCellStyle())
tw.AppendRow(table.Row{strings.Repeat("x", width)})
return tw
}
func singleCellStyle() table.Style {
return table.Style{
Format: table.FormatOptions{
Footer: text.FormatDefault,
Header: text.FormatDefault,
Row: text.FormatDefault,
},
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ func SelectBeaconTask(tasks []*clientpb.BeaconTask) (*clientpb.BeaconTask, error
buf := bytes.NewBufferString("")
table := tabwriter.NewWriter(buf, 0, 2, 2, ' ', 0)
for _, task := range tasks {
shortID := strings.Split(task.ID, "-")[0]
shortID, _, _ := strings.Cut(task.ID, "-")
fmt.Fprintf(table, "%s\t%s\t%s\t\n", shortID, task.Description, prettyState(task.State))
}
table.Flush()
+2 -4
View File
@@ -545,12 +545,10 @@ func isRetryableDownloadError(err error) bool {
if errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) {
if netErr, ok := errors.AsType[net.Error](err); ok {
return netErr.Timeout() || netErr.Temporary()
}
var statusErr httpStatusError
if errors.As(err, &statusErr) {
if statusErr, ok := errors.AsType[httpStatusError](err); ok {
switch statusErr.status {
case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
+2 -2
View File
@@ -99,8 +99,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+3 -2
View File
@@ -24,6 +24,7 @@ import (
"io"
"os"
"path/filepath"
"slices"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/core"
@@ -98,7 +99,7 @@ func WasmCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
func isRegistered(name string, cmd *cobra.Command, con *console.SliverClient) bool {
// Check if we have already registered this wasm module
if wasmRegistrationCache[idOf(con)] != nil {
if util.Contains(wasmRegistrationCache[idOf(con)], name) {
if slices.Contains(wasmRegistrationCache[idOf(con)], name) {
return true
}
}
@@ -274,7 +275,7 @@ func WasmLsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
} else {
for _, extName := range loaded.Names {
cacheLine := ""
if util.Contains(wasmRegistrationCache[idOf(con)], extName) {
if slices.Contains(wasmRegistrationCache[idOf(con)], extName) {
cacheLine = " (cached)"
} else {
wasmRegistrationCache[idOf(con)] = append(wasmRegistrationCache[idOf(con)], extName)
Loaded 100 of 1661 files, more files were not shown because too many files have changed in this diff. Show more