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