From 863ea9bba621e425ece656017abd92f865ade467 Mon Sep 17 00:00:00 2001 From: Lukas Rist Date: Sat, 8 Aug 2026 12:17:47 +0200 Subject: [PATCH 1/3] modbus support --- go.mod | 1 + go.sum | 15 +++ protocols/tcp/modbus.go | 219 +++++++++++++++++++++++++++++++++++ protocols/tcp/modbus_test.go | 88 ++++++++++++++ protocols/tcp/smtp.go | 3 +- 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 protocols/tcp/modbus.go create mode 100644 protocols/tcp/modbus_test.go diff --git a/go.mod b/go.mod index f41166e..331dd6d 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tevino/abool v1.2.0 // indirect github.com/x-cray/logrus-prefixed-formatter v0.5.2 // indirect + github.com/xiegeo/modbusone v1.0.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index 09c26f8..35d06f3 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/discoviking/fsm v0.0.0-20150126104936-f4a273feecca/go.mod h1:W+3LQaEkN8qAwwcw0KC546sUEnX86GIT8CcMLZC4mG0= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -53,10 +55,15 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -114,11 +121,15 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5/go.mod h1:f1SCnEOt6sc3fOJfPQDRDzHOtSXuTtnz0ImG9kPRDV0= github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA= github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/xiegeo/coloredgoroutine v0.1.1/go.mod h1:d3jyamWlthEBXOL5qUpKOaaKSJM75HuCIn/z9f4ylrs= +github.com/xiegeo/modbusone v1.0.1 h1:owliTRNAg9rP8RM62cMZD+iXu7Kzq4EM1i8zcL3//uY= +github.com/xiegeo/modbusone v1.0.1/go.mod h1:Q991qN56vRM6oQftesnNxOVd92fmHvjL0nrYEoAgUs0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -137,6 +148,7 @@ golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180831094639-fa5fdf94c789/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -145,10 +157,13 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/protocols/tcp/modbus.go b/protocols/tcp/modbus.go new file mode 100644 index 0000000..4cbbe47 --- /dev/null +++ b/protocols/tcp/modbus.go @@ -0,0 +1,219 @@ +package tcp + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + + "github.com/mushorg/glutton/connection" + "github.com/mushorg/glutton/producer" + "github.com/mushorg/glutton/protocols/interfaces" + + "github.com/xiegeo/modbusone" +) + +type parsedModbus struct { + Direction string `json:"direction,omitempty"` + Header mongoMsgHeader `json:"header,omitempty"` + Payload []byte `json:"payload,omitempty"` + OpCodeStr string `json:"opcode_str,omitempty"` +} + +type modbusServer struct { + events []parsedModbus + conn net.Conn + logger interfaces.Logger + handler modbusone.ProtocolHandler +} + +func (s *modbusServer) read() ([]byte, error) { + data := make([]byte, modbusone.MBAPHeaderLength+modbusone.MaxPDUSize) + n, err := io.ReadFull(s.conn, data[:modbusone.TCPHeaderLength]) + if err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return nil, io.EOF + } + return nil, err + } + + if data[2] != 0 || data[3] != 0 { + return nil, fmt.Errorf("MBAP protocol of %X %X is unknown", data[2], data[3]) + } + + l := int(data[4])*256 + int(data[5]) + if l <= 2 { + return nil, fmt.Errorf("MBAP data length of %v is too short", l) + } + if len(data) < l+modbusone.TCPHeaderLength { + return nil, fmt.Errorf("MBAP data length of %v is too long", l) + } + n, err = io.ReadFull(s.conn, data[modbusone.TCPHeaderLength:l+modbusone.TCPHeaderLength]) + if err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return nil, io.EOF + } + return nil, err + } + + s.events = append(s.events, parsedModbus{ + Direction: "read", + Payload: data[:n+modbusone.TCPHeaderLength], + }) + return data[:n+modbusone.TCPHeaderLength], nil +} + +func (s *modbusServer) write(data []byte) error { + l := len(data) + 1 // PDU + byte of slaveID + bs := make([]byte, modbusone.TCPHeaderLength+l) + bs[4] = byte(l / 256) + bs[5] = byte(l) + copy(bs[modbusone.MBAPHeaderLength:], data) + if _, err := s.conn.Write(bs); err != nil { + return err + } + s.events = append(s.events, parsedModbus{ + Direction: "write", + Payload: data, + }) + return nil +} + +func (s *modbusServer) writeError(req modbusone.PDU, err error) { + if err := s.write(modbusone.ExceptionReplyPacket(req, modbusone.ToExceptionCode(err))); err != nil { + s.logger.Error("Error writing Modbus exception reply", slog.String("protocol", "modbus"), producer.ErrAttr(err)) + } + +} + +const size = 0x10000 + +var ( + discretes [size]bool + coils [size]bool + inputRegisters [size]uint16 + holdingRegisters [size]uint16 +) + +func HandleModbus(ctx context.Context, conn net.Conn, md connection.Metadata, logger interfaces.Logger, h interfaces.Honeypot) error { + server := &modbusServer{ + conn: conn, + logger: logger, + handler: &modbusone.SimpleHandler{ + ReadDiscreteInputs: func(address, quantity uint16) ([]bool, error) { + fmt.Printf("ReadDiscreteInputs from %v, quantity %v\n", address, quantity) + return discretes[address : address+quantity], nil + }, + WriteDiscreteInputs: func(address uint16, values []bool) error { + fmt.Printf("WriteDiscreteInputs from %v, quantity %v\n", address, len(values)) + for i, v := range values { + discretes[address+uint16(i)] = v + } + return nil + }, + + ReadCoils: func(address, quantity uint16) ([]bool, error) { + fmt.Printf("ReadCoils from %v, quantity %v\n", address, quantity) + return coils[address : address+quantity], nil + }, + WriteCoils: func(address uint16, values []bool) error { + fmt.Printf("WriteCoils from %v, quantity %v\n", address, len(values)) + for i, v := range values { + coils[address+uint16(i)] = v + } + return nil + }, + + ReadInputRegisters: func(address, quantity uint16) ([]uint16, error) { + fmt.Printf("ReadInputRegisters from %v, quantity %v\n", address, quantity) + return inputRegisters[address : address+quantity], nil + }, + WriteInputRegisters: func(address uint16, values []uint16) error { + fmt.Printf("WriteInputRegisters from %v, quantity %v\n", address, len(values)) + for i, v := range values { + inputRegisters[address+uint16(i)] = v + } + return nil + }, + + ReadHoldingRegisters: func(address, quantity uint16) ([]uint16, error) { + fmt.Printf("ReadHoldingRegisters from %v, quantity %v\n", address, quantity) + return holdingRegisters[address : address+quantity], nil + }, + WriteHoldingRegisters: func(address uint16, values []uint16) error { + fmt.Printf("WriteHoldingRegisters from %v, quantity %v\n", address, len(values)) + for i, v := range values { + holdingRegisters[address+uint16(i)] = v + } + return nil + }, + + OnErrorImp: func(req modbusone.PDU, errRep modbusone.PDU) { + fmt.Printf("error received: %v from req: %v\n", errRep, req) + }, + }, + } + + defer func() { + if err := conn.Close(); err != nil { + logger.Error("Failed to close connection", slog.String("protocol", "modbus"), producer.ErrAttr(err)) + } + }() + + for { + if err := h.UpdateConnectionTimeout(ctx, conn); err != nil { + logger.Debug("Failed to set connection timeout", slog.String("protocol", "modbus"), producer.ErrAttr(err)) + return nil + } + data, err := server.read() + if err != nil { + if err != io.EOF { + logger.Error("Error reading from connection", slog.String("protocol", "modbus"), producer.ErrAttr(err)) + } + break + } + + if len(data) == 0 { + continue + } + + p := modbusone.PDU(data[modbusone.MBAPHeaderLength:]) + if err := p.ValidateRequest(); err != nil { + return fmt.Errorf("invalid modbus request: %w", err) + } + + fc := p.GetFunctionCode() + switch { + case fc.IsReadToServer(): + data, err := server.handler.OnRead(p) + if err != nil { + server.writeError(p, err) + continue + } + if err := server.write(p.MakeReadReply(data)); err != nil { + logger.Error("Error writing to connection", slog.String("protocol", "modbus"), producer.ErrAttr(err)) + continue + } + case fc.IsWriteToServer(): + data, err := p.GetRequestValues() + if err != nil { + server.writeError(p, err) + continue + } + err = server.handler.OnWrite(p, data) + if err != nil { + server.writeError(p, err) + continue + } + if err := server.write(p.MakeWriteReply()); err != nil { + logger.Error("Error writing to connection", slog.String("protocol", "modbus"), producer.ErrAttr(err)) + continue + } + default: + logger.Warn("Unsupported Modbus function code", slog.String("protocol", "modbus"), slog.String("function_code", fmt.Sprintf("ExceptionCode:0x%02X", byte(fc)))) + } + } + + return nil +} diff --git a/protocols/tcp/modbus_test.go b/protocols/tcp/modbus_test.go new file mode 100644 index 0000000..eb8d36d --- /dev/null +++ b/protocols/tcp/modbus_test.go @@ -0,0 +1,88 @@ +package tcp + +import ( + "context" + "net" + "testing" + + "github.com/mushorg/glutton/connection" + "github.com/mushorg/glutton/protocols/mocks" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/xiegeo/modbusone" +) + +type nopLogger struct{} + +func (nopLogger) Debug(string, ...any) {} +func (nopLogger) Info(string, ...any) {} +func (nopLogger) Warn(string, ...any) {} +func (nopLogger) Error(string, ...any) {} + +func TestModbusServer(t *testing.T) { + ctx := context.Background() + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer l.Close() + + accepted := make(chan net.Conn, 1) + go func() { + conn, err := l.Accept() + if err != nil { + close(accepted) + return + } + accepted <- conn + }() + + clientConn, err := net.Dial("tcp", l.Addr().String()) + require.NoError(t, err) + defer clientConn.Close() + + serverConn, ok := <-accepted + require.True(t, ok, "failed to accept connection") + require.NotNil(t, serverConn) + + h := &mocks.MockHoneypot{} + h.EXPECT().UpdateConnectionTimeout(mock.Anything, mock.Anything).Return(nil) + + done := make(chan error, 1) + go func() { + done <- HandleModbus(ctx, serverConn, connection.Metadata{}, nopLogger{}, h) + }() + + client := modbusone.NewTCPClient(clientConn, 0) + go client.Serve(&modbusone.SimpleHandler{ + WriteCoils: func(uint16, []bool) error { return nil }, + WriteDiscreteInputs: func(uint16, []bool) error { return nil }, + WriteHoldingRegisters: func(uint16, []uint16) error { return nil }, + WriteInputRegisters: func(uint16, []uint16) error { return nil }, + }) + defer client.Close() + + tests := []struct { + name string + fc modbusone.FunctionCode + address uint16 + quantity uint16 + }{ + {"ReadCoils", modbusone.FcReadCoils, 0, 1}, + {"ReadDiscreteInputs", modbusone.FcReadDiscreteInputs, 0, 1}, + {"ReadHoldingRegisters", modbusone.FcReadHoldingRegisters, 0, 1}, + {"ReadInputRegisters", modbusone.FcReadInputRegisters, 0, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pdu, err := tt.fc.MakeRequestHeader(tt.address, tt.quantity) + require.NoError(t, err) + require.NoError(t, client.DoTransaction(pdu)) + }) + } + + require.NoError(t, clientConn.Close()) + require.NoError(t, <-done) + h.AssertExpectations(t) +} diff --git a/protocols/tcp/smtp.go b/protocols/tcp/smtp.go index acf4dc5..1e4266c 100644 --- a/protocols/tcp/smtp.go +++ b/protocols/tcp/smtp.go @@ -27,7 +27,8 @@ type Client struct { } func (c *Client) w(s string) { - c.bufout.WriteString(s + "\r\n") + c.bufout.WriteString(s) + c.bufout.WriteString("\r\n") c.bufout.Flush() } func (c *Client) read() (string, error) { From 3c8e84a6fc895b626aa4267fd6b397a7e1de77d3 Mon Sep 17 00:00:00 2001 From: Lukas Rist Date: Sat, 8 Aug 2026 12:19:21 +0200 Subject: [PATCH 2/3] enable modbus handler --- protocols/protocols.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/protocols/protocols.go b/protocols/protocols.go index d64be46..6bddbab 100644 --- a/protocols/protocols.go +++ b/protocols/protocols.go @@ -76,6 +76,9 @@ func MapTCPProtocolHandlers(log interfaces.Logger, h interfaces.Honeypot) map[st protocolHandlers["http"] = func(ctx context.Context, conn net.Conn, md connection.Metadata) error { return tcp.HandleHTTP(ctx, conn, md, log, h) } + protocolHandlers["modbus"] = func(ctx context.Context, conn net.Conn, md connection.Metadata) error { + return tcp.HandleModbus(ctx, conn, md, log, h) + } protocolHandlers["proxy_tcp"] = func(ctx context.Context, conn net.Conn, md connection.Metadata) error { return tcp.HandleProxyTCP(ctx, conn, md, log, h) } From 6ae3e11ace5ef670ff98ad8f653ece9f0186c19f Mon Sep 17 00:00:00 2001 From: Lukas Rist Date: Sat, 8 Aug 2026 12:20:20 +0200 Subject: [PATCH 3/3] added modbus rule --- config/rules.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/rules.yaml b/config/rules.yaml index d991241..de6c56c 100644 --- a/config/rules.yaml +++ b/config/rules.yaml @@ -35,6 +35,9 @@ rules: - match: tcp dst port 27017 type: conn_handler target: mongodb + - match: tcp dst port 502 + type: conn_handler + target: modbus - match: tcp dst port 9889 type: proxy_tcp target: 127.0.0.1:9889