Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ Hayduk connects to a separate Metasploit Framework instance through `msfrpcd`. I

**[Download the latest release](https://github.com/jolovicdev/hayduk/releases/latest)** · [Quickstart](#quickstart) · [Try the Docker lab](#try-the-docker-lab) · [Team mode](#team-mode)

![Hayduk Metasploit GUI demo showing module selection, a detected service, and a live shell session](docs/demo.gif)
![Hayduk campaign overview with network topology, host inspection, module library, and live console](docs/screenshot.png)

*Interface shown with illustrative campaign data.*

## Quickstart

Expand All @@ -22,18 +24,7 @@ The steps below assume Hayduk and Metasploit run on the same machine. If you nee

### 1. Download and extract Hayduk

Open the [latest release](https://github.com/jolovicdev/hayduk/releases/latest) and choose the archive for your operating system and processor:

| Your machine | Release platform | Architecture |
|---|---|---|
| Linux on Intel or AMD 64-bit | `linux` | `amd64` |
| Linux on ARM64 | `linux` | `arm64` |
| macOS on Intel | `darwin` | `amd64` |
| macOS on Apple silicon | `darwin` | `arm64` |
| Windows on Intel or AMD 64-bit | `windows` | `amd64` |
| Windows on ARM64 | `windows` | `arm64` |

Extract the archive into a folder. There is no Hayduk installer or separate UI setup.
Open the [latest release](https://github.com/jolovicdev/hayduk/releases/latest) and choose the archive matching your operating system and processor. Extract the archive into a folder. There is no Hayduk installer or separate UI setup.

### 2. Start Metasploit RPC

Expand Down Expand Up @@ -85,21 +76,27 @@ Keep Hayduk running while you use the UI. Press `Ctrl+C` in its terminal to stop

Use a system or network you are authorized to test. Scans run from the connected Metasploit instance, so targets must be reachable from that machine.

Right-click hosts, sessions, table rows, and modules in the tree to open their action menus.
Click a module to configure it. Right-click hosts, sessions, table rows, and modules in the tree to open their action menus.

Use the campaign summary cards to open hosts, services, sessions, or credentials. **Discover hosts**, **Scan services**, and **Export report** are also available directly above the network map.

The network map adapts host columns to the available canvas. Choose **Focus** to expand the map and host inspector. **Arrange hosts** replaces saved positions with an automatic layout. Host cards show open service counts and access state; violet paths identify pivot routes.

1. **Choose a workspace.** Click the **workspace** chip to switch between existing Metasploit workspaces, or use the current workspace. The active workspace scopes the database tables, topology, and exported report, keeping each client's campaign data separate. Events and sessions retain their originating workspace for report attribution; the **Sessions** tab shows sessions across workspaces.
2. **Discover hosts.** Open **Campaign → Discover hosts…**, enter your target host or CIDR range, select a scanner, and click **Configure…**. Review the module options and click **Launch**.
3. **Scan and inspect services.** Open **Campaign → Scan services…** and configure a scan for your target. Review the options before launching it. Open **View → Topology** to see discovered hosts, or **View → Services** to inspect service results.
4. **Launch an exploit and open a session.** Right-click an exploit module in the tree and choose **Launch…**, or open **Campaign → Find attacks…** and click a match's **Launch** button to prefill the target host and matched port. Review the module options and payload, then click **Launch**. If a session opens, click its row in the **Sessions** tab, or right-click its host and choose **Interact with session <ID>**, to open the live console in **Interact**.
5. **Export a report.** Choose **File → Export report…** to download a self-contained HTML campaign report.

![Hayduk browser interface with network topology and Metasploit campaign controls](docs/screenshot.png)
![Hayduk demo: select a host on the topology map and run commands in its live shell session](docs/demo.gif)

*Demo uses illustrative campaign data.*

## Features

| Capability | What you can do |
|---|---|
| Network topology | View hosts grouped by subnet, access states, and pivot routes; retain node positions across reloads. |
| Network topology | Explore adaptive subnet groups, host service counts, and pivot routes. Drag hosts, zoom, or expand the map in Focus mode. |
| Metasploit modules | Browse the module tree, inspect reliability ranks, configure options, and select payloads. |
| Campaign workflows | Discover hosts, scan services, and find exploit candidates matching known services. |
| Session management | Interact with Meterpreter and shell sessions, upgrade shells, and terminate sessions. |
Expand All @@ -108,9 +105,13 @@ Right-click hosts, sessions, table rows, and modules in the tree to open their a
| Reporting | Export a self-contained HTML report for campaign review and client delivery. |
| Team mode | Share a campaign with multiple operators on a trusted network. |

![Hayduk graph focus mode with adaptive subnet layout, host states, and a routed network](docs/topology.png)

*Graph focus mode with illustrative campaign data.*

## Try the Docker lab

The repository includes a disposable Metasploit lab with a database and optional target containers. The demo above uses this lab.
The repository includes a disposable Metasploit lab with a database and optional target containers. Run it to try the workflow from the demo against live targets.

You need Git, Docker, and Docker Compose. Run these commands from a shell that supports the repository's `.sh` scripts:

Expand Down
2 changes: 1 addition & 1 deletion cmd/hayduk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"github.com/jolovicdev/hayduk/internal/server"
)

var version = "0.1.4"
var version = "0.1.5"

func main() {
listen := flag.String("listen", "127.0.0.1:0", "host:port to bind")
Expand Down
Binary file modified docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/demo.mp4
Binary file not shown.
Binary file modified docs/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/topology.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.26.1

require (
github.com/gorilla/websocket v1.5.3
github.com/jolovicdev/go-msf/v2 v2.0.1
github.com/jolovicdev/go-msf/v2 v2.1.0
)

require (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jolovicdev/go-msf/v2 v2.0.1 h1:q5fn0NOh0dFdXfYCIlnt4WWTC2FArI4NBrFmrMQidEE=
github.com/jolovicdev/go-msf/v2 v2.0.1/go.mod h1:A0bd46BNl9ncqgiZpzt17bnbSHfDkPbkpCwtaMPVHB4=
github.com/jolovicdev/go-msf/v2 v2.1.0 h1:24LegpNc4SLbZMBh9ZjzvOolkbTS6/WtsImw14bePnQ=
github.com/jolovicdev/go-msf/v2 v2.1.0/go.mod h1:A0bd46BNl9ncqgiZpzt17bnbSHfDkPbkpCwtaMPVHB4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
Expand Down
6 changes: 2 additions & 4 deletions internal/engine/attacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import (
"github.com/jolovicdev/hayduk/internal/protocol"
)

// The matcher is deliberately honest and dumb: an exploit is offered when its
// refname contains the path token of a service the host runs (windows/smb/...
// for smb). It knows nothing about versions or patch levels; the dialog copy
// says as much.
// Matches use service tokens in module paths, such as windows/smb/ for SMB.
// Versions and patch levels are not checked.

const attackMatchCap = 200

Expand Down
55 changes: 53 additions & 2 deletions internal/engine/commands.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package engine

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -44,6 +45,15 @@ func (e *Engine) execCommand(ctx context.Context, operator, method string, param
// input while msf still runs the command.
e.mu.Lock()
if e.console != nil {
// msfrpcd's web consoles do not echo commands. Store the echo
// in the engine buffer so every browser receives it on replay.
// Reuse the trailing idle prompt to avoid displaying it twice.
echo := e.consolePrompt + strings.TrimRight(p.Command, "\n") + "\n"
if e.consolePrompt != "" && bytes.HasSuffix(e.consoleOut, []byte(e.consolePrompt)) {
e.consoleOut = e.consoleOut[:len(e.consoleOut)-len(e.consolePrompt)]
}
e.consoleOut = appendCapped(e.consoleOut, []byte(echo))
e.bus.send(protocol.ConsoleOutputMsg{Type: protocol.KindConsoleOutput, Data: echo})
e.consoleWritePending = true
e.consoleWriteGen++
}
Expand Down Expand Up @@ -291,14 +301,26 @@ func (e *Engine) moduleExecute(ctx context.Context, rpc gomsf.RPCCaller, operato
}
}

// Register before the RPC so sessions and job events that arrive
// before its response can be attributed to this launch.
var runKey string
if p.Type == string(gomsf.ExploitModuleType) {
runKey = e.beginExploitRun(p.Type+"/"+p.Name, ws, operator)
}
var res *gomsf.ModuleExecuteResult
if p.Payload != "" {
payload, perr := gomsf.NewModuleWithContext(ctx, rpc, gomsf.PayloadModuleType, p.Payload)
if perr != nil {
if runKey != "" {
e.forgetExploitRun(runKey)
}
return nil, mapErr(perr)
}
for k, v := range p.PayloadOptions {
if err := payload.SetOption(k, v); err != nil {
if runKey != "" {
e.forgetExploitRun(runKey)
}
return nil, mapErr(err)
}
}
Expand All @@ -307,6 +329,9 @@ func (e *Engine) moduleExecute(ctx context.Context, rpc gomsf.RPCCaller, operato
res, err = mod.Execute(ctx)
}
if err != nil {
if runKey != "" {
e.forgetExploitRun(runKey)
}
e.eventfOpIn(ws, operator, protocol.LevelError, "%s/%s failed: %v", p.Type, p.Name, err)
return nil, mapErr(err)
}
Expand All @@ -317,6 +342,9 @@ func (e *Engine) moduleExecute(ctx context.Context, rpc gomsf.RPCCaller, operato
} else {
e.eventfOpIn(ws, operator, protocol.LevelSuccess, "%s/%s ran inline", p.Type, p.Name)
}
if runKey != "" {
e.armExploitRun(runKey, res.UUID, res.JobID)
}
return mustJSON(protocol.ExecPayload{JobID: res.JobID, UUID: res.UUID}), nil
}

Expand Down Expand Up @@ -417,10 +445,31 @@ func (e *Engine) sessionWrite(ctx context.Context, p protocol.SessionWriteParams
return &protocol.ErrorBody{Code: protocol.CodeBusy, Message: "session " + p.SID + " is not attached; attach first"}
}
data := strings.TrimSuffix(p.Data, "\n") + "\n"
prompt := p.SID + " sh > "
if session.Type == "meterpreter" {
prompt = "meterpreter " + p.SID + " > "
}
echo := prompt + strings.TrimSuffix(p.Data, "\n") + "\n"
// Session streams do not echo commands. Store the echo in the engine
// transcript for re-attach and other operators, using the browser's
// prompt format. The echo must precede the write: the monitor can
// deliver the command's output while the write is still in flight.
e.mu.Lock()
if e.interactSID == p.SID {
e.interactOut = appendCapped(e.interactOut, []byte(echo))
e.bus.send(protocol.SessionOutputMsg{Type: protocol.KindSessionOutput, SID: p.SID, Data: echo})
}
e.mu.Unlock()
var err error
if session.Type == "meterpreter" {
return mapErr(gomsf.NewMeterpreterSession(rpc, p.SID).Write(ctx, data))
err = gomsf.NewMeterpreterSession(rpc, p.SID).Write(ctx, data)
} else {
err = gomsf.NewShellSession(rpc, p.SID).Write(ctx, data)
}
if err != nil {
return mapErr(err)
}
return mapErr(gomsf.NewShellSession(rpc, p.SID).Write(ctx, data))
return nil
}

func (e *Engine) sessionUpgrade(ctx context.Context, operator, ws string, p protocol.SessionUpgradeParams) *protocol.ErrorBody {
Expand Down Expand Up @@ -474,6 +523,8 @@ func mapErr(err error) *protocol.ErrorBody {
return &protocol.ErrorBody{Code: protocol.CodeRPC, Message: err.Error()}
case errors.Is(err, gomsf.ErrUnexpectedResponse):
return &protocol.ErrorBody{Code: protocol.CodeUnexpected, Message: err.Error()}
case errors.Is(err, gomsf.ErrConsoleNotFound):
return &protocol.ErrorBody{Code: protocol.CodeUnexpected, Message: err.Error()}
case errors.Is(err, gomsf.ErrCommandTimeout):
return &protocol.ErrorBody{Code: protocol.CodeTimeout, Message: err.Error()}
case errors.Is(err, gomsf.ErrSessionNotFound):
Expand Down
5 changes: 5 additions & 0 deletions internal/engine/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ func (e *Engine) bootstrap(ctx context.Context, p protocol.ConnectParams, gen ui
e.sessionTags[sid] = sessionTag{workspace: st.Workspace, uuid: st.UUID}
}
e.jobs = make(map[string]*protocol.JobState)
e.exploitRuns = nil
e.earlySessions = nil
e.errStreak = 0
e.gen = gen + 1
e.mu.Unlock()
Expand Down Expand Up @@ -340,6 +342,9 @@ func (e *Engine) Disconnect() {
e.jobs = make(map[string]*protocol.JobState)
e.interactSID = ""
e.interactOut = nil
// Clear pending runs so their timers cannot warn after disconnect.
e.exploitRuns = nil
e.earlySessions = nil
if e.conn.Status != "disconnected" {
e.conn.Status = "disconnected"
e.conn.Error = ""
Expand Down
5 changes: 5 additions & 0 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ type Engine struct {
// counters and get reused, so attribution carries across a reconnect
// only when the session is the same one. Survives disconnects.
sessionTags map[string]sessionTag
// exploitRuns is keyed by daemon job id or a synthetic inline key.
// earlySessions holds sessions awaiting a run's execution UUID.
// Both are cleared on disconnect.
exploitRuns map[string]*exploitRun
earlySessions []earlySession
events []*protocol.EventEntry
operators map[string]int
seq int64
Expand Down
36 changes: 31 additions & 5 deletions internal/engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1410,10 +1410,25 @@ func TestConsoleWriteRestoresReadinessOnlyAfterIdleRead(t *testing.T) {
t.Fatal(err)
}
}
// The echo precedes the idle read that restores readiness.
expectEcho := func() {
deadline := time.After(5 * time.Second)
for {
select {
case m := <-sub.C():
out, ok := m.(protocol.ConsoleOutputMsg)
if ok && out.Data == "msf > silent-command\n" {
return
}
case <-deadline:
t.Fatal("the written command was never echoed")
}
}
}
expectQuiet := func() {
select {
case m := <-sub.C():
t.Fatalf("console.write broadcast %+v before an idle post-write read", m)
t.Fatalf("console.write broadcast %+v beyond the echo", m)
default:
}
}
Expand All @@ -1434,6 +1449,7 @@ func TestConsoleWriteRestoresReadinessOnlyAfterIdleRead(t *testing.T) {
}

write()
expectEcho()
expectQuiet()
e.consoleRead(con, &gomsf.ConsoleReadResult{Prompt: "msf > ", Busy: false}, e.consoleGeneration())
expectRestore()
Expand Down Expand Up @@ -1463,6 +1479,9 @@ func TestConsoleWriteRestoresReadinessOnlyAfterIdleRead(t *testing.T) {
if strings.HasSuffix(up.Data, "msf > ") {
t.Fatalf("stale read streamed a ready prompt: %q", up.Data)
}
if strings.Contains(up.Data, "background output") {
sawUpdate = true
}
}
case <-time.After(200 * time.Millisecond):
goto drained
Expand Down Expand Up @@ -1497,7 +1516,12 @@ func TestReconnectKeepsSessionAttribution(t *testing.T) {
})

e := connectedEngine(t, f)
// sessions 1 and 2 open while client-alpha is active
// sessions 1 and 2 open while client-alpha is active: the daemon lists
// them, so the reconcile pass that prunes unlisted sessions keeps them
daemonSessions.Store(map[string]interface{}{
"1": map[string]interface{}{"type": "shell", "target_host": "10.0.0.1", "uuid": "uuid-1"},
"2": map[string]interface{}{"type": "shell", "uuid": "uuid-2"},
})
e.mu.Lock()
mon := e.monitor
e.mu.Unlock()
Expand Down Expand Up @@ -1549,16 +1573,18 @@ func TestSessionAttributionValidatesUUID(t *testing.T) {
})

e := connectedEngine(t, f)
// the daemon lists session 1, so the reconcile pass that prunes
// unlisted sessions keeps it while the open event attributes it
daemonSessions.Store(map[string]interface{}{
"1": map[string]interface{}{"type": "shell", "uuid": "uuid-old"},
})
e.mu.Lock()
mon := e.monitor
e.mu.Unlock()
e.sessionOpened(mon, gomsf.Event{SessionID: "1",
Session: &gomsf.Session{Type: "shell", UUID: "uuid-old"}})

// same daemon, same session: the uuid matches, attribution restores
daemonSessions.Store(map[string]interface{}{
"1": map[string]interface{}{"type": "shell", "uuid": "uuid-old"},
})
e.Disconnect()
if err := e.Connect(context.Background(), protocol.ConnectParams{}); err != nil {
t.Fatalf("reconnect: %+v", err)
Expand Down
Loading