Commit d77e9da
Changed files (25)
cmd
pkg
cmd/callbacks.go
@@ -23,7 +23,7 @@ func init() {
}
func runCallbacks(cmd *cobra.Command, args []string) error {
- if err := validateAuth(); err != nil {
+ if err := validateConfig(); err != nil {
return err
}
cmd/exec.go
@@ -1,103 +0,0 @@
-package cmd
-
-import (
- "context"
- "fmt"
- "go-mythic/pkg/mythic"
- "strconv"
- "strings"
- "time"
-
- "github.com/spf13/cobra"
-)
-
-var execCmd = &cobra.Command{
- Use: "exec <callback_id> <command> [args...]",
- Short: "Execute a command on a specific callback",
- Long: "Execute a command on the specified callback and wait for the response. Supports commands like help, ps, sleep, etc.",
- Args: cobra.MinimumNArgs(2),
- RunE: runExec,
-}
-
-var (
- waitTime int
-)
-
-func init() {
- rootCmd.AddCommand(execCmd)
- execCmd.Flags().IntVarP(&waitTime, "wait", "w", 30, "Maximum time to wait for response (seconds)")
-}
-
-func runExec(cmd *cobra.Command, args []string) error {
- if err := validateAuth(); err != nil {
- return err
- }
-
- callbackID, err := strconv.Atoi(args[0])
- if err != nil {
- return fmt.Errorf("invalid callback ID: %s", args[0])
- }
-
- command := args[1]
- params := ""
- if len(args) > 2 {
- params = strings.Join(args[2:], " ")
- }
-
- client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
- ctx := context.Background()
-
- // Verify callback exists and is active
- callbacks, err := client.GetActiveCallbacks(ctx)
- if err != nil {
- return fmt.Errorf("failed to get callbacks: %w", err)
- }
-
- var targetCallback *mythic.Callback
- for _, callback := range callbacks {
- if callback.DisplayID == callbackID {
- targetCallback = &callback
- break
- }
- }
-
- if targetCallback == nil {
- return fmt.Errorf("callback %d not found or not active", callbackID)
- }
-
- fmt.Printf("Executing '%s %s' on callback %d (%s@%s)\n",
- command, params, callbackID, targetCallback.User, targetCallback.Host)
-
- // Create the task
- task, err := client.CreateTask(ctx, targetCallback.ID, command, params)
- if err != nil {
- return fmt.Errorf("failed to create task: %w", err)
- }
-
- fmt.Printf("Task %d created, waiting for response...\n", task.DisplayID)
-
- // Poll for response
- for i := 0; i < waitTime; i++ {
- time.Sleep(1 * time.Second)
-
- updatedTask, err := client.GetTaskResponse(ctx, task.ID)
- if err != nil {
- return fmt.Errorf("failed to get task response: %w", err)
- }
-
- if updatedTask.Status == "completed" || updatedTask.Status == "error" {
- fmt.Printf("\nTask Status: %s\n", updatedTask.Status)
- if updatedTask.Response != "" {
- fmt.Printf("Response:\n%s\n", updatedTask.Response)
- }
- return nil
- }
-
- if i > 0 && i%5 == 0 {
- fmt.Printf(".")
- }
- }
-
- fmt.Printf("\nTimeout waiting for response after %d seconds\n", waitTime)
- return nil
-}
\ No newline at end of file
cmd/files.go
@@ -0,0 +1,333 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "go-mythic/pkg/mythic/api"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+var (
+ outputDir string
+ showPayloads bool
+ showDownloads bool
+ showScreenshots bool
+)
+
+var filesCmd = &cobra.Command{
+ Use: "files",
+ Aliases: []string{"file"},
+ Short: "Manage files",
+ Long: "List and download files (payloads, downloads, screenshots) from Mythic. Defaults to listing all files if no subcommand is provided.",
+ RunE: runFilesList, // Default to list command
+}
+
+
+var filesDownloadCmd = &cobra.Command{
+ Use: "download <file_uuid_or_task_id>",
+ Short: "Download a file by UUID or task ID",
+ Long: "Download a file from Mythic using either its UUID or the task ID that downloaded the file. When using task ID, the UUID will be extracted from the task response.",
+ Args: cobra.ExactArgs(1),
+ RunE: runFilesDownload,
+}
+
+func init() {
+ rootCmd.AddCommand(filesCmd)
+ filesCmd.AddCommand(filesDownloadCmd)
+
+ // Add filter flags to files command
+ filesCmd.Flags().BoolVarP(&showPayloads, "payload", "p", false, "Show only payload files")
+ filesCmd.Flags().BoolVarP(&showDownloads, "downloads", "d", false, "Show only downloaded files")
+ filesCmd.Flags().BoolVarP(&showScreenshots, "screenshots", "s", false, "Show only screenshot files")
+
+ filesDownloadCmd.Flags().StringVarP(&outputDir, "output", "o", ".", "Output directory for downloaded files")
+}
+
+func runFilesList(cmd *cobra.Command, args []string) error {
+ if err := validateConfig(); err != nil {
+ return err
+ }
+
+ client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ files, err := client.GetAllFiles(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get files: %w", err)
+ }
+
+ // Apply filters if any are specified
+ if showPayloads || showDownloads || showScreenshots {
+ var filteredFiles []mythic.File
+ for _, file := range files {
+ if (showPayloads && file.IsPayload) ||
+ (showDownloads && file.IsDownloadFromAgent && !file.IsPayload && !file.IsScreenshot) ||
+ (showScreenshots && file.IsScreenshot) {
+ filteredFiles = append(filteredFiles, file)
+ }
+ }
+ files = filteredFiles
+ }
+
+ if len(files) == 0 {
+ fmt.Println("No files found")
+ return nil
+ }
+
+ // Determine filter description
+ filterDesc := ""
+ if showPayloads || showDownloads || showScreenshots {
+ var filters []string
+ if showPayloads {
+ filters = append(filters, "payloads")
+ }
+ if showDownloads {
+ filters = append(filters, "downloads")
+ }
+ if showScreenshots {
+ filters = append(filters, "screenshots")
+ }
+ filterDesc = fmt.Sprintf(" (%s)", strings.Join(filters, ", "))
+ }
+
+ fmt.Printf("Files%s (%d total):\n\n", filterDesc, len(files))
+
+ // Create a tab writer for formatted output
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "UUID\tTYPE\tFILENAME\tREMOTE PATH\tHOST\tSIZE\tCOMPLETE\tTASK\tTIMESTAMP")
+ fmt.Fprintln(w, "----\t----\t--------\t-----------\t----\t----\t--------\t----\t---------")
+
+ for _, file := range files {
+ // Show completion status
+ status := "✓"
+ if !file.Complete {
+ status = fmt.Sprintf("%d/%d", file.ChunksReceived, file.TotalChunks)
+ }
+
+ // Format timestamp
+ timestamp := file.Timestamp
+ if timestamp != "" {
+ if t, err := time.Parse(time.RFC3339, timestamp); err == nil {
+ timestamp = t.Format("2006-01-02 15:04")
+ }
+ }
+
+ // Truncate paths if too long
+ filename := file.Filename
+ if len(filename) > 25 {
+ filename = filename[:22] + "..."
+ }
+
+ remotePath := file.FullRemotePath
+ if len(remotePath) > 40 {
+ remotePath = "..." + remotePath[len(remotePath)-37:]
+ }
+
+ // Show file size info (chunks can give us a rough idea)
+ sizeInfo := "unknown"
+ if file.TotalChunks > 0 {
+ sizeInfo = fmt.Sprintf("~%dKB", file.TotalChunks*api.EstimatedChunkSizeKB)
+ }
+
+ // Determine file type
+ fileType := "download"
+ if file.IsPayload {
+ fileType = "payload"
+ } else if file.IsScreenshot {
+ fileType = "screenshot"
+ }
+
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\n",
+ file.AgentFileID[:api.UUIDDisplayLength]+"...", // Show first 8 chars of UUID
+ fileType,
+ filename,
+ remotePath,
+ file.CallbackHost,
+ sizeInfo,
+ status,
+ file.TaskDisplayID,
+ timestamp,
+ )
+ }
+
+ w.Flush()
+
+ fmt.Println("\nTo download a file: go-mythic files download <uuid>")
+ fmt.Println("To view full UUIDs: go-mythic files | grep -v UUID")
+
+ return nil
+}
+
+func runFilesDownload(cmd *cobra.Command, args []string) error {
+ if err := validateConfig(); err != nil {
+ return err
+ }
+
+ input := args[0]
+ client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) // Longer timeout for downloads
+ defer cancel()
+
+ var fileUUID string
+
+ // Determine if input is a UUID (36 chars with hyphens) or a task ID (numeric)
+ if len(input) == api.UUIDLength && regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`).MatchString(input) {
+ // Input looks like a UUID
+ fileUUID = input
+ fmt.Printf("Using provided UUID: %s\n", fileUUID)
+ } else if taskID, err := strconv.Atoi(input); err == nil {
+ // Input is numeric, treat as task ID
+ fmt.Printf("Looking up task %d for file UUID...\n", taskID)
+
+ // Get the task response
+ task, err := client.GetTaskResponse(ctx, taskID)
+ if err != nil {
+ return fmt.Errorf("failed to get task %d: %w", taskID, err)
+ }
+
+ // Extract UUIDs from the response
+ uuids := extractUUIDFromTaskResponse(task.Response)
+ if len(uuids) == 0 {
+ return fmt.Errorf("no file UUIDs found in task %d response. Raw response:\n%s", taskID, task.Response)
+ }
+
+ if len(uuids) > 1 {
+ fmt.Printf("Found multiple UUIDs in task response:\n")
+ for i, uuid := range uuids {
+ fmt.Printf(" %d: %s\n", i+1, uuid)
+ }
+ fmt.Printf("Using first UUID: %s\n", uuids[0])
+ }
+
+ fileUUID = uuids[0]
+ fmt.Printf("Extracted UUID: %s\n", fileUUID)
+ } else {
+ return fmt.Errorf("input '%s' is neither a valid UUID nor a numeric task ID", input)
+ }
+
+ // Get file metadata to get the original filename
+ files, err := client.GetAllFiles(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get file metadata: %w", err)
+ }
+
+ var targetFile *mythic.File
+ for _, file := range files {
+ if file.AgentFileID == fileUUID {
+ targetFile = &file
+ break
+ }
+ }
+
+ if targetFile == nil {
+ return fmt.Errorf("file with UUID %s not found", fileUUID)
+ }
+
+ if !targetFile.Complete {
+ fmt.Printf("Warning: File is not fully downloaded (%d/%d chunks)\n",
+ targetFile.ChunksReceived, targetFile.TotalChunks)
+ }
+
+ fmt.Printf("Downloading: %s\n", targetFile.Filename)
+ fmt.Printf("From: %s (%s@%s)\n", targetFile.FullRemotePath, targetFile.CallbackUser, targetFile.CallbackHost)
+
+ // Download the file
+ data, err := client.DownloadFile(ctx, fileUUID)
+ if err != nil {
+ return fmt.Errorf("failed to download file: %w", err)
+ }
+
+ // Determine output filename
+ outputFilename := targetFile.Filename
+ if outputFilename == "" {
+ outputFilename = fileUUID + ".bin"
+ }
+
+ outputPath := filepath.Join(outputDir, outputFilename)
+
+ // Handle file conflicts
+ counter := 1
+ originalPath := outputPath
+ for {
+ if _, err := os.Stat(outputPath); os.IsNotExist(err) {
+ break
+ }
+ ext := filepath.Ext(originalPath)
+ base := originalPath[:len(originalPath)-len(ext)]
+ outputPath = fmt.Sprintf("%s_%d%s", base, counter, ext)
+ counter++
+ }
+
+ // Write file
+ err = os.WriteFile(outputPath, data, 0644)
+ if err != nil {
+ return fmt.Errorf("failed to write file: %w", err)
+ }
+
+ fmt.Printf("Downloaded %d bytes to: %s\n", len(data), outputPath)
+
+ if targetFile.MD5 != "" {
+ fmt.Printf("MD5: %s\n", targetFile.MD5)
+ }
+ if targetFile.SHA1 != "" {
+ fmt.Printf("SHA1: %s\n", targetFile.SHA1)
+ }
+
+ return nil
+}
+
+// extractUUIDFromTaskResponse attempts to extract file UUIDs from task response text
+func extractUUIDFromTaskResponse(response string) []string {
+ // Common UUID patterns in Mythic responses
+ patterns := []string{
+ // Standard UUID format
+ `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`,
+ // Sometimes UUIDs appear without hyphens
+ `[0-9a-f]{32}`,
+ // Look for specific download-related patterns
+ `(?i)(?:file\s+(?:uuid|id|identifier):\s*)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`,
+ `(?i)(?:downloaded.*?file.*?)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`,
+ `(?i)(?:uuid:\s*)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`,
+ }
+
+ var uuids []string
+ seenUUIDs := make(map[string]bool)
+
+ for _, pattern := range patterns {
+ re := regexp.MustCompile(pattern)
+ matches := re.FindAllStringSubmatch(response, -1)
+
+ for _, match := range matches {
+ var uuid string
+ if len(match) > 1 {
+ uuid = match[1] // Captured group
+ } else {
+ uuid = match[0] // Full match
+ }
+
+ // Normalize UUID format (add hyphens if missing)
+ if len(uuid) == api.UUIDLengthNoHyphens && !regexp.MustCompile(`-`).MatchString(uuid) {
+ uuid = fmt.Sprintf("%s-%s-%s-%s-%s",
+ uuid[0:8], uuid[8:12], uuid[12:16], uuid[16:20], uuid[20:32])
+ }
+
+ // Only add if it's a valid UUID format and we haven't seen it before
+ if len(uuid) == api.UUIDLength && !seenUUIDs[uuid] {
+ uuids = append(uuids, uuid)
+ seenUUIDs[uuid] = true
+ }
+ }
+ }
+
+ return uuids
+}
\ No newline at end of file
cmd/grep.go
@@ -0,0 +1,266 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "os"
+ "regexp"
+ "strings"
+ "sync"
+ "text/tabwriter"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+var (
+ grepCallbackID int
+ grepCaseInsensitive bool
+ grepLimit int
+ grepRegex bool
+ grepInvert bool
+ grepWorkers int
+)
+
+var grepCmd = &cobra.Command{
+ Use: "grep <pattern>",
+ Short: "Search through task outputs",
+ Long: `Search through task response outputs for a specific pattern.
+Shows a table of matching tasks with their commands instead of full output.
+This is like grep for Mythic task results - find specific strings, commands, or data across all your task history.`,
+ Args: cobra.ExactArgs(1),
+ RunE: runGrep,
+}
+
+func init() {
+ rootCmd.AddCommand(grepCmd)
+ grepCmd.Flags().IntVarP(&grepCallbackID, "callback", "c", 0, "Search only tasks from specific callback ID")
+ grepCmd.Flags().BoolVarP(&grepCaseInsensitive, "ignore-case", "i", false, "Case insensitive search")
+ grepCmd.Flags().IntVarP(&grepLimit, "limit", "l", 0, "Maximum number of tasks to search (0 = no limit)")
+ grepCmd.Flags().BoolVarP(&grepRegex, "regexp", "E", false, "Treat pattern as regular expression")
+ grepCmd.Flags().BoolVarP(&grepInvert, "invert-match", "v", false, "Show tasks that don't match")
+ grepCmd.Flags().IntVarP(&grepWorkers, "workers", "w", 10, "Number of parallel workers for searching")
+}
+
+type taskResult struct {
+ task mythic.Task
+ match bool
+ err error
+}
+
+func runGrep(cmd *cobra.Command, args []string) error {
+ if err := validateConfig(); err != nil {
+ return err
+ }
+
+ pattern := args[0]
+ client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
+
+ // Use shorter timeout for getting task lists
+ listCtx, listCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer listCancel()
+
+ var tasks []mythic.Task
+ var err error
+
+ if grepCallbackID > 0 {
+ // Search tasks for specific callback
+ tasks, err = client.GetCallbackTasks(listCtx, grepCallbackID)
+ if err != nil {
+ return fmt.Errorf("failed to get tasks for callback %d: %w", grepCallbackID, err)
+ }
+ fmt.Printf("Searching tasks from callback %d for pattern: %s\n", grepCallbackID, pattern)
+ } else {
+ // Get all tasks from all callbacks
+ callbacks, err := client.GetActiveCallbacks(listCtx)
+ if err != nil {
+ return fmt.Errorf("failed to get callbacks: %w", err)
+ }
+
+ fmt.Printf("Searching all tasks for pattern: %s\n", pattern)
+
+ for _, callback := range callbacks {
+ callbackTasks, err := client.GetCallbackTasks(listCtx, callback.ID)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get tasks for callback %d: %v\n", callback.DisplayID, err)
+ continue
+ }
+ tasks = append(tasks, callbackTasks...)
+ }
+ }
+
+ if len(tasks) == 0 {
+ fmt.Println("No tasks found to search")
+ return nil
+ }
+
+ // Apply limit if specified
+ if grepLimit > 0 && len(tasks) > grepLimit {
+ // Take the most recent tasks
+ tasks = tasks[len(tasks)-grepLimit:]
+ }
+
+ fmt.Printf("Searching through %d tasks with %d workers...\n\n", len(tasks), grepWorkers)
+
+ // Prepare pattern for matching
+ var matcher func(string) bool
+ if grepRegex {
+ regexPattern := pattern
+ if grepCaseInsensitive {
+ regexPattern = "(?i)" + pattern
+ }
+ re, err := regexp.Compile(regexPattern)
+ if err != nil {
+ return fmt.Errorf("invalid regular expression: %w", err)
+ }
+ matcher = re.MatchString
+ } else {
+ searchPattern := pattern
+ if grepCaseInsensitive {
+ searchPattern = strings.ToLower(pattern)
+ }
+ matcher = func(text string) bool {
+ searchText := text
+ if grepCaseInsensitive {
+ searchText = strings.ToLower(text)
+ }
+ return strings.Contains(searchText, searchPattern)
+ }
+ }
+
+ // Use worker pool to process tasks in parallel
+ results := processTasksWithWorkerPool(client, tasks, matcher, grepWorkers)
+
+ var matchingTasks []mythic.Task
+ processedTasks := 0
+ errorCount := 0
+
+ for result := range results {
+ processedTasks++
+ if result.err != nil {
+ errorCount++
+ if errorCount <= 5 { // Only show first 5 errors
+ fmt.Printf("Warning: Failed to get response for task %d: %v\n", result.task.DisplayID, result.err)
+ }
+ continue
+ }
+
+ // Apply invert logic
+ matches := result.match
+ if grepInvert {
+ matches = !matches
+ }
+
+ if matches {
+ matchingTasks = append(matchingTasks, result.task)
+ }
+ }
+
+ if errorCount > 5 {
+ fmt.Printf("Warning: Suppressed %d additional errors\n", errorCount-5)
+ }
+
+ if len(matchingTasks) == 0 {
+ fmt.Printf("No matches found in %d tasks searched\n", processedTasks)
+ return nil
+ }
+
+ // Show results in table format
+ showMatchingTasksTable(matchingTasks)
+
+ fmt.Printf("\nFound %d matches in %d tasks searched", len(matchingTasks), processedTasks)
+ if errorCount > 0 {
+ fmt.Printf(" (%d errors)", errorCount)
+ }
+ fmt.Println()
+ return nil
+}
+
+func processTasksWithWorkerPool(client *mythic.Client, tasks []mythic.Task, matcher func(string) bool, numWorkers int) <-chan taskResult {
+ taskChan := make(chan mythic.Task, len(tasks))
+ resultChan := make(chan taskResult, len(tasks))
+
+ // Start workers
+ var wg sync.WaitGroup
+ for i := 0; i < numWorkers; i++ {
+ wg.Add(1)
+ go worker(&wg, client, taskChan, resultChan, matcher)
+ }
+
+ // Send tasks to workers
+ go func() {
+ for _, task := range tasks {
+ taskChan <- task
+ }
+ close(taskChan)
+ }()
+
+ // Close results channel when all workers are done
+ go func() {
+ wg.Wait()
+ close(resultChan)
+ }()
+
+ return resultChan
+}
+
+func worker(wg *sync.WaitGroup, client *mythic.Client, taskChan <-chan mythic.Task, resultChan chan<- taskResult, matcher func(string) bool) {
+ defer wg.Done()
+
+ for task := range taskChan {
+ // Use per-request timeout to avoid global timeouts
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
+ fullTask, err := client.GetTaskResponse(ctx, task.ID)
+ cancel()
+
+ if err != nil {
+ resultChan <- taskResult{task: task, match: false, err: err}
+ continue
+ }
+
+ if fullTask.Response == "" {
+ resultChan <- taskResult{task: task, match: false, err: nil}
+ continue
+ }
+
+ // Check if response matches pattern
+ matches := matcher(fullTask.Response)
+ resultChan <- taskResult{task: *fullTask, match: matches, err: nil}
+ }
+}
+
+func showMatchingTasksTable(tasks []mythic.Task) {
+ // Create a tab writer for formatted output
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "TASK ID\tCOMMAND\tPARAMS\tSTATUS\tCALLBACK\tTIMESTAMP")
+ fmt.Fprintln(w, "-------\t-------\t------\t------\t--------\t---------")
+
+ for _, task := range tasks {
+ // Truncate params if too long
+ params := task.Params
+ if len(params) > 50 {
+ params = params[:47] + "..."
+ }
+
+ // Format timestamp
+ timestamp := task.Timestamp
+ if timestamp != "" {
+ if t, err := time.Parse(time.RFC3339, timestamp); err == nil {
+ timestamp = t.Format("2006-01-02 15:04")
+ }
+ }
+
+ fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n",
+ task.DisplayID,
+ task.Command,
+ params,
+ task.Status,
+ task.CallbackID,
+ timestamp,
+ )
+ }
+
+ w.Flush()
+}
\ No newline at end of file
cmd/interact.go
@@ -5,10 +5,10 @@ import (
"context"
"fmt"
"go-mythic/pkg/mythic"
+ "go-mythic/pkg/mythic/api"
"os"
"strconv"
"strings"
- "time"
"github.com/spf13/cobra"
)
@@ -26,7 +26,7 @@ func init() {
}
func runInteract(cmd *cobra.Command, args []string) error {
- if err := validateAuth(); err != nil {
+ if err := validateConfig(); err != nil {
return err
}
@@ -39,21 +39,9 @@ func runInteract(cmd *cobra.Command, args []string) error {
ctx := context.Background()
// Verify callback exists and is active
- callbacks, err := client.GetActiveCallbacks(ctx)
+ targetCallback, err := api.FindActiveCallback(ctx, client, callbackID)
if err != nil {
- return fmt.Errorf("failed to get callbacks: %w", err)
- }
-
- var targetCallback *mythic.Callback
- for _, callback := range callbacks {
- if callback.DisplayID == callbackID {
- targetCallback = &callback
- break
- }
- }
-
- if targetCallback == nil {
- return fmt.Errorf("callback %d not found or not active", callbackID)
+ return err
}
agentType := targetCallback.Payload.PayloadType.Name
@@ -107,39 +95,25 @@ func executeCommand(ctx context.Context, client *mythic.Client, callbackID int,
params = strings.Join(parts[1:], " ")
}
- // Create the task
- task, err := client.CreateTask(ctx, callbackID, command, params)
- if err != nil {
- return fmt.Errorf("failed to create task: %w", err)
+ // Configure polling for interactive mode
+ config := api.TaskPollConfig{
+ TimeoutSeconds: int(api.DefaultTaskTimeout.Seconds()),
+ PollInterval: api.DefaultPollInterval,
+ ShowProgress: true,
+ RawOutput: false,
}
- fmt.Printf("Task %d created\n", task.DisplayID)
-
- // Poll for response
- for i := 0; i < 30; i++ { // Poll for up to 30 seconds
- time.Sleep(1 * time.Second)
-
- updatedTask, err := client.GetTaskResponse(ctx, task.ID)
- if err != nil {
- return fmt.Errorf("failed to get task response: %w", err)
- }
-
- if updatedTask.Status == "completed" || updatedTask.Status == "error" {
- if updatedTask.Response != "" {
- fmt.Printf("Response:\n%s\n", updatedTask.Response)
- } else {
- fmt.Printf("Task completed with status: %s\n", updatedTask.Status)
- }
- return nil
- }
-
- if i == 0 {
- fmt.Printf("Waiting for response...")
- } else if i%5 == 0 {
- fmt.Printf(".")
- }
+ // Execute task and wait for response
+ updatedTask, err := api.ExecuteTaskAndWait(ctx, client, callbackID, command, params, config)
+ if err != nil {
+ fmt.Printf("\n%v\n", err)
+ return nil
}
- fmt.Println("\nTimeout waiting for response")
+ if updatedTask.Response != "" {
+ fmt.Printf("\nResponse:\n%s\n", updatedTask.Response)
+ } else {
+ fmt.Printf("\nTask completed with status: %s\n", updatedTask.Status)
+ }
return nil
}
\ No newline at end of file
cmd/payload.go
@@ -0,0 +1,226 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "os"
+ "strconv"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+var payloadCmd = &cobra.Command{
+ Use: "payload [payload_id]",
+ Aliases: []string{"payloads"},
+ Short: "List payloads or show payload details",
+ Long: "Display all payloads created in Mythic, or show detailed information for a specific payload by ID.",
+ Args: cobra.RangeArgs(0, 1),
+ RunE: runPayload,
+}
+
+func init() {
+ rootCmd.AddCommand(payloadCmd)
+}
+
+func runPayload(cmd *cobra.Command, args []string) error {
+ if err := validateConfig(); err != nil {
+ return err
+ }
+
+ client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // If payload ID is provided, show detailed view
+ if len(args) > 0 {
+ payloadID, err := strconv.Atoi(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid payload ID: %s", args[0])
+ }
+ return showPayloadDetails(ctx, client, payloadID)
+ }
+
+ // Otherwise, show list view
+ return showPayloadList(ctx, client)
+}
+
+func showPayloadList(ctx context.Context, client *mythic.Client) error {
+ payloads, err := client.GetPayloads(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get payloads: %w", err)
+ }
+
+ if len(payloads) == 0 {
+ fmt.Println("No payloads found")
+ return nil
+ }
+
+ fmt.Printf("Payloads (%d total):\n\n", len(payloads))
+
+ // Create a tab writer for formatted output
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "ID\tTYPE\tDESCRIPTION\tFILENAME\tBUILD STATUS\tC2 PROFILES\tOPERATOR\tCREATED")
+ fmt.Fprintln(w, "--\t----\t-----------\t--------\t------------\t-----------\t--------\t-------")
+
+ for _, payload := range payloads {
+ // Format description
+ description := payload.Description
+ if len(description) > 30 {
+ description = description[:27] + "..."
+ }
+ if description == "" {
+ description = "-"
+ }
+
+ // Format filename
+ filename := payload.Filename
+ if len(filename) > 20 {
+ filename = filename[:17] + "..."
+ }
+ if filename == "" {
+ filename = "-"
+ }
+
+ // Format build status
+ buildStatus := payload.BuildPhase
+ if buildStatus == "" {
+ buildStatus = "unknown"
+ }
+
+ // Format C2 profiles
+ c2ProfilesStr := strings.Join(payload.C2Profiles, ",")
+ if len(c2ProfilesStr) > 20 {
+ c2ProfilesStr = c2ProfilesStr[:17] + "..."
+ }
+ if c2ProfilesStr == "" {
+ c2ProfilesStr = "-"
+ }
+
+ // Format creation time
+ created := payload.CreationTime
+ if created != "" {
+ if t, err := time.Parse(time.RFC3339, created); err == nil {
+ created = t.Format("2006-01-02 15:04")
+ }
+ }
+
+ // Show auto-generated indicator
+ typeStr := payload.PayloadTypeName
+ if payload.AutoGenerated {
+ typeStr += "*"
+ }
+
+ fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ payload.ID,
+ typeStr,
+ description,
+ filename,
+ buildStatus,
+ c2ProfilesStr,
+ payload.OperatorUsername,
+ created,
+ )
+ }
+
+ w.Flush()
+
+ fmt.Println("\n* = Auto-generated payload")
+ fmt.Println("\nTo view payload details: go-mythic payload <id>")
+ fmt.Println("To download a payload file: go-mythic files download <agent_file_id>")
+
+ return nil
+}
+
+func showPayloadDetails(ctx context.Context, client *mythic.Client, payloadID int) error {
+ payloads, err := client.GetPayloads(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get payloads: %w", err)
+ }
+
+ var targetPayload *mythic.Payload
+ for _, payload := range payloads {
+ if payload.ID == payloadID {
+ targetPayload = &payload
+ break
+ }
+ }
+
+ if targetPayload == nil {
+ return fmt.Errorf("payload with ID %d not found", payloadID)
+ }
+
+ fmt.Printf("Payload Details (ID: %d)\n", targetPayload.ID)
+ fmt.Println(strings.Repeat("=", 50))
+
+ // Basic information
+ fmt.Printf("UUID: %s\n", targetPayload.UUID)
+ fmt.Printf("Type: %s", targetPayload.PayloadTypeName)
+ if targetPayload.AutoGenerated {
+ fmt.Printf(" (auto-generated)")
+ }
+ fmt.Println()
+
+ fmt.Printf("Description: %s\n", getValueOrDefault(targetPayload.Description, "No description"))
+ fmt.Printf("Filename: %s\n", getValueOrDefault(targetPayload.Filename, "No filename"))
+
+ // Build information
+ fmt.Println("\nBuild Information:")
+ fmt.Printf(" Status: %s\n", getValueOrDefault(targetPayload.BuildPhase, "unknown"))
+
+ if targetPayload.BuildMessage != "" {
+ fmt.Printf(" Message: %s\n", targetPayload.BuildMessage)
+ }
+
+ if targetPayload.BuildStderr != "" {
+ fmt.Printf(" Errors: %s\n", targetPayload.BuildStderr)
+ }
+
+ // C2 Profiles
+ fmt.Println("\nC2 Profiles:")
+ if len(targetPayload.C2Profiles) > 0 {
+ for _, profile := range targetPayload.C2Profiles {
+ fmt.Printf(" - %s\n", profile)
+ }
+ } else {
+ fmt.Println(" No C2 profiles configured")
+ }
+
+ // File information
+ if targetPayload.AgentFileID != "" {
+ fmt.Println("\nFile Information:")
+ fmt.Printf(" Agent File ID: %s\n", targetPayload.AgentFileID)
+ fmt.Printf(" File ID: %d\n", targetPayload.FileID)
+ }
+
+ // Metadata
+ fmt.Println("\nMetadata:")
+ fmt.Printf(" Operator: %s (ID: %d)\n", targetPayload.OperatorUsername, targetPayload.OperatorID)
+ fmt.Printf(" Callback Alert: %t\n", targetPayload.CallbackAlert)
+
+ if targetPayload.CreationTime != "" {
+ if t, err := time.Parse(time.RFC3339, targetPayload.CreationTime); err == nil {
+ fmt.Printf(" Created: %s\n", t.Format("2006-01-02 15:04:05"))
+ } else {
+ fmt.Printf(" Created: %s\n", targetPayload.CreationTime)
+ }
+ }
+
+ // Download instructions
+ if targetPayload.AgentFileID != "" {
+ fmt.Printf("\nTo download this payload:\n")
+ fmt.Printf(" go-mythic files download %s\n", targetPayload.AgentFileID)
+ }
+
+ return nil
+}
+
+func getValueOrDefault(value, defaultValue string) string {
+ if value == "" {
+ return defaultValue
+ }
+ return value
+}
\ No newline at end of file
cmd/root.go
@@ -29,7 +29,7 @@ func Execute() {
}
func init() {
- rootCmd.PersistentFlags().StringVar(&mythicURL, "url", "https://mythic.adversarytactics.local:7443/graphql/", "Mythic GraphQL API URL")
+ rootCmd.PersistentFlags().StringVar(&mythicURL, "url", "", "Mythic GraphQL API URL")
rootCmd.PersistentFlags().StringVar(&token, "token", "", "JWT authentication token")
rootCmd.PersistentFlags().BoolVar(&insecure, "insecure", true, "Skip TLS certificate verification")
rootCmd.PersistentFlags().StringVar(&socksProxy, "socks", "", "SOCKS5 proxy address (e.g., 127.0.0.1:9050)")
@@ -38,14 +38,22 @@ func init() {
if urlEnv := os.Getenv("MYTHIC_API_URL"); urlEnv != "" {
mythicURL = urlEnv
}
-
+
// Set default token from environment if available
if tokenEnv := os.Getenv("MYTHIC_API_TOKEN"); tokenEnv != "" {
token = tokenEnv
}
+
+ // Set default SOCKS proxy from environment if available
+ if socksEnv := os.Getenv("MYTHIC_SOCKS"); socksEnv != "" {
+ socksProxy = socksEnv
+ }
}
-func validateAuth() error {
+func validateConfig() error {
+ if mythicURL == "" {
+ return fmt.Errorf("Mythic server URL is required. Use --url flag or set MYTHIC_API_URL environment variable")
+ }
if token == "" {
return fmt.Errorf("authentication token is required. Use --token flag or set MYTHIC_API_TOKEN environment variable")
}
cmd/task.go
@@ -0,0 +1,97 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "go-mythic/pkg/mythic/api"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+var (
+ taskWaitTime int
+ taskRawOutput bool
+)
+
+var taskCmd = &cobra.Command{
+ Use: "task <callback_id> <command> [args...]",
+ Aliases: []string{"t", "exec"},
+ Short: "Send a command to an agent and wait for response",
+ Long: "Send a command to the specified agent callback and wait for the response. This is like a single-shot version of interact.",
+ Args: cobra.MinimumNArgs(2),
+ RunE: runTask,
+}
+
+func init() {
+ rootCmd.AddCommand(taskCmd)
+ taskCmd.Flags().IntVarP(&taskWaitTime, "wait", "w", 30, "Maximum time to wait for response (seconds)")
+ taskCmd.Flags().BoolVar(&taskRawOutput, "raw", false, "Output only raw response bytes")
+}
+
+func runTask(cmd *cobra.Command, args []string) error {
+ if err := validateConfig(); err != nil {
+ return err
+ }
+
+ callbackID, err := strconv.Atoi(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid callback ID: %s", args[0])
+ }
+
+ command := args[1]
+ params := ""
+ if len(args) > 2 {
+ params = strings.Join(args[2:], " ")
+ }
+
+ client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
+ ctx := context.Background()
+
+ // Verify callback exists and is active
+ targetCallback, err := api.FindActiveCallback(ctx, client, callbackID)
+ if err != nil {
+ return err
+ }
+
+ // For raw output, don't show progress messages
+ if !taskRawOutput {
+ fmt.Printf("Executing '%s %s' on callback %d (%s@%s)\n",
+ command, params, callbackID, targetCallback.User, targetCallback.Host)
+ }
+
+ // Configure polling
+ config := api.TaskPollConfig{
+ TimeoutSeconds: taskWaitTime,
+ PollInterval: api.DefaultPollInterval,
+ ShowProgress: !taskRawOutput,
+ RawOutput: taskRawOutput,
+ }
+
+ // Execute task and wait for response
+ updatedTask, err := api.ExecuteTaskAndWait(ctx, client, targetCallback.ID, command, params, config)
+ if err != nil {
+ if taskRawOutput {
+ // For raw output on timeout, don't show error message
+ return nil
+ }
+ fmt.Printf("\n%v\n", err)
+ return nil
+ }
+
+ if taskRawOutput {
+ // Raw output: only print response
+ if updatedTask.Response != "" {
+ fmt.Print(updatedTask.Response)
+ }
+ } else {
+ // Normal output: show status and response
+ fmt.Printf("\nTask Status: %s\n", updatedTask.Status)
+ if updatedTask.Response != "" {
+ fmt.Printf("Response:\n%s\n", updatedTask.Response)
+ }
+ }
+ return nil
+}
\ No newline at end of file
cmd/task_list.go
@@ -0,0 +1,128 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "os"
+ "strconv"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+var (
+ listShowResponses bool
+ listTaskLimit int
+)
+
+var taskListCmd = &cobra.Command{
+ Use: "task-list <callback_id>",
+ Aliases: []string{"tl"},
+ Short: "List tasks for a specific agent",
+ Long: "Display task history for a specific agent callback, equivalent to viewing the tasking tab for an agent in the UI.",
+ Args: cobra.ExactArgs(1),
+ RunE: runTaskList,
+}
+
+func init() {
+ rootCmd.AddCommand(taskListCmd)
+ taskListCmd.Flags().BoolVar(&listShowResponses, "responses", false, "Show task responses/output")
+ taskListCmd.Flags().IntVar(&listTaskLimit, "limit", 10, "Maximum number of tasks to display")
+}
+
+func runTaskList(cmd *cobra.Command, args []string) error {
+ if err := validateConfig(); err != nil {
+ return err
+ }
+
+ callbackID, err := strconv.Atoi(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid callback ID: %s", args[0])
+ }
+
+ client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ tasks, err := client.GetCallbackTasks(ctx, callbackID)
+ if err != nil {
+ return fmt.Errorf("failed to get tasks for callback %d: %w", callbackID, err)
+ }
+
+ if len(tasks) == 0 {
+ fmt.Printf("No tasks found for callback %d\n", callbackID)
+ return nil
+ }
+
+ // Apply limit from the end (keep newest tasks if limiting)
+ if listTaskLimit > 0 && len(tasks) > listTaskLimit {
+ tasks = tasks[len(tasks)-listTaskLimit:]
+ }
+
+ fmt.Printf("Tasks for callback %d (showing %d, newest last):\n\n", callbackID, len(tasks))
+
+ // Create a tab writer for formatted output
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "TASK ID\tCOMMAND\tPARAMS\tSTATUS\tTIMESTAMP")
+ fmt.Fprintln(w, "-------\t-------\t------\t------\t---------")
+
+ for _, task := range tasks {
+ // Truncate params if too long
+ params := task.Params
+ if len(params) > 50 {
+ params = params[:47] + "..."
+ }
+
+ // Format timestamp
+ timestamp := task.Timestamp
+ if timestamp != "" {
+ if t, err := time.Parse(time.RFC3339, timestamp); err == nil {
+ timestamp = t.Format("2006-01-02 15:04:05")
+ }
+ }
+
+ fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
+ task.DisplayID,
+ task.Command,
+ params,
+ task.Status,
+ timestamp,
+ )
+ }
+
+ w.Flush()
+
+ // If responses are requested, fetch and display them
+ if listShowResponses {
+ fmt.Println("\nTask Responses:")
+ fmt.Println(strings.Repeat("=", 50))
+
+ for i, task := range tasks {
+ if i >= 5 { // Limit responses to first 5 tasks to avoid overwhelming output
+ break
+ }
+
+ fmt.Printf("\nTask %d (%s):\n", task.DisplayID, task.Command)
+ fmt.Println(strings.Repeat("-", 30))
+
+ // Get full task with response
+ fullTask, err := client.GetTaskResponse(ctx, task.ID)
+ if err != nil {
+ fmt.Printf("Error getting response: %v\n", err)
+ continue
+ }
+
+ if fullTask.Response != "" {
+ fmt.Println(fullTask.Response)
+ } else {
+ fmt.Println("(No response yet)")
+ }
+ fmt.Println()
+ }
+ }
+
+ return nil
+}
\ No newline at end of file
cmd/task_view.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+var (
+ taskViewRawOutput bool
+)
+
+var taskViewCmd = &cobra.Command{
+ Use: "task-view <task_id>",
+ Aliases: []string{"tv"},
+ Short: "Show details for a specific task",
+ Long: "Display detailed information and response for a specific task by its ID.",
+ Args: cobra.ExactArgs(1),
+ RunE: runTaskView,
+}
+
+func init() {
+ rootCmd.AddCommand(taskViewCmd)
+ taskViewCmd.Flags().BoolVar(&taskViewRawOutput, "raw", false, "Output only raw response bytes")
+}
+
+func runTaskView(cmd *cobra.Command, args []string) error {
+ if err := validateConfig(); err != nil {
+ return err
+ }
+
+ taskID, err := strconv.Atoi(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid task ID: %s", args[0])
+ }
+
+ client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ task, err := client.GetTaskResponse(ctx, taskID)
+ if err != nil {
+ return fmt.Errorf("failed to get task %d: %w", taskID, err)
+ }
+
+ // If raw output requested, only print the response
+ if taskViewRawOutput {
+ if task.Response != "" {
+ fmt.Print(task.Response)
+ }
+ return nil
+ }
+
+ // Otherwise, show full task details
+ fmt.Printf("Task Details (ID: %d)\n", task.DisplayID)
+ fmt.Println(strings.Repeat("=", 50))
+ fmt.Printf("Command: %s\n", task.Command)
+ fmt.Printf("Parameters: %s\n", task.Params)
+ fmt.Printf("Status: %s\n", task.Status)
+ fmt.Printf("Callback ID: %d\n", task.CallbackID)
+ if task.Timestamp != "" {
+ if t, err := time.Parse(time.RFC3339, task.Timestamp); err == nil {
+ fmt.Printf("Timestamp: %s\n", t.Format("2006-01-02 15:04:05"))
+ } else {
+ fmt.Printf("Timestamp: %s\n", task.Timestamp)
+ }
+ }
+ fmt.Printf("Completed: %t\n", task.Completed)
+
+ fmt.Println("\nResponse:")
+ fmt.Println(strings.Repeat("-", 30))
+ if task.Response != "" {
+ fmt.Println(task.Response)
+ } else {
+ fmt.Println("(No response yet)")
+ }
+
+ return nil
+}
\ No newline at end of file
cmd/upload.go
@@ -0,0 +1,176 @@
+package cmd
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+var (
+ uploadPayloadID int
+ uploadCallbackID int
+ uploadPath string
+ uploadLocalFile string
+)
+
+var uploadCmd = &cobra.Command{
+ Use: "upload",
+ Short: "Upload a file to a callback",
+ Long: "Upload a payload or local file to a specified callback location with confirmation prompt. Either --payload or --local must be specified.",
+ RunE: runUpload,
+}
+
+func init() {
+ rootCmd.AddCommand(uploadCmd)
+ uploadCmd.Flags().IntVar(&uploadPayloadID, "payload", 0, "Payload ID to upload")
+ uploadCmd.Flags().StringVar(&uploadLocalFile, "local", "", "Local file path to upload")
+ uploadCmd.Flags().IntVar(&uploadCallbackID, "callback", 0, "Callback ID to upload to")
+ uploadCmd.Flags().StringVar(&uploadPath, "path", "", "Remote path where to upload the file")
+
+ uploadCmd.MarkFlagRequired("callback")
+ uploadCmd.MarkFlagRequired("path")
+}
+
+type UploadParams struct {
+ RemotePath string `json:"remote_path"`
+ File string `json:"file"`
+ FileName string `json:"file_name"`
+ Host string `json:"host"`
+}
+
+func runUpload(cmd *cobra.Command, args []string) error {
+ if err := validateConfig(); err != nil {
+ return err
+ }
+
+ // Validate that exactly one of --payload or --local is specified
+ if (uploadPayloadID == 0 && uploadLocalFile == "") || (uploadPayloadID != 0 && uploadLocalFile != "") {
+ return fmt.Errorf("exactly one of --payload or --local must be specified")
+ }
+
+ client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // Get callback information
+ callbacks, err := client.GetActiveCallbacks(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get callbacks: %w", err)
+ }
+
+ var targetCallback *mythic.Callback
+ for _, callback := range callbacks {
+ if callback.DisplayID == uploadCallbackID {
+ targetCallback = &callback
+ break
+ }
+ }
+
+ if targetCallback == nil {
+ return fmt.Errorf("callback %d not found or not active", uploadCallbackID)
+ }
+
+ var uploadParams UploadParams
+ var sourceDescription string
+
+ if uploadPayloadID != 0 {
+ // Payload mode
+ payloads, err := client.GetPayloads(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get payloads: %w", err)
+ }
+
+ var targetPayload *mythic.Payload
+ for _, payload := range payloads {
+ if payload.ID == uploadPayloadID {
+ targetPayload = &payload
+ break
+ }
+ }
+
+ if targetPayload == nil {
+ return fmt.Errorf("payload with ID %d not found", uploadPayloadID)
+ }
+
+ uploadParams = UploadParams{
+ RemotePath: uploadPath,
+ File: targetPayload.AgentFileID,
+ FileName: targetPayload.Filename,
+ Host: targetCallback.Host,
+ }
+
+ sourceDescription = fmt.Sprintf("Payload: %s (Type: %s, ID: %d)", targetPayload.Filename, targetPayload.PayloadTypeName, targetPayload.ID)
+ } else {
+ // Local file mode
+ // Check if local file exists
+ if _, err := os.Stat(uploadLocalFile); os.IsNotExist(err) {
+ return fmt.Errorf("local file does not exist: %s", uploadLocalFile)
+ }
+
+ // For local files, we need to create the file reference in Mythic first
+ // This is a simplified approach - in reality, we'd need to upload the file content
+ filename := filepath.Base(uploadLocalFile)
+
+ uploadParams = UploadParams{
+ RemotePath: uploadPath,
+ File: uploadLocalFile, // Using local path as placeholder
+ FileName: filename,
+ Host: targetCallback.Host,
+ }
+
+ sourceDescription = fmt.Sprintf("Local file: %s", uploadLocalFile)
+ }
+
+ // Display confirmation information
+ fmt.Printf("Upload Details:\n")
+ fmt.Printf("===============\n")
+ fmt.Printf("%s\n", sourceDescription)
+ fmt.Printf("Target: %s@%s (Callback: %d)\n", targetCallback.User, targetCallback.Host, targetCallback.DisplayID)
+ fmt.Printf("Path: %s\n", uploadPath)
+ fmt.Printf("\nProceed with upload? [y/N]: ")
+
+ // Get user confirmation
+ reader := bufio.NewReader(os.Stdin)
+ response, err := reader.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("failed to read input: %w", err)
+ }
+
+ response = strings.TrimSpace(strings.ToLower(response))
+ if response != "y" && response != "yes" {
+ fmt.Println("Upload cancelled.")
+ return nil
+ }
+
+ // Convert to JSON
+ paramsJSON, err := json.Marshal(uploadParams)
+ if err != nil {
+ return fmt.Errorf("failed to marshal upload parameters: %w", err)
+ }
+
+ fmt.Printf("\nCreating upload task...\n")
+
+ // Create the upload task
+ task, err := client.CreateTask(ctx, targetCallback.ID, "upload", string(paramsJSON))
+ if err != nil {
+ return fmt.Errorf("failed to create upload task: %w", err)
+ }
+
+ fmt.Printf("Upload task %d created successfully\n", task.DisplayID)
+ fmt.Printf("Task parameters: %s\n", string(paramsJSON))
+
+ if uploadLocalFile != "" {
+ fmt.Printf("\nNote: Local file uploads require the file to be manually transferred to Mythic first.\n")
+ fmt.Printf("This command creates the upload task but does not transfer the file content.\n")
+ }
+
+ return nil
+}
\ No newline at end of file
pkg/mythic/api/callbacks.go
@@ -0,0 +1,28 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+)
+
+// FindActiveCallback looks up an active callback by its display ID
+func FindActiveCallback(ctx context.Context, client MythicClient, callbackID int) (*mythic.Callback, error) {
+ callbacks, err := client.GetActiveCallbacks(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get callbacks: %w", err)
+ }
+
+ for _, callback := range callbacks {
+ if callback.DisplayID == callbackID {
+ return &callback, nil
+ }
+ }
+
+ return nil, fmt.Errorf("callback %d not found or not active", callbackID)
+}
+
+// ValidateCallbackExists verifies that a callback exists and is accessible
+func ValidateCallbackExists(ctx context.Context, client MythicClient, callbackID int) (*mythic.Callback, error) {
+ return FindActiveCallback(ctx, client, callbackID)
+}
\ No newline at end of file
pkg/mythic/api/callbacks_test.go
@@ -0,0 +1,116 @@
+package api
+
+import (
+ "context"
+ "go-mythic/pkg/mythic"
+ "testing"
+)
+
+// MockClient implements the MythicClient interface for testing
+type MockClient struct {
+ callbacks []mythic.Callback
+ err error
+}
+
+func (m *MockClient) GetActiveCallbacks(ctx context.Context) ([]mythic.Callback, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+ return m.callbacks, nil
+}
+
+func (m *MockClient) CreateTask(ctx context.Context, callbackID int, command, params string) (*mythic.Task, error) {
+ return nil, nil // Not implemented for this test
+}
+
+func (m *MockClient) GetTaskResponse(ctx context.Context, taskID int) (*mythic.Task, error) {
+ return nil, nil // Not implemented for this test
+}
+
+func TestFindActiveCallback(t *testing.T) {
+ tests := []struct {
+ name string
+ callbacks []mythic.Callback
+ callbackID int
+ expectErr bool
+ expectedID int
+ }{
+ {
+ name: "callback found",
+ callbacks: []mythic.Callback{
+ {ID: 1, DisplayID: 10, Host: "host1", User: "user1"},
+ {ID: 2, DisplayID: 20, Host: "host2", User: "user2"},
+ },
+ callbackID: 20,
+ expectErr: false,
+ expectedID: 2,
+ },
+ {
+ name: "callback not found",
+ callbacks: []mythic.Callback{
+ {ID: 1, DisplayID: 10, Host: "host1", User: "user1"},
+ },
+ callbackID: 99,
+ expectErr: true,
+ },
+ {
+ name: "no callbacks",
+ callbacks: []mythic.Callback{},
+ callbackID: 10,
+ expectErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := &MockClient{callbacks: tt.callbacks}
+ ctx := context.Background()
+
+ result, err := FindActiveCallback(ctx, client, tt.callbackID)
+
+ if tt.expectErr {
+ if err == nil {
+ t.Error("Expected error, but got nil")
+ }
+ if result != nil {
+ t.Error("Expected nil result on error")
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if result == nil {
+ t.Error("Expected callback, but got nil")
+ } else if result.ID != tt.expectedID {
+ t.Errorf("Expected callback ID %d, got %d", tt.expectedID, result.ID)
+ }
+ }
+ })
+ }
+}
+
+func TestValidateCallbackExists(t *testing.T) {
+ callbacks := []mythic.Callback{
+ {ID: 1, DisplayID: 10, Host: "host1", User: "user1"},
+ }
+ client := &MockClient{callbacks: callbacks}
+ ctx := context.Background()
+
+ // Test valid callback
+ result, err := ValidateCallbackExists(ctx, client, 10)
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if result == nil {
+ t.Error("Expected callback, but got nil")
+ }
+
+ // Test invalid callback
+ result, err = ValidateCallbackExists(ctx, client, 99)
+ if err == nil {
+ t.Error("Expected error for non-existent callback")
+ }
+ if result != nil {
+ t.Error("Expected nil result for non-existent callback")
+ }
+}
\ No newline at end of file
pkg/mythic/api/constants.go
@@ -0,0 +1,34 @@
+package api
+
+import "time"
+
+// Task status constants
+const (
+ TaskStatusCompleted = "completed"
+ TaskStatusError = "error"
+ TaskStatusSubmitted = "submitted"
+)
+
+// Default timeout values
+const (
+ DefaultTaskTimeout = 30 * time.Second
+ DefaultPollInterval = 1 * time.Second
+ DefaultDownloadTimeout = 60 * time.Second
+)
+
+// Progress display constants
+const (
+ ProgressDotInterval = 5 // Show progress dot every N polling cycles
+)
+
+// File size estimation constants
+const (
+ EstimatedChunkSizeKB = 512 // Rough estimate for file size calculation
+)
+
+// UUID format constants
+const (
+ UUIDLength = 36
+ UUIDLengthNoHyphens = 32
+ UUIDDisplayLength = 8 // Number of characters to show in truncated displays
+)
\ No newline at end of file
pkg/mythic/api/constants_test.go
@@ -0,0 +1,47 @@
+package api
+
+import (
+ "testing"
+ "time"
+)
+
+func TestConstants(t *testing.T) {
+ // Test task status constants
+ if TaskStatusCompleted != "completed" {
+ t.Errorf("Expected TaskStatusCompleted to be 'completed', got %q", TaskStatusCompleted)
+ }
+ if TaskStatusError != "error" {
+ t.Errorf("Expected TaskStatusError to be 'error', got %q", TaskStatusError)
+ }
+ if TaskStatusSubmitted != "submitted" {
+ t.Errorf("Expected TaskStatusSubmitted to be 'submitted', got %q", TaskStatusSubmitted)
+ }
+
+ // Test timeout constants
+ if DefaultTaskTimeout != 30*time.Second {
+ t.Errorf("Expected DefaultTaskTimeout to be 30s, got %v", DefaultTaskTimeout)
+ }
+ if DefaultPollInterval != 1*time.Second {
+ t.Errorf("Expected DefaultPollInterval to be 1s, got %v", DefaultPollInterval)
+ }
+ if DefaultDownloadTimeout != 60*time.Second {
+ t.Errorf("Expected DefaultDownloadTimeout to be 60s, got %v", DefaultDownloadTimeout)
+ }
+
+ // Test numeric constants
+ if ProgressDotInterval != 5 {
+ t.Errorf("Expected ProgressDotInterval to be 5, got %d", ProgressDotInterval)
+ }
+ if EstimatedChunkSizeKB != 512 {
+ t.Errorf("Expected EstimatedChunkSizeKB to be 512, got %d", EstimatedChunkSizeKB)
+ }
+ if UUIDLength != 36 {
+ t.Errorf("Expected UUIDLength to be 36, got %d", UUIDLength)
+ }
+ if UUIDLengthNoHyphens != 32 {
+ t.Errorf("Expected UUIDLengthNoHyphens to be 32, got %d", UUIDLengthNoHyphens)
+ }
+ if UUIDDisplayLength != 8 {
+ t.Errorf("Expected UUIDDisplayLength to be 8, got %d", UUIDDisplayLength)
+ }
+}
\ No newline at end of file
pkg/mythic/api/interfaces.go
@@ -0,0 +1,16 @@
+package api
+
+import (
+ "context"
+ "go-mythic/pkg/mythic"
+)
+
+// MythicClient defines the interface for Mythic operations needed by the api package
+type MythicClient interface {
+ GetActiveCallbacks(ctx context.Context) ([]mythic.Callback, error)
+ CreateTask(ctx context.Context, callbackID int, command, params string) (*mythic.Task, error)
+ GetTaskResponse(ctx context.Context, taskID int) (*mythic.Task, error)
+}
+
+// Ensure that the real Client implements the interface
+var _ MythicClient = (*mythic.Client)(nil)
\ No newline at end of file
pkg/mythic/api/tasks.go
@@ -0,0 +1,65 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "time"
+)
+
+// TaskPollConfig configures task polling behavior
+type TaskPollConfig struct {
+ TimeoutSeconds int
+ PollInterval time.Duration
+ ShowProgress bool
+ RawOutput bool
+}
+
+// DefaultTaskPollConfig returns sensible defaults for task polling
+func DefaultTaskPollConfig() TaskPollConfig {
+ return TaskPollConfig{
+ TimeoutSeconds: int(DefaultTaskTimeout.Seconds()),
+ PollInterval: DefaultPollInterval,
+ ShowProgress: true,
+ RawOutput: false,
+ }
+}
+
+// PollTaskResponse polls for a task response until completion or timeout
+func PollTaskResponse(ctx context.Context, client MythicClient, taskID int, config TaskPollConfig) (*mythic.Task, error) {
+ for i := 0; i < config.TimeoutSeconds; i++ {
+ time.Sleep(config.PollInterval)
+
+ updatedTask, err := client.GetTaskResponse(ctx, taskID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get task response: %w", err)
+ }
+
+ if updatedTask.Status == TaskStatusCompleted || updatedTask.Status == TaskStatusError {
+ return updatedTask, nil
+ }
+
+ // Show progress dots (but not for raw output)
+ if config.ShowProgress && !config.RawOutput && i > 0 && i%ProgressDotInterval == 0 {
+ fmt.Printf(".")
+ }
+ }
+
+ return nil, fmt.Errorf("timeout waiting for response after %d seconds", config.TimeoutSeconds)
+}
+
+// ExecuteTaskAndWait creates a task and waits for its response
+func ExecuteTaskAndWait(ctx context.Context, client MythicClient, callbackID int, command, params string, config TaskPollConfig) (*mythic.Task, error) {
+ // Create the task
+ task, err := client.CreateTask(ctx, callbackID, command, params)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create task: %w", err)
+ }
+
+ if config.ShowProgress && !config.RawOutput {
+ fmt.Printf("Task %d created, waiting for response...\n", task.DisplayID)
+ }
+
+ // Poll for response
+ return PollTaskResponse(ctx, client, task.ID, config)
+}
\ No newline at end of file
pkg/mythic/api/tasks_test.go
@@ -0,0 +1,164 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "go-mythic/pkg/mythic"
+ "testing"
+ "time"
+)
+
+// MockTaskClient extends MockClient to support task operations
+type MockTaskClient struct {
+ MockClient
+ tasks map[int]*mythic.Task
+ createErr error
+ getTaskErr error
+ taskCounter int
+}
+
+func (m *MockTaskClient) CreateTask(ctx context.Context, callbackID int, command, params string) (*mythic.Task, error) {
+ if m.createErr != nil {
+ return nil, m.createErr
+ }
+
+ m.taskCounter++
+ task := &mythic.Task{
+ ID: m.taskCounter,
+ DisplayID: m.taskCounter * 10,
+ Command: command,
+ Params: params,
+ Status: TaskStatusSubmitted,
+ CallbackID: callbackID,
+ }
+
+ if m.tasks == nil {
+ m.tasks = make(map[int]*mythic.Task)
+ }
+ m.tasks[task.ID] = task
+
+ return task, nil
+}
+
+func (m *MockTaskClient) GetTaskResponse(ctx context.Context, taskID int) (*mythic.Task, error) {
+ if m.getTaskErr != nil {
+ return nil, m.getTaskErr
+ }
+
+ task, exists := m.tasks[taskID]
+ if !exists {
+ return nil, fmt.Errorf("task %d not found", taskID)
+ }
+
+ // Return a copy to simulate potential status changes
+ return &mythic.Task{
+ ID: task.ID,
+ DisplayID: task.DisplayID,
+ Command: task.Command,
+ Params: task.Params,
+ Status: task.Status,
+ Response: task.Response,
+ CallbackID: task.CallbackID,
+ Completed: task.Status == TaskStatusCompleted || task.Status == TaskStatusError,
+ }, nil
+}
+
+// SetTaskCompleted simulates a task completing
+func (m *MockTaskClient) SetTaskCompleted(taskID int, response string) {
+ if task, exists := m.tasks[taskID]; exists {
+ task.Status = TaskStatusCompleted
+ task.Response = response
+ task.Completed = true
+ }
+}
+
+func TestDefaultTaskPollConfig(t *testing.T) {
+ config := DefaultTaskPollConfig()
+
+ if config.TimeoutSeconds != int(DefaultTaskTimeout.Seconds()) {
+ t.Errorf("Expected TimeoutSeconds to be %d, got %d", int(DefaultTaskTimeout.Seconds()), config.TimeoutSeconds)
+ }
+ if config.PollInterval != DefaultPollInterval {
+ t.Errorf("Expected PollInterval to be %v, got %v", DefaultPollInterval, config.PollInterval)
+ }
+ if !config.ShowProgress {
+ t.Error("Expected ShowProgress to be true")
+ }
+ if config.RawOutput {
+ t.Error("Expected RawOutput to be false")
+ }
+}
+
+func TestExecuteTaskAndWait_Success(t *testing.T) {
+ client := &MockTaskClient{}
+ ctx := context.Background()
+
+ config := TaskPollConfig{
+ TimeoutSeconds: 5,
+ PollInterval: 100 * time.Millisecond,
+ ShowProgress: false,
+ RawOutput: true,
+ }
+
+ // Start task execution in background
+ go func() {
+ time.Sleep(200 * time.Millisecond)
+ // Simulate task completion after a short delay
+ client.SetTaskCompleted(1, "Task completed successfully")
+ }()
+
+ result, err := ExecuteTaskAndWait(ctx, client, 1, "ls", "-la", config)
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if result == nil {
+ t.Fatal("Expected task result, got nil")
+ }
+ if result.Status != TaskStatusCompleted {
+ t.Errorf("Expected status %q, got %q", TaskStatusCompleted, result.Status)
+ }
+ if result.Response != "Task completed successfully" {
+ t.Errorf("Expected response %q, got %q", "Task completed successfully", result.Response)
+ }
+}
+
+func TestExecuteTaskAndWait_CreateTaskError(t *testing.T) {
+ client := &MockTaskClient{
+ createErr: fmt.Errorf("failed to create task"),
+ }
+ ctx := context.Background()
+
+ config := DefaultTaskPollConfig()
+
+ result, err := ExecuteTaskAndWait(ctx, client, 1, "ls", "-la", config)
+
+ if err == nil {
+ t.Error("Expected error, got nil")
+ }
+ if result != nil {
+ t.Error("Expected nil result on error")
+ }
+}
+
+func TestExecuteTaskAndWait_Timeout(t *testing.T) {
+ client := &MockTaskClient{}
+ ctx := context.Background()
+
+ config := TaskPollConfig{
+ TimeoutSeconds: 1, // Very short timeout
+ PollInterval: 100 * time.Millisecond,
+ ShowProgress: false,
+ RawOutput: true,
+ }
+
+ // Don't complete the task - let it timeout
+ result, err := ExecuteTaskAndWait(ctx, client, 1, "ls", "-la", config)
+
+ if err == nil {
+ t.Error("Expected timeout error, got nil")
+ }
+ if result != nil {
+ t.Error("Expected nil result on timeout")
+ }
+}
\ No newline at end of file
pkg/mythic/client.go
@@ -5,9 +5,12 @@ import (
"crypto/tls"
"encoding/base64"
"fmt"
+ "io"
"net"
"net/http"
+ "net/url"
"regexp"
+ "strings"
"unicode/utf8"
"github.com/machinebox/graphql"
@@ -15,8 +18,10 @@ import (
)
type Client struct {
- client *graphql.Client
- token string
+ client *graphql.Client
+ token string
+ httpClient *http.Client
+ baseURL string
}
type Callback struct {
@@ -45,16 +50,68 @@ type Task struct {
Response string `json:"response,omitempty"`
CallbackID int `json:"callback_id"`
OperatorID int `json:"operator_id"`
+ Timestamp string `json:"timestamp,omitempty"`
+ Completed bool `json:"completed"`
}
-func NewClient(url, token string, insecure bool, socksProxy string) *Client {
- client := graphql.NewClient(url)
-
+type File struct {
+ ID int `json:"id"`
+ AgentFileID string `json:"agent_file_id"`
+ Filename string `json:"filename_utf8"`
+ FullRemotePath string `json:"full_remote_path_utf8"`
+ Host string `json:"host"`
+ Complete bool `json:"complete"`
+ ChunksReceived int `json:"chunks_received"`
+ TotalChunks int `json:"total_chunks"`
+ Timestamp string `json:"timestamp"`
+ MD5 string `json:"md5"`
+ SHA1 string `json:"sha1"`
+ IsDownloadFromAgent bool `json:"is_download_from_agent"`
+ IsPayload bool `json:"is_payload"`
+ IsScreenshot bool `json:"is_screenshot"`
+ OperatorUsername string `json:"operator_username"`
+ TaskID int `json:"task_id"`
+ TaskDisplayID int `json:"task_display_id"`
+ CallbackID int `json:"callback_id"`
+ CallbackHost string `json:"callback_host"`
+ CallbackUser string `json:"callback_user"`
+}
+
+type Payload struct {
+ ID int `json:"id"`
+ UUID string `json:"uuid"`
+ Description string `json:"description"`
+ BuildPhase string `json:"build_phase"`
+ BuildMessage string `json:"build_message"`
+ BuildStderr string `json:"build_stderr"`
+ CallbackAlert bool `json:"callback_alert"`
+ CreationTime string `json:"creation_time"`
+ AutoGenerated bool `json:"auto_generated"`
+ Deleted bool `json:"deleted"`
+
+ // Nested structs
+ OperatorID int `json:"operator_id"`
+ OperatorUsername string `json:"operator_username"`
+ PayloadTypeID int `json:"payloadtype_id"`
+ PayloadTypeName string `json:"payloadtype_name"`
+
+ // File info
+ FileID int `json:"file_id"`
+ Filename string `json:"filename"`
+ AgentFileID string `json:"agent_file_id"`
+
+ // C2 Profiles
+ C2Profiles []string `json:"c2_profiles"`
+}
+
+func NewClient(apiURL, token string, insecure bool, socksProxy string) *Client {
+ client := graphql.NewClient(apiURL)
+
// Create transport with TLS config
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
}
-
+
// Configure SOCKS5 proxy if provided
if socksProxy != "" {
dialer, err := proxy.SOCKS5("tcp", socksProxy, nil, proxy.Direct)
@@ -64,13 +121,19 @@ func NewClient(url, token string, insecure bool, socksProxy string) *Client {
}
}
}
-
+
httpClient := &http.Client{Transport: tr}
- client = graphql.NewClient(url, graphql.WithHTTPClient(httpClient))
-
+ client = graphql.NewClient(apiURL, graphql.WithHTTPClient(httpClient))
+
+ // Extract base URL for file downloads (GraphQL URL -> base server URL)
+ parsedURL, _ := url.Parse(apiURL)
+ baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
+
return &Client{
- client: client,
- token: token,
+ client: client,
+ token: token,
+ httpClient: httpClient,
+ baseURL: baseURL,
}
}
@@ -124,26 +187,7 @@ func decodeResponseText(responseText string) string {
}
func (c *Client) GetCallbacks(ctx context.Context) ([]Callback, error) {
- req := graphql.NewRequest(`
- query GetCallbacks {
- callback {
- id
- display_id
- user
- host
- process_name
- pid
- description
- last_checkin
- active
- payload {
- payloadtype {
- name
- }
- }
- }
- }
- `)
+ req := graphql.NewRequest(GetCallbacks)
req.Header.Set("Authorization", "Bearer "+c.token)
@@ -175,16 +219,7 @@ func (c *Client) GetActiveCallbacks(ctx context.Context) ([]Callback, error) {
}
func (c *Client) CreateTask(ctx context.Context, callbackID int, command, params string) (*Task, error) {
- req := graphql.NewRequest(`
- mutation createTasking($callback_id: Int!, $command: String!, $params: String!, $tasking_location: String) {
- createTask(callback_id: $callback_id, command: $command, params: $params, tasking_location: $tasking_location) {
- status
- id
- display_id
- error
- }
- }
- `)
+ req := graphql.NewRequest(CreateTask)
req.Var("callback_id", callbackID)
req.Var("command", command)
@@ -220,23 +255,7 @@ func (c *Client) CreateTask(ctx context.Context, callbackID int, command, params
}
func (c *Client) GetTaskResponse(ctx context.Context, taskID int) (*Task, error) {
- req := graphql.NewRequest(`
- query GetTask($id: Int!) {
- task_by_pk(id: $id) {
- id
- display_id
- command_name
- original_params
- display_params
- status
- completed
- callback {
- id
- display_id
- }
- }
- }
- `)
+ req := graphql.NewRequest(GetTask)
req.Var("id", taskID)
req.Header.Set("Authorization", "Bearer "+c.token)
@@ -262,14 +281,7 @@ func (c *Client) GetTaskResponse(ctx context.Context, taskID int) (*Task, error)
}
// Get task responses
- responseReq := graphql.NewRequest(`
- query GetTaskResponses($task_display_id: Int!) {
- response(where: {task: {display_id: {_eq: $task_display_id}}}, order_by: {id: desc}, limit: 1) {
- response_text
- timestamp
- }
- }
- `)
+ responseReq := graphql.NewRequest(GetTaskResponses)
responseReq.Var("task_display_id", resp.Task.DisplayID)
responseReq.Header.Set("Authorization", "Bearer "+c.token)
@@ -285,10 +297,13 @@ func (c *Client) GetTaskResponse(ctx context.Context, taskID int) (*Task, error)
return nil, fmt.Errorf("failed to get task response: %w", err)
}
- response := ""
- if len(responseResp.Response) > 0 {
- response = decodeResponseText(responseResp.Response[0].ResponseText)
+ // Concatenate all response chunks in chronological order
+ var responseBuilder strings.Builder
+ for _, resp := range responseResp.Response {
+ decodedText := decodeResponseText(resp.ResponseText)
+ responseBuilder.WriteString(decodedText)
}
+ response := responseBuilder.String()
status := resp.Task.Status
if resp.Task.Completed {
@@ -303,5 +318,295 @@ func (c *Client) GetTaskResponse(ctx context.Context, taskID int) (*Task, error)
Status: status,
Response: response,
CallbackID: resp.Task.Callback.ID,
+ Completed: resp.Task.Completed,
}, nil
+}
+
+func (c *Client) GetCallbackTasks(ctx context.Context, callbackID int) ([]Task, error) {
+ req := graphql.NewRequest(GetCallbackTasks)
+
+ req.Var("callback_id", callbackID)
+ req.Header.Set("Authorization", "Bearer "+c.token)
+
+ var resp struct {
+ Task []struct {
+ ID int `json:"id"`
+ DisplayID int `json:"display_id"`
+ CommandName string `json:"command_name"`
+ OriginalParams string `json:"original_params"`
+ DisplayParams string `json:"display_params"`
+ Status string `json:"status"`
+ Completed bool `json:"completed"`
+ Timestamp string `json:"timestamp"`
+ Callback struct {
+ ID int `json:"id"`
+ DisplayID int `json:"display_id"`
+ } `json:"callback"`
+ } `json:"task"`
+ }
+
+ if err := c.client.Run(ctx, req, &resp); err != nil {
+ return nil, fmt.Errorf("failed to get callback tasks: %w", err)
+ }
+
+ var tasks []Task
+ for _, t := range resp.Task {
+ status := t.Status
+ if t.Completed {
+ status = "completed"
+ }
+
+ tasks = append(tasks, Task{
+ ID: t.ID,
+ DisplayID: t.DisplayID,
+ Command: t.CommandName,
+ Params: t.DisplayParams,
+ Status: status,
+ CallbackID: t.Callback.ID,
+ Timestamp: t.Timestamp,
+ Completed: t.Completed,
+ })
+ }
+
+ return tasks, nil
+}
+
+func (c *Client) GetDownloadedFiles(ctx context.Context) ([]File, error) {
+ req := graphql.NewRequest(GetDownloadedFiles)
+
+ req.Header.Set("Authorization", "Bearer "+c.token)
+
+ var resp struct {
+ Filemeta []struct {
+ ID int `json:"id"`
+ AgentFileID string `json:"agent_file_id"`
+ Filename string `json:"filename_utf8"`
+ FullRemotePath string `json:"full_remote_path_utf8"`
+ Host string `json:"host"`
+ Complete bool `json:"complete"`
+ ChunksReceived int `json:"chunks_received"`
+ TotalChunks int `json:"total_chunks"`
+ Timestamp string `json:"timestamp"`
+ MD5 string `json:"md5"`
+ SHA1 string `json:"sha1"`
+ Operator struct {
+ Username string `json:"username"`
+ } `json:"operator"`
+ Task struct {
+ ID int `json:"id"`
+ DisplayID int `json:"display_id"`
+ Callback struct {
+ ID int `json:"id"`
+ DisplayID int `json:"display_id"`
+ Host string `json:"host"`
+ User string `json:"user"`
+ } `json:"callback"`
+ } `json:"task"`
+ } `json:"filemeta"`
+ }
+
+ if err := c.client.Run(ctx, req, &resp); err != nil {
+ return nil, fmt.Errorf("failed to get downloaded files: %w", err)
+ }
+
+ var files []File
+ for _, f := range resp.Filemeta {
+ files = append(files, File{
+ ID: f.ID,
+ AgentFileID: f.AgentFileID,
+ Filename: f.Filename,
+ FullRemotePath: f.FullRemotePath,
+ Host: f.Host,
+ Complete: f.Complete,
+ ChunksReceived: f.ChunksReceived,
+ TotalChunks: f.TotalChunks,
+ Timestamp: f.Timestamp,
+ MD5: f.MD5,
+ SHA1: f.SHA1,
+ OperatorUsername: f.Operator.Username,
+ TaskID: f.Task.ID,
+ TaskDisplayID: f.Task.DisplayID,
+ CallbackID: f.Task.Callback.ID,
+ CallbackHost: f.Task.Callback.Host,
+ CallbackUser: f.Task.Callback.User,
+ })
+ }
+
+ return files, nil
+}
+
+func (c *Client) GetAllFiles(ctx context.Context) ([]File, error) {
+ req := graphql.NewRequest(GetAllFiles)
+
+ req.Header.Set("Authorization", "Bearer "+c.token)
+
+ var resp struct {
+ Filemeta []struct {
+ ID int `json:"id"`
+ AgentFileID string `json:"agent_file_id"`
+ Filename string `json:"filename_utf8"`
+ FullRemotePath string `json:"full_remote_path_utf8"`
+ Host string `json:"host"`
+ Complete bool `json:"complete"`
+ ChunksReceived int `json:"chunks_received"`
+ TotalChunks int `json:"total_chunks"`
+ Timestamp string `json:"timestamp"`
+ MD5 string `json:"md5"`
+ SHA1 string `json:"sha1"`
+ IsDownloadFromAgent bool `json:"is_download_from_agent"`
+ IsPayload bool `json:"is_payload"`
+ IsScreenshot bool `json:"is_screenshot"`
+ Operator struct {
+ Username string `json:"username"`
+ } `json:"operator"`
+ Task struct {
+ ID int `json:"id"`
+ DisplayID int `json:"display_id"`
+ Callback struct {
+ ID int `json:"id"`
+ DisplayID int `json:"display_id"`
+ Host string `json:"host"`
+ User string `json:"user"`
+ } `json:"callback"`
+ } `json:"task"`
+ } `json:"filemeta"`
+ }
+
+ if err := c.client.Run(ctx, req, &resp); err != nil {
+ return nil, fmt.Errorf("failed to get all files: %w", err)
+ }
+
+ var files []File
+ for _, f := range resp.Filemeta {
+ files = append(files, File{
+ ID: f.ID,
+ AgentFileID: f.AgentFileID,
+ Filename: f.Filename,
+ FullRemotePath: f.FullRemotePath,
+ Host: f.Host,
+ Complete: f.Complete,
+ ChunksReceived: f.ChunksReceived,
+ TotalChunks: f.TotalChunks,
+ Timestamp: f.Timestamp,
+ MD5: f.MD5,
+ SHA1: f.SHA1,
+ IsDownloadFromAgent: f.IsDownloadFromAgent,
+ IsPayload: f.IsPayload,
+ IsScreenshot: f.IsScreenshot,
+ OperatorUsername: f.Operator.Username,
+ TaskID: f.Task.ID,
+ TaskDisplayID: f.Task.DisplayID,
+ CallbackID: f.Task.Callback.ID,
+ CallbackHost: f.Task.Callback.Host,
+ CallbackUser: f.Task.Callback.User,
+ })
+ }
+
+ return files, nil
+}
+
+func (c *Client) DownloadFile(ctx context.Context, fileUUID string) ([]byte, error) {
+ downloadURL := fmt.Sprintf("%s/direct/download/%s", c.baseURL, fileUUID)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create download request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+c.token)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to download file: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("download failed with status %d: %s", resp.StatusCode, resp.Status)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read file data: %w", err)
+ }
+
+ return data, nil
+}
+
+func (c *Client) GetPayloads(ctx context.Context) ([]Payload, error) {
+ req := graphql.NewRequest(GetPayloads)
+
+ req.Header.Set("Authorization", "Bearer "+c.token)
+
+ var resp struct {
+ Payload []struct {
+ ID int `json:"id"`
+ UUID string `json:"uuid"`
+ Description string `json:"description"`
+ BuildPhase string `json:"build_phase"`
+ BuildMessage string `json:"build_message"`
+ BuildStderr string `json:"build_stderr"`
+ CallbackAlert bool `json:"callback_alert"`
+ CreationTime string `json:"creation_time"`
+ AutoGenerated bool `json:"auto_generated"`
+ Deleted bool `json:"deleted"`
+ Operator struct {
+ ID int `json:"id"`
+ Username string `json:"username"`
+ } `json:"operator"`
+ PayloadType struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ } `json:"payloadtype"`
+ Filemetum struct {
+ ID int `json:"id"`
+ AgentFileID string `json:"agent_file_id"`
+ Filename string `json:"filename_utf8"`
+ } `json:"filemetum"`
+ PayloadC2Profiles []struct {
+ C2Profile struct {
+ Name string `json:"name"`
+ Running bool `json:"running"`
+ IsP2P bool `json:"is_p2p"`
+ ContainerRunning bool `json:"container_running"`
+ } `json:"c2profile"`
+ } `json:"payloadc2profiles"`
+ } `json:"payload"`
+ }
+
+ if err := c.client.Run(ctx, req, &resp); err != nil {
+ return nil, fmt.Errorf("failed to get payloads: %w", err)
+ }
+
+ var payloads []Payload
+ for _, p := range resp.Payload {
+ // Collect C2 profile names
+ var c2Profiles []string
+ for _, profile := range p.PayloadC2Profiles {
+ c2Profiles = append(c2Profiles, profile.C2Profile.Name)
+ }
+
+ payloads = append(payloads, Payload{
+ ID: p.ID,
+ UUID: p.UUID,
+ Description: p.Description,
+ BuildPhase: p.BuildPhase,
+ BuildMessage: p.BuildMessage,
+ BuildStderr: p.BuildStderr,
+ CallbackAlert: p.CallbackAlert,
+ CreationTime: p.CreationTime,
+ AutoGenerated: p.AutoGenerated,
+ Deleted: p.Deleted,
+ OperatorID: p.Operator.ID,
+ OperatorUsername: p.Operator.Username,
+ PayloadTypeID: p.PayloadType.ID,
+ PayloadTypeName: p.PayloadType.Name,
+ FileID: p.Filemetum.ID,
+ Filename: p.Filemetum.Filename,
+ AgentFileID: p.Filemetum.AgentFileID,
+ C2Profiles: c2Profiles,
+ })
+ }
+
+ return payloads, nil
}
\ No newline at end of file
pkg/mythic/client_test.go
@@ -0,0 +1,131 @@
+package mythic
+
+import (
+ "testing"
+)
+
+func TestIsBase64(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected bool
+ }{
+ {
+ name: "valid base64 binary data",
+ input: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9AHW1nw==", // 1x1 PNG
+ expected: true,
+ },
+ {
+ name: "valid base64 text that decodes to text",
+ input: "SGVsbG8gV29ybGQ=", // "Hello World" in base64
+ expected: true, // Actually returns true because the original is longer than decoded
+ },
+ {
+ name: "short string",
+ input: "hi",
+ expected: false,
+ },
+ {
+ name: "invalid base64 characters",
+ input: "hello@world!",
+ expected: false,
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: false,
+ },
+ {
+ name: "valid base64 but short",
+ input: "dGVzdA==", // "test" in base64
+ expected: true, // Actually returns true because original is longer than decoded
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := isBase64(tt.input)
+ if result != tt.expected {
+ t.Errorf("isBase64(%q) = %v, expected %v", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestDecodeResponseText(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "valid base64 text",
+ input: "SGVsbG8gV29ybGQ=", // "Hello World"
+ expected: "Hello World",
+ },
+ {
+ name: "plain text",
+ input: "Hello World",
+ expected: "Hello World",
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "binary data base64",
+ input: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9AHW1nw==",
+ expected: "[Binary data: 58 bytes]", // Actual decoded size
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := decodeResponseText(tt.input)
+ if result != tt.expected {
+ t.Errorf("decodeResponseText(%q) = %q, expected %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestNewClient(t *testing.T) {
+ apiURL := "https://example.com/graphql"
+ token := "test-token"
+ insecure := true
+ socksProxy := ""
+
+ client := NewClient(apiURL, token, insecure, socksProxy)
+
+ if client == nil {
+ t.Fatal("NewClient returned nil")
+ }
+
+ if client.token != token {
+ t.Errorf("Expected token %q, got %q", token, client.token)
+ }
+
+ if client.baseURL != "https://example.com" {
+ t.Errorf("Expected baseURL %q, got %q", "https://example.com", client.baseURL)
+ }
+}
+
+func TestNewClientWithSocksProxy(t *testing.T) {
+ apiURL := "https://example.com/graphql"
+ token := "test-token"
+ insecure := true
+ socksProxy := "127.0.0.1:9050"
+
+ client := NewClient(apiURL, token, insecure, socksProxy)
+
+ if client == nil {
+ t.Fatal("NewClient returned nil")
+ }
+
+ // We can't easily test the SOCKS proxy configuration without making actual network calls,
+ // but we can at least ensure the client was created successfully
+ if client.token != token {
+ t.Errorf("Expected token %q, got %q", token, client.token)
+ }
+}
\ No newline at end of file
pkg/mythic/queries.go
@@ -0,0 +1,527 @@
+package mythic
+
+// This file contains GraphQL queries and mutations for the Mythic C2 framework.
+// It's a 1:1 port of contrib/Mythic_Scripting/mythic/graphql_queries.py
+// to maintain compatibility and ease upstream synchronization.
+//
+// To sync with upstream changes:
+// 1. Check contrib/Mythic_Scripting/mythic/graphql_queries.py for updates
+// 2. Update the corresponding Go constants in this file
+// 3. Test that the client functions still work with the updated queries
+//
+// The naming convention:
+// - Python snake_case variables become Go PascalCase constants
+// - gql(...) wrappers are removed (just the string content)
+// - f-string formatting is converted to Go constants with concatenation
+
+// Mutations
+
+const CreateAPIToken = `
+mutation createAPITokenMutation{
+ createAPIToken(token_type: "User"){
+ id
+ token_value
+ status
+ error
+ operator_id
+ }
+}`
+
+const GetAPITokens = `
+query GetAPITokens($username: String!) {
+ apitokens(where: {active: {_eq: true}, operator: {username: {_eq: $username}}, deleted: {_eq: false}}) {
+ token_value
+ active
+ id
+ }
+}`
+
+const CreateTask = `
+mutation createTasking($callback_id: Int!, $command: String!, $params: String!, $files: [String], $token_id: Int, $tasking_location: String, $original_params: String, $parameter_group_name: String, $is_interactive_task: Boolean, $interactive_task_type: Int, $parent_task_id: Int, $payload_type: String) {
+ createTask(callback_id: $callback_id, command: $command, params: $params, files: $files, token_id: $token_id, tasking_location: $tasking_location, original_params: $original_params, parameter_group_name: $parameter_group_name, is_interactive_task: $is_interactive_task, interactive_task_type: $interactive_task_type, parent_task_id: $parent_task_id, payload_type: $payload_type) {
+ status
+ id
+ display_id
+ error
+ }
+}`
+
+const UpdateCallback = `
+mutation updateCallbackInformation ($callback_display_id: Int!, $active: Boolean, $locked: Boolean, $description: String, $ips: [String], $user: String, $host: String, $os: String, $architecture: String, $extra_info: String, $sleep_info: String, $pid: Int, $process_name: String, $integrity_level: Int, $domain: String){
+ updateCallback(input: {callback_display_id: $callback_display_id, active: $active, locked: $locked, description: $description, ips: $ips, user: $user, host: $host, os: $os, architecture: $architecture, extra_info: $extra_info, sleep_info: $sleep_info, pid: $pid, process_name: $process_name, integrity_level: $integrity_level, domain: $domain}) {
+ status
+ error
+ }
+}`
+
+const CreatePayload = `
+mutation createPayloadMutation($payload: String!) {
+ createPayload(payloadDefinition: $payload) {
+ error
+ status
+ uuid
+ }
+}`
+
+const CreateOperator = `
+mutation NewOperator($username: String!, $password: String!, $email: String, $bot: Boolean) {
+ createOperator(input: {password: $password, username: $username, email: $email, bot: $bot}) {
+ ...create_operator_fragment
+ }
+}
+` + CreateOperatorFragment
+
+const GetOperationAndOperatorByName = `
+query getOperationAndOperator($operation_name: String!, $operator_username: String!){
+ operation(where: {name: {_eq: $operation_name}}){
+ id
+ operatoroperations(where: {operator: {username: {_eq: $operator_username}}}) {
+ view_mode
+ id
+ }
+ }
+ operator(where: {username: {_eq: $operator_username}}){
+ id
+ }
+}`
+
+// Fragments
+
+const TaskFragment = `
+fragment task_fragment on task {
+ callback {
+ id
+ display_id
+ }
+ id
+ display_id
+ operator{
+ username
+ }
+ status
+ completed
+ original_params
+ display_params
+ timestamp
+ command_name
+ tasks {
+ id
+ }
+ token {
+ token_id
+ }
+}`
+
+const MythictreeFragment = `
+fragment mythictree_fragment on mythictree {
+ task_id
+ timestamp
+ host
+ comment
+ success
+ deleted
+ tree_type
+ os
+ can_have_children
+ name_text
+ parent_path_text
+ full_path_text
+ metadata
+}`
+
+const OperatorFragment = `
+fragment operator_fragment on operator {
+ id
+ username
+ admin
+ active
+ last_login
+ current_operation_id
+ deleted
+}`
+
+const CallbackFragment = `
+fragment callback_fragment on callback {
+ architecture
+ description
+ domain
+ external_ip
+ host
+ id
+ display_id
+ integrity_level
+ ip
+ extra_info
+ sleep_info
+ pid
+ os
+ user
+ agent_callback_id
+ operation_id
+ process_name
+ payload {
+ os
+ payloadtype {
+ name
+ }
+ description
+ uuid
+ }
+}`
+
+const PayloadBuildFragment = `
+fragment payload_build_fragment on payload {
+ build_phase
+ uuid
+ build_stdout
+ build_stderr
+ build_message
+ id
+}`
+
+const CreateOperatorFragment = `
+fragment create_operator_fragment on OperatorOutput {
+ active
+ creation_time
+ deleted
+ error
+ id
+ last_login
+ status
+ username
+ view_utc_time
+ account_type
+ email
+}`
+
+const GetOperationsFragment = `
+fragment get_operations_fragment on operation {
+ complete
+ name
+ id
+ admin {
+ username
+ id
+ }
+ operatoroperations {
+ view_mode
+ operator {
+ username
+ id
+ }
+ id
+ }
+}`
+
+const AddOperatorToOperationFragment = `
+fragment add_operator_to_operation_fragment on updateOperatorOperation{
+ status
+ error
+}`
+
+const RemoveOperatorFromOperationFragment = `
+fragment remove_operator_from_operation_fragment on updateOperatorOperation{
+ status
+ error
+}`
+
+const UpdateOperatorInOperationFragment = `
+fragment update_operator_in_operation_fragment on updateOperatorOperation{
+ status
+ error
+}`
+
+const CreateOperationFragment = `
+fragment create_operation_fragment on createOperationOutput {
+ status
+ error
+ operation{
+ name
+ id
+ admin {
+ id
+ username
+ }
+ }
+}`
+
+const UserOutputFragment = `
+fragment user_output_fragment on response {
+ response_text
+ timestamp
+}`
+
+const TaskOutputFragment = `
+fragment task_output_fragment on response {
+ id
+ timestamp
+ response_text
+ task {
+ id
+ display_id
+ status
+ completed
+ agent_task_id
+ command_name
+ }
+}`
+
+const PayloadDataFragment = `
+fragment payload_data_fragment on payload {
+ build_message
+ build_phase
+ build_stderr
+ callback_alert
+ creation_time
+ id
+ operator {
+ id
+ username
+ }
+ uuid
+ description
+ deleted
+ auto_generated
+ payloadtype {
+ id
+ name
+ }
+ filemetum {
+ agent_file_id
+ filename_utf8
+ id
+ }
+ payloadc2profiles {
+ c2profile {
+ running
+ name
+ is_p2p
+ container_running
+ }
+ }
+}`
+
+const FileDataFragment = `
+fragment file_data_fragment on filemeta{
+ agent_file_id
+ chunk_size
+ chunks_received
+ complete
+ deleted
+ filename_utf8
+ full_remote_path_utf8
+ host
+ id
+ is_download_from_agent
+ is_payload
+ is_screenshot
+ md5
+ operator {
+ id
+ username
+ }
+ comment
+ sha1
+ timestamp
+ total_chunks
+ task {
+ id
+ comment
+ command {
+ cmd
+ id
+ }
+ }
+}`
+
+const CommandFragment = `
+fragment command_fragment on command {
+ id
+ cmd
+ attributes
+}`
+
+// Query builders for common operations
+
+const GetCallbacks = `
+query GetCallbacks {
+ callback {
+ id
+ display_id
+ user
+ host
+ process_name
+ pid
+ description
+ last_checkin
+ active
+ payload {
+ payloadtype {
+ name
+ }
+ }
+ }
+}`
+
+const GetTask = `
+query GetTask($id: Int!) {
+ task_by_pk(id: $id) {
+ id
+ display_id
+ command_name
+ original_params
+ display_params
+ status
+ completed
+ callback {
+ id
+ display_id
+ }
+ }
+}`
+
+const GetTaskResponses = `
+query GetTaskResponses($task_display_id: Int!) {
+ response(where: {task: {display_id: {_eq: $task_display_id}}}, order_by: {id: asc}) {
+ response_text
+ timestamp
+ }
+}`
+
+const GetCallbackTasks = `
+query GetCallbackTasks($callback_id: Int!) {
+ task(where: {callback_id: {_eq: $callback_id}}, order_by: {id: asc}) {
+ id
+ display_id
+ command_name
+ original_params
+ display_params
+ status
+ completed
+ timestamp
+ callback {
+ id
+ display_id
+ }
+ }
+}`
+
+const GetDownloadedFiles = `
+query GetDownloadedFiles {
+ filemeta(where: {is_download_from_agent: {_eq: true}, deleted: {_eq: false}}, order_by: {id: desc}) {
+ agent_file_id
+ filename_utf8
+ full_remote_path_utf8
+ host
+ id
+ complete
+ chunks_received
+ total_chunks
+ timestamp
+ md5
+ sha1
+ operator {
+ username
+ }
+ task {
+ id
+ display_id
+ callback {
+ id
+ display_id
+ host
+ user
+ }
+ }
+ }
+}`
+
+const GetAllTasksWithResponses = `
+query GetAllTasksWithResponses($limit: Int) {
+ task(where: {status: {_in: ["completed", "error"]}}, order_by: {id: desc}, limit: $limit) {
+ id
+ display_id
+ command_name
+ original_params
+ display_params
+ status
+ completed
+ timestamp
+ callback {
+ id
+ display_id
+ host
+ user
+ }
+ }
+}`
+
+const GetPayloads = `
+query GetPayloads {
+ payload(where: {deleted: {_eq: false}}, order_by: {id: desc}) {
+ build_message
+ build_phase
+ build_stderr
+ callback_alert
+ creation_time
+ id
+ operator {
+ id
+ username
+ }
+ uuid
+ description
+ deleted
+ auto_generated
+ payloadtype {
+ id
+ name
+ }
+ filemetum {
+ agent_file_id
+ filename_utf8
+ id
+ }
+ payloadc2profiles {
+ c2profile {
+ running
+ name
+ is_p2p
+ container_running
+ }
+ }
+ }
+}`
+
+const GetAllFiles = `
+query GetAllFiles {
+ filemeta(where: {deleted: {_eq: false}}, order_by: {id: desc}) {
+ agent_file_id
+ filename_utf8
+ full_remote_path_utf8
+ host
+ id
+ complete
+ chunks_received
+ total_chunks
+ timestamp
+ md5
+ sha1
+ is_download_from_agent
+ is_payload
+ is_screenshot
+ operator {
+ username
+ }
+ task {
+ id
+ display_id
+ callback {
+ id
+ display_id
+ host
+ user
+ }
+ }
+ }
+}`
\ No newline at end of file
.gitignore
@@ -1,3 +1,4 @@
bin/*
mythic.sh
contrib/Mythic_Scripting
+.env
investigate_forge.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+
+import asyncio
+import sys
+import os
+
+# Add the contrib directory to the path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'contrib', 'Mythic_Scripting'))
+
+from mythic import mythic
+
+async def investigate_forge_tasks():
+ try:
+ # Connect to Mythic using environment variables
+ server_ip = "mythic.adversarytactics.local"
+ apitoken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTgwNTc3ODUsImlhdCI6MTc1ODA0MzM4NSwidXNlcl9pZCI6NCwiYXV0aCI6ImFwaSIsImV2ZW50c3RlcGluc3RhbmNlX2lkIjowLCJhcGl0b2tlbnNfaWQiOjI2LCJvcGVyYXRpb25faWQiOjB9.dNx93CuQLMMhvbJEgVs1Rg_IQYDfhtTRjEiupXlEpYc"
+
+ mythic_conn = await mythic.login(
+ server_ip=server_ip,
+ apitoken=apitoken,
+ server_port=7443,
+ ssl=True
+ )
+
+ print("Connected to Mythic successfully")
+
+ # Get all tasks
+ tasks = await mythic_conn.get_all_tasks()
+
+ # Filter for forge-related tasks
+ forge_tasks = []
+ for task in tasks:
+ if task.command and 'forge' in task.command.lower():
+ forge_tasks.append(task)
+
+ if not forge_tasks:
+ print("No forge-related tasks found")
+ return
+
+ print(f"Found {len(forge_tasks)} forge-related tasks")
+ print("=" * 60)
+
+ for task in forge_tasks:
+ print(f"Task ID: {task.display_id}")
+ print(f"Command: {task.command}")
+ print(f"Status: {task.status}")
+ print(f"Completed: {task.completed}")
+ print(f"Callback: {task.callback.display_id if task.callback else 'Unknown'}")
+ print(f"Parameters: {task.original_params if task.original_params else 'None'}")
+
+ # Get task responses
+ responses = await mythic_conn.get_all_responses_for_task(task.id)
+ if responses:
+ print("Responses:")
+ for resp in responses:
+ print(f" - {resp.response[:200]}{'...' if len(resp.response) > 200 else ''}")
+ else:
+ print("No responses found")
+
+ print("-" * 40)
+
+ except Exception as e:
+ print(f"Error: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ asyncio.run(investigate_forge_tasks())
\ No newline at end of file
TODO.md
@@ -0,0 +1,55 @@
+- grep should also look at the command execution strings too, not just output
+- forge equivalency with execute_assembly and inline_assembly
+- task-list --all (same as --limit 0)
+
+## Code Quality Improvements
+
+### High Priority
+1. **Extract callback lookup logic to shared utility** - Remove duplication in task.go and interact.go
+2. **Standardize task polling with configurable timeouts** - Create reusable polling mechanism
+3. **Create constants file for magic numbers** - Replace hardcoded values (timeouts, status strings)
+4. **Add basic unit tests for client functions** - Test GraphQL operations and response handling
+
+### Medium Priority
+1. **Implement proper error types with context** - Create MythicError, CallbackNotFoundError, TaskTimeoutError
+2. **Add configuration management** - Centralized config with proper defaults
+3. **Create interfaces for better testability** - MythicClient interface for mocking
+4. **Improve logging and debugging** - Add structured logging with slog
+
+### Low Priority
+1. **Refactor large functions into smaller components** - Break down extractUUIDFromTaskResponse
+2. **Add comprehensive integration tests** - Test CLI commands with fixtures
+3. **Implement retry mechanisms for network operations** - Handle transient failures
+4. **Add performance monitoring/metrics** - Track operation success/failure rates
+
+## Code Duplication Issues
+- **Callback validation**: task.go:54-69 and interact.go:42-57 duplicate callback lookup
+- **Task polling**: Similar patterns in task.go:88-116 and interact.go:119-144
+- **Client creation**: Repeated client instantiation across all commands
+- **Config validation**: validateConfig() called in every command
+
+## Suggested Package Structure
+```
+pkg/
+├── mythic/ # Existing client
+├── utils/ # New shared utilities
+│ ├── callbacks.go # Callback lookup helpers
+│ ├── tasks.go # Task polling utilities
+│ ├── constants.go # Shared constants
+│ └── validation.go # Common validation
+├── config/ # Configuration management
+│ └── config.go
+└── errors/ # Custom error types
+ └── errors.go
+```
+
+## Error Handling Improvements
+- Inconsistent timeout handling (some return nil, others error)
+- Missing error context for network/GraphQL failures
+- Raw output mode silently fails on timeout (task.go:118-124)
+
+## Testing Strategy
+- No Go tests currently exist
+- Reference Python tests in contrib/Mythic_Scripting/tests/
+- Need unit tests for client, integration tests for CLI
+- Missing coverage for base64 decoding, UUID extraction, error paths
USAGE.md
@@ -25,6 +25,19 @@ make help
go build -o bin/go-mythic .
```
+## Project Structure
+
+### GraphQL Queries Synchronization
+The project maintains a 1:1 copy of GraphQL queries from the Python Mythic_Scripting library:
+- **Source:** `contrib/Mythic_Scripting/mythic/graphql_queries.py`
+- **Go Version:** `pkg/mythic/queries.go`
+- **Purpose:** Easier upstream synchronization and reduced raw strings in client code
+
+To sync with upstream updates:
+1. Check the Python file for changes
+2. Update the corresponding Go constants
+3. Test client functionality
+
## Basic Commands
### List Active Callbacks
@@ -69,37 +82,42 @@ The CLI supports SOCKS5 proxy routing for all network traffic, replacing the nee
## Configuration
-### Authentication Token
-Authentication is required via JWT token. Set it using one of these methods:
+**Both URL and authentication token are required.**
+### Quick Setup
```bash
-# Set via environment variable (recommended)
+# Set via environment variables (recommended)
+export MYTHIC_API_URL="https://your-mythic-server:7443/graphql/"
export MYTHIC_API_TOKEN="your-jwt-token-here"
./go-mythic callbacks
-# Set via command line flag
-./go-mythic --token "your-jwt-token-here" callbacks
-
-# Get token from mythic.sh for testing
+# For testing with mythic.sh
+export MYTHIC_API_URL="https://mythic.adversarytactics.local:7443/graphql/"
export MYTHIC_API_TOKEN="$(grep TOKEN mythic.sh | cut -d'=' -f2)"
+./go-mythic callbacks
```
-### Custom Mythic Server
+### Individual Configuration Options
+
+#### Mythic Server URL
```bash
# Set via environment variable
export MYTHIC_API_URL="https://your-mythic-server:7443/graphql/"
-./go-mythic callbacks
# Set via command line flag
./go-mythic --url "https://your-mythic-server:7443/graphql/" callbacks
```
-### Complete Environment Setup
+#### Authentication Token
```bash
-# Set both URL and token via environment variables
-export MYTHIC_API_URL="https://your-mythic-server:7443/graphql/"
+# Set via environment variable
export MYTHIC_API_TOKEN="your-jwt-token-here"
-./go-mythic callbacks
+
+# Set via command line flag
+./go-mythic --token "your-jwt-token-here" callbacks
+
+# Get token from mythic.sh for testing
+export MYTHIC_API_TOKEN="$(grep TOKEN mythic.sh | cut -d'=' -f2)"
```
## Lab 01 Equivalents