main
Raw Download raw file
  1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"mysh/pkg/mythic"
  7	"mysh/pkg/mythic/api"
  8	"os"
  9	"path/filepath"
 10	"regexp"
 11	"strconv"
 12	"strings"
 13	"time"
 14
 15	"github.com/jedib0t/go-pretty/v6/table"
 16	"github.com/spf13/cobra"
 17)
 18
 19var (
 20	outputDir       string
 21	showPayloads    bool
 22	showDownloads   bool
 23	showScreenshots bool
 24	showUploads     bool
 25)
 26
 27var filesCmd = &cobra.Command{
 28	Use:     "files",
 29	Aliases: []string{"file"},
 30	Short:   "Manage files",
 31	Long:    "List and download files (payloads, downloads, uploads, screenshots) from Mythic. Defaults to listing all files if no subcommand is provided.",
 32	RunE:    runFilesList, // Default to list command
 33}
 34
 35var filesDownloadCmd = &cobra.Command{
 36	Use:   "download <file_uuid_or_task_id>",
 37	Short: "Download a file by UUID or task ID",
 38	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.",
 39	Args:  cobra.ExactArgs(1),
 40	RunE:  runFilesDownload,
 41}
 42
 43func init() {
 44	rootCmd.AddCommand(filesCmd)
 45	filesCmd.AddCommand(filesDownloadCmd)
 46
 47	// Add filter flags to files command
 48	filesCmd.Flags().BoolVarP(&showPayloads, "payload", "p", false, "Show only payload files")
 49	filesCmd.Flags().BoolVarP(&showDownloads, "downloads", "d", false, "Show only downloaded files")
 50	filesCmd.Flags().BoolVarP(&showScreenshots, "screenshots", "s", false, "Show only screenshot files")
 51	filesCmd.Flags().BoolVarP(&showUploads, "uploads", "u", false, "Show only uploaded files (assemblies, etc)")
 52
 53	filesDownloadCmd.Flags().StringVarP(&outputDir, "output", "o", ".", "Output directory for downloaded files")
 54}
 55
 56func runFilesList(cmd *cobra.Command, args []string) error {
 57	if err := validateConfig(); err != nil {
 58		return err
 59	}
 60
 61	client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
 62	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 63	defer cancel()
 64
 65	files, err := client.GetAllFiles(ctx)
 66	if err != nil {
 67		return fmt.Errorf("failed to get files: %w", err)
 68	}
 69
 70	// Apply filters if any are specified
 71	if showPayloads || showDownloads || showScreenshots || showUploads {
 72		var filteredFiles []mythic.File
 73		for _, file := range files {
 74			if (showPayloads && file.IsPayload) ||
 75				(showDownloads && file.IsDownloadFromAgent && !file.IsPayload && !file.IsScreenshot) ||
 76				(showScreenshots && file.IsScreenshot) ||
 77				(showUploads && !file.IsDownloadFromAgent && !file.IsPayload && !file.IsScreenshot) {
 78				filteredFiles = append(filteredFiles, file)
 79			}
 80		}
 81		files = filteredFiles
 82	}
 83
 84	if len(files) == 0 {
 85		fmt.Println("No files found")
 86		return nil
 87	}
 88
 89	// Determine filter description
 90	filterDesc := ""
 91	if showPayloads || showDownloads || showScreenshots || showUploads {
 92		var filters []string
 93		if showPayloads {
 94			filters = append(filters, "payloads")
 95		}
 96		if showDownloads {
 97			filters = append(filters, "downloads")
 98		}
 99		if showScreenshots {
100			filters = append(filters, "screenshots")
101		}
102		if showUploads {
103			filters = append(filters, "uploads")
104		}
105		filterDesc = fmt.Sprintf(" (%s)", strings.Join(filters, ", "))
106	}
107
108	fmt.Printf("Files%s (%d total):\n\n", filterDesc, len(files))
109
110	// Create a table for formatted output
111	t := table.NewWriter()
112	t.SetOutputMirror(os.Stdout)
113
114	// Configure table styling - header borders only
115	t.SetStyle(table.Style{
116		Name: "HeaderOnlyBorders",
117		Box:  table.StyleBoxDefault,
118		Options: table.Options{
119			DrawBorder:      false, // No outer border
120			SeparateColumns: false, // No column separators
121			SeparateFooter:  false, // No footer separator
122			SeparateHeader:  true,  // Keep header separator
123			SeparateRows:    false, // No row separators
124		},
125	})
126
127	// Define columns with priorities for responsive display
128	columns := []ColumnInfo{
129		{Header: "UUID", Number: 1, MinWidth: 8, MaxWidth: 36, Priority: 5},         // Can drop on small screens
130		{Header: "TYPE", Number: 2, MinWidth: 6, MaxWidth: 10, Priority: 3},         // Important
131		{Header: "FILENAME", Number: 3, MinWidth: 10, MaxWidth: 30, Priority: 1},    // Never drop - most important
132		{Header: "REMOTE PATH", Number: 4, MinWidth: 12, MaxWidth: 40, Priority: 6}, // First to drop
133		{Header: "HOST", Number: 5, MinWidth: 8, MaxWidth: 20, Priority: 4},         // Medium priority
134		{Header: "SIZE", Number: 6, MinWidth: 6, MaxWidth: 10, Priority: 3},         // Important
135		{Header: "COMPLETE", Number: 7, MinWidth: 6, MaxWidth: 8, Priority: 2},      // High priority
136		{Header: "TASK", Number: 8, MinWidth: 4, MaxWidth: 8, Priority: 5},          // Can drop
137		{Header: "TIMESTAMP", Number: 9, MinWidth: 12, MaxWidth: 16, Priority: 7},   // First to drop
138	}
139
140	// Configure table for current terminal width
141	dt := configureTableForTerminal(t, columns)
142
143	for _, file := range files {
144		// Show completion status
145		status := "✓"
146		if !file.Complete {
147			status = fmt.Sprintf("%d/%d", file.ChunksReceived, file.TotalChunks)
148		}
149
150		// Format timestamp
151		timestamp := file.Timestamp
152		if timestamp != "" {
153			if t, err := time.Parse(time.RFC3339, timestamp); err == nil {
154				timestamp = t.Format("2006-01-02 15:04")
155			}
156		}
157
158		// Use full paths - table library will handle truncation
159		filename := file.Filename
160		remotePath := file.FullRemotePath
161
162		// Show file size info (chunks can give us a rough idea)
163		sizeInfo := "unknown"
164		if file.TotalChunks > 0 {
165			sizeInfo = fmt.Sprintf("~%dKB", file.TotalChunks*api.EstimatedChunkSizeKB)
166		}
167
168		// Determine file type
169		fileType := "download"
170		if file.IsPayload {
171			fileType = "payload"
172		} else if file.IsScreenshot {
173			fileType = "screenshot"
174		} else if !file.IsDownloadFromAgent {
175			fileType = "upload"
176		}
177
178		// Prepare all column data in the order defined by columns
179		allData := []interface{}{
180			file.AgentFileID,
181			fileType,
182			filename,
183			remotePath,
184			file.CallbackHost,
185			sizeInfo,
186			status,
187			file.TaskDisplayID,
188			timestamp,
189		}
190
191		// Add row with only visible columns
192		dt.AppendRowForColumns(allData)
193	}
194
195	t.Render()
196
197	fmt.Println("\nTo download a file: mysh files download <uuid>")
198
199	return nil
200}
201
202func runFilesDownload(cmd *cobra.Command, args []string) error {
203	if err := validateConfig(); err != nil {
204		return err
205	}
206
207	input := args[0]
208	client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
209	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) // Longer timeout for downloads
210	defer cancel()
211
212	var fileUUID string
213
214	// Determine if input is a UUID (36 chars with hyphens) or a task ID (numeric)
215	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) {
216		// Input looks like a UUID
217		fileUUID = input
218		fmt.Printf("Using provided UUID: %s\n", fileUUID)
219	} else if taskID, err := strconv.Atoi(input); err == nil {
220		// Input is numeric, treat as task ID
221		fmt.Printf("Looking up task %d for file UUID...\n", taskID)
222
223		// Get the task response
224		task, err := client.GetTaskResponse(ctx, taskID)
225		if err != nil {
226			return fmt.Errorf("failed to get task %d: %w", taskID, err)
227		}
228
229		// Extract UUIDs from the response
230		uuids := extractUUIDFromTaskResponse(task.Response)
231		if len(uuids) == 0 {
232			return fmt.Errorf("no file UUIDs found in task %d response. Raw response:\n%s", taskID, task.Response)
233		}
234
235		if len(uuids) > 1 {
236			fmt.Printf("Found multiple UUIDs in task response:\n")
237			for i, uuid := range uuids {
238				fmt.Printf("  %d: %s\n", i+1, uuid)
239			}
240			fmt.Printf("Using first UUID: %s\n", uuids[0])
241		}
242
243		fileUUID = uuids[0]
244		fmt.Printf("Extracted UUID: %s\n", fileUUID)
245	} else {
246		return fmt.Errorf("input '%s' is neither a valid UUID nor a numeric task ID", input)
247	}
248
249	// Get file metadata to get the original filename
250	files, err := client.GetAllFiles(ctx)
251	if err != nil {
252		return fmt.Errorf("failed to get file metadata: %w", err)
253	}
254
255	var targetFile *mythic.File
256	for _, file := range files {
257		if file.AgentFileID == fileUUID {
258			targetFile = &file
259			break
260		}
261	}
262
263	if targetFile == nil {
264		return fmt.Errorf("file with UUID %s not found", fileUUID)
265	}
266
267	if !targetFile.Complete {
268		fmt.Printf("Warning: File is not fully downloaded (%d/%d chunks)\n",
269			targetFile.ChunksReceived, targetFile.TotalChunks)
270	}
271
272	fmt.Printf("Downloading: %s\n", targetFile.Filename)
273	fmt.Printf("From: %s (%s@%s)\n", targetFile.FullRemotePath, targetFile.CallbackUser, targetFile.CallbackHost)
274
275	// Download the file
276	data, err := client.DownloadFile(ctx, fileUUID)
277	if err != nil {
278		return fmt.Errorf("failed to download file: %w", err)
279	}
280
281	// Determine output filename
282	outputFilename := targetFile.Filename
283	if outputFilename == "" {
284		outputFilename = fileUUID + ".bin"
285	}
286
287	outputPath := filepath.Join(outputDir, outputFilename)
288
289	// Handle file conflicts
290	counter := 1
291	originalPath := outputPath
292	for {
293		if _, err := os.Stat(outputPath); os.IsNotExist(err) {
294			break
295		}
296		ext := filepath.Ext(originalPath)
297		base := originalPath[:len(originalPath)-len(ext)]
298		outputPath = fmt.Sprintf("%s_%d%s", base, counter, ext)
299		counter++
300	}
301
302	// Write file
303	err = os.WriteFile(outputPath, data, 0644)
304	if err != nil {
305		return fmt.Errorf("failed to write file: %w", err)
306	}
307
308	fmt.Printf("Downloaded %d bytes to: %s\n", len(data), outputPath)
309
310	if targetFile.MD5 != "" {
311		fmt.Printf("MD5: %s\n", targetFile.MD5)
312	}
313	if targetFile.SHA1 != "" {
314		fmt.Printf("SHA1: %s\n", targetFile.SHA1)
315	}
316
317	return nil
318}
319
320// extractUUIDFromTaskResponse attempts to extract file UUIDs from task response text
321func extractUUIDFromTaskResponse(response string) []string {
322	// Common UUID patterns in Mythic responses
323	patterns := []string{
324		// Standard UUID format
325		`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`,
326		// Sometimes UUIDs appear without hyphens
327		`[0-9a-f]{32}`,
328		// Look for specific download-related patterns
329		`(?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})`,
330		`(?i)(?:downloaded.*?file.*?)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`,
331		`(?i)(?:uuid:\s*)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`,
332	}
333
334	var uuids []string
335	seenUUIDs := make(map[string]bool)
336
337	for _, pattern := range patterns {
338		re := regexp.MustCompile(pattern)
339		matches := re.FindAllStringSubmatch(response, -1)
340
341		for _, match := range matches {
342			var uuid string
343			if len(match) > 1 {
344				uuid = match[1] // Captured group
345			} else {
346				uuid = match[0] // Full match
347			}
348
349			// Normalize UUID format (add hyphens if missing)
350			if len(uuid) == api.UUIDLengthNoHyphens && !regexp.MustCompile(`-`).MatchString(uuid) {
351				uuid = fmt.Sprintf("%s-%s-%s-%s-%s",
352					uuid[0:8], uuid[8:12], uuid[12:16], uuid[16:20], uuid[20:32])
353			}
354
355			// Only add if it's a valid UUID format and we haven't seen it before
356			if len(uuid) == api.UUIDLength && !seenUUIDs[uuid] {
357				uuids = append(uuids, uuid)
358				seenUUIDs[uuid] = true
359			}
360		}
361	}
362
363	return uuids
364}