diff --git a/docs/BUILDING-SLIVER.md b/docs/BUILDING-SLIVER.md index 0fc2cb2..616bb68 100644 --- a/docs/BUILDING-SLIVER.md +++ b/docs/BUILDING-SLIVER.md @@ -82,8 +82,8 @@ GOWORK=off GOOS=darwin GOARCH=amd64 /tmp/wasmforge-bin build \ - **Platform-specific source.** Windows and macOS Sliver sources are NOT interchangeable. Windows `runner.go` references `handlers.WrapperHandler` (token impersonation) which only exists in `handlers_windows.go`. Always generate source targeting the correct OS. - **Beacon vs Session.** Beacons check in periodically (tasks are queued). Sessions are interactive and support SOCKS5 proxying. Choose based on your use case. - **`--skip-symbols`.** Recommended to reduce compile time. Sliver's symbol obfuscation adds significant build time and isn't needed since WasmForge already provides its own obfuscation layers. -- **`--tags`.** Use for programs that gate features behind build tags (e.g., [Tribunus](https://github.com/praetorian-inc/tribunus) uses `-tags "shell,ps,netstat"` to enable specific command handlers). -- **Binary sizes.** ~34.5MB (Windows), ~29.7MB (macOS), ~12.9MB ([Tribunus](https://github.com/praetorian-inc/tribunus)). +- **`--tags`.** Use for programs that gate features behind build tags (e.g., `-tags "shell,ps,netstat"` to enable specific command handlers in an agent that supports it). +- **Binary sizes.** ~34.5MB (Windows), ~29.7MB (macOS). ## Verified Commands @@ -92,7 +92,6 @@ GOWORK=off GOOS=darwin GOARCH=amd64 /tmp/wasmforge-bin build \ | Windows | Sliver | Beacon | `whoami`, `ls`, `pwd`, `execute`, `info`, `getprivs`, `ps`, `netstat`, `ifconfig`, `execute-assembly` (Rubeus, Seatbelt) | | macOS | Sliver | Beacon | `pwd`, `ls`, `download`, `mkdir`, `execute` | | macOS | Sliver | Session | `pwd`, `ls`, `download`, `mkdir`, `execute`, SOCKS5 proxy | -| Windows | [Tribunus](https://github.com/praetorian-inc/tribunus) (Mythic) | Agent | `shell` (whoami, dir, ipconfig, hostname, echo, ver), `whoami`, `ps`, `netstat` | ## Related Documentation diff --git a/docs/MACOS.md b/docs/MACOS.md index c043ccf..e83d6ea 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -65,9 +65,6 @@ Go programs using [`ebitengine/purego`](https://github.com/ebitengine/purego) fo | ---------------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- | | **Sliver** (beacon) | C2 framework | `pwd`, `ls`, `download`, `mkdir`, `execute` | | **Sliver** (session) | C2 framework, interactive mode | `pwd`, `ls`, `download`, `mkdir`, `execute`, SOCKS5 proxy | -| **[Sibyl](https://github.com/praetorian-inc/sibyl)** | Mythic C2 agent using `purego/objc` | NSURLSession transport, ObjC class registration, TLS delegate, `dispatch_semaphore`, full agent init | - -[Sibyl](https://github.com/praetorian-inc/sibyl) demonstrates the most complex purego/ObjC integration validated to date — a fully native-looking macOS agent that compiles through WasmForge with zero source modifications. ## Building Sliver for macOS diff --git a/docs/internals/HOST-API-CONTRACT.md b/docs/internals/HOST-API-CONTRACT.md index cfc7276..733b131 100644 --- a/docs/internals/HOST-API-CONTRACT.md +++ b/docs/internals/HOST-API-CONTRACT.md @@ -126,7 +126,7 @@ Registered in `internal/hostmod/pipe.go`. ## --win32-apis Go-side surface (non-NativeAOT) Functions used by `wasmforge build --win32-apis` with Go source input -(Sliver, Tribunus, gogokatz, goffloader, etc.). Registered via +(Sliver, goffloader, etc.). Registered via `internal/hostmod/win32.go`. Not exercised by the Rubeus/Seatbelt parity tests, but kept for the broader product surface. diff --git a/internal/build/compiler.go b/internal/build/compiler.go index 862ec13..775d05c 100644 --- a/internal/build/compiler.go +++ b/internal/build/compiler.go @@ -124,8 +124,9 @@ func CompileWASM(patchedGOROOT, pkg, tmpDir string, verbose, win32APIs bool, tar } // Inject wasip1 overrides for unsafe host pointer dereference patterns. - // Sibyl's transport/unsafe_helpers.go uses unsafe.Slice on host pointers - // which traps in WASM. Replace with darwin.ReadHostMemory-based versions. + // Guests that use unsafe.Slice on host pointers (common in transport + // helpers around purego/ObjC) trap in WASM. Replace with + // darwin.ReadHostMemory-based versions. if workDir != "" && workDir != originalWorkDir && targetGOOS == "darwin" { if n, err := injectUnsafeHelperOverrides(workDir, verbose); err != nil && verbose { fmt.Fprintf(os.Stderr, "wasmforge: unsafe helper override warning: %v\n", err) @@ -441,8 +442,8 @@ func injectSysshimVendored(shadowDir, sysshimDir string, verbose bool) error { } // injectUnsafeHelperOverrides walks the shadow copy looking for Go files that -// use unsafe.Slice to dereference host pointers (e.g., Sibyl's -// transport/unsafe_helpers.go). For each match, it injects a _wasip1.go +// use unsafe.Slice to dereference host pointers (a common pattern in transport +// helpers built around purego/ObjC). For each match, it injects a _wasip1.go // override that uses darwin.ReadHostMemory instead. func injectUnsafeHelperOverrides(workDir string, verbose bool) (int, error) { count := 0 diff --git a/internal/sysshim/purego/objc/objc.go b/internal/sysshim/purego/objc/objc.go index 9b69028..d77b17a 100644 --- a/internal/sysshim/purego/objc/objc.go +++ b/internal/sysshim/purego/objc/objc.go @@ -121,7 +121,7 @@ const maxRegAllocStructSize = 16 // Send is a convenience method for sending messages to objects that can return any type. // Note: objc_msgSend_stret is not registered in this sysshim. Struct returns >16 bytes -// on amd64 will panic. Sibyl only uses pointer/integer returns via ObjC messaging. +// on amd64 will panic. Validated guests only use pointer/integer returns via ObjC messaging. func Send[T any](id ID, sel SEL, args ...any) T { var zero T if reflect.ValueOf(zero).Kind() == reflect.Struct && diff --git a/internal/sysshim/unix/unix.go b/internal/sysshim/unix/unix.go index d72c00a..073f5f6 100644 --- a/internal/sysshim/unix/unix.go +++ b/internal/sysshim/unix/unix.go @@ -1,5 +1,6 @@ // Package unix provides minimal stubs of golang.org/x/sys/unix for wasip1. -// Only functions actually used by Sibyl are implemented. +// Only functions actually exercised by validated guests are implemented; +// adding more is mechanical when a new caller surfaces. //go:build wasip1 diff --git a/test/.gitignore b/test/.gitignore index 1fcb5e6..b7bc2d1 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -7,9 +7,6 @@ testdata/assemblies/ # Sliver implant source snapshots. testdata/sliver-src/ -# Tribunus agent source snapshots. -testdata/tribunus-src/ - # Temp files from test runs. *.zip *.exe diff --git a/test/c2/mythic.go b/test/c2/mythic.go deleted file mode 100644 index fb740d5..0000000 --- a/test/c2/mythic.go +++ /dev/null @@ -1,252 +0,0 @@ -//go:build mythic - -package c2 - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" -) - -// MythicClient wraps Mythic's Hasura GraphQL API for test automation. -type MythicClient struct { - baseURL string - httpClient *http.Client - token string -} - -// NewMythicClient authenticates to Mythic and returns a client. -func NewMythicClient(apiURL, username, passwordEnv string) (*MythicClient, error) { - password := os.Getenv(passwordEnv) - if password == "" { - return nil, fmt.Errorf("env var %s not set", passwordEnv) - } - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - // Authenticate to get JWT. - authURL := strings.TrimSuffix(apiURL, "/graphql") + "/auth" - authBody, _ := json.Marshal(map[string]string{ - "username": username, - "password": password, - }) - - resp, err := client.Post(authURL, "application/json", bytes.NewReader(authBody)) - if err != nil { - return nil, fmt.Errorf("authenticating: %w", err) - } - defer resp.Body.Close() - - var authResp struct { - AccessToken string `json:"access_token"` - Status string `json:"status"` - } - body, _ := io.ReadAll(resp.Body) - if err := json.Unmarshal(body, &authResp); err != nil { - return nil, fmt.Errorf("parsing auth response: %w (body: %s)", err, body) - } - if authResp.AccessToken == "" { - return nil, fmt.Errorf("auth failed: %s", body) - } - - return &MythicClient{ - baseURL: apiURL, - httpClient: client, - token: authResp.AccessToken, - }, nil -} - -// graphqlQuery executes a GraphQL query and returns the parsed response. -func (m *MythicClient) graphqlQuery(query string, variables map[string]any) (map[string]any, error) { - reqBody, _ := json.Marshal(map[string]any{ - "query": query, - "variables": variables, - }) - - graphqlURL := m.baseURL - if !strings.HasSuffix(graphqlURL, "/graphql") { - graphqlURL += "/graphql" - } - - req, err := http.NewRequest("POST", graphqlURL, bytes.NewReader(reqBody)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+m.token) - - resp, err := m.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - var result map[string]any - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("parsing graphql response: %w (body: %s)", err, body) - } - - if errors, ok := result["errors"]; ok { - return nil, fmt.Errorf("graphql errors: %v", errors) - } - - data, ok := result["data"].(map[string]any) - if !ok { - return nil, fmt.Errorf("unexpected response structure: %s", body) - } - return data, nil -} - -// WaitForCallback polls for a callback matching the given payload UUID. -// If payloadUUID is empty, matches the most recently created callback. -func (m *MythicClient) WaitForCallback(payloadUUID string, timeout time.Duration) (*Beacon, error) { - deadline := time.Now().Add(timeout) - - // Use filtered query when UUID provided, unfiltered (latest) otherwise. - var query string - var variables map[string]any - - if payloadUUID != "" { - query = `query GetCallbacks($uuid: String!) { - callback(where: {registered_payload: {uuid: {_eq: $uuid}}}, order_by: {id: desc}, limit: 1) { - id - host - user - pid - os - } - }` - variables = map[string]any{"uuid": payloadUUID} - } else { - query = `query GetLatestCallback { - callback(order_by: {id: desc}, limit: 1) { - id - host - user - pid - os - } - }` - variables = nil - } - - for time.Now().Before(deadline) { - data, err := m.graphqlQuery(query, variables) - if err != nil { - time.Sleep(2 * time.Second) - continue - } - - callbacks, ok := data["callback"].([]any) - if ok && len(callbacks) > 0 { - cb := callbacks[0].(map[string]any) - return &Beacon{ - ID: fmt.Sprintf("%.0f", cb["id"]), - Hostname: fmt.Sprintf("%v", cb["host"]), - Username: fmt.Sprintf("%v", cb["user"]), - OS: fmt.Sprintf("%v", cb["os"]), - PID: int(cb["pid"].(float64)), - }, nil - } - - time.Sleep(5 * time.Second) - } - - return nil, fmt.Errorf("no callback for payload %s within %v", payloadUUID, timeout) -} - -// CreateTask issues a command to a callback and returns the task ID. -func (m *MythicClient) CreateTask(callbackID int, command, params string) (int, error) { - query := `mutation CreateTask($callback_id: Int!, $command: String!, $params: String!) { - createTask(callback_id: $callback_id, command: $command, params: $params) { - id - status - error - } - }` - - data, err := m.graphqlQuery(query, map[string]any{ - "callback_id": callbackID, - "command": command, - "params": params, - }) - if err != nil { - return 0, err - } - - task, ok := data["createTask"].(map[string]any) - if !ok { - return 0, fmt.Errorf("unexpected createTask response: %v", data) - } - - if errStr, ok := task["error"].(string); ok && errStr != "" { - return 0, fmt.Errorf("task creation error: %s", errStr) - } - - taskID := int(task["id"].(float64)) - return taskID, nil -} - -// WaitForTaskResult polls until a task completes and returns its output. -func (m *MythicClient) WaitForTaskResult(taskID int, timeout time.Duration) (string, error) { - deadline := time.Now().Add(timeout) - - query := `query GetTask($id: Int!) { - task_by_pk(id: $id) { - status - completed - responses { - response - } - } - }` - - for time.Now().Before(deadline) { - data, err := m.graphqlQuery(query, map[string]any{"id": taskID}) - if err != nil { - time.Sleep(2 * time.Second) - continue - } - - task, ok := data["task_by_pk"].(map[string]any) - if !ok { - time.Sleep(2 * time.Second) - continue - } - - completed, _ := task["completed"].(bool) - if !completed { - time.Sleep(2 * time.Second) - continue - } - - responses, ok := task["responses"].([]any) - if !ok || len(responses) == 0 { - return "", nil - } - - var output strings.Builder - for _, r := range responses { - resp := r.(map[string]any) - if s, ok := resp["response"].(string); ok { - output.WriteString(s) - output.WriteString("\n") - } - } - return strings.TrimSpace(output.String()), nil - } - - return "", fmt.Errorf("task %d did not complete within %v", taskID, timeout) -} diff --git a/test/config.go b/test/config.go index cfd40c4..3bcdf61 100644 --- a/test/config.go +++ b/test/config.go @@ -14,7 +14,6 @@ type Config struct { WasmForge WasmForgeConfig `toml:"wasmforge"` Remote RemoteConfig `toml:"remote"` Sliver SliverConfig `toml:"sliver"` - Mythic MythicConfig `toml:"mythic"` Proxy ProxyConfig `toml:"proxy"` Dotnet DotnetConfig `toml:"dotnet"` } @@ -62,14 +61,6 @@ type SliverConfig struct { NativeRubeusPath string `toml:"native_rubeus_path"` } -type MythicConfig struct { - Enabled bool `toml:"enabled"` - APIURL string `toml:"api_url"` - Username string `toml:"username"` - PasswordEnv string `toml:"password_env"` - TribunusSource string `toml:"tribunus_source"` -} - type ProxyConfig struct { Enabled bool `toml:"enabled"` ChiselSource string `toml:"chisel_source"` @@ -123,7 +114,6 @@ func LoadConfig() (*Config, error) { cfg.Sliver.RubeusPath = expandHome(cfg.Sliver.RubeusPath) cfg.Sliver.NativeSeatbeltPath = expandHome(cfg.Sliver.NativeSeatbeltPath) cfg.Sliver.NativeRubeusPath = expandHome(cfg.Sliver.NativeRubeusPath) - cfg.Mythic.TribunusSource = expandHome(cfg.Mythic.TribunusSource) cfg.Proxy.ChiselSource = expandHome(cfg.Proxy.ChiselSource) cfg.Proxy.LigoloSource = expandHome(cfg.Proxy.LigoloSource) cfg.Dotnet.SeatbeltSource = expandHome(cfg.Dotnet.SeatbeltSource) diff --git a/test/mythic_test.go b/test/mythic_test.go deleted file mode 100644 index 16100ad..0000000 --- a/test/mythic_test.go +++ /dev/null @@ -1,186 +0,0 @@ -//go:build integration && mythic - -package test - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/praetorian-inc/wftest/c2" -) - -func TestMythic(t *testing.T) { - if testing.Short() { - t.Skip("skipping C2 test in short mode") - } - - cfg, err := LoadConfig() - if err != nil { - t.Fatalf("loading config: %v", err) - } - if !cfg.Mythic.Enabled { - t.Skip("mythic tests disabled in config") - } - if cfg.Mythic.TribunusSource == "" { - t.Skip("mythic tribunus_source not configured") - } - if !cfg.Remote.Enabled || !labctlAvailable() { - t.Skip("remote testing not available (labctl or remote.enabled)") - } - - // Connect to Mythic. - mythicClient, err := c2.NewMythicClient(cfg.Mythic.APIURL, cfg.Mythic.Username, cfg.Mythic.PasswordEnv) - if err != nil { - t.Fatalf("connecting to Mythic: %v", err) - } - - // Copy source to temp dir for config patching. - tmpDir, err := os.MkdirTemp("", "wftest-tribunus-*") - if err != nil { - t.Fatalf("creating temp dir: %v", err) - } - t.Cleanup(func() { os.RemoveAll(tmpDir) }) - - if err := copyDir(cfg.Mythic.TribunusSource, tmpDir); err != nil { - t.Fatalf("copying tribunus source: %v", err) - } - - // Build with WasmForge. - t.Log("building Tribunus agent...") - bin := WasmForgeBuild(t, cfg, tmpDir, BuildOpts{ - GOOS: "windows", - GOARCH: "amd64", - Win32APIs: true, - Tags: "shell,ps,netstat,execute_assembly,execute_coff,execute_pe,injection,token,portscan,ldapsearch,socks", - }) - t.Logf("built Tribunus agent in %v (%s)", bin.Duration, bin.Path) - - // Deploy to Win11. - machine := cfg.Remote.Win11.Machine - remotePath := cfg.Remote.Win11.WorkDir + `\tribunus-test.exe` - - labctlKill(t, machine, "tribunus-test.exe") - labctlPush(t, bin.Path, machine, remotePath, true) - labctlExecBackground(t, machine, remotePath) - - // Wait for callback. - t.Log("waiting for Mythic callback...") - // Use empty UUID to match any callback (the payload UUID is compiled into the binary). - callback, err := mythicClient.WaitForCallback("", 90*time.Second) - if err != nil { - t.Fatalf("no Mythic callback: %v", err) - } - t.Logf("got callback: %s (host=%s, user=%s, pid=%d)", - callback.ID, callback.Hostname, callback.Username, callback.PID) - - callbackID := 0 - fmt.Sscanf(callback.ID, "%d", &callbackID) - if callbackID == 0 { - t.Fatalf("invalid callback ID: %s", callback.ID) - } - - // Cleanup on exit. - t.Cleanup(func() { - labctlKill(t, machine, "tribunus-test.exe") - labctlCleanup(t, "kali") - }) - - // Run command battery. - mythicCommands := []struct { - name string - command string - params string - check func(t *testing.T, output string) - }{ - { - name: "shell/whoami", - command: "shell", - params: "whoami", - check: func(t *testing.T, output string) { - if output == "" { - t.Fatal("whoami returned empty") - } - t.Logf("whoami: %s", strings.TrimSpace(output)) - }, - }, - { - name: "shell/hostname", - command: "shell", - params: "hostname", - check: func(t *testing.T, output string) { - if output == "" { - t.Fatal("hostname returned empty") - } - t.Logf("hostname: %s", strings.TrimSpace(output)) - }, - }, - { - name: "ps", - command: "ps", - params: "", - check: func(t *testing.T, output string) { - if output == "" { - t.Fatal("ps returned empty") - } - t.Logf("ps output: %d bytes", len(output)) - }, - }, - { - name: "netstat", - command: "netstat", - params: "", - check: func(t *testing.T, output string) { - if output == "" { - t.Fatal("netstat returned empty") - } - t.Logf("netstat output: %d bytes", len(output)) - }, - }, - } - - for _, mc := range mythicCommands { - mc := mc - t.Run(mc.name, func(t *testing.T) { - taskID, err := mythicClient.CreateTask(callbackID, mc.command, mc.params) - if err != nil { - t.Fatalf("creating task %q: %v", mc.command, err) - } - - output, err := mythicClient.WaitForTaskResult(taskID, 60*time.Second) - if err != nil { - t.Fatalf("waiting for task %d: %v", taskID, err) - } - - mc.check(t, output) - }) - } -} - -// copyDir recursively copies src to dst. -func copyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - - if info.IsDir() { - return os.MkdirAll(target, 0o755) - } - - data, err := os.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(target, data, info.Mode()) - }) -} diff --git a/test/testconfig.example.toml b/test/testconfig.example.toml index e34191c..34fbe3a 100644 --- a/test/testconfig.example.toml +++ b/test/testconfig.example.toml @@ -43,18 +43,6 @@ rubeus_path = "" native_seatbelt_path = "" native_rubeus_path = "" -[mythic] -# Enable Mythic/Tribunus integration tests. -enabled = false -# Mythic API URL (Hasura GraphQL endpoint). -api_url = "https://10.3.10.99:7443" -# Mythic admin username. -username = "mythic_admin" -# Env var containing Mythic admin password. -password_env = "WFTEST_MYTHIC_PASSWORD" -# Path to Tribunus agent source. -tribunus_source = "" - [proxy] # Enable proxy tool tests (chisel, ligolo-ng). enabled = false diff --git a/testdata/darwin_objc/main.go b/testdata/darwin_objc/main.go index 096a159..89b4fd0 100644 --- a/testdata/darwin_objc/main.go +++ b/testdata/darwin_objc/main.go @@ -82,7 +82,7 @@ func main() { passed++ } - // Test 7: Get NSURLSession class (Sibyl's transport) + // Test 7: Get NSURLSession class (typical transport bootstrapping) nsURLSessionClass := objc.GetClass("NSURLSession") if nsURLSessionClass == 0 { fmt.Fprintf(os.Stderr, "FAIL: GetClass NSURLSession = 0\n") diff --git a/testdata/win32_clr_load/main.go b/testdata/win32_clr_load/main.go index 05eca0f..5bbe151 100644 --- a/testdata/win32_clr_load/main.go +++ b/testdata/win32_clr_load/main.go @@ -1,5 +1,5 @@ // win32_clr_load tests _AppDomain::Load_3 with a real .NET assembly. -// Uses ICorRuntimeHost + SyscallN (same as go-clr/Tribunus). +// Uses ICorRuntimeHost + SyscallN (the standard go-clr pattern). // // Build: GOOS=windows GOARCH=amd64 wasmforge build --win32-apis -v -o clr_load.exe ./testdata/win32_clr_load // Run: Copy Seatbelt.exe to C:\Temp\ on Win11, then run clr_load.exe