From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/ui/intruder.go | 742 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 internal/ui/intruder.go (limited to 'internal/ui/intruder.go') diff --git a/internal/ui/intruder.go b/internal/ui/intruder.go new file mode 100644 index 0000000..7527b82 --- /dev/null +++ b/internal/ui/intruder.go @@ -0,0 +1,742 @@ +package ui + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + internalhttp "github.com/shiv/internal/http" + "github.com/shiv/internal/store" + "github.com/shiv/internal/ui/widgets" +) + +var intruderMarkerRegex = regexp.MustCompile(`\$<[^>]+>`) + +func applyPayloadEncoding(payload, encoding string) string { + switch encoding { + case "URL encode": + return url.QueryEscape(payload) + case "Double URL encode": + return url.QueryEscape(url.QueryEscape(payload)) + case "Base64": + return base64.StdEncoding.EncodeToString([]byte(payload)) + case "Hex": + return hex.EncodeToString([]byte(payload)) + default: + return payload + } +} + +type intruderResult struct { + payload string + statusCode int + size int + durationMs int64 + err string + rawResp string + rawReq string +} + +type intruderTab struct { + win fyne.Window + projectStore *store.Store + repeater *repeaterTab + loot *lootTab + + reqEditor *widgets.TextViewEntry + payloadEntry *widget.Entry + filterEntry *widget.Entry + startBtn *widget.Button + stopBtn *widget.Button + progressLabel *widget.Label + responsePane *widgets.TextView + requestPane *widgets.TextView + + sendToRepeaterBtn *widget.Button + sendToLootBtn *widget.Button + viewFullBtn *widget.Button + loadPayloadsBtn *widget.Button + + payloadEncoding string // "none", "url", "double-url", "base64", "hex" + + selectedResult *intruderResult + + config store.IntruderConfig + + mu sync.RWMutex + results []intruderResult + filtered []intruderResult + + table *widgets.DataTable + + running atomic.Bool + stopChan chan struct{} +} + +func newIntruderTab(win fyne.Window, projectStore *store.Store, repeater *repeaterTab, loot *lootTab) *intruderTab { + return &intruderTab{ + win: win, + projectStore: projectStore, + repeater: repeater, + loot: loot, + config: projectStore.LoadIntruderConfig(), + } +} + +var intruderColumns = []widgets.DataTableColumn{ + {Header: "Payload", Width: 200}, + {Header: "Status", Width: 80}, + {Header: "Size", Width: 100}, + {Header: "Duration", Width: 100}, +} + +func (t *intruderTab) showConfigDialog() { + delayEntry := widget.NewEntry() + delayEntry.SetText(strconv.Itoa(t.config.DelayMs)) + delayEntry.SetPlaceHolder("0") + + stopOnStatusEntry := widget.NewEntry() + if t.config.StopOnStatus != 0 { + stopOnStatusEntry.SetText(strconv.Itoa(t.config.StopOnStatus)) + } + stopOnStatusEntry.SetPlaceHolder("0 = disabled") + + maxRedirectsEntry := widget.NewEntry() + maxRedirectsEntry.SetText(strconv.Itoa(t.config.MaxRedirects)) + maxRedirectsEntry.SetPlaceHolder("10") + + followRedirectsSelect := widget.NewSelect([]string{"never", "always", "in-scope"}, nil) + followRedirectsSelect.SetSelected(t.config.FollowRedirects) + + timeoutEntry := widget.NewEntry() + timeoutEntry.SetText(strconv.Itoa(t.config.TimeoutMs)) + timeoutEntry.SetPlaceHolder("30000") + + encodingSelect := widget.NewSelect( + []string{"None", "URL encode", "Double URL encode", "Base64", "Hex"}, + nil, + ) + enc := t.payloadEncoding + if enc == "" { + enc = "None" + } + encodingSelect.SetSelected(enc) + + form := widget.NewForm( + widget.NewFormItem("Delay between requests (ms)", delayEntry), + widget.NewFormItem("Stop on status code", stopOnStatusEntry), + widget.NewFormItem("Follow redirects", followRedirectsSelect), + widget.NewFormItem("Max redirects", maxRedirectsEntry), + widget.NewFormItem("Timeout (ms)", timeoutEntry), + widget.NewFormItem("Payload encoding", encodingSelect), + ) + + sized := container.NewGridWrap(fyne.NewSize(420, 310), form) + configDialog := dialog.NewCustomConfirm("Attack Configuration", "Save", "Cancel", sized, func(confirmed bool) { + if !confirmed { + return + } + if delay, err := strconv.Atoi(strings.TrimSpace(delayEntry.Text)); err == nil && delay >= 0 { + t.config.DelayMs = delay + } + if statusText := strings.TrimSpace(stopOnStatusEntry.Text); statusText == "" || statusText == "0" { + t.config.StopOnStatus = 0 + } else if status, err := strconv.Atoi(statusText); err == nil && status > 0 { + t.config.StopOnStatus = status + } + if maxRedirects, err := strconv.Atoi(strings.TrimSpace(maxRedirectsEntry.Text)); err == nil && maxRedirects >= 0 { + t.config.MaxRedirects = maxRedirects + } + t.config.FollowRedirects = followRedirectsSelect.Selected + if timeout, err := strconv.Atoi(strings.TrimSpace(timeoutEntry.Text)); err == nil && timeout > 0 { + t.config.TimeoutMs = timeout + } + t.payloadEncoding = encodingSelect.Selected + t.config.RawRequest = t.reqEditor.GetText() + t.config.Payloads = t.payloadEntry.Text + t.projectStore.SaveIntruderConfig(t.config) + }, t.win) + + closeOnEscape(t.win, configDialog.Dismiss) + configDialog.Show() +} + +func (t *intruderTab) buildRequestPane() fyne.CanvasObject { + t.reqEditor = widgets.NewTextViewEntry() + t.reqEditor.SetWindow(t.win) + t.reqEditor.SetPlaceHolder("Paste raw HTTP request here, mark injection points with $\n\nExample:\nGET /search?q=$ HTTP/1.1\nHost: example.com") + t.reqEditor.SetText(t.config.RawRequest) + + t.payloadEntry = widget.NewMultiLineEntry() + t.payloadEntry.SetPlaceHolder("One payload per line:\nadmin\npassword\n' OR 1=1--") + t.payloadEntry.SetMinRowsVisible(8) + t.payloadEntry.SetText(t.config.Payloads) + + t.loadPayloadsBtn = widget.NewButtonWithIcon("Load from file", theme.FolderOpenIcon(), func() { + fileDialog := dialog.NewFileOpen(func(readCloser fyne.URIReadCloser, err error) { + if err != nil || readCloser == nil { + return + } + defer readCloser.Close() + data, err := io.ReadAll(readCloser) + if err != nil { + return + } + t.payloadEntry.SetText(string(data)) + }, t.win) + fileDialog.SetFilter(storage.NewExtensionFileFilter([]string{".txt"})) + fileDialog.Show() + }) + + payloadPane := container.NewBorder( + paneHeader("Payloads"), + nil, nil, nil, + t.payloadEntry, + ) + + configSplit := container.NewHSplit( + container.NewBorder(paneHeader("Request"), nil, nil, nil, t.reqEditor.Build()), + payloadPane, + ) + configSplit.SetOffset(0.6) + return configSplit +} + +func (t *intruderTab) tableCellValue(row, col int) string { + t.mu.RLock() + defer t.mu.RUnlock() + if row >= len(t.filtered) { + return "" + } + result := t.filtered[row] + switch col { + case 0: + return result.payload + case 1: + if result.err != "" { + return "ERR" + } + return fmt.Sprintf("%d", result.statusCode) + case 2: + if result.err != "" { + return "-" + } + return fmt.Sprintf("%db", result.size) + case 3: + if result.err != "" { + return "-" + } + return fmt.Sprintf("%dms", result.durationMs) + } + return "" +} + +func (t *intruderTab) tableCellStyle(row, col int) widget.Importance { + if col != 1 { + return widget.MediumImportance + } + t.mu.RLock() + defer t.mu.RUnlock() + if row >= len(t.filtered) { + return widget.MediumImportance + } + result := t.filtered[row] + if result.err != "" { + return widget.DangerImportance + } + switch { + case result.statusCode >= 500: + return widget.DangerImportance + case result.statusCode >= 400: + return widget.WarningImportance + case result.statusCode >= 200 && result.statusCode < 300: + return widget.SuccessImportance + } + return widget.MediumImportance +} + +func (t *intruderTab) tableOnSelect(row int) { + t.mu.RLock() + if row >= len(t.filtered) { + t.mu.RUnlock() + return + } + result := t.filtered[row] + t.mu.RUnlock() + t.selectedResult = &result + t.responsePane.SetText(result.rawResp) + t.requestPane.SetText(result.rawReq) + t.sendToRepeaterBtn.Enable() + t.sendToLootBtn.Enable() + if len(result.rawResp) > internalhttp.MaxDisplayBytes { + t.viewFullBtn.Show() + } else { + t.viewFullBtn.Hide() + } +} + +func (t *intruderTab) buildResultsTable() fyne.CanvasObject { + t.filterEntry = widget.NewEntry() + t.filterEntry.SetPlaceHolder("Filter results — payload, status...") + t.filterEntry.OnChanged = func(_ string) { t.applyFilter() } + + t.table = widgets.NewDataTable() + t.table.SetWindow(t.win) + t.table.Columns = intruderColumns + t.table.RowCount = func() int { + t.mu.RLock() + defer t.mu.RUnlock() + return len(t.filtered) + } + t.table.CellValue = t.tableCellValue + t.table.CellStyle = t.tableCellStyle + t.table.RowID = func(row int) int64 { return int64(row) } + t.table.OnSelect = t.tableOnSelect + t.table.MenuItems = func(row int) []widgets.ContextMenuItem { + t.mu.RLock() + if row >= len(t.filtered) { + t.mu.RUnlock() + return nil + } + result := t.filtered[row] + t.mu.RUnlock() + return t.contextMenuItems(result) + } + + return container.NewBorder( + container.NewBorder(nil, nil, newBoldLabel("Results"), nil, t.filterEntry), + nil, nil, nil, + t.table.Build(), + ) +} + +func (t *intruderTab) buildResultDetailPane() fyne.CanvasObject { + t.responsePane = widgets.NewTextView() + t.responsePane.SetWindow(t.win) + t.responsePane.SetPlaceHolder("Select a result to view response...") + + t.requestPane = widgets.NewTextView() + t.requestPane.SetWindow(t.win) + t.requestPane.SetPlaceHolder("Select a result to view request...") + + copyReqBtn := widget.NewButton("Copy", func() { + if t.selectedResult != nil { + fyne.CurrentApp().Clipboard().SetContent(t.selectedResult.rawReq) + } + }) + copyRespBtn := widget.NewButton("Copy", func() { + if t.selectedResult != nil { + fyne.CurrentApp().Clipboard().SetContent(t.selectedResult.rawResp) + } + }) + t.viewFullBtn = widget.NewButton("View Full", func() { + if t.selectedResult != nil { + showFullBodyDialog([]byte(t.selectedResult.rawResp), t.win) + } + }) + t.viewFullBtn.Hide() + + detailPane := container.NewHSplit( + container.NewBorder( + paneHeader("Request", copyReqBtn), + nil, nil, nil, t.requestPane.Build(), + ), + container.NewBorder( + paneHeader("Response", t.viewFullBtn, copyRespBtn), + nil, nil, nil, t.responsePane.Build(), + ), + ) + return detailPane +} + +func (t *intruderTab) clearResults() { + t.mu.Lock() + t.results = nil + t.filtered = nil + t.mu.Unlock() + t.table.Refresh() + t.progressLabel.SetText("") + t.responsePane.SetText("") + t.requestPane.SetText("") + t.selectedResult = nil + t.sendToRepeaterBtn.Disable() + t.sendToLootBtn.Disable() +} + +func (t *intruderTab) sendSelectedToRepeater() { + if t.selectedResult == nil { + return + } + host, port, useTLS := internalhttp.ParseHostFromRaw(t.selectedResult.rawReq) + path := PathOf(internalhttp.ExtractURL(t.selectedResult.rawReq)) + if len(path) > 20 { + path = path[:20] + "..." + } + t.repeater.AddTab(fmt.Sprintf("Intruder %s", path), host, port, useTLS, t.selectedResult.rawReq) +} + +func (t *intruderTab) buildToolbar() fyne.CanvasObject { + t.progressLabel = widget.NewLabel("") + t.progressLabel.Importance = widget.LowImportance + + t.startBtn = widget.NewButtonWithIcon("Start Attack", theme.MediaPlayIcon(), func() { t.startAttack() }) + t.startBtn.Importance = widget.HighImportance + t.stopBtn = widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), func() { t.stopAttack() }) + t.stopBtn.Disable() + + configBtn := widget.NewButtonWithIcon("Config", AppIcon("settings"), func() { t.showConfigDialog() }) + + t.sendToRepeaterBtn = widget.NewButtonWithIcon("Repeater", theme.MailForwardIcon(), func() { t.sendSelectedToRepeater() }) + t.sendToRepeaterBtn.Disable() + t.sendToLootBtn = widget.NewButtonWithIcon("Loot", AppIcon("loot"), func() { + if t.selectedResult != nil { + t.loot.showAddDialog(nil, t.selectedResult.rawReq, t.selectedResult.rawResp) + } + }) + t.sendToLootBtn.Disable() + + clearBtn := widget.NewButtonWithIcon("Clear Results", AppIcon("delete"), func() { t.clearResults() }) + + return container.NewHBox(t.startBtn, t.stopBtn, configBtn, clearBtn, t.progressLabel, layout.NewSpacer(), t.loadPayloadsBtn) +} + +func (t *intruderTab) build() fyne.CanvasObject { + requestPane := t.buildRequestPane() + detailPane := t.buildResultDetailPane() + tablePane := t.buildResultsTable() + toolbar := t.buildToolbar() + + resultsSplit := container.NewHSplit(tablePane, detailPane) + resultsSplit.SetOffset(0.4) + + mainSplit := container.NewVSplit(requestPane, resultsSplit) + mainSplit.SetOffset(0.45) + + return container.NewBorder(toolbar, nil, nil, nil, mainSplit) +} + +func (t *intruderTab) followRedirect(rawResp string, originalHost string) (string, bool) { + firstLine := strings.SplitN(rawResp, "\r\n", 2)[0] + parts := strings.Fields(firstLine) + if len(parts) < 2 { + return "", false + } + + var statusCode int + fmt.Sscanf(parts[1], "%d", &statusCode) + if statusCode < 300 || statusCode >= 400 { + return "", false + } + + location := "" + for _, line := range strings.Split(rawResp, "\r\n") { + if strings.HasPrefix(strings.ToLower(line), "location:") { + location = strings.TrimSpace(line[9:]) + break + } + } + if location == "" { + return "", false + } + + switch t.config.FollowRedirects { + case "never": + return "", false + case "in-scope": + // location may be an absolute URL — extract the hostname before the scope check. + scopeHost := location + if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") { + rest := location[strings.Index(location, "//")+2:] + if slash := strings.Index(rest, "/"); slash >= 0 { + scopeHost = rest[:slash] + } else { + scopeHost = rest + } + } + if !t.projectStore.InScope(scopeHost) { + return "", false + } + } + + var redirectHost, redirectPath string + var useTLS bool + + if strings.HasPrefix(location, "https://") { + rest := location[8:] + useTLS = true + if slash := strings.Index(rest, "/"); slash >= 0 { + redirectHost = rest[:slash] + redirectPath = rest[slash:] + } else { + redirectHost = rest + redirectPath = "/" + } + } else if strings.HasPrefix(location, "http://") { + rest := location[7:] + useTLS = false + if slash := strings.Index(rest, "/"); slash >= 0 { + redirectHost = rest[:slash] + redirectPath = rest[slash:] + } else { + redirectHost = rest + redirectPath = "/" + } + } else { + redirectHost = originalHost + redirectPath = location + useTLS = strings.HasSuffix(originalHost, ":443") + } + + port := 443 + if !useTLS { + port = 80 + } + if host, portStr, err := net.SplitHostPort(redirectHost); err == nil { + redirectHost = host + fmt.Sscanf(portStr, "%d", &port) + } + + redirectReq := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\n\r\n", redirectPath, redirectHost) + result, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{ + Host: redirectHost, + Port: port, + TLS: useTLS, + RawReq: redirectReq, + TimeoutMs: t.config.TimeoutMs, + }) + if err != nil { + return "", false + } + return result.Raw, true +} + +func (t *intruderTab) startAttack() { + rawReq := t.reqEditor.GetText() + if rawReq == "" { + dialog.ShowInformation("Error", "Please enter a request.", t.win) + return + } + + markers := intruderMarkerRegex.FindAllString(rawReq, -1) + if len(markers) == 0 { + dialog.ShowInformation("Error", "No injection points found. Mark them with $.", t.win) + return + } + + payloadsText := strings.TrimSpace(t.payloadEntry.Text) + if payloadsText == "" { + dialog.ShowInformation("Error", "Please enter at least one payload.", t.win) + return + } + + payloads := strings.Split(payloadsText, "\n") + var cleanPayloads []string + for _, payload := range payloads { + payload = strings.TrimSpace(payload) + if payload != "" { + cleanPayloads = append(cleanPayloads, payload) + } + } + + if len(cleanPayloads) == 0 { + dialog.ShowInformation("Error", "No valid payloads found.", t.win) + return + } + + t.config.RawRequest = rawReq + t.config.Payloads = t.payloadEntry.Text + t.projectStore.SaveIntruderConfig(t.config) + + t.mu.Lock() + t.results = nil + t.filtered = nil + t.mu.Unlock() + t.table.ClearSelection() + t.table.Refresh() + t.responsePane.SetText("") + t.requestPane.SetText("") + t.selectedResult = nil + t.sendToRepeaterBtn.Disable() + t.sendToLootBtn.Disable() + + t.running.Store(true) + t.stopChan = make(chan struct{}) + t.startBtn.Disable() + t.stopBtn.Enable() + + config := t.config + encoding := t.payloadEncoding + total := len(cleanPayloads) * len(markers) + done := 0 + + go func() { + for _, marker := range markers { + for _, payload := range cleanPayloads { + select { + case <-t.stopChan: + fyne.Do(func() { + t.progressLabel.SetText(fmt.Sprintf("Stopped after %d/%d requests", done, total)) + t.startBtn.Enable() + t.stopBtn.Disable() + }) + return + default: + } + + if config.DelayMs > 0 { + time.Sleep(time.Duration(config.DelayMs) * time.Millisecond) + } + + encodedPayload := applyPayloadEncoding(payload, encoding) + injected := strings.ReplaceAll(rawReq, marker, encodedPayload) + host, port, useTLS := internalhttp.ParseHostFromRaw(injected) + + var result intruderResult + result.payload = payload + result.rawReq = injected + + if host == "" { + result.err = "no Host header found" + } else { + start := time.Now() + resp, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{ + Host: host, + Port: port, + TLS: useTLS, + RawReq: injected, + TimeoutMs: config.TimeoutMs, + }) + elapsed := time.Since(start).Milliseconds() + if err != nil { + result.err = err.Error() + } else { + rawResp := resp.Raw + if config.FollowRedirects != "never" && config.MaxRedirects > 0 { + for redirectCount := 0; redirectCount < config.MaxRedirects; redirectCount++ { + redirectResp, followed := t.followRedirect(rawResp, host) + if !followed { + break + } + rawResp = redirectResp + } + } + result.durationMs = elapsed + result.size = len(rawResp) + result.rawResp = rawResp + firstLine := strings.SplitN(rawResp, "\r\n", 2)[0] + parts := strings.Fields(firstLine) + if len(parts) >= 2 { + fmt.Sscanf(parts[1], "%d", &result.statusCode) + } + } + } + + done++ + shouldStop := config.StopOnStatus != 0 && result.statusCode == config.StopOnStatus + resultCopy := result + + fyne.Do(func() { + t.mu.Lock() + t.results = append(t.results, resultCopy) + t.mu.Unlock() + t.applyFilter() + t.progressLabel.SetText(fmt.Sprintf("%d/%d", done, total)) + }) + + if shouldStop { + fyne.Do(func() { + t.progressLabel.SetText(fmt.Sprintf("Stopped — status %d matched after %d requests", config.StopOnStatus, done)) + t.startBtn.Enable() + t.stopBtn.Disable() + }) + return + } + } + } + + fyne.Do(func() { + t.running.Store(false) + t.startBtn.Enable() + t.stopBtn.Disable() + t.progressLabel.SetText(fmt.Sprintf("Done — %d requests", done)) + }) + }() +} + +func (t *intruderTab) stopAttack() { + if t.stopChan != nil { + close(t.stopChan) + } + t.running.Store(false) +} + +func (t *intruderTab) applyFilter() { + query := strings.ToLower(t.filterEntry.Text) + + t.mu.Lock() + var filtered []intruderResult + for _, result := range t.results { + if query != "" { + searchable := strings.ToLower(fmt.Sprintf("%s %d %s", result.payload, result.statusCode, result.err)) + if !strings.Contains(searchable, query) { + continue + } + } + filtered = append(filtered, result) + } + t.filtered = filtered + t.mu.Unlock() + + t.table.Refresh() +} + +func (t *intruderTab) contextMenuItems(result intruderResult) []widgets.ContextMenuItem { + return []widgets.ContextMenuItem{ + { + Label: "Send to Repeater", + Action: func() { + host, port, useTLS := internalhttp.ParseHostFromRaw(result.rawReq) + path := PathOf(internalhttp.ExtractURL(result.rawReq)) + if len(path) > 20 { + path = path[:20] + "..." + } + t.repeater.AddTab(fmt.Sprintf("Intruder %s", path), host, port, useTLS, result.rawReq) + }, + }, + { + Label: "Send to Loot", + Action: func() { + t.loot.showAddDialog(nil, result.rawReq, result.rawResp) + }, + }, + { + Label: "Copy Request", + Action: func() { + fyne.CurrentApp().Clipboard().SetContent(result.rawReq) + }, + }, + { + Label: "Copy Response", + Action: func() { + fyne.CurrentApp().Clipboard().SetContent(result.rawResp) + }, + }, + } +} -- cgit v1.2.3