From 16b3b200e1828ec0ad7cfdf88bf67b3a0c80cda0 Mon Sep 17 00:00:00 2001 From: vnxme <46669194+vnxme@users.noreply.github.com> Date: Mon, 21 Oct 2024 21:24:22 +0300 Subject: [PATCH] winbox: initial commit of matcher (#255) --- README.md | 1 + imports.go | 1 + .../gd_matcher_winbox.caddytest | 85 ++++ modules/l4winbox/matcher.go | 423 ++++++++++++++++++ modules/l4winbox/matcher_test.go | 136 ++++++ 5 files changed, 646 insertions(+) create mode 100644 integration/caddyfile_adapt/gd_matcher_winbox.caddytest create mode 100644 modules/l4winbox/matcher.go create mode 100644 modules/l4winbox/matcher_test.go diff --git a/README.md b/README.md index d7f6a56..4f502a6 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Current matchers: - **layer4.matchers.socks5** - matches connections that look like [SOCKSv5](https://www.rfc-editor.org/rfc/rfc1928.html). - **layer4.matchers.ssh** - matches connections that look like SSH connections. - **layer4.matchers.tls** - matches connections that start with TLS handshakes. In addition, any [`tls.handshake_match` modules](https://caddyserver.com/docs/modules/) can be used for matching on TLS-specific properties of the ClientHello, such as ServerName (SNI). +- **layer4.matchers.winbox** - matches connections that look like those initiated by [Winbox](https://help.mikrotik.com/docs/display/ROS/WinBox), a graphical tool for MikroTik hardware and software routers management. - **layer4.matchers.wireguard** - matches connections the look like [WireGuard](https://www.wireguard.com/protocol/) connections. - **layer4.matchers.xmpp** - matches connections that look like [XMPP](https://xmpp.org/about/technology-overview/). diff --git a/imports.go b/imports.go index 6b4c2f8..b252764 100644 --- a/imports.go +++ b/imports.go @@ -33,6 +33,7 @@ import ( _ "github.com/mholt/caddy-l4/modules/l4tee" _ "github.com/mholt/caddy-l4/modules/l4throttle" _ "github.com/mholt/caddy-l4/modules/l4tls" + _ "github.com/mholt/caddy-l4/modules/l4winbox" _ "github.com/mholt/caddy-l4/modules/l4wireguard" _ "github.com/mholt/caddy-l4/modules/l4xmpp" ) diff --git a/integration/caddyfile_adapt/gd_matcher_winbox.caddytest b/integration/caddyfile_adapt/gd_matcher_winbox.caddytest new file mode 100644 index 0000000..922c271 --- /dev/null +++ b/integration/caddyfile_adapt/gd_matcher_winbox.caddytest @@ -0,0 +1,85 @@ +{ + layer4 { + :443 { + @w1 winbox { + modes standard romon + username toms + } + route @w1 { + proxy 192.168.0.1:8291 + } + @w2 winbox { + modes standard + username_regexp ^andris|edgars|juris$ + } + route @w2 { + proxy 192.168.0.2:8291 + } + } + } +} +---------- +{ + "apps": { + "layer4": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "winbox": { + "modes": [ + "standard", + "romon" + ], + "username": "toms" + } + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "192.168.0.1:8291" + ] + } + ] + } + ] + }, + { + "match": [ + { + "winbox": { + "modes": [ + "standard" + ], + "username_regexp": "^andris|edgars|juris$" + } + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "192.168.0.2:8291" + ] + } + ] + } + ] + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/modules/l4winbox/matcher.go b/modules/l4winbox/matcher.go new file mode 100644 index 0000000..b82d93a --- /dev/null +++ b/modules/l4winbox/matcher.go @@ -0,0 +1,423 @@ +// Copyright 2024 VNXME +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package l4winbox + +import ( + "errors" + "io" + "regexp" + "strings" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + + "github.com/mholt/caddy-l4/layer4" +) + +func init() { + caddy.RegisterModule(&MatchWinbox{}) +} + +// MatchWinbox matches any connections that look like those initiated by Winbox, a graphical tool developed +// by SIA Mikrotīkls, Latvia for their hardware and software routers management. As of v3.41 and v4.0 the tool +// used an undocumented proprietary protocol. This matcher is based on a number of recent studies describing +// RouterOS architecture and vulnerabilities, especially the ones published by Margin Research. +type MatchWinbox struct { + // Modes contains a list of supported Winbox modes to match against incoming auth messages:. + // + // - `standard` mode is a default one (it used to be called 'secure' mode in previous versions of Winbox); + // + // - `romon` mode makes the destination router act as an agent so that its neighbour routers + // in isolated L2 segments could be reachable by the clients behind the agent. + // + // Notes: Each mode shall only be present once in the list. Values in the list are case-insensitive. + // If the list is empty, MatchWinbox will consider all modes as acceptable. + Modes []string `json:"modes,omitempty"` + // Username is a plaintext username value to search for in the incoming connections. In Winbox it is what + // the user types into the login field. According to the docs, it must start and end with an alphanumeric + // character, but it can also include "_", ".", "#", "-", and "@" symbols. No maximum username length is + // specified in the docs, so this matcher applies a reasonable limit of no more than 255 characters. If + // Username contains at least one character, UsernameRegexp is ignored. If Username contains placeholders, + // they are evaluated at match. + Username string `json:"username,omitempty"` + // UsernameRegexp is a username pattern to match the incoming connections against. This matcher verifies + // that any username matches MessageAuthUsernameRegexp, so UsernameRegexp must not provide a wider pattern. + // UsernameRegexp is only checked when Username is empty. If UsernameRegexp contains any placeholders, they + // are evaluated at provision. + UsernameRegexp string `json:"username_regexp,omitempty"` + + acceptStandard bool + acceptRoMON bool + usernameRegexp *regexp.Regexp +} + +// CaddyModule returns the Caddy module information. +func (m *MatchWinbox) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "layer4.matchers.winbox", + New: func() caddy.Module { return new(MatchWinbox) }, + } +} + +// Match returns true if the connection bytes match the regular expression. +func (m *MatchWinbox) Match(cx *layer4.Connection) (bool, error) { + // Read a minimum number of bytes + hdr := make([]byte, 2) + n, err := io.ReadFull(cx, hdr) + if err != nil || hdr[0] < MessageAuthBytesMin-2 || hdr[1] != MessageChunkTypeAuth { + return false, err + } + + // Only allocate a larger buffer when the first chunk is full + l := int(hdr[0]) + if l == MessageChunkBytesMax { + l = MessageAuthBytesMax - 2 + } + + // Read the remaining bytes + buf := make([]byte, 2+l+1) + copy(buf[:2], hdr[:2]) + n, err = io.ReadAtLeast(cx, buf[2:], int(hdr[0])) + if err != nil || n > l { + return false, err + } + + // Parse MessageAuth + msg := &MessageAuth{} + if err = msg.FromBytes(buf[:n+2]); err != nil { + return false, nil + } + + // Check the acceptable modes + if msg.GetRoMON() { + if !m.acceptRoMON { + return false, nil + } + } else { + if !m.acceptStandard { + return false, nil + } + } + + // Replace placeholders in filters + repl := cx.Context.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + userName := repl.ReplaceAll(m.Username, "") + + // Check a plaintext username, if provided + if len(userName) > 0 && userName != msg.GetUsername() { + return false, nil + } + + // Check a username regexp, if provided + if len(userName) == 0 && len(m.UsernameRegexp) > 0 && !m.usernameRegexp.MatchString(msg.GetUsername()) { + return false, nil + } + + // Add a username to the replacer + repl.Set("l4.winbox.username", msg.GetUsername()) + + return true, nil +} + +// Provision prepares m's internal structures. +func (m *MatchWinbox) Provision(_ caddy.Context) (err error) { + repl := caddy.NewReplacer() + m.usernameRegexp, err = regexp.Compile(repl.ReplaceAll(m.UsernameRegexp, "")) + if err != nil { + return err + } + + if len(m.Modes) > 0 { + for _, mode := range m.Modes { + mode = strings.ToLower(repl.ReplaceAll(mode, "")) + switch mode { + case ModeStandard: + m.acceptStandard = true + case ModeRoMON: + m.acceptRoMON = true + default: + return ErrInvalidMode + } + } + } else { + m.acceptStandard, m.acceptRoMON = true, true + } + + return nil +} + +// UnmarshalCaddyfile sets up the MatchWinbox from Caddyfile tokens. Syntax: +// +// winbox { +// modes [<...>] +// username +// username_regexp +// } +// winbox +// +// Note: username and username_regexp options are mutually exclusive. +func (m *MatchWinbox) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + _, wrapper := d.Next(), d.Val() // consume wrapper name + + // No same-line argument are supported + if d.CountRemainingArgs() > 0 { + return d.ArgErr() + } + + var hasModes, hasUsername bool + for nesting := d.Nesting(); d.NextBlock(nesting); { + optionName := d.Val() + switch optionName { + case "modes": + if hasModes { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() == 0 || d.CountRemainingArgs() > 2 { + return d.ArgErr() + } + m.Modes, hasModes = append(m.Modes, d.RemainingArgs()...), true + case "username": + if hasUsername { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() != 1 { + return d.ArgErr() + } + _, val := d.NextArg(), d.Val() + m.Username, hasUsername = val, true + case "username_regexp": + if hasUsername { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() != 1 { + return d.ArgErr() + } + _, val := d.NextArg(), d.Val() + m.UsernameRegexp, hasUsername = val, true + default: + return d.ArgErr() + } + + // No nested blocks are supported + if d.NextBlock(nesting + 1) { + return d.Errf("malformed %s option '%s': blocks are not supported", wrapper, optionName) + } + } + + return nil +} + +// MessageAuth is the first message the client sends to the server. It contains a plaintext username, +// an optional '+r' string concatenated to the username to request the RoMON mode, and a public key. +type MessageAuth struct { + PublicKeyParity uint8 + PublicKeyBytes []byte + Username string +} + +// MessageChunk is a part of a bigger message. It may contain no more than 255 bytes. +type MessageChunk struct { + Bytes []byte + Length uint8 + Type uint8 +} + +func (msg *MessageAuth) DisableRoMON() { + if msg.GetRoMON() { + msg.Username = msg.Username[:len(msg.Username)-len(MessageAuthUsernameRoMONSuffix)] + } +} + +func (msg *MessageAuth) EnableRoMON() { + if !msg.GetRoMON() { + msg.Username = msg.Username + MessageAuthUsernameRoMONSuffix + } +} + +func (msg *MessageAuth) FromBytes(src []byte) error { + l := len(src) + if l < MessageAuthBytesMin { + return ErrNotEnoughSourceBytes + } + + p, q := 0, l/(MessageChunkBytesMax+2)+1 + chunks := make([]*MessageChunk, 0, q) + var chunk *MessageChunk + for i := 0; i < q; i++ { + chunk = &MessageChunk{} + p = i * (MessageChunkBytesMax + 2) + + chunk.Length = src[p] + if (q > 1 && i < q-1 && int(chunk.Length) != MessageChunkBytesMax) || + (l < p+2+int(chunk.Length)) || int(chunk.Length) < MessageChunkBytesMin { + return ErrIncorrectSourceBytes + } + + chunk.Type = src[p+1] + if (i == 0 && chunk.Type != MessageChunkTypeAuth) || (i > 0 && chunk.Type != MessageChunkTypePrev) { + return ErrIncorrectSourceBytes + } + + chunk.Bytes = src[p+2 : p+2+int(chunk.Length)] + chunks = append(chunks, chunk) + } + + return msg.FromChunks(chunks) +} + +func (msg *MessageAuth) FromChunks(chunks []*MessageChunk) error { + l := 0 + for _, chunk := range chunks { + switch chunk.Type { + case MessageChunkTypeAuth, MessageChunkTypePrev: + l += int(chunk.Length) + default: + return ErrIncorrectSourceBytes + } + } + + src := make([]byte, 0, l) + for _, chunk := range chunks { + src = append(src, chunk.Bytes[:min(int(chunk.Length), len(chunk.Bytes))]...) + } + + var foundDelimiter bool + for i, b := range src { + if b == MessageChunkBytesDelimiter { + msg.Username = string(src[:i]) + msg.PublicKeyBytes = src[i+1 : len(src)-1] + msg.PublicKeyParity = src[len(src)-1] + foundDelimiter = true + break + } + } + + if !foundDelimiter || len(msg.Username) == 0 || len(msg.PublicKeyBytes) != MessageAuthPublicKeyBytesTotal || + msg.PublicKeyParity > 1 || !MessageAuthUsernameRegexp.MatchString(msg.GetUsername()) { + return ErrIncorrectSourceBytes + } + return nil +} + +func (msg *MessageAuth) GetPublicKey() ([]byte, uint8) { + return msg.PublicKeyBytes, msg.PublicKeyParity +} + +func (msg *MessageAuth) GetRoMON() bool { + return strings.HasSuffix(msg.Username, MessageAuthUsernameRoMONSuffix) +} + +func (msg *MessageAuth) GetUsername() string { + if msg.GetRoMON() { + return msg.Username[:len(msg.Username)-len(MessageAuthUsernameRoMONSuffix)] + } + return msg.Username +} + +func (msg *MessageAuth) ToChunks() []*MessageChunk { + l := len(msg.PublicKeyBytes) + len(msg.Username) + 2 + dst := make([]byte, 0, l) + dst = append(dst, msg.Username...) + dst = append(dst, MessageChunkBytesDelimiter) + dst = append(dst, msg.PublicKeyBytes...) + dst = append(dst, msg.PublicKeyParity) + + p, q := 0, l/MessageChunkBytesMax+1 + chunks := make([]*MessageChunk, 0, q) + var chunk *MessageChunk + var ll int + for i := 0; i < q; i++ { + p = i * MessageChunkBytesMax + ll = min(MessageChunkBytesMax, l-p) + if ll == 0 { + break + } + + chunk = &MessageChunk{} + chunk.Length = uint8(ll) + if i == 0 { + chunk.Type = MessageChunkTypeAuth + } else { + chunk.Type = MessageChunkTypePrev + } + chunk.Bytes = dst[p : p+ll] + chunks = append(chunks, chunk) + } + + dst = dst[:0] + return chunks +} + +func (msg *MessageAuth) ToBytes() []byte { + chunks := msg.ToChunks() + + l := 0 + for _, chunk := range chunks { + l += 2 + int(chunk.Length) + } + + dst := make([]byte, 0, l) + for _, chunk := range chunks { + dst = append(dst, chunk.Length) + dst = append(dst, chunk.Type) + dst = append(dst, chunk.Bytes...) + } + + return dst +} + +// Interface guards +var ( + _ caddy.Provisioner = (*MatchWinbox)(nil) + _ caddyfile.Unmarshaler = (*MatchWinbox)(nil) + _ layer4.ConnMatcher = (*MatchWinbox)(nil) +) + +var ( + ErrInvalidMode = errors.New("invalid mode") + ErrIncorrectSourceBytes = errors.New("incorrect source bytes") + ErrNotEnoughSourceBytes = errors.New("not enough source bytes") + + MessageAuthUsernameRegexp = regexp.MustCompile("^[0-9A-Za-z](?:[-#.0-9@A-Z_a-z]+[0-9A-Za-z])?$") +) + +const ( + MessageAuthBytesMax = 4 + MessageAuthUsernameBytesMax + 1 + MessageAuthPublicKeyBytesTotal + 1 + MessageAuthBytesMin = 2 + MessageAuthUsernameBytesMin + 1 + MessageAuthPublicKeyBytesTotal + 1 + MessageAuthPublicKeyBytesTotal = 32 + MessageAuthUsernameBytesMax = 255 // Assume nobody sets usernames longer than 255 characters + MessageAuthUsernameBytesMin = 1 + MessageAuthUsernameRoMONSuffix = "+r" + MessageChunkBytesMin = 1 + MessageChunkBytesMax = 255 + + MessageChunkBytesDelimiter uint8 = 0x00 + MessageChunkTypeAuth uint8 = 0x06 + MessageChunkTypePrev uint8 = 0xFF + + ModeStandard = "standard" + ModeRoMON = "romon" +) + +// References: +// https://help.mikrotik.com/docs/display/ROS/WinBox +// https://help.mikrotik.com/docs/display/ROS/User +// https://margin.re/2022/02/mikrotik-authentication-revealed/ +// https://margin.re/2022/06/pulling-mikrotik-into-the-limelight/ +// https://github.com/MarginResearch/FOISted +// https://github.com/MarginResearch/mikrotik_authentication +// https://github.com/MarginResearch/resources/blob/83e402a86370f7c3acf8bb3ad982c1fee89c9b53/documents/Pulling_MikroTik_into_the_Limelight.pdf +// https://romhack.io/wp-content/uploads/sites/3/2023/09/RomHack-2023-Ting-Yu-Chen-NiN-9-Years-of-Overlooked-MikroTik-Pre-Auth-RCE.pdf +// https://github.com/Cisco-Talos/Winbox_Protocol_Dissector diff --git a/modules/l4winbox/matcher_test.go b/modules/l4winbox/matcher_test.go new file mode 100644 index 0000000..7087e95 --- /dev/null +++ b/modules/l4winbox/matcher_test.go @@ -0,0 +1,136 @@ +// Copyright 2024 VNXME +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package l4winbox + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "testing" + + "github.com/caddyserver/caddy/v2" + "go.uber.org/zap" + + "github.com/mholt/caddy-l4/layer4" +) + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("Unexpected error: %s\n", err) + } +} + +func Test_MessageAuth_FromBytes_ToBytes(t *testing.T) { + var msg *MessageAuth + var err error + for _, packet := range [][]byte{packetS1, packetS2, packetR1, packetR2} { + msg = &MessageAuth{} + if err = msg.FromBytes(packet); err != nil { + t.Fatalf("Failed to parse MessageAuth from bytes: %s\n", err) + } + if !bytes.Equal(packet, msg.ToBytes()) { + t.Fatalf("Bytes don't match.\nExpected: %x\nComposed: %x", packet, msg.ToBytes()) + } + } +} + +func Test_MatchWinbox_Match(t *testing.T) { + type test struct { + matcher *MatchWinbox + data []byte + shouldMatch bool + } + + m0 := &MatchWinbox{} + m1 := &MatchWinbox{Modes: []string{ModeStandard}} + m2 := &MatchWinbox{Modes: []string{ModeRoMON}} + m3 := &MatchWinbox{Username: "toms"} + m4 := &MatchWinbox{UsernameRegexp: "^andris|edgars|juris$"} + + tests := []test{ + {matcher: m0, data: packetS1[:len(packetS1)-1], shouldMatch: false}, + {matcher: m0, data: packetS2[:len(packetS2)-1], shouldMatch: false}, + {matcher: m0, data: packetR1[:len(packetR1)-1], shouldMatch: false}, + {matcher: m0, data: packetR2[:len(packetR2)-1], shouldMatch: false}, + + {matcher: m0, data: packetS1, shouldMatch: true}, + {matcher: m0, data: packetS2, shouldMatch: true}, + {matcher: m0, data: packetR1, shouldMatch: true}, + {matcher: m0, data: packetR2, shouldMatch: true}, + + {matcher: m1, data: packetS1, shouldMatch: true}, + {matcher: m1, data: packetS2, shouldMatch: true}, + {matcher: m1, data: packetR1, shouldMatch: false}, + {matcher: m1, data: packetR2, shouldMatch: false}, + + {matcher: m2, data: packetS1, shouldMatch: false}, + {matcher: m2, data: packetS2, shouldMatch: false}, + {matcher: m2, data: packetR1, shouldMatch: true}, + {matcher: m2, data: packetR2, shouldMatch: true}, + + {matcher: m3, data: packetS1, shouldMatch: true}, + {matcher: m3, data: packetS2, shouldMatch: false}, + {matcher: m3, data: packetR1, shouldMatch: true}, + {matcher: m3, data: packetR2, shouldMatch: false}, + + {matcher: m4, data: packetS1, shouldMatch: false}, + {matcher: m4, data: packetS2, shouldMatch: true}, + {matcher: m4, data: packetR1, shouldMatch: false}, + {matcher: m4, data: packetR2, shouldMatch: true}, + } + + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + + for i, tc := range tests { + func() { + err := tc.matcher.Provision(ctx) + assertNoError(t, err) + + in, out := net.Pipe() + defer func() { + _, _ = io.Copy(io.Discard, out) + _ = out.Close() + }() + + cx := layer4.WrapConnection(out, []byte{}, zap.NewNop()) + go func() { + _, err := in.Write(tc.data) + assertNoError(t, err) + _ = in.Close() + }() + + matched, err := tc.matcher.Match(cx) + assertNoError(t, err) + + if matched != tc.shouldMatch { + if tc.shouldMatch { + t.Fatalf("Test %d: matcher did not match | %+v\n", i, tc.matcher) + } else { + t.Fatalf("Test %d: matcher should not match | %+v\n", i, tc.matcher) + } + } + }() + } +} + +// Packet examples +var packetS1 = []byte{38, 6, 116, 111, 109, 115, 0, 16, 224, 171, 254, 156, 62, 32, 96, 105, 79, 183, 32, 18, 98, 154, 210, 88, 231, 107, 124, 235, 252, 112, 176, 226, 63, 148, 136, 155, 149, 250, 151, 0} +var packetS2 = []byte{40, 6, 97, 110, 100, 114, 105, 115, 0, 14, 185, 111, 184, 198, 199, 177, 230, 112, 205, 86, 92, 179, 165, 60, 173, 240, 56, 44, 175, 102, 201, 198, 26, 252, 174, 71, 206, 89, 58, 169, 17, 1} +var packetR1 = []byte{40, 6, 116, 111, 109, 115, 43, 114, 0, 65, 165, 44, 39, 101, 48, 138, 138, 139, 207, 103, 177, 231, 74, 148, 181, 203, 140, 104, 13, 19, 95, 116, 84, 172, 115, 20, 170, 6, 178, 163, 172, 0} +var packetR2 = []byte{42, 6, 97, 110, 100, 114, 105, 115, 43, 114, 0, 18, 158, 36, 11, 95, 8, 113, 35, 73, 92, 164, 206, 35, 223, 100, 63, 183, 98, 232, 182, 51, 93, 1, 56, 212, 183, 106, 169, 185, 69, 244, 81, 0}