github.com/mholt/caddy-l4@v0.0.0-20241104153248-ec8fae209322/modules/l4winbox/matcher.go (about)

     1  // Copyright 2024 VNXME
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package l4winbox
    16  
    17  import (
    18  	"errors"
    19  	"io"
    20  	"regexp"
    21  	"strings"
    22  
    23  	"github.com/caddyserver/caddy/v2"
    24  	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
    25  
    26  	"github.com/mholt/caddy-l4/layer4"
    27  )
    28  
    29  func init() {
    30  	caddy.RegisterModule(&MatchWinbox{})
    31  }
    32  
    33  // MatchWinbox matches any connections that look like those initiated by Winbox, a graphical tool developed
    34  // by SIA Mikrotīkls, Latvia for their hardware and software routers management. As of v3.41 and v4.0 the tool
    35  // used an undocumented proprietary protocol. This matcher is based on a number of recent studies describing
    36  // RouterOS architecture and vulnerabilities, especially the ones published by Margin Research.
    37  type MatchWinbox struct {
    38  	// Modes contains a list of supported Winbox modes to match against incoming auth messages:.
    39  	//
    40  	//	- `standard` mode is a default one (it used to be called 'secure' mode in previous versions of Winbox);
    41  	//
    42  	//	- `romon` mode makes the destination router act as an agent so that its neighbour routers
    43  	//	in isolated L2 segments could be reachable by the clients behind the agent.
    44  	//
    45  	// Notes: Each mode shall only be present once in the list. Values in the list are case-insensitive.
    46  	// If the list is empty, MatchWinbox will consider all modes as acceptable.
    47  	Modes []string `json:"modes,omitempty"`
    48  	// Username is a plaintext username value to search for in the incoming connections. In Winbox it is what
    49  	// the user types into the login field. According to the docs, it must start and end with an alphanumeric
    50  	// character, but it can also include "_", ".", "#", "-", and "@" symbols. No maximum username length is
    51  	// specified in the docs, so this matcher applies a reasonable limit of no more than 255 characters. If
    52  	// Username contains at least one character, UsernameRegexp is ignored. If Username contains placeholders,
    53  	// they are evaluated at match.
    54  	Username string `json:"username,omitempty"`
    55  	// UsernameRegexp is a username pattern to match the incoming connections against. This matcher verifies
    56  	// that any username matches MessageAuthUsernameRegexp, so UsernameRegexp must not provide a wider pattern.
    57  	// UsernameRegexp is only checked when Username is empty. If UsernameRegexp contains any placeholders, they
    58  	// are evaluated at provision.
    59  	UsernameRegexp string `json:"username_regexp,omitempty"`
    60  
    61  	acceptStandard bool
    62  	acceptRoMON    bool
    63  	usernameRegexp *regexp.Regexp
    64  }
    65  
    66  // CaddyModule returns the Caddy module information.
    67  func (m *MatchWinbox) CaddyModule() caddy.ModuleInfo {
    68  	return caddy.ModuleInfo{
    69  		ID:  "layer4.matchers.winbox",
    70  		New: func() caddy.Module { return new(MatchWinbox) },
    71  	}
    72  }
    73  
    74  // Match returns true if the connection bytes match the regular expression.
    75  func (m *MatchWinbox) Match(cx *layer4.Connection) (bool, error) {
    76  	// Read a minimum number of bytes
    77  	hdr := make([]byte, 2)
    78  	n, err := io.ReadFull(cx, hdr)
    79  	if err != nil || hdr[0] < MessageAuthBytesMin-2 || hdr[1] != MessageChunkTypeAuth {
    80  		return false, err
    81  	}
    82  
    83  	// Only allocate a larger buffer when the first chunk is full
    84  	l := int(hdr[0])
    85  	if l == MessageChunkBytesMax {
    86  		l = MessageAuthBytesMax - 2
    87  	}
    88  
    89  	// Read the remaining bytes
    90  	buf := make([]byte, 2+l+1)
    91  	copy(buf[:2], hdr[:2])
    92  	n, err = io.ReadAtLeast(cx, buf[2:], int(hdr[0]))
    93  	if err != nil || n > l {
    94  		return false, err
    95  	}
    96  
    97  	// Parse MessageAuth
    98  	msg := &MessageAuth{}
    99  	if err = msg.FromBytes(buf[:n+2]); err != nil {
   100  		return false, nil
   101  	}
   102  
   103  	// Check the acceptable modes
   104  	if msg.GetRoMON() {
   105  		if !m.acceptRoMON {
   106  			return false, nil
   107  		}
   108  	} else {
   109  		if !m.acceptStandard {
   110  			return false, nil
   111  		}
   112  	}
   113  
   114  	// Replace placeholders in filters
   115  	repl := cx.Context.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
   116  	userName := repl.ReplaceAll(m.Username, "")
   117  
   118  	// Check a plaintext username, if provided
   119  	if len(userName) > 0 && userName != msg.GetUsername() {
   120  		return false, nil
   121  	}
   122  
   123  	// Check a username regexp, if provided
   124  	if len(userName) == 0 && len(m.UsernameRegexp) > 0 && !m.usernameRegexp.MatchString(msg.GetUsername()) {
   125  		return false, nil
   126  	}
   127  
   128  	// Add a username to the replacer
   129  	repl.Set("l4.winbox.username", msg.GetUsername())
   130  
   131  	return true, nil
   132  }
   133  
   134  // Provision prepares m's internal structures.
   135  func (m *MatchWinbox) Provision(_ caddy.Context) (err error) {
   136  	repl := caddy.NewReplacer()
   137  	m.usernameRegexp, err = regexp.Compile(repl.ReplaceAll(m.UsernameRegexp, ""))
   138  	if err != nil {
   139  		return err
   140  	}
   141  
   142  	if len(m.Modes) > 0 {
   143  		for _, mode := range m.Modes {
   144  			mode = strings.ToLower(repl.ReplaceAll(mode, ""))
   145  			switch mode {
   146  			case ModeStandard:
   147  				m.acceptStandard = true
   148  			case ModeRoMON:
   149  				m.acceptRoMON = true
   150  			default:
   151  				return ErrInvalidMode
   152  			}
   153  		}
   154  	} else {
   155  		m.acceptStandard, m.acceptRoMON = true, true
   156  	}
   157  
   158  	return nil
   159  }
   160  
   161  // UnmarshalCaddyfile sets up the MatchWinbox from Caddyfile tokens. Syntax:
   162  //
   163  //	winbox {
   164  //		modes <standard|romon> [<...>]
   165  //		username <value>
   166  //		username_regexp <pattern>
   167  //	}
   168  //	winbox
   169  //
   170  // Note: username and username_regexp options are mutually exclusive.
   171  func (m *MatchWinbox) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
   172  	_, wrapper := d.Next(), d.Val() // consume wrapper name
   173  
   174  	// No same-line argument are supported
   175  	if d.CountRemainingArgs() > 0 {
   176  		return d.ArgErr()
   177  	}
   178  
   179  	var hasModes, hasUsername bool
   180  	for nesting := d.Nesting(); d.NextBlock(nesting); {
   181  		optionName := d.Val()
   182  		switch optionName {
   183  		case "modes":
   184  			if hasModes {
   185  				return d.Errf("duplicate %s option '%s'", wrapper, optionName)
   186  			}
   187  			if d.CountRemainingArgs() == 0 || d.CountRemainingArgs() > 2 {
   188  				return d.ArgErr()
   189  			}
   190  			m.Modes, hasModes = append(m.Modes, d.RemainingArgs()...), true
   191  		case "username":
   192  			if hasUsername {
   193  				return d.Errf("duplicate %s option '%s'", wrapper, optionName)
   194  			}
   195  			if d.CountRemainingArgs() != 1 {
   196  				return d.ArgErr()
   197  			}
   198  			_, val := d.NextArg(), d.Val()
   199  			m.Username, hasUsername = val, true
   200  		case "username_regexp":
   201  			if hasUsername {
   202  				return d.Errf("duplicate %s option '%s'", wrapper, optionName)
   203  			}
   204  			if d.CountRemainingArgs() != 1 {
   205  				return d.ArgErr()
   206  			}
   207  			_, val := d.NextArg(), d.Val()
   208  			m.UsernameRegexp, hasUsername = val, true
   209  		default:
   210  			return d.ArgErr()
   211  		}
   212  
   213  		// No nested blocks are supported
   214  		if d.NextBlock(nesting + 1) {
   215  			return d.Errf("malformed %s option '%s': blocks are not supported", wrapper, optionName)
   216  		}
   217  	}
   218  
   219  	return nil
   220  }
   221  
   222  // MessageAuth is the first message the client sends to the server. It contains a plaintext username,
   223  // an optional '+r' string concatenated to the username to request the RoMON mode, and a public key.
   224  type MessageAuth struct {
   225  	PublicKeyParity uint8
   226  	PublicKeyBytes  []byte
   227  	Username        string
   228  }
   229  
   230  // MessageChunk is a part of a bigger message. It may contain no more than 255 bytes.
   231  type MessageChunk struct {
   232  	Bytes  []byte
   233  	Length uint8
   234  	Type   uint8
   235  }
   236  
   237  func (msg *MessageAuth) DisableRoMON() {
   238  	if msg.GetRoMON() {
   239  		msg.Username = msg.Username[:len(msg.Username)-len(MessageAuthUsernameRoMONSuffix)]
   240  	}
   241  }
   242  
   243  func (msg *MessageAuth) EnableRoMON() {
   244  	if !msg.GetRoMON() {
   245  		msg.Username = msg.Username + MessageAuthUsernameRoMONSuffix
   246  	}
   247  }
   248  
   249  func (msg *MessageAuth) FromBytes(src []byte) error {
   250  	l := len(src)
   251  	if l < MessageAuthBytesMin {
   252  		return ErrNotEnoughSourceBytes
   253  	}
   254  
   255  	p, q := 0, l/(MessageChunkBytesMax+2)+1
   256  	chunks := make([]*MessageChunk, 0, q)
   257  	var chunk *MessageChunk
   258  	for i := 0; i < q; i++ {
   259  		chunk = &MessageChunk{}
   260  		p = i * (MessageChunkBytesMax + 2)
   261  
   262  		chunk.Length = src[p]
   263  		if (q > 1 && i < q-1 && int(chunk.Length) != MessageChunkBytesMax) ||
   264  			(l < p+2+int(chunk.Length)) || int(chunk.Length) < MessageChunkBytesMin {
   265  			return ErrIncorrectSourceBytes
   266  		}
   267  
   268  		chunk.Type = src[p+1]
   269  		if (i == 0 && chunk.Type != MessageChunkTypeAuth) || (i > 0 && chunk.Type != MessageChunkTypePrev) {
   270  			return ErrIncorrectSourceBytes
   271  		}
   272  
   273  		chunk.Bytes = src[p+2 : p+2+int(chunk.Length)]
   274  		chunks = append(chunks, chunk)
   275  	}
   276  
   277  	return msg.FromChunks(chunks)
   278  }
   279  
   280  func (msg *MessageAuth) FromChunks(chunks []*MessageChunk) error {
   281  	l := 0
   282  	for _, chunk := range chunks {
   283  		switch chunk.Type {
   284  		case MessageChunkTypeAuth, MessageChunkTypePrev:
   285  			l += int(chunk.Length)
   286  		default:
   287  			return ErrIncorrectSourceBytes
   288  		}
   289  	}
   290  
   291  	src := make([]byte, 0, l)
   292  	for _, chunk := range chunks {
   293  		src = append(src, chunk.Bytes[:min(int(chunk.Length), len(chunk.Bytes))]...)
   294  	}
   295  
   296  	var foundDelimiter bool
   297  	for i, b := range src {
   298  		if b == MessageChunkBytesDelimiter {
   299  			msg.Username = string(src[:i])
   300  			msg.PublicKeyBytes = src[i+1 : len(src)-1]
   301  			msg.PublicKeyParity = src[len(src)-1]
   302  			foundDelimiter = true
   303  			break
   304  		}
   305  	}
   306  
   307  	if !foundDelimiter || len(msg.Username) == 0 || len(msg.PublicKeyBytes) != MessageAuthPublicKeyBytesTotal ||
   308  		msg.PublicKeyParity > 1 || !MessageAuthUsernameRegexp.MatchString(msg.GetUsername()) {
   309  		return ErrIncorrectSourceBytes
   310  	}
   311  	return nil
   312  }
   313  
   314  func (msg *MessageAuth) GetPublicKey() ([]byte, uint8) {
   315  	return msg.PublicKeyBytes, msg.PublicKeyParity
   316  }
   317  
   318  func (msg *MessageAuth) GetRoMON() bool {
   319  	return strings.HasSuffix(msg.Username, MessageAuthUsernameRoMONSuffix)
   320  }
   321  
   322  func (msg *MessageAuth) GetUsername() string {
   323  	if msg.GetRoMON() {
   324  		return msg.Username[:len(msg.Username)-len(MessageAuthUsernameRoMONSuffix)]
   325  	}
   326  	return msg.Username
   327  }
   328  
   329  func (msg *MessageAuth) ToChunks() []*MessageChunk {
   330  	l := len(msg.PublicKeyBytes) + len(msg.Username) + 2
   331  	dst := make([]byte, 0, l)
   332  	dst = append(dst, msg.Username...)
   333  	dst = append(dst, MessageChunkBytesDelimiter)
   334  	dst = append(dst, msg.PublicKeyBytes...)
   335  	dst = append(dst, msg.PublicKeyParity)
   336  
   337  	p, q := 0, l/MessageChunkBytesMax+1
   338  	chunks := make([]*MessageChunk, 0, q)
   339  	var chunk *MessageChunk
   340  	var ll int
   341  	for i := 0; i < q; i++ {
   342  		p = i * MessageChunkBytesMax
   343  		ll = min(MessageChunkBytesMax, l-p)
   344  		if ll == 0 {
   345  			break
   346  		}
   347  
   348  		chunk = &MessageChunk{}
   349  		chunk.Length = uint8(ll)
   350  		if i == 0 {
   351  			chunk.Type = MessageChunkTypeAuth
   352  		} else {
   353  			chunk.Type = MessageChunkTypePrev
   354  		}
   355  		chunk.Bytes = dst[p : p+ll]
   356  		chunks = append(chunks, chunk)
   357  	}
   358  
   359  	dst = dst[:0]
   360  	return chunks
   361  }
   362  
   363  func (msg *MessageAuth) ToBytes() []byte {
   364  	chunks := msg.ToChunks()
   365  
   366  	l := 0
   367  	for _, chunk := range chunks {
   368  		l += 2 + int(chunk.Length)
   369  	}
   370  
   371  	dst := make([]byte, 0, l)
   372  	for _, chunk := range chunks {
   373  		dst = append(dst, chunk.Length)
   374  		dst = append(dst, chunk.Type)
   375  		dst = append(dst, chunk.Bytes...)
   376  	}
   377  
   378  	return dst
   379  }
   380  
   381  // Interface guards
   382  var (
   383  	_ caddy.Provisioner     = (*MatchWinbox)(nil)
   384  	_ caddyfile.Unmarshaler = (*MatchWinbox)(nil)
   385  	_ layer4.ConnMatcher    = (*MatchWinbox)(nil)
   386  )
   387  
   388  var (
   389  	ErrInvalidMode          = errors.New("invalid mode")
   390  	ErrIncorrectSourceBytes = errors.New("incorrect source bytes")
   391  	ErrNotEnoughSourceBytes = errors.New("not enough source bytes")
   392  
   393  	MessageAuthUsernameRegexp = regexp.MustCompile("^[0-9A-Za-z](?:[-#.0-9@A-Z_a-z]+[0-9A-Za-z])?$")
   394  )
   395  
   396  const (
   397  	MessageAuthBytesMax            = 4 + MessageAuthUsernameBytesMax + 1 + MessageAuthPublicKeyBytesTotal + 1
   398  	MessageAuthBytesMin            = 2 + MessageAuthUsernameBytesMin + 1 + MessageAuthPublicKeyBytesTotal + 1
   399  	MessageAuthPublicKeyBytesTotal = 32
   400  	MessageAuthUsernameBytesMax    = 255 // Assume nobody sets usernames longer than 255 characters
   401  	MessageAuthUsernameBytesMin    = 1
   402  	MessageAuthUsernameRoMONSuffix = "+r"
   403  	MessageChunkBytesMin           = 1
   404  	MessageChunkBytesMax           = 255
   405  
   406  	MessageChunkBytesDelimiter uint8 = 0x00
   407  	MessageChunkTypeAuth       uint8 = 0x06
   408  	MessageChunkTypePrev       uint8 = 0xFF
   409  
   410  	ModeStandard = "standard"
   411  	ModeRoMON    = "romon"
   412  )
   413  
   414  // References:
   415  //	https://help.mikrotik.com/docs/display/ROS/WinBox
   416  //	https://help.mikrotik.com/docs/display/ROS/User
   417  //	https://margin.re/2022/02/mikrotik-authentication-revealed/
   418  //	https://margin.re/2022/06/pulling-mikrotik-into-the-limelight/
   419  //	https://github.com/MarginResearch/FOISted
   420  //	https://github.com/MarginResearch/mikrotik_authentication
   421  //	https://github.com/MarginResearch/resources/blob/83e402a86370f7c3acf8bb3ad982c1fee89c9b53/documents/Pulling_MikroTik_into_the_Limelight.pdf
   422  //	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
   423  //	https://github.com/Cisco-Talos/Winbox_Protocol_Dissector