Skip to content

Commit 00f612d

Browse files
committed
basic mctp protocol handler
1 parent 137110b commit 00f612d

11 files changed

Lines changed: 832 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ For full Docker, privileges, and host-placement guidance, see [docs/setup.md](do
8181
| Jabber/XMPP | instant messaging stream |
8282
| ADB | Android Debug Bridge probes |
8383
| MongoDB | wire protocol queries |
84+
| HiSilicon DVR (MCTP) | `HI_SRDK_*` control calls on tcp/9000 |
8485
| Hadoop YARN | `POST */cluster/apps/new-application` |
8586
| Docker Engine API | `GET /v1.16/version` |
8687
| HTTP | generic web requests |

‎config/rules.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ rules:
4444
- match: tcp dst port 4840
4545
type: conn_handler
4646
target: opcua
47+
- match: tcp dst port 9000
48+
type: conn_handler
49+
target: mctp
4750
- match: tcp dst port 443
4851
type: proxy_tcp
4952
target: 127.0.0.1:443

‎docs/configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ rules:
8383

8484
### Rule types
8585

86-
`**conn_handler**` — `target` is a handler key. Current TCP keys: `smtp`, `rdp`, `smb`, `ftp`, `sip`, `rfb`, `telnet`, `mqtt`, `iscsi`, `bittorrent`, `memcache`, `jabber`, `adb`, `mongodb`, `http`, `mcp`, `modbus`, `opcua`, `proxy_tcp`, `tcp`. UDP keys: `sip`, `openvpn`, `mdns`, `l2tp`, `raknet`, `kerberos`, `coap`, `proxy_udp`, `udp`. If the target isn't registered, the listener accepts the connection but no handler runs.
86+
`**conn_handler**` — `target` is a handler key. Current TCP keys: `smtp`, `rdp`, `smb`, `ftp`, `sip`, `rfb`, `telnet`, `mqtt`, `iscsi`, `bittorrent`, `memcache`, `jabber`, `adb`, `mongodb`, `http`, `mcp`, `modbus`, `opcua`, `mctp`, `proxy_tcp`, `tcp`. UDP keys: `sip`, `openvpn`, `mdns`, `l2tp`, `raknet`, `kerberos`, `coap`, `proxy_udp`, `udp`. If the target isn't registered, the listener accepts the connection but no handler runs.
8787

8888
`**proxy_tcp**` — forwards a matched TCP connection to an upstream `host:port`. The address is parsed at rule-load time and stored in rule metadata; at dispatch the proxy handler dials it and pipes bytes both directions. Tunable via `dial_timeout`, `conn_timeout`, `max_tcp_payload`, and `capture_traffic.enabled` in the main config.
8989

‎docs/logging.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Example shape:
9494
| `memcache` (TCP) | Array of per-direction frames: `direction`, `command`, `status`, `payload` | Writes set `status` (`STORED`, `END`, `ERROR`, `CLIENT_ERROR`, `VERSION`). `set` frames aggregate the command line plus the data chunk. |
9595
| `modbus` (TCP) | Array of per-direction frames: `direction`, `command`, `function_code`, `unit_id`, `address`, `quantity`, `status`, `payload` | `command` copies `function_code`. Exception writes set `status` `Exception`. |
9696
| `opcua` (TCP) | Array of per-direction frames: `direction`, `command`, `path`, `message_type`, `service`, `endpoint_url`, `security_policy`, `application_uri`, `application_name`, `username`, `payload` | `command` is `service` (or `message_type` if empty). `path` copies `endpoint_url`. Username identity is recorded; password bytes are not copied into `decoded`. |
97+
| `mctp` (TCP) | Array of per-direction frames: `direction`, `command`, `method`, `cseq`, `func_version`, `segments`, `status`, `return_code`, `payload`, `truncated` | HiSilicon DVR control protocol (tcp/9000). `command` is the `HI_SRDK_*` function, `method` the request method (`REMOTE`), `segments` the number of body data segments. Writes set `status` `200` and `return_code` `0`. Bodies over 64 KiB are stored up to the cap with `truncated`. Non-MCTP traffic on the port falls back to `tcp`. |
9798
| `mongodb` (TCP) | Array of per-direction frames: `direction`, `header`, `opcode_str`, `command`, `status`, `payload` | `command` is the BSON command name. Writes set `status` `ok`. |
9899
| `mcp` (TCP) | Array of per-direction frames: `direction`, `command`, `path`, `status`, `session_id`, `payload` | `command` is the JSON-RPC method or HTTP verb. Writes set HTTP `status`. Shares the idle session table with HTTP. |
99100
| `telnet` (TCP) | Array of per-direction frames: `direction`, `command`, `path`, `message`, `payload_hash` | Login reads set `command` `username`/`password`; shell reads use the first token. `wget`/`curl` lines set `path` to the http(s) URL and `payload_hash` when the sample fetch succeeds. IAC negotiation is a separate `read` frame with no command. Process log: Info `telnet login` with `src_ip`/`src_port`/`dest_port`/`username`/`password`; shell lines at Debug. |

‎protocols/mctp_dispatch_test.go‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package protocols
2+
3+
import (
4+
"context"
5+
"io"
6+
"net"
7+
"testing"
8+
"time"
9+
10+
"github.com/mushorg/glutton/connection"
11+
"github.com/spf13/viper"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
type nopLogger struct{}
16+
17+
func (nopLogger) Debug(string, ...any) {}
18+
func (nopLogger) Info(string, ...any) {}
19+
func (nopLogger) Warn(string, ...any) {}
20+
func (nopLogger) Error(string, ...any) {}
21+
22+
// protocolHoneypot records the handler name of each produced TCP event.
23+
type protocolHoneypot struct {
24+
produced chan string
25+
}
26+
27+
func (h *protocolHoneypot) ProduceTCP(protocol string, _ net.Conn, _ connection.Metadata, _ []byte, _ interface{}) error {
28+
h.produced <- protocol
29+
return nil
30+
}
31+
func (h *protocolHoneypot) ProduceUDP(string, *net.UDPAddr, *net.UDPAddr, connection.Metadata, []byte, interface{}) error {
32+
return nil
33+
}
34+
func (h *protocolHoneypot) ReplyUDP(*net.UDPAddr, *net.UDPAddr, []byte) error { return nil }
35+
func (h *protocolHoneypot) ConnectionByFlow([2]uint64) connection.Metadata {
36+
return connection.Metadata{}
37+
}
38+
func (h *protocolHoneypot) UpdateConnectionTimeout(_ context.Context, conn net.Conn) error {
39+
return conn.SetDeadline(time.Now().Add(2 * time.Second))
40+
}
41+
func (h *protocolHoneypot) MetadataByConnection(net.Conn) (connection.Metadata, error) {
42+
return connection.Metadata{}, nil
43+
}
44+
45+
// dispatchMCTP runs the "mctp" target on a loopback connection, lets send
46+
// drive the client side, and returns the produced handler names.
47+
func dispatchMCTP(t *testing.T, send func(net.Conn)) []string {
48+
t.Helper()
49+
t.Chdir(t.TempDir()) // HandleTCP stores payloads in ./payloads
50+
prev := viper.GetInt("max_tcp_payload")
51+
viper.Set("max_tcp_payload", 4096)
52+
t.Cleanup(func() { viper.Set("max_tcp_payload", prev) })
53+
54+
l, err := net.Listen("tcp", "127.0.0.1:0")
55+
require.NoError(t, err)
56+
defer l.Close()
57+
58+
hp := &protocolHoneypot{produced: make(chan string, 4)}
59+
handler := MapTCPProtocolHandlers(nopLogger{}, hp)["mctp"]
60+
require.NotNil(t, handler)
61+
62+
done := make(chan error, 1)
63+
go func() {
64+
conn, err := l.Accept()
65+
if err != nil {
66+
done <- err
67+
return
68+
}
69+
done <- handler(context.Background(), conn, connection.Metadata{TargetPort: 9000})
70+
}()
71+
72+
client, err := net.Dial("tcp", l.Addr().String())
73+
require.NoError(t, err)
74+
require.NoError(t, client.SetDeadline(time.Now().Add(3*time.Second)))
75+
send(client)
76+
require.NoError(t, client.Close())
77+
78+
select {
79+
case err := <-done:
80+
require.NoError(t, err)
81+
case <-time.After(3 * time.Second):
82+
t.Fatal("handler did not finish")
83+
}
84+
close(hp.produced)
85+
var names []string
86+
for name := range hp.produced {
87+
names = append(names, name)
88+
}
89+
return names
90+
}
91+
92+
func TestMCTPTargetRoutesMCTP(t *testing.T) {
93+
names := dispatchMCTP(t, func(c net.Conn) {
94+
_, err := c.Write([]byte("REMOTE HI_SRDK_DEV_GetHddInfo MCTP/1.0\r\nCSeq:173\r\nContent-Length:15\r\n\r\nSegment-Num:0\r\n"))
95+
require.NoError(t, err)
96+
buf := make([]byte, 256)
97+
n, err := c.Read(buf)
98+
require.NoError(t, err)
99+
require.Contains(t, string(buf[:n]), "MCTP/1.0 200 OK\r\n")
100+
})
101+
require.Equal(t, []string{"mctp"}, names)
102+
}
103+
104+
func TestMCTPTargetFallsBackToTCP(t *testing.T) {
105+
// FastCGI BEGIN_REQUEST header, as sent by PHP-FPM exploit scanners on :9000
106+
names := dispatchMCTP(t, func(c net.Conn) {
107+
_, err := c.Write([]byte{0x01, 0x01, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
108+
require.NoError(t, err)
109+
_, _ = io.ReadAll(c) // random-bytes reply until the handler closes
110+
})
111+
require.Equal(t, []string{"tcp"}, names)
112+
}
113+
114+
func TestMCTPTargetShortPayloadFallsBackToTCP(t *testing.T) {
115+
names := dispatchMCTP(t, func(c net.Conn) {
116+
_, err := c.Write([]byte("REM"))
117+
require.NoError(t, err)
118+
_, _ = io.ReadAll(c)
119+
})
120+
require.Equal(t, []string{"tcp"}, names)
121+
}
122+
123+
func TestMCTPTargetDelayedRequest(t *testing.T) {
124+
names := dispatchMCTP(t, func(c net.Conn) {
125+
time.Sleep(300 * time.Millisecond) // longer than the request-line peek window
126+
_, err := c.Write([]byte("REMOTE HI_SRDK_SYS_GetSystemAttr MCTP/1.0\r\nCSeq:1\r\nContent-Length:0\r\n\r\n"))
127+
require.NoError(t, err)
128+
buf := make([]byte, 256)
129+
_, err = c.Read(buf)
130+
require.NoError(t, err)
131+
})
132+
require.Equal(t, []string{"mctp"}, names)
133+
}
134+
135+
func TestMCTPTargetSilentClientNoEvent(t *testing.T) {
136+
names := dispatchMCTP(t, func(net.Conn) {})
137+
require.Empty(t, names)
138+
}

‎protocols/protocols.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,28 @@ import (
44
"context"
55
"net"
66
"strings"
7+
"time"
78

89
"github.com/mushorg/glutton/connection"
910
"github.com/mushorg/glutton/producer"
1011
"github.com/mushorg/glutton/protocols/interfaces"
1112
"github.com/mushorg/glutton/protocols/spicy"
1213
spicyHandlers "github.com/mushorg/glutton/protocols/spicy/handlers"
1314
"github.com/mushorg/glutton/protocols/tcp"
15+
"github.com/mushorg/glutton/protocols/tcp/mctp"
1416
"github.com/mushorg/glutton/protocols/udp"
1517
"github.com/spf13/viper"
1618
)
1719

1820
// peek enough of the HTTP request line to detect /mcp or /sse
1921
const mcpRequestLinePeek = 96
2022

23+
const (
24+
// mctpPeekLen covers the "REMOTE " method prefix of an MCTP request line.
25+
mctpPeekLen = len("REMOTE ")
26+
mctpPeekTimeout = 200 * time.Millisecond
27+
)
28+
2129
type TCPHandlerFunc func(ctx context.Context, conn net.Conn, md connection.Metadata) error
2230

2331
type UDPHandlerFunc func(ctx context.Context, srcAddr, dstAddr *net.UDPAddr, data []byte, md connection.Metadata) error
@@ -74,6 +82,7 @@ func MapTCPProtocolHandlers(log interfaces.Logger, h interfaces.Honeypot) map[st
7482
"mcp": bindTCP(tcp.HandleMCP, log, h),
7583
"modbus": bindTCP(tcp.HandleModbus, log, h),
7684
"opcua": bindTCP(tcp.HandleOPCUA, log, h),
85+
"mctp": mctpOrTCP(log, h),
7786
"proxy_tcp": bindTCP(tcp.HandleProxyTCP, log, h),
7887
"tcp": catchAllTCP(log, h),
7988
}
@@ -106,6 +115,32 @@ func catchAllTCP(log interfaces.Logger, h interfaces.Honeypot) TCPHandlerFunc {
106115
}
107116
}
108117

118+
// mctpOrTCP serves the DVR port (tcp/9000): MCTP requests go to the MCTP
119+
// handler; anything else (FastCGI, MinIO, Portainer probes, ...) falls back to
120+
// the generic TCP handler so it is still stored.
121+
func mctpOrTCP(log interfaces.Logger, h interfaces.Honeypot) TCPHandlerFunc {
122+
return func(ctx context.Context, conn net.Conn, md connection.Metadata) error {
123+
if err := h.UpdateConnectionTimeout(ctx, conn); err != nil {
124+
log.Debug("failed to set connection timeout", producer.ErrAttr(err))
125+
return conn.Close()
126+
}
127+
bufConn := newBufferedConn(conn)
128+
// wait (up to the connection timeout) for the client to speak first
129+
if _, err := bufConn.peek(1); err != nil {
130+
log.Debug("failed to peek connection", producer.ErrAttr(err))
131+
return conn.Close()
132+
}
133+
// the request line normally arrives in the first segment
134+
if err := bufConn.SetReadDeadline(time.Now().Add(mctpPeekTimeout)); err != nil {
135+
log.Debug("failed to set peek deadline", producer.ErrAttr(err))
136+
}
137+
if snip, err := bufConn.peek(mctpPeekLen); err == nil && mctp.LooksLikeMCTP(snip) {
138+
return tcp.HandleMCTP(ctx, bufConn, md, log, h)
139+
}
140+
return tcp.HandleTCP(ctx, bufConn, md, log, h)
141+
}
142+
}
143+
109144
func handleDetectedHTTP(ctx context.Context, bufConn BufferedConn, md connection.Metadata, log interfaces.Logger, h interfaces.Honeypot) error {
110145
reqLine, httpConn, peekErr := Peek(bufConn, mcpRequestLinePeek)
111146
if peekErr == nil && tcp.LooksLikeMCP(reqLine) {

‎protocols/protocols_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ func TestMapTCPProtocolHandlers(t *testing.T) {
6262
require.Contains(t, m, "mcp", "expected MCP handler")
6363
require.Contains(t, m, "memcache", "expected memcache handler")
6464
require.Contains(t, m, "opcua", "expected OPC UA handler")
65+
require.Contains(t, m, "mctp", "expected MCTP handler")
6566
ctx := context.Background()
6667
conn, close := testConn(t)
6768
defer close()

‎protocols/tcp/mctp.go‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package tcp
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"errors"
7+
"io"
8+
"log/slog"
9+
"net"
10+
"strconv"
11+
12+
"github.com/mushorg/glutton/connection"
13+
"github.com/mushorg/glutton/producer"
14+
"github.com/mushorg/glutton/protocols/helpers"
15+
"github.com/mushorg/glutton/protocols/interfaces"
16+
"github.com/mushorg/glutton/protocols/tcp/mctp"
17+
)
18+
19+
const (
20+
// mctpMaxBody caps the stored request body; real requests are a few hundred bytes.
21+
mctpMaxBody = 64 * 1024
22+
// mctpMaxRequests bounds the frames recorded for one connection.
23+
mctpMaxRequests = 100
24+
)
25+
26+
type parsedMCTP struct {
27+
Direction string `json:"direction,omitempty"`
28+
Command string `json:"command,omitempty"` // HI_SRDK_* function
29+
Method string `json:"method,omitempty"` // REMOTE
30+
CSeq string `json:"cseq,omitempty"`
31+
FuncVersion string `json:"func_version,omitempty"`
32+
Segments int `json:"segments,omitempty"` // data segments in the request body
33+
Status string `json:"status,omitempty"` // response code on writes
34+
ReturnCode string `json:"return_code,omitempty"`
35+
Payload []byte `json:"payload,omitempty"`
36+
Truncated bool `json:"truncated,omitempty"`
37+
}
38+
39+
type mctpServer struct {
40+
events []parsedMCTP
41+
conn net.Conn
42+
reader *bufio.Reader
43+
}
44+
45+
func (s *mctpServer) read() (mctp.Request, error) {
46+
req, raw, truncated, err := mctp.ReadRequest(s.reader, mctpMaxBody)
47+
if len(raw) == 0 {
48+
return req, err
49+
}
50+
s.events = append(s.events, parsedMCTP{
51+
Direction: "read",
52+
Command: req.Function,
53+
Method: req.Method,
54+
CSeq: req.Header("CSeq"),
55+
FuncVersion: req.Header("Func-Version"),
56+
Segments: len(req.Segments),
57+
Payload: raw,
58+
Truncated: truncated,
59+
})
60+
return req, err
61+
}
62+
63+
func (s *mctpServer) write(cseq string, returnCode int) error {
64+
data := mctp.BuildResponse(cseq, returnCode)
65+
if _, err := s.conn.Write(data); err != nil {
66+
return err
67+
}
68+
s.events = append(s.events, parsedMCTP{
69+
Direction: "write",
70+
CSeq: cseq,
71+
Status: "200",
72+
ReturnCode: strconv.Itoa(returnCode),
73+
Payload: data,
74+
})
75+
return nil
76+
}
77+
78+
// HandleMCTP takes a net.Conn and answers MCTP/1.0, the HiSilicon DVR control
79+
// protocol on tcp/9000 (HI_SRDK_* calls). Every call gets an empty success reply.
80+
func HandleMCTP(ctx context.Context, conn net.Conn, md connection.Metadata, logger interfaces.Logger, h interfaces.Honeypot) error {
81+
server := &mctpServer{events: []parsedMCTP{}, conn: conn, reader: bufio.NewReader(conn)}
82+
endReason := connection.EndHandlerClose
83+
defer func() {
84+
md.EndReason = endReason
85+
if err := h.ProduceTCP("mctp", conn, md, helpers.FirstOrEmpty[parsedMCTP](server.events).Payload, server.events); err != nil {
86+
logger.Error("Failed to produce message", slog.String("protocol", "mctp"), producer.ErrAttr(err))
87+
}
88+
if err := conn.Close(); err != nil {
89+
logger.Debug("Failed to close MCTP connection", slog.String("protocol", "mctp"), producer.ErrAttr(err))
90+
}
91+
}()
92+
93+
for i := 0; i < mctpMaxRequests; i++ {
94+
if err := h.UpdateConnectionTimeout(ctx, conn); err != nil {
95+
logger.Debug("Failed to set connection timeout", slog.String("protocol", "mctp"), producer.ErrAttr(err))
96+
endReason = connection.EndTimeout
97+
return nil
98+
}
99+
req, err := server.read()
100+
if err != nil {
101+
if errors.Is(err, mctp.ErrMalformed) || errors.Is(err, mctp.ErrLineTooLong) || errors.Is(err, mctp.ErrBodyTooLarge) {
102+
logger.Debug("Malformed MCTP request", slog.String("protocol", "mctp"), producer.ErrAttr(err))
103+
endReason = connection.EndReadError
104+
return err
105+
}
106+
logger.Debug("Failed to read data", slog.String("protocol", "mctp"), producer.ErrAttr(err))
107+
if errors.Is(err, io.ErrUnexpectedEOF) {
108+
err = io.EOF
109+
}
110+
endReason = connection.EndReasonFromRead(err)
111+
return nil
112+
}
113+
if i == 0 {
114+
host, port, _ := net.SplitHostPort(conn.RemoteAddr().String())
115+
logger.Info("MCTP request received",
116+
slog.String("handler", "mctp"),
117+
slog.String("src_ip", host),
118+
slog.String("src_port", port),
119+
slog.String("dest_port", strconv.Itoa(int(md.TargetPort))),
120+
slog.String("function", req.Function),
121+
)
122+
}
123+
if err := server.write(req.Header("CSeq"), 0); err != nil {
124+
logger.Error("Failed to write MCTP response", slog.String("protocol", "mctp"), producer.ErrAttr(err))
125+
endReason = connection.EndWriteError
126+
return err
127+
}
128+
}
129+
endReason = connection.EndMaxFrames
130+
return nil
131+
}

0 commit comments

Comments
 (0)