Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0a2310b
test(config): fix config ai example assertion
wuchulonly Mar 22, 2026
26e9705
test(llm): isolate env vars in resolve tests
wuchulonly Mar 22, 2026
536aa5d
chore(deps): add suo5 and proxyclient dependencies
wuchulonly Mar 22, 2026
db5889a
feat(pipeline): add webshell pipeline client commands
wuchulonly Mar 22, 2026
8e4fc97
feat(webshell-bridge): add webshell bridge server binary
wuchulonly Mar 22, 2026
046d21e
docs(protocol): add webshell bridge documentation
wuchulonly Mar 22, 2026
49712da
refactor(webshell-bridge): replace TCP transport with HTTP memory cha…
wuchulonly Mar 22, 2026
b95d771
docs(protocol): update webshell bridge for HTTP memory channel archit…
wuchulonly Mar 22, 2026
b9b3c02
feat(webshell-bridge): add long-poll, HMAC auth, jitter and DLL auto-…
wuchulonly Mar 22, 2026
b3c3f2f
docs(protocol): add DLL auto-load usage and manual loading section
wuchulonly Mar 22, 2026
17acc8e
feat(webshell-bridge): add pipelinectl debug utility
wuchulonly Mar 22, 2026
5702b46
feat(webshell-bridge): add deps delivery, streaming and URL refactor
wuchulonly Mar 22, 2026
53a9d35
refactor(webshell-bridge): remove standalone bridge binary
wuchulonly Mar 22, 2026
86b5d58
chore(deps): update IoM-go submodule and move suo5 to indirect
wuchulonly Mar 22, 2026
f12afe5
feat(pipeline): preserve raw custom params through DB roundtrips
wuchulonly Mar 22, 2026
a8a4ae4
feat(listener): add WebShellPipeline with suo5 transport
wuchulonly Mar 22, 2026
243a0ef
refactor(pipeline): update webshell commands for suo5 transport
wuchulonly Mar 22, 2026
9bee95d
docs(protocol): update webshell bridge for in-listener architecture
wuchulonly Mar 22, 2026
3afd34e
refactor(pipeline): reuse MaleficParser, extract runtimeErrorHandler,…
M09Ic Mar 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/command/config/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 --") {
Expand Down
103 changes: 101 additions & 2 deletions client/command/pipeline/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>",
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 <name>",
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 <name>",
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.
Expand Down
5 changes: 3 additions & 2 deletions client/command/pipeline/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ 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{
consts.CommandPipelineTcp: true,
consts.HTTPPipeline: true,
consts.CommandPipelineBind: true,
consts.CommandRem: true,
"webshell": true,
}
for _, cmd := range cmds {
delete(want, cmd.Name())
Expand Down
206 changes: 206 additions & 0 deletions client/command/pipeline/webshell.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading