main
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}