main
1package cmd
2
3import (
4 "context"
5 "fmt"
6 "mysh/pkg/mythic"
7 "os"
8 "sort"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/jedib0t/go-pretty/v6/table"
14 "github.com/spf13/cobra"
15)
16
17var (
18 sortByID bool
19 sortByType bool
20 sortByHost bool
21 sortByUser bool
22 sortByProcess bool
23 sortByLast bool
24 sortByDescription bool
25 sortReverse bool
26 showCommands bool
27)
28
29var callbacksCmd = &cobra.Command{
30 Use: "callbacks [callback_id]",
31 Aliases: []string{"cb", "callback"},
32 Short: "List active callbacks or show detailed callback information",
33 Long: "Display all active callbacks from the Mythic server, or show detailed information about a specific callback including available commands.",
34 Args: cobra.MaximumNArgs(1),
35 RunE: runCallbacks,
36}
37
38func init() {
39 rootCmd.AddCommand(callbacksCmd)
40
41 // Sort flags - mutually exclusive
42 callbacksCmd.Flags().BoolVar(&sortByID, "id", false, "Sort by callback ID (default)")
43 callbacksCmd.Flags().BoolVar(&sortByType, "type", false, "Sort by agent type")
44 callbacksCmd.Flags().BoolVar(&sortByHost, "host", false, "Sort by hostname")
45 callbacksCmd.Flags().BoolVar(&sortByUser, "user", false, "Sort by username")
46 callbacksCmd.Flags().BoolVar(&sortByProcess, "process", false, "Sort by process name")
47 callbacksCmd.Flags().BoolVar(&sortByLast, "last", false, "Sort by last checkin time")
48 callbacksCmd.Flags().BoolVar(&sortByDescription, "description", false, "Sort by description")
49
50 // Reverse sort flag
51 callbacksCmd.Flags().BoolVar(&sortReverse, "rev", false, "Reverse sort order")
52
53 // Commands flag for detailed view
54 callbacksCmd.Flags().BoolVar(&showCommands, "commands", false, "Show available commands in detailed callback view")
55}
56
57// formatTimeSince formats a timestamp into a human-readable "time ago" format
58// like "0s ago", "1m 30s ago", "2h 15m ago", etc.
59func formatTimeSince(timestampStr string) string {
60 if timestampStr == "" {
61 return "unknown"
62 }
63
64 // Parse the timestamp - try common formats
65 var parsedTime time.Time
66 var err error
67
68 // Try RFC3339 format first (most common)
69 parsedTime, err = time.Parse(time.RFC3339, timestampStr)
70 if err != nil {
71 // Try RFC3339Nano format
72 parsedTime, err = time.Parse(time.RFC3339Nano, timestampStr)
73 if err != nil {
74 // Try other common formats
75 parsedTime, err = time.Parse("2006-01-02T15:04:05", timestampStr)
76 if err != nil {
77 // If all parsing fails, return the original string
78 return timestampStr
79 }
80 }
81 }
82
83 duration := time.Since(parsedTime)
84
85 // Format based on duration
86 if duration < time.Minute {
87 seconds := int(duration.Seconds())
88 return fmt.Sprintf("%ds ago", seconds)
89 } else if duration < time.Hour {
90 minutes := int(duration.Minutes())
91 seconds := int(duration.Seconds()) % 60
92 if seconds == 0 {
93 return fmt.Sprintf("%dm ago", minutes)
94 }
95 return fmt.Sprintf("%dm %02ds ago", minutes, seconds)
96 } else if duration < 24*time.Hour {
97 hours := int(duration.Hours())
98 minutes := int(duration.Minutes()) % 60
99 if minutes == 0 {
100 return fmt.Sprintf("%dh ago", hours)
101 }
102 return fmt.Sprintf("%dh %02dm ago", hours, minutes)
103 } else {
104 days := int(duration.Hours() / 24)
105 hours := int(duration.Hours()) % 24
106 if hours == 0 {
107 return fmt.Sprintf("%dd ago", days)
108 }
109 return fmt.Sprintf("%dd %02dh ago", days, hours)
110 }
111}
112
113// getSortField determines which field to sort by, returns "id" as default
114func getSortField() string {
115 if sortByType {
116 return "type"
117 }
118 if sortByHost {
119 return "host"
120 }
121 if sortByUser {
122 return "user"
123 }
124 if sortByProcess {
125 return "process"
126 }
127 if sortByLast {
128 return "last"
129 }
130 if sortByDescription {
131 return "description"
132 }
133 // Default to ID sort (newest/highest last)
134 return "id"
135}
136
137// sortCallbacks sorts callbacks based on the specified field
138func sortCallbacks(callbacks []mythic.Callback, sortField string, reverse bool) {
139 sort.Slice(callbacks, func(i, j int) bool {
140 var result bool
141
142 switch sortField {
143 case "type":
144 typeI := callbacks[i].Payload.PayloadType.Name
145 typeJ := callbacks[j].Payload.PayloadType.Name
146 if typeI == "" {
147 typeI = "unknown"
148 }
149 if typeJ == "" {
150 typeJ = "unknown"
151 }
152 result = strings.ToLower(typeI) < strings.ToLower(typeJ)
153 case "host":
154 result = strings.ToLower(callbacks[i].Host) < strings.ToLower(callbacks[j].Host)
155 case "user":
156 result = strings.ToLower(callbacks[i].User) < strings.ToLower(callbacks[j].User)
157 case "process":
158 result = strings.ToLower(callbacks[i].ProcessName) < strings.ToLower(callbacks[j].ProcessName)
159 case "last":
160 // Parse timestamps for comparison
161 timeI, errI := time.Parse(time.RFC3339, callbacks[i].LastCheckin)
162 timeJ, errJ := time.Parse(time.RFC3339, callbacks[j].LastCheckin)
163 if errI != nil || errJ != nil {
164 // Fallback to string comparison if parsing fails
165 result = callbacks[i].LastCheckin < callbacks[j].LastCheckin
166 } else {
167 result = timeI.Before(timeJ)
168 }
169 case "description":
170 result = strings.ToLower(callbacks[i].Description) < strings.ToLower(callbacks[j].Description)
171 default: // "id"
172 result = callbacks[i].DisplayID < callbacks[j].DisplayID
173 }
174
175 if reverse {
176 return !result
177 }
178 return result
179 })
180}
181
182func runCallbacks(cmd *cobra.Command, args []string) error {
183 if err := validateConfig(); err != nil {
184 return err
185 }
186
187 client := mythic.NewClient(mythicURL, token, insecure, socksProxy)
188 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
189 defer cancel()
190
191 // Check if a specific callback ID was provided
192 if len(args) == 1 {
193 callbackID, err := strconv.Atoi(args[0])
194 if err != nil {
195 return fmt.Errorf("invalid callback ID: %s", args[0])
196 }
197 return showDetailedCallback(ctx, client, callbackID)
198 }
199
200 // Show table of all active callbacks
201 callbacks, err := client.GetActiveCallbacks(ctx)
202 if err != nil {
203 return fmt.Errorf("failed to get active callbacks: %w", err)
204 }
205
206 if len(callbacks) == 0 {
207 fmt.Println("No active callbacks found")
208 return nil
209 }
210
211 // Sort callbacks based on flags
212 sortField := getSortField()
213 // For ID sort, default to ascending order with highest ID last (reverse=false)
214 // For other sorts, use the --rev flag
215 shouldReverse := sortReverse
216 // Note: No special logic needed for ID sort - ascending is the natural order
217
218 sortCallbacks(callbacks, sortField, shouldReverse)
219
220 // Create a table for formatted output
221 t := table.NewWriter()
222 t.SetOutputMirror(os.Stdout)
223
224 // Configure table styling - header borders only
225 t.SetStyle(table.Style{
226 Name: "HeaderOnlyBorders",
227 Box: table.StyleBoxDefault,
228 Options: table.Options{
229 DrawBorder: false, // No outer border
230 SeparateColumns: false, // No column separators
231 SeparateFooter: false, // No footer separator
232 SeparateHeader: true, // Keep header separator
233 SeparateRows: false, // No row separators
234 },
235 })
236
237 // Define columns with priorities (1=highest, higher numbers can be dropped)
238 columns := []ColumnInfo{
239 {Header: "ID", Number: 1, MinWidth: 3, MaxWidth: 6, Priority: 1}, // Always show
240 {Header: "TYPE", Number: 2, MinWidth: 6, MaxWidth: 10, Priority: 3}, // High priority
241 {Header: "HOST", Number: 3, MinWidth: 8, MaxWidth: 16, Priority: 2}, // Very important
242 {Header: "USER", Number: 4, MinWidth: 6, MaxWidth: 12, Priority: 4}, // Important
243 {Header: "PROCESS", Number: 5, MinWidth: 8, MaxWidth: 12, Priority: 6}, // Can drop
244 {Header: "PID", Number: 6, MinWidth: 3, MaxWidth: 6, Priority: 7}, // Can drop
245 {Header: "LAST CHECKIN", Number: 7, MinWidth: 8, MaxWidth: 10, Priority: 5},
246 {Header: "DESCRIPTION", Number: 8, MinWidth: 10, MaxWidth: 25, Priority: 8}, // First to drop
247 }
248
249 // Configure table for current terminal width
250 dt := configureTableForTerminal(t, columns)
251
252 for _, callback := range callbacks {
253 agentType := callback.Payload.PayloadType.Name
254 if agentType == "" {
255 agentType = "unknown"
256 }
257
258 lastCheckin := formatTimeSince(callback.LastCheckin)
259
260 // Prepare all column data
261 allData := []interface{}{
262 callback.DisplayID,
263 agentType,
264 callback.Host,
265 callback.User,
266 callback.ProcessName,
267 callback.PID,
268 lastCheckin,
269 callback.Description,
270 }
271
272 // Add row with only visible columns
273 dt.AppendRowForColumns(allData)
274 }
275
276 t.Render()
277 return nil
278}
279
280// wrapDescription wraps text to fit within the specified width, adding proper indentation for continuation lines
281func wrapDescription(text string, maxWidth int, indent string) string {
282 if text == "" {
283 return ""
284 }
285
286 // If the text fits on one line, return as-is
287 if len(text) <= maxWidth {
288 return text
289 }
290
291 words := strings.Fields(text)
292 if len(words) == 0 {
293 return text
294 }
295
296 var lines []string
297 var currentLine []string
298 currentLength := 0
299
300 for _, word := range words {
301 // Check if adding this word would exceed the line width
302 wordLen := len(word)
303 if currentLength > 0 {
304 wordLen += 1 // space before word
305 }
306
307 if currentLength == 0 || currentLength + wordLen <= maxWidth {
308 // Word fits on current line
309 currentLine = append(currentLine, word)
310 if currentLength == 0 {
311 currentLength = len(word)
312 } else {
313 currentLength += 1 + len(word) // space + word
314 }
315 } else {
316 // Word doesn't fit, finish current line and start new one
317 if len(currentLine) > 0 {
318 lines = append(lines, strings.Join(currentLine, " "))
319 }
320
321 // Handle very long single words that exceed maxWidth
322 if len(word) > maxWidth {
323 // Split the word if it's longer than maxWidth
324 for len(word) > maxWidth {
325 lines = append(lines, word[:maxWidth])
326 word = word[maxWidth:]
327 }
328 if len(word) > 0 {
329 currentLine = []string{word}
330 currentLength = len(word)
331 } else {
332 currentLine = []string{}
333 currentLength = 0
334 }
335 } else {
336 // Start new line with this word
337 currentLine = []string{word}
338 currentLength = len(word)
339 }
340 }
341 }
342
343 // Add the final line
344 if len(currentLine) > 0 {
345 lines = append(lines, strings.Join(currentLine, " "))
346 }
347
348 // Join lines with appropriate indentation
349 result := lines[0]
350 for i := 1; i < len(lines); i++ {
351 result += "\n" + indent + lines[i]
352 }
353
354 return result
355}
356
357func showDetailedCallback(ctx context.Context, client *mythic.Client, callbackID int) error {
358 callback, err := client.GetDetailedCallback(ctx, callbackID)
359 if err != nil {
360 return err
361 }
362
363 // Display detailed callback information
364 fmt.Printf("Callback %d\n", callback.DisplayID)
365 fmt.Printf("\n")
366
367 // Agent Overview Section
368 fmt.Printf("Agent Overview\n")
369 fmt.Printf(" ID: %d\n", callback.DisplayID)
370 fmt.Printf(" Status: %s\n", getStatusString(callback.Active))
371 fmt.Printf(" Agent Type: %s\n", callback.Payload.PayloadType.Name)
372 fmt.Printf(" Operator: %s\n", callback.Operator.Username)
373 fmt.Printf(" Last Checkin: %s\n", formatTimeSince(callback.LastCheckin))
374 if callback.Description != "" {
375 fmt.Printf(" Description: %s\n", callback.Description)
376 }
377 fmt.Printf("\n")
378
379 // System Information Section
380 fmt.Printf("System Information\n")
381 fmt.Printf(" Hostname: %s\n", callback.Host)
382 fmt.Printf(" Username: %s\n", callback.User)
383 fmt.Printf(" Process: %s (PID: %d)\n", callback.ProcessName, callback.PID)
384 if callback.OS != "" {
385 fmt.Printf(" Operating System: %s\n", callback.OS)
386 }
387 if callback.Architecture != "" {
388 fmt.Printf(" Architecture: %s\n", callback.Architecture)
389 }
390 if callback.Domain != "" {
391 fmt.Printf(" Domain: %s\n", callback.Domain)
392 }
393 if callback.IntegrityLevel > 0 {
394 fmt.Printf(" Integrity Level: %d\n", callback.IntegrityLevel)
395 }
396 fmt.Printf("\n")
397
398 // Network Context Section
399 fmt.Printf("Network Context\n")
400 if callback.IP != "" {
401 fmt.Printf(" Internal IP: %s\n", callback.IP)
402 }
403 if callback.ExternalIP != "" {
404 fmt.Printf(" External IP: %s\n", callback.ExternalIP)
405 }
406 if callback.SleepInfo != "" {
407 fmt.Printf(" Sleep Config: %s\n", callback.SleepInfo)
408 }
409 if callback.CryptoType != "" {
410 fmt.Printf(" Crypto Type: %s\n", callback.CryptoType)
411 }
412 fmt.Printf("\n")
413
414 // Payload Information Section
415 fmt.Printf("Payload Information\n")
416 fmt.Printf(" UUID: %s\n", callback.Payload.UUID)
417 fmt.Printf(" Type: %s\n", callback.Payload.PayloadType.Name)
418 if callback.Payload.PayloadType.Author != "" {
419 fmt.Printf(" Author: %s\n", callback.Payload.PayloadType.Author)
420 }
421 if callback.Payload.PayloadType.Note != "" {
422 fmt.Printf(" Type Note: %s\n", callback.Payload.PayloadType.Note)
423 }
424 if callback.Payload.Description != "" {
425 fmt.Printf(" Description: %s\n", callback.Payload.Description)
426 }
427 if callback.AgentCallbackID != "" {
428 fmt.Printf(" Agent ID: %s\n", callback.AgentCallbackID)
429 }
430 // Available Commands Section - only show if --commands flag is used
431 if showCommands {
432 fmt.Printf("\n")
433 fmt.Printf("Available Commands (%d loaded)\n", len(callback.LoadedCommands))
434 if len(callback.LoadedCommands) > 0 {
435 // Sort commands alphabetically
436 commands := make([]mythic.LoadedCommand, len(callback.LoadedCommands))
437 copy(commands, callback.LoadedCommands)
438 sort.Slice(commands, func(i, j int) bool {
439 return commands[i].Command.Cmd < commands[j].Command.Cmd
440 })
441
442 // Find the longest command name for consistent alignment
443 maxCmdLen := 0
444 for _, cmd := range commands {
445 if len(cmd.Command.Cmd) > maxCmdLen {
446 maxCmdLen = len(cmd.Command.Cmd)
447 }
448 }
449
450 // Calculate available width for descriptions
451 termWidth := getTerminalWidth()
452 prefixWidth := 2 + maxCmdLen + 1 // " longest_command_name "
453 adminFlagMaxWidth := 10 // " [ADMIN]" plus some buffer
454 availableWidth := termWidth - prefixWidth - adminFlagMaxWidth
455
456 // Ensure minimum width for readability
457 if availableWidth < 30 {
458 availableWidth = 30
459 }
460
461 // Create indent string to match the command name alignment
462 indent := " " + strings.Repeat(" ", maxCmdLen) + " "
463
464 for _, cmd := range commands {
465 adminFlag := ""
466 if cmd.Command.NeedsAdmin {
467 adminFlag = " [ADMIN]"
468 }
469
470 // Wrap the description
471 wrappedDesc := wrapDescription(cmd.Command.Description, availableWidth, indent)
472
473 // Print the command with wrapped description using dynamic width
474 fmt.Printf(" %-*s %s%s\n", maxCmdLen, cmd.Command.Cmd, wrappedDesc, adminFlag)
475 }
476 } else {
477 fmt.Printf(" No commands loaded\n")
478 }
479 }
480
481 // Show extra info if available
482 if callback.ExtraInfo != "" {
483 fmt.Printf("\nExtra Information:\n%s\n", callback.ExtraInfo)
484 }
485
486 return nil
487}
488
489func getStatusString(active bool) string {
490 if active {
491 return "Active"
492 }
493 return "Inactive"
494}