From 2bcb258e903ac52a610cf135a19a5a853ba3b96b Mon Sep 17 00:00:00 2001 From: vnxme <46669194+vnxme@users.noreply.github.com> Date: Sat, 20 Jul 2024 13:57:41 +0300 Subject: [PATCH 1/3] Remote Desktop Protocol (RDP) matcher: initial commit --- README.md | 1 + imports.go | 1 + modules/l4rdp/matcher.go | 773 ++++++++++++++++++++++++++++++++++ modules/l4rdp/matcher_test.go | 383 +++++++++++++++++ 4 files changed, 1158 insertions(+) create mode 100644 modules/l4rdp/matcher.go create mode 100644 modules/l4rdp/matcher_test.go diff --git a/README.md b/README.md index d7b2495..d1a1d79 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Current matchers: - **layer4.matchers.remote_ip** - matches connections based on remote IP (or CIDR range). - **layer4.matchers.local_ip** - matches connections based on local IP (or CIDR range). - **layer4.matchers.proxy_protocol** - matches connections that start with [HAPROXY proxy protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt). +- **layer4.matchers.rdp** - matches connections that look like [RDP](https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-RDPBCGR/%5BMS-RDPBCGR%5D.pdf). - **layer4.matchers.socks4** - matches connections that look like [SOCKSv4](https://www.openssh.com/txt/socks4.protocol). - **layer4.matchers.socks5** - matches connections that look like [SOCKSv5](https://www.rfc-editor.org/rfc/rfc1928.html). diff --git a/imports.go b/imports.go index 24bbf22..52a258a 100644 --- a/imports.go +++ b/imports.go @@ -22,6 +22,7 @@ import ( _ "github.com/mholt/caddy-l4/modules/l4postgres" _ "github.com/mholt/caddy-l4/modules/l4proxy" _ "github.com/mholt/caddy-l4/modules/l4proxyprotocol" + _ "github.com/mholt/caddy-l4/modules/l4rdp" _ "github.com/mholt/caddy-l4/modules/l4socks" _ "github.com/mholt/caddy-l4/modules/l4ssh" _ "github.com/mholt/caddy-l4/modules/l4subroute" diff --git a/modules/l4rdp/matcher.go b/modules/l4rdp/matcher.go new file mode 100644 index 0000000..2737f7a --- /dev/null +++ b/modules/l4rdp/matcher.go @@ -0,0 +1,773 @@ +// 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 l4rdp + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "io" + "net" + "net/netip" + "strconv" + "strings" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/mholt/caddy-l4/layer4" +) + +func init() { + caddy.RegisterModule(&MatchRDP{}) +} + +// MatchRDP is able to match RDP connections. +type MatchRDP struct { + CookieHash string `json:"cookie_hash,omitempty"` + CookieIPs []string `json:"cookie_ips,omitempty"` + CookiePorts []uint16 `json:"cookie_ports,omitempty"` + + cookieIPs []netip.Prefix +} + +// CaddyModule returns the Caddy module information. +func (m *MatchRDP) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "layer4.matchers.rdp", + New: func() caddy.Module { return new(MatchRDP) }, + } +} + +// Match returns true if the connection looks like RDP. +func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { + // Read a number of bytes to parse headers + headerBuf := make([]byte, RDPConnReqBytesMin) + n, err := io.ReadFull(cx, headerBuf) + if err != nil || n < int(RDPConnReqBytesMin) { + return false, nil + } + + // Parse TPKTHeader + h := &TPKTHeader{} + if err = h.FromBytes(headerBuf[TPKTHeaderBytesStart : TPKTHeaderBytesStart+TPKTHeaderBytesTotal]); err != nil { + return false, nil + } + + // Validate TPKTHeader + if h.Version != TPKTHeaderVersion || h.Reserved != TPKTHeaderReserved || + h.Length < RDPConnReqBytesMin || h.Length > RDPConnReqBytesMax { + return false, nil + } + + // Parse X224Crq + x := &X224Crq{} + if err = x.FromBytes(headerBuf[X224CrqBytesStart : X224CrqBytesStart+X224CrqBytesTotal]); err != nil { + return false, nil + } + + // Validate X224Crq + if x.TypeCredit != X224CrqTypeCredit || x.DstRef != X224CrqDstRef || + x.SrcRef != X224CrqSrcRef || x.ClassOptions != X224CrqClassOptions || + uint16(x.Length) != (h.Length-TPKTHeaderBytesTotal-1) { + return false, nil + } + + // Calculate and validate payload length + // NOTE: at this stage we can't be absolutely sure that the protocol is RDP, though payloads are optional. + // This behaviour may be changed in the future if there are many false negative matches due to some RDP + // clients sending RDP connection requests containing TPKTHeader and X224Crq headers only. + payloadBytesTotal := uint16(x.Length) - (X224CrqBytesTotal - 1) + if payloadBytesTotal == 0 { + return false, nil + } + + // Read a number of bytes to parse payload + payloadBuf := make([]byte, payloadBytesTotal) + n, err = io.ReadFull(cx, payloadBuf) + if err != nil || n < int(payloadBytesTotal) { + return false, nil + } + + // Validate the remaining connection buffer + // NOTE: if at least 1 byte remains, we can technically be sure, the protocol isn't RDP. + // This behaviour may be changed in the future if there are many false negative matches. + extraBuf := make([]byte, 1) + n, err = io.ReadFull(cx, extraBuf) + if err == nil && n == len(extraBuf) { + return false, nil + } + + // Find CRLF which divides token/cookie from RDPNegReq and RDPCorrInfo + var RDPNegReqBytesStart uint16 = 0 + for index, b := range payloadBuf { + if b == ASCIIByteCR && payloadBuf[index+1] == ASCIIByteLF { + RDPNegReqBytesStart = uint16(index) + 2 // start after CR LF + break + } + } + + // Process optional RDPCookie + var hasValidCookie bool + for RDPNegReqBytesStart >= RDPCookieBytesMin { + RDPCookieBytesTotal := RDPNegReqBytesStart // include CR LF + + // Parse RDPCookie + c := string(payloadBuf[RDPCookieBytesStart : RDPCookieBytesStart+RDPCookieBytesTotal]) + + // Validate RDPCookie + if RDPCookieBytesTotal > RDPCookieBytesMax || !strings.HasPrefix(c, RDPCookiePrefix) { + break + } + + // Extract hash (username truncated to 9 characters from the left) + // NOTE: according to the RDP protocol specification, if domain/username is provided, hash will be domain/us + hashBytesStart := uint16(len(RDPCookiePrefix)) + hashBytesTotal := RDPCookieBytesTotal - hashBytesStart - 2 // exclude CR LF + hash := c[hashBytesStart : hashBytesStart+hashBytesTotal] + + // Add hash to the replacer + repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) + repl.Set("l4.rdp.cookie_hash", hash) + + if len(m.CookieHash) > 0 && m.CookieHash != hash { + break + } + + hasValidCookie = true + break + } + + // NOTE: we can stop validation because hash hasn't matched + if !hasValidCookie && len(m.CookieHash) > 0 { + return false, nil + } + + // Process optional RDPToken + var hasValidToken bool + for RDPNegReqBytesStart >= RDPTokenBytesMin { + if hasValidCookie { + break + } // RDPCookie and RDPToken are mutually exclusive + + RDPTokenBytesTotal := RDPNegReqBytesStart // include CR LF + + // Parse RDPToken + t := &RDPToken{} + if err = t.FromBytes(payloadBuf[RDPTokenBytesStart : RDPTokenBytesStart+RDPTokenBytesTotal]); err != nil { + break + } + + // Validate RDPToken + if t.Version != RDPTokenVersion || t.Reserved != RDPTokenReserved || + t.Length != RDPTokenBytesTotal || t.LengthIndicator != uint8(t.Length-5) || + t.TypeCredit != x.TypeCredit || t.DstRef != x.DstRef || t.SrcRef != x.SrcRef || + t.ClassOptions != x.ClassOptions { + break + } + + // NOTE: RDPToken without a cookie value is technically correct + l := t.Length - RDPTokenBytesMin + if l == 0 { + hasValidToken = (len(m.cookieIPs) == 0) && (len(m.CookiePorts) == 0) + break + } + + // Validate RDPToken.Optional (1/6) + // NOTE: maximum length has been calculated for a cookie having IPv4 address. If it supports IPv6 addresses, + // RDPTokenOptionalCookieBytesMax constant has to be adjusted accordingly. The IP parsing process + // would also need to be redesigned to provide for solutions relevant for both address families. + RDPTokenOptionalCookieBytesTotal := l - 2 // exclude CR LF + if RDPTokenOptionalCookieBytesTotal < RDPTokenOptionalCookieBytesMin || + RDPTokenOptionalCookieBytesTotal > RDPTokenOptionalCookieBytesMax { + break + } + + // Validate RDPToken.Optional (2/6) + c := string(t.Optional[RDPTokenOptionalCookieBytesStart:RDPTokenOptionalCookieBytesTotal]) + if !strings.HasPrefix(c, RDPTokenOptionalCookiePrefix) { + break + } + + // Validate RDPToken.Optional (3/6) + d := strings.Split(c[len(RDPTokenOptionalCookiePrefix):], string(RDPTokenOptionalCookieSeparator)) + if len(d) != 3 { + break + } + + // Validate RDPToken.Optional (4/6) + ipStr, portStr, reservedStr := d[0], d[1], d[2] + if reservedStr != RDPTokenOptionalCookieReserved { + break + } + + // Validate RDPToken.Optional (5/6) + ipNum, err := strconv.ParseUint(ipStr, 10, 32) + if err != nil { + break + } + ipBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(ipBuf, uint32(ipNum)) + ipVal := make(net.IP, 4) + if err = binary.Read(bytes.NewBuffer(ipBuf), binary.BigEndian, &ipVal); err != nil { + break + } + + // Validate RDPToken.Optional (6/6) + portNum, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + break + } + portBuf := make([]byte, 4) + binary.LittleEndian.PutUint16(portBuf, uint16(portNum)) + portVal := uint16(0) + if err = binary.Read(bytes.NewBuffer(portBuf), binary.BigEndian, &portVal); err != nil { + break + } + + // Add IP and port to the replacer + repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) + repl.Set("l4.rdp.cookie_ip", ipVal.String()) + repl.Set("l4.rdp.cookie_port", strconv.Itoa(int(portVal))) + + if len(m.cookieIPs) > 0 { + var found bool + for _, prefix := range m.cookieIPs { + if prefix.Contains(netip.AddrFrom4([4]byte(ipVal))) { + found = true + break + } + } + if !found { + break + } + } + + if len(m.CookiePorts) > 0 { + var found bool + for _, port := range m.CookiePorts { + if port == portVal { + found = true + break + } + } + if !found { + break + } + } + + hasValidToken = true + break + } + + // NOTE: we can stop validation because IPs or ports haven't matched + if !hasValidToken && (len(m.cookieIPs) > 0 || len(m.CookiePorts) > 0) { + return false, nil + } + + // Validate RDPCookie and RDPToken presence to match payload boundaries + // NOTE: if there is anything before CR LF, but both RDPCookie and RDPToken parsing has failed, + // we can technically be sure, the protocol isn't RDP. + if RDPNegReqBytesStart > 0 && (!hasValidCookie && !hasValidToken) { + return false, nil + } + + // NOTE: Given RDPNegReq and RDPCorrInfo are optional, we have found CR LF at the end of the payload, + // and all the validations above have passed, we can reasonably treat the protocol in question as RDP. + // This behaviour may be changed in the future if there are many false positive matches. + if RDPNegReqBytesStart == payloadBytesTotal { + return true, nil + } + + // Validate RDPNegReq boundaries + if RDPNegReqBytesStart+RDPNegReqBytesTotal > payloadBytesTotal { + return false, nil + } + + // Parse RDPNegReq + r := &RDPNegReq{} + if err = r.FromBytes(payloadBuf[RDPNegReqBytesStart : RDPNegReqBytesStart+RDPNegReqBytesTotal]); err != nil { + return false, nil + } + + // Validate RDPNegReq + // NOTE: for simplicity, we treat a RDPNegReq with all flags and protocols set as valid. + // This behaviour may be changed in the future if there are many false positive matches. + if r.Type != RDPNegReqType || r.Length != RDPNegReqLength || + r.Flags|RDPNegReqFlagsAll != RDPNegReqFlagsAll || r.Protocols|RDPNegReqProtocolsAll != RDPNegReqProtocolsAll || + (r.Protocols&RDPNegReqProtoHybridEx == RDPNegReqProtoHybridEx && r.Protocols&RDPNegReqProtoHybrid == 0) || + (r.Protocols&RDPNegReqProtoHybrid == RDPNegReqProtoHybrid && r.Protocols&RDPNegReqProtoSSL == 0) { + return false, nil + } + + // Validate RDPCorrInfo presence to match payload boundaries + // NOTE: nothing must be present after RDPNegReq unless RDPNegReqFlagCorrInfo is set, + // otherwise we can reasonably treat the connection as RDP, given all the validations above have passed. + if r.Flags&RDPNegReqFlagCorrInfo == 0 { + if RDPNegReqBytesStart+RDPNegReqBytesTotal < payloadBytesTotal { + return false, nil + } else { + return true, nil + } + } + + // Validate RDPCorrInfo boundaries + RDPCorrInfoBytesStart := RDPNegReqBytesStart + RDPNegReqBytesTotal + if RDPCorrInfoBytesStart+RDPCorrInfoBytesTotal > payloadBytesTotal { + return false, nil + } + + // Parse RDPCorrInfo + i := &RDPCorrInfo{} + if err = i.FromBytes(payloadBuf[RDPCorrInfoBytesStart : RDPCorrInfoBytesStart+RDPCorrInfoBytesTotal]); err != nil { + return false, nil + } + + // Validate RDPCorrInfo (1/3) + // NOTE: the first byte of RDPCorrInfo.Identity must not be equal 0x00 or 0xF4 + if i.Type != RDPCorrInfoType || i.Flags != RDPCorrInfoFlags || i.Length != RDPCorrInfoLength || + i.Identity[0] == RDPCorrInfoReserved || i.Identity[0] == RDPCorrInfoIdentityF4 { + return false, nil + } + + // Validate RDPCorrInfo (2/3) + // NOTE: no byte of RDPCorrInfo.Identity must be equal 0x0D + for _, b := range i.Identity { + if b == ASCIIByteCR { + return false, nil + } + } + + // Add base64 of identity bytes to the replacer + repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) + repl.Set("l4.rdp.correlation_id", base64.StdEncoding.EncodeToString(i.Identity[:])) + + // Validate RDPCorrInfo (3/3) + // NOTE: any byte of RDPCorrInfo.Reserved must be equal 0x00 + for _, b := range i.Reserved { + if b != RDPCorrInfoReserved { + return false, nil + } + } + + return true, nil +} + +// Provision parses m's IP ranges, either from IP or CIDR expressions. +func (m *MatchRDP) Provision(_ caddy.Context) (err error) { + m.cookieIPs, err = layer4.ParseNetworks(m.CookieIPs) + if err != nil { + return err + } + return nil +} + +// UnmarshalCaddyfile sets up the MatchRDP from Caddyfile tokens. Syntax: +// +// rdp { +// cookie_hash +// cookie_ip +// cookie_port +// } +// rdp +// +// Note: according to the protocol documentation, RDP cookies are optional, i.e. it depends on the client whether +// they are included in the first packet (RDP Connection Request) or not. Besides, no valid RDP CR packet must +// contain cookie_hash ("mstshash") and cookie_ip:cookie_port ("msts") at the same time, i.e. Match will always return +// false if cookie_hash and any of cookie_ip and cookie_port are set simultaneously. If this matcher has cookie_hash +// option, but a valid RDP Connection Request packet doesn't have it, Match will return false. If this matcher has +// a set of cookie_ip and cookie_port options, or any of them, but a valid RDP Connection Request packet doesn't +// have them, Match will return false as well. +func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + _, wrapper := d.Next(), d.Val() // consume wrapper name + + // No same-line arguments are supported + if d.CountRemainingArgs() > 0 { + return d.ArgErr() + } + + var hasCookieHash bool + for nesting := d.Nesting(); d.NextBlock(nesting); { + optionName := d.Val() + switch optionName { + case "cookie_hash": + if hasCookieHash { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() != 1 { + return d.ArgErr() + } + _, val := d.NextArg(), d.Val() + m.CookieHash, hasCookieHash = val[:min(RDPCookieHashBytesMax, uint16(len(val)))], true + case "cookie_ip": + if d.CountRemainingArgs() == 0 { + return d.ArgErr() + } + prefixes, err := layer4.ParseNetworks(d.RemainingArgs()) + if err != nil { + return d.Errf("parsing %s option '%s': %v", wrapper, optionName, err) + } + for _, prefix := range prefixes { + m.CookieIPs = append(m.CookieIPs, prefix.String()) + } + case "cookie_port": + if d.CountRemainingArgs() == 0 { + return d.ArgErr() + } + for d.NextArg() { + val := d.Val() + num, err := strconv.ParseUint(val, 10, 16) + if err != nil { + return d.Errf("parsing %s option '%s': %v", wrapper, optionName, err) + } + m.CookiePorts = append(m.CookiePorts, uint16(num)) + } + 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 +} + +type RDPCorrInfo struct { + Type uint8 + Flags uint8 + Length uint16 + Identity [16]uint8 + Reserved [16]uint8 +} + +func (i *RDPCorrInfo) FromBytes(src []byte) error { + return binary.Read(bytes.NewBuffer(src), RDPCorrInfoBytesOrder, i) +} + +func (i *RDPCorrInfo) ToBytes() ([]byte, error) { + dst := bytes.NewBuffer(make([]byte, 0, RDPCorrInfoBytesTotal)) + err := binary.Write(dst, RDPCorrInfoBytesOrder, i) + return dst.Bytes(), err +} + +type RDPNegReq struct { + Type uint8 + Flags uint8 + Length uint16 + Protocols uint32 +} + +func (r *RDPNegReq) FromBytes(src []byte) error { + return binary.Read(bytes.NewBuffer(src), RDPNegReqBytesOrder, r) +} + +func (r *RDPNegReq) ToBytes() ([]byte, error) { + dst := bytes.NewBuffer(make([]byte, 0, RDPNegReqBytesTotal)) + err := binary.Write(dst, RDPNegReqBytesOrder, r) + return dst.Bytes(), err +} + +type RDPToken struct { + Version uint8 + Reserved uint8 + Length uint16 + LengthIndicator uint8 + TypeCredit uint8 + DstRef uint16 + SrcRef uint16 + ClassOptions uint8 + Optional []uint8 +} + +func (t *RDPToken) FromBytes(src []byte) error { + buf := bytes.NewBuffer(src) + if err := binary.Read(buf, RDPTokenBytesOrder, &t.Version); err != nil { + return err + } + if err := binary.Read(buf, RDPTokenBytesOrder, &t.Reserved); err != nil { + return err + } + if err := binary.Read(buf, RDPTokenBytesOrder, &t.Length); err != nil { + return err + } + if err := binary.Read(buf, RDPTokenBytesOrder, &t.LengthIndicator); err != nil { + return err + } + if err := binary.Read(buf, RDPTokenBytesOrder, &t.TypeCredit); err != nil { + return err + } + if err := binary.Read(buf, RDPTokenBytesOrder, &t.DstRef); err != nil { + return err + } + if err := binary.Read(buf, RDPTokenBytesOrder, &t.SrcRef); err != nil { + return err + } + if err := binary.Read(buf, RDPTokenBytesOrder, &t.ClassOptions); err != nil { + return err + } + if buf.Len() > 0 { + t.Optional = append(t.Optional, buf.Bytes()...) + } + return nil +} + +func (t *RDPToken) ToBytes() ([]byte, error) { + dst := bytes.NewBuffer(make([]byte, 0, RDPTokenBytesMin+uint16(len(t.Optional)))) + if err := binary.Write(dst, RDPTokenBytesOrder, &t.Version); err != nil { + return nil, err + } + if err := binary.Write(dst, RDPTokenBytesOrder, &t.Reserved); err != nil { + return nil, err + } + if err := binary.Write(dst, RDPTokenBytesOrder, &t.Length); err != nil { + return nil, err + } + if err := binary.Write(dst, RDPTokenBytesOrder, &t.LengthIndicator); err != nil { + return nil, err + } + if err := binary.Write(dst, RDPTokenBytesOrder, &t.TypeCredit); err != nil { + return nil, err + } + if err := binary.Write(dst, RDPTokenBytesOrder, &t.DstRef); err != nil { + return nil, err + } + if err := binary.Write(dst, RDPTokenBytesOrder, &t.SrcRef); err != nil { + return nil, err + } + if err := binary.Write(dst, RDPTokenBytesOrder, &t.ClassOptions); err != nil { + return nil, err + } + return append(dst.Bytes(), t.Optional...), nil +} + +type TPKTHeader struct { + Version byte + Reserved byte + Length uint16 +} + +func (h *TPKTHeader) FromBytes(src []byte) error { + return binary.Read(bytes.NewBuffer(src), TPKTHeaderBytesOrder, h) +} + +func (h *TPKTHeader) ToBytes() ([]byte, error) { + dst := bytes.NewBuffer(make([]byte, 0, TPKTHeaderBytesTotal)) + err := binary.Write(dst, TPKTHeaderBytesOrder, h) + return dst.Bytes(), err +} + +type X224Crq struct { + Length uint8 + TypeCredit uint8 + DstRef uint16 + SrcRef uint16 + ClassOptions uint8 +} + +func (x *X224Crq) FromBytes(src []byte) error { + return binary.Read(bytes.NewBuffer(src), X224CrqBytesOrder, x) +} + +func (x *X224Crq) ToBytes() ([]byte, error) { + dst := bytes.NewBuffer(make([]byte, 0, X224CrqBytesTotal)) + err := binary.Write(dst, X224CrqBytesOrder, x) + return dst.Bytes(), err +} + +// Interface guards +var ( + _ caddy.Provisioner = (*MatchRDP)(nil) + _ caddyfile.Unmarshaler = (*MatchRDP)(nil) + _ layer4.ConnMatcher = (*MatchRDP)(nil) +) + +// Constants specific to RDP Connection Request. Packet structure is described in the comments below. +const ( + ASCIIByteCR uint8 = 0x0D + ASCIIByteLF uint8 = 0x0A + + RDPCookieBytesMax = RDPCookieBytesMin + (RDPCookieHashBytesMax - 1) + RDPCookieBytesMin = uint16(len(RDPCookiePrefix) + 1 + 2) // 2 bytes for CR LF and at least 1 character + RDPCookieBytesStart uint16 = 0 + RDPCookieHashBytesMax uint16 = 9 + RDPCookiePrefix = "Cookie: mstshash=" + + RDPCorrInfoBytesTotal uint16 = 36 + RDPCorrInfoType uint8 = 0x06 + RDPCorrInfoFlags uint8 = 0x00 + RDPCorrInfoLength = RDPCorrInfoBytesTotal + RDPCorrInfoIdentityF4 uint8 = 0xF4 + RDPCorrInfoReserved uint8 = 0x00 + + RDPNegReqBytesTotal uint16 = 8 + RDPNegReqType uint8 = 0x01 + RDPNegReqFlagAdminMode uint8 = 0x01 + RDPNegReqFlagAuthMode uint8 = 0x02 + RDPNegReqFlagCorrInfo uint8 = 0x08 + RDPNegReqFlagsAll = RDPNegReqFlagAdminMode | RDPNegReqFlagAuthMode | RDPNegReqFlagCorrInfo + RDPNegReqLength = RDPNegReqBytesTotal + RDPNegReqProtoStandard uint32 = 0x00000000 + RDPNegReqProtoSSL uint32 = 0x00000001 + RDPNegReqProtoHybrid uint32 = 0x00000002 + RDPNegReqProtoRDSTLS uint32 = 0x00000004 + RDPNegReqProtoHybridEx uint32 = 0x00000008 + RDPNegReqProtoRDSAAD uint32 = 0x00000010 + RDPNegReqProtocolsAll = RDPNegReqProtoStandard | RDPNegReqProtoSSL | RDPNegReqProtoHybrid | + RDPNegReqProtoRDSTLS | RDPNegReqProtoHybridEx | RDPNegReqProtoRDSAAD + + RDPTokenBytesMax = RDPTokenBytesMin + RDPTokenOptionalCookieBytesMax + RDPTokenBytesMin uint16 = 11 + RDPTokenBytesStart uint16 = 0 + RDPTokenVersion uint8 = 0x03 + RDPTokenReserved uint8 = 0x00 + RDPTokenOptionalCookieBytesMax = uint16(len(RDPTokenOptionalCookiePrefix)) + + 10 + // decimal representation of 2^32 has 10 digits, so 10 bytes are required at most + 2 + // 2 bytes for separators + 5 + // decimal representation of 2^16 has 5 digits, so 5 bytes are required at most + 4 + // 4 reserved bytes for trailing zeros + 2 + // 2 bytes for CR LF + 0 + RDPTokenOptionalCookieBytesMin = uint16(len(RDPTokenOptionalCookiePrefix)) + + 1 + // at least 1 byte (1 digit) for IP + 2 + // 2 bytes for separators + 1 + // at least 1 byte (1 digit) for port + 4 + // 4 reserved bytes for trailing zeros + 2 + // 2 bytes for CR LF + 0 + RDPTokenOptionalCookieBytesStart uint16 = 0 + RDPTokenOptionalCookiePrefix = "Cookie: msts=" + RDPTokenOptionalCookieReserved = "0000" + RDPTokenOptionalCookieSeparator uint8 = 0x2E + + TPKTHeaderBytesStart uint16 = 0 + TPKTHeaderBytesTotal uint16 = 4 + TPKTHeaderReserved uint8 = 0x00 + TPKTHeaderVersion uint8 = 0x03 + + X224CrqBytesStart = TPKTHeaderBytesStart + TPKTHeaderBytesTotal + X224CrqBytesTotal uint16 = 7 + X224CrqTypeCredit uint8 = 0xE0 // also known as TPDU code + X224CrqDstRef uint16 = 0x0000 + X224CrqSrcRef uint16 = 0x0000 + X224CrqClassOptions uint8 = 0x00 + + RDPConnReqBytesMaxCookie = RDPConnReqBytesMin + RDPCookieBytesMax + 2 + RDPNegReqBytesTotal + RDPCorrInfoBytesTotal // 2 bytes for CR LF + RDPConnReqBytesMaxToken = RDPConnReqBytesMin + RDPTokenBytesMax + RDPNegReqBytesTotal + RDPCorrInfoBytesTotal + RDPConnReqBytesMax = max(RDPConnReqBytesMaxCookie, RDPConnReqBytesMaxToken) + RDPConnReqBytesMin = TPKTHeaderBytesTotal + X224CrqBytesTotal +) + +// Variables specific to RDP Connection Request. Packet structure is described in the comments below. +var ( + RDPCorrInfoBytesOrder = binary.LittleEndian + RDPNegReqBytesOrder = binary.LittleEndian + RDPTokenBytesOrder = binary.BigEndian + TPKTHeaderBytesOrder = binary.BigEndian + X224CrqBytesOrder = binary.BigEndian +) + +// Remote Desktop Protocol (RDP) +// ref: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/d2a48824-e362-4ed1-bda8-0eb7cbb28b8c +// ref: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/18a27ef9-6f9a-4501-b000-94b1fe3c2c10 +// ref: https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-RDPBCGR/%5BMS-RDPBCGR%5D.pdf +// X.224 CR PDU, a packet each RDP connections begins with, has at least 11 bytes and may contain 6 elements: +// +// ref: https://go.microsoft.com/fwlink/?LinkId=90541 +// 1. MANDATORY tpktHeader (4 bytes): +// tpktHeader.version (1 byte) must equal +// 0x03 = 0b00000011 +// tpktHeader.reserved (1 byte) must equal +// 0x00 +// tpktHeader.length (2 bytes) must equal the total length of tpktHeader, including: +// tpktHeader, x224Crq, routingToken, cookie, rdpNegReq, rdpCorrelationInfo +// +// ref: https://go.microsoft.com/fwlink/?LinkId=90588 +// 2. MANDATORY x224Crq (7 bytes): +// x224Crq.length (1 byte) must equal the total length of fixed and variable parts of x224Crq +// 0x0E = 0b00001110 - 14 = tpktHeader.length - 4 - 1 +// x224Crq.TypeCredit (1 byte) must equal +// 0xE0 = 0x11100000 +// x224Crq.dstRef (2 bytes) must equal +// 0x00 +// x224Crq.srcRef (2 bytes) must equal +// 0x00 +// x224Crq.classOptions (1 byte) must equal +// 0x00 +// +// ref: https://go.microsoft.com/fwlink/?LinkId=90204 +// 3. OPTIONAL routingToken (variable length; must not be present if cookie is present): +// routingToken.version (1 byte) must equal +// 0x03 +// routingToken.reserved (1 byte) must equal +// 0x00 +// routingToken.length (2 bytes, big-endian) must equal the total length of routingToken, including: +// version, reserved, length, lengthIndicator, typeCredit, dstRef, srcRef, classOptions, optional +// routingToken.lengthIndicator (1 byte) must equal the total length of the following components: +// typeCredit, dstRef, srcRef, classOptions, optional; i.e. it must be 5 bytes less than length +// routingToken.typeCredit (1 byte) must equal +// [???; it must probably equal to x224Crq.typeCredit] +// routingToken.dstRef (2 bytes) must equal +// [???; it must probably equal to x224Crq.dstRef] +// routingToken.srcRef (2 bytes) must equal +// [???; it must probably equal to x224Crq.srcRef] +// routingToken.classOptions (1 byte) must equal +// [???; it must probably equal to x224Crq.classOptions] +// routingToken.optional (variable length) may contain a cookie (max 37 bytes) formatted as follows: +// 0x436F6F6B69653A206D7374733D (Cookie: msts=) +// [IP']0x2E[PORT']0x2E[RESERVED] ([number].[number].[number]) +// 0x0D0A (CR LF); +// where decimal IP and PORT values are converted into hex, byte order is reversed, +// then resulting hex values are converted back into decimals to get IP' and PORT', +// and RESERVED must equal 0x30303030; see ref for additional guidance on cookie format +// +// ref: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/cbe1ed0a-d320-4ea5-be5a-f2eb6e032853#Appendix_A_43 +// 4. OPTIONAL cookie (ANSI string of variable length, max 28 bytes; must not be present if routingToken is present; +// all Microsoft RDP clients >5.0 include cookie, if a username is specified before connecting): +// 0x436F6F6B69653A206D737473686173683D (Cookie: mstshash=) +// [IDENTIFIER] +// 0x0D0A (CR LF); +// where a username cut to have no more than 9 symbols is commonly used as IDENTIFIER +// +// ref: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/902b090b-9cb3-4efc-92bf-ee13373371e3 +// 5. OPTIONAL rdpNegReq (8 bytes): +// rdpNegReq.type (1 byte) must equal +// 0x01 (TYPE_RDP_NEG_REQ) +// rdpNegReq.flags (1 byte) contains the following flags: +// 0x01 (RESTRICTED_ADMIN_MODE_REQUIRED) +// 0x02 (REDIRECTED_AUTHENTICATION_MODE_REQUIRED) +// 0x08 (CORRELATION_INFO_PRESENT) +// rdpNegReq.length (2 bytes) must equal +// 0x0008 - 8 bytes in total +// rdpNegReq.requestedProtocols (4 bytes) contains the following flags: +// 0x00000000 (PROTOCOL_RDP) - Standard RDP Security +// 0x00000001 (PROTOCOL_SSL) - TLS 1.0, 1.1, or 1.2 +// 0x00000002 (PROTOCOL_HYBRID) - CredSSP, requires PROTOCOL_SSL flag +// 0x00000004 (PROTOCOL_RDSTLS) - RDSTLS protocol +// 0x00000008 (PROTOCOL_HYBRID_EX) - CredSSP with EUAR PDU, requires PROTOCOL_HYBRID flag +// 0x00000010 (PROTOCOL_RDSAAD) - RDS-AAD-Auth Security +// +// 6. OPTIONAL rdpCorrelationInfo (36 bytes; must only be present if CORRELATION_INFO_PRESENT is set in rdpNegReq.flags): +// rdpCorrelationInfo.type (1 byte) must equal +// 0x06 (TYPE_RDP_CORRELATION_INFO) +// rdpCorrelationInfo.flags (1 byte) must equal +// 0x00 +// rdpCorrelationInfo.length (2 bytes) must equal +// 0x0024 - 36 bytes in total +// rdpCorrelationInfo.correlationId (16 bytes) - a unique identifier to associate with the connection; +// the first byte SHOULD NOT have a value of 0x00 or 0xF4 and the value 0x0D SHOULD NOT be present at all +// rdpCorrelationInfo.reserved (16 bytes) must equal +// 16x[0x00] - all bytes are zeroed diff --git a/modules/l4rdp/matcher_test.go b/modules/l4rdp/matcher_test.go new file mode 100644 index 0000000..9b4657a --- /dev/null +++ b/modules/l4rdp/matcher_test.go @@ -0,0 +1,383 @@ +// 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 l4rdp + +import ( + "bytes" + "context" + "io" + "net" + "testing" + + "github.com/caddyserver/caddy/v2" + "github.com/mholt/caddy-l4/layer4" + "go.uber.org/zap" +) + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("Unexpected error: %s\n", err) + } +} + +func Test_MatchRDP_ProcessTPKTHeader(t *testing.T) { + p := [][]byte{ + packetValid1[0:4], packetValid2[0:4], packetValid3[0:4], packetValid4[0:4], + packetValid5[0:4], packetValid6[0:4], packetValid7[0:4], packetValid8[0:4], + } + for _, b := range p { + func() { + s := &TPKTHeader{} + errFrom := s.FromBytes(b) + assertNoError(t, errFrom) + sb, errTo := s.ToBytes() + assertNoError(t, errTo) + if !bytes.Equal(b, sb) { + t.Fatalf("test %T bytes processing: resulting bytes [% x] don't match original bytes [% x]", *s, b, sb) + } + }() + } +} + +func Test_MatchRDP_ProcessX224Crq(t *testing.T) { + p := [][]byte{ + packetValid1[4:11], packetValid2[4:11], packetValid3[4:11], packetValid4[4:11], + packetValid5[4:11], packetValid6[4:11], packetValid7[4:11], packetValid8[4:11], + } + for _, b := range p { + func() { + s := &X224Crq{} + errFrom := s.FromBytes(b) + assertNoError(t, errFrom) + sb, errTo := s.ToBytes() + assertNoError(t, errTo) + if !bytes.Equal(b, sb) { + t.Fatalf("test %T bytes processing: resulting bytes [% x] don't match original bytes [% x]", *s, b, sb) + } + }() + } +} + +func Test_MatchRDP_ProcessRDPCookie(t *testing.T) { + p := [][]byte{ + packetValid3[11:35], packetValid4[11:35], packetValid6[11:35], + } + for _, b := range p { + func() { + s := string(b) + if s != RDPCookiePrefix+"a0123"+string(ASCIIByteCR)+string(ASCIIByteLF) { + t.Fatalf("test RDPCookie bytes processing: resulting bytes [% x] don't match original bytes [% x]", b, []byte(s)) + } + }() + } +} + +func Test_MatchRDP_ProcessRDPToken(t *testing.T) { + p := [][]byte{ + packetValid5[11:56], packetValid7[11:56], packetValid8[11:56], + } + for _, b := range p { + func() { + s := &RDPToken{} + errFrom := s.FromBytes(b) + assertNoError(t, errFrom) + sb, errTo := s.ToBytes() + assertNoError(t, errTo) + if !bytes.Equal(b, sb) { + t.Fatalf("test %T bytes processing: resulting bytes [% x] don't match original bytes [% x]", *s, b, sb) + } + }() + } +} + +func Test_MatchRDP_ProcessRDPNegReq(t *testing.T) { + p := [][]byte{ + packetValid1[11:19], packetValid2[11:19], packetValid3[35:43], packetValid4[35:43], + packetValid5[56:64], packetValid8[56:64], + } + for _, b := range p { + func() { + s := &RDPNegReq{} + errFrom := s.FromBytes(b) + assertNoError(t, errFrom) + sb, errTo := s.ToBytes() + assertNoError(t, errTo) + if !bytes.Equal(b, sb) { + t.Fatalf("test %T bytes processing: resulting bytes [% x] don't match original bytes [% x]", *s, b, sb) + } + }() + } +} + +func Test_MatchRDP_ProcessRDPCorrInfo(t *testing.T) { + p := [][]byte{ + packetValid2[19:55], packetValid3[43:79], packetValid8[64:100], + } + for _, b := range p { + func() { + s := &RDPCorrInfo{} + errFrom := s.FromBytes(b) + assertNoError(t, errFrom) + sb, errTo := s.ToBytes() + assertNoError(t, errTo) + if !bytes.Equal(b, sb) { + t.Fatalf("test %T bytes processing: resulting bytes [% x] don't match original bytes [% x]", *s, b, sb) + } + }() + } +} + +func Test_MatchRDP_Match(t *testing.T) { + type test struct { + matcher *MatchRDP + data []byte + shouldMatch bool + } + + tests := []test{ + // without filters + {matcher: &MatchRDP{}, data: packetTooShort, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetInvalid1, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetInvalid2, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetInvalid3, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetInvalid4, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetInvalid5, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetInvalid6, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetSemiValid1, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetSemiValid2, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetSemiValid3, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetSemiValid4, shouldMatch: false}, + {matcher: &MatchRDP{}, data: packetValid1, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetValid2, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetValid3, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetValid4, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetValid5, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetValid6, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetValid7, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetValid8, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetExtraByte, shouldMatch: false}, + // with filtered hash + {matcher: &MatchRDP{CookieHash: ""}, data: packetValid3, shouldMatch: true}, + {matcher: &MatchRDP{CookieHash: "a0123"}, data: packetValid3, shouldMatch: true}, + {matcher: &MatchRDP{CookieHash: "admin"}, data: packetValid3, shouldMatch: false}, + // with filtered port + {matcher: &MatchRDP{CookiePorts: []uint16{}}, data: packetValid5, shouldMatch: true}, + {matcher: &MatchRDP{CookiePorts: []uint16{3389}}, data: packetValid5, shouldMatch: true}, + {matcher: &MatchRDP{CookiePorts: []uint16{5000}}, data: packetValid5, shouldMatch: false}, + // with filtered IP + {matcher: &MatchRDP{CookieIPs: []string{}}, data: packetValid7, shouldMatch: true}, + {matcher: &MatchRDP{CookieIPs: []string{"127.0.0.1/8"}}, data: packetValid7, shouldMatch: true}, + {matcher: &MatchRDP{CookieIPs: []string{"192.168.0.1/16"}}, data: packetValid7, shouldMatch: false}, + } + + 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 packetTooShort = []byte{ + 0x00, 0x00, 0x00, 0x00, // TPKTHeader +} +var packetInvalid1 = []byte{ + 0x00, 0x00, 0x00, 0x00, // TPKTHeader + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq +} +var packetInvalid2 = []byte{ + 0x03, 0x00, 0x00, 0x0B, // TPKTHeader + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq +} +var packetInvalid3 = []byte{ + 0x03, 0x00, 0x00, 0x13, // TPKTHeader + 0x0E, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq +} +var packetInvalid4 = []byte{ + 0x03, 0x00, 0x00, 0x37, // TPKTHeader + 0x32, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq + 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (1/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) +} +var packetInvalid5 = []byte{ + 0x03, 0x00, 0x00, 0x4F, // TPKTHeader + 0x4A, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x30, // RDPCookie (1/3) + 0x61, 0x30, 0x31, 0x32, 0x33, // RDPCookie (2/3) + 0x0D, 0x0A, // RDPCookie (3/3) + 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq + 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) +} +var packetInvalid6 = []byte{ + 0x03, 0x00, 0x00, 0x1E, // TPKTHeader + 0x19, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPToken (1/1) + 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq +} +var packetSemiValid1 = []byte{ // we can't be sure it's RDP + 0x03, 0x00, 0x00, 0x0B, // TPKTHeader + 0x06, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq +} +var packetSemiValid2 = []byte{ // we assume hash must have at least 1 symbol + 0x03, 0x00, 0x00, 0x4F, // TPKTHeader + 0x4A, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x3D, // RDPCookie (1/3) + // RDPCookie (2/3) + 0x0D, 0x0A, // RDPCookie (3/3) + 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq + 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) +} +var packetSemiValid3 = []byte{ // we assume hash must have at most 9 symbols + 0x03, 0x00, 0x00, 0x4F, // TPKTHeader + 0x4A, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x3D, // RDPCookie (1/3) + 0x61, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, // RDPCookie (2/3) + 0x0D, 0x0A, // RDPCookie (3/3) + 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq + 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) +} +var packetSemiValid4 = []byte{ // an empty RDPToken doesn't seem to be a valid part of RDP Connection Request + 0x03, 0x00, 0x00, 0x1E, // TPKTHeader + 0x19, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPToken (1/1) + 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq +} +var packetValid1 = []byte{ + 0x03, 0x00, 0x00, 0x13, // TPKTHeader + 0x0E, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq +} +var packetValid2 = []byte{ + 0x03, 0x00, 0x00, 0x37, // TPKTHeader + 0x32, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq + 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) +} +var packetValid3 = []byte{ + 0x03, 0x00, 0x00, 0x4F, // TPKTHeader + 0x4A, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x3D, // RDPCookie (1/3) + 0x61, 0x30, 0x31, 0x32, 0x33, // RDPCookie (2/3) + 0x0D, 0x0A, // RDPCookie (3/3) + 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq + 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) +} +var packetValid4 = []byte{ + 0x03, 0x00, 0x00, 0x2B, // TPKTHeader + 0x26, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x3D, // RDPCookie (1/3) + 0x61, 0x30, 0x31, 0x32, 0x33, // RDPCookie (2/3) + 0x0D, 0x0A, // RDPCookie (3/3) + 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq +} +var packetValid5 = []byte{ + 0x03, 0x00, 0x00, 0x40, // TPKTHeader + 0x3B, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x03, 0x00, 0x00, 0x2D, 0x28, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPToken (1/6) + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x3D, // RDPToken (2/6) + 0x31, 0x36, 0x37, 0x37, 0x37, 0x33, 0x34, 0x33, // RDPToken (3/6) + 0x2E, 0x31, 0x35, 0x36, 0x32, 0x39, // RDPToken (4/6) + 0x2E, 0x30, 0x30, 0x30, 0x30, // RDPToken (5/6) + 0x0D, 0x0A, // RDPToken (6/6) + 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq +} +var packetValid6 = []byte{ + 0x03, 0x00, 0x00, 0x23, // TPKTHeader + 0x1E, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x3D, // RDPCookie (1/3) + 0x61, 0x30, 0x31, 0x32, 0x33, // RDPCookie (2/3) + 0x0D, 0x0A, // RDPCookie (3/3) +} +var packetValid7 = []byte{ + 0x03, 0x00, 0x00, 0x38, // TPKTHeader + 0x33, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x03, 0x00, 0x00, 0x2D, 0x28, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPToken (1/6) + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x3D, // RDPToken (2/6) + 0x31, 0x36, 0x37, 0x37, 0x37, 0x33, 0x34, 0x33, // RDPToken (3/6) + 0x2E, 0x31, 0x35, 0x36, 0x32, 0x39, // RDPToken (4/6) + 0x2E, 0x30, 0x30, 0x30, 0x30, // RDPToken (5/6) + 0x0D, 0x0A, // RDPToken (6/6) +} +var packetValid8 = []byte{ + 0x03, 0x00, 0x00, 0x64, // TPKTHeader + 0x5F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x03, 0x00, 0x00, 0x2D, 0x28, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPToken (1/6) + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x3D, // RDPToken (2/6) + 0x31, 0x36, 0x37, 0x37, 0x37, 0x33, 0x34, 0x33, // RDPToken (3/6) + 0x2E, 0x31, 0x35, 0x36, 0x32, 0x39, // RDPToken (4/6) + 0x2E, 0x30, 0x30, 0x30, 0x30, // RDPToken (5/6) + 0x0D, 0x0A, // RDPToken (6/6) + 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq + 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) +} +var packetExtraByte = []byte{ + 0x03, 0x00, 0x00, 0x64, // TPKTHeader + 0x5F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x03, 0x00, 0x00, 0x2D, 0x28, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPToken (1/6) + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x3D, // RDPToken (2/6) + 0x31, 0x36, 0x37, 0x37, 0x37, 0x33, 0x34, 0x33, // RDPToken (3/6) + 0x2E, 0x31, 0x35, 0x36, 0x32, 0x39, // RDPToken (4/6) + 0x2E, 0x30, 0x30, 0x30, 0x30, // RDPToken (5/6) + 0x0D, 0x0A, // RDPToken (6/6) + 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq + 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) + 0x00, // wrong byte +} From cf0b61834f58ee61e6432506f85bfaeb5d9a8959 Mon Sep 17 00:00:00 2001 From: vnxme <46669194+vnxme@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:08:19 +0300 Subject: [PATCH 2/3] Remote Desktop Protocol (RDP) matcher: relax protocol checks - allow longer cookie hashes - parse custom routing info --- modules/l4rdp/matcher.go | 123 +++++++++++++++++++++++++++------- modules/l4rdp/matcher_test.go | 33 ++++++--- 2 files changed, 119 insertions(+), 37 deletions(-) diff --git a/modules/l4rdp/matcher.go b/modules/l4rdp/matcher.go index 2737f7a..47e8d87 100644 --- a/modules/l4rdp/matcher.go +++ b/modules/l4rdp/matcher.go @@ -38,6 +38,7 @@ type MatchRDP struct { CookieHash string `json:"cookie_hash,omitempty"` CookieIPs []string `json:"cookie_ips,omitempty"` CookiePorts []uint16 `json:"cookie_ports,omitempty"` + CustomInfo string `json:"custom_info,omitempty"` cookieIPs []netip.Prefix } @@ -131,8 +132,8 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { break } - // Extract hash (username truncated to 9 characters from the left) - // NOTE: according to the RDP protocol specification, if domain/username is provided, hash will be domain/us + // Extract hash (username truncated to max number of characters from the left) + // NOTE: according to mstsc.exe tests, if "domain" and "username" are provided, hash will be "domain/us" hashBytesStart := uint16(len(RDPCookiePrefix)) hashBytesTotal := RDPCookieBytesTotal - hashBytesStart - 2 // exclude CR LF hash := c[hashBytesStart : hashBytesStart+hashBytesTotal] @@ -156,11 +157,7 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { // Process optional RDPToken var hasValidToken bool - for RDPNegReqBytesStart >= RDPTokenBytesMin { - if hasValidCookie { - break - } // RDPCookie and RDPToken are mutually exclusive - + for !hasValidCookie && RDPNegReqBytesStart >= RDPTokenBytesMin { RDPTokenBytesTotal := RDPNegReqBytesStart // include CR LF // Parse RDPToken @@ -276,10 +273,47 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { return false, nil } - // Validate RDPCookie and RDPToken presence to match payload boundaries - // NOTE: if there is anything before CR LF, but both RDPCookie and RDPToken parsing has failed, - // we can technically be sure, the protocol isn't RDP. - if RDPNegReqBytesStart > 0 && (!hasValidCookie && !hasValidToken) { + // Process RDPCustom + var hasValidCustom bool + for !(hasValidCookie || hasValidToken) && RDPNegReqBytesStart >= RDPCustomBytesMin { + RDPCustomBytesTotal := RDPNegReqBytesStart // include CR LF + + // Parse RDPCustom + c := string(payloadBuf[RDPCustomBytesStart : RDPCustomBytesStart+RDPCustomBytesTotal]) + + // Validate RDPCustom + if RDPCustomBytesTotal > RDPCustomBytesMax { + break + } + + // Extract info (everything before CR LF) + // NOTE: according to Apache Guacamole tests, if "load balance info/cookie" option is non-empty, + // its contents is included into the RDP Connection Request packet without any changes + infoBytesTotal := RDPCustomBytesTotal - RDPCustomInfoBytesStart - 2 // exclude CR LF + info := c[RDPCustomInfoBytesStart : RDPCustomInfoBytesStart+infoBytesTotal] + + // Add info to the replacer + repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) + repl.Set("l4.rdp.custom_info", info) + + if len(m.CustomInfo) > 0 && m.CustomInfo != info { + break + } + + hasValidCustom = true + break + } + + // NOTE: we can stop validation because info hasn't matched + if !hasValidCustom && len(m.CustomInfo) > 0 { + return false, nil + } + + // Validate RDPCookie, RDPToken and RDPCustom presence to match payload boundaries + // NOTE: if there is anything before CR LF, but RDPCookie and RDPToken parsing has failed, + // we can technically be sure, the protocol isn't RDP. However, given RDPCustom has no mandatory prefix + // by definition (it's an extension to the official documentation), this condition can barely be met. + if RDPNegReqBytesStart > 0 && (!hasValidCookie && !hasValidToken && !hasValidCustom) { return false, nil } @@ -377,18 +411,27 @@ func (m *MatchRDP) Provision(_ caddy.Context) (err error) { // // rdp { // cookie_hash +// } +// rdp { // cookie_ip // cookie_port // } +// rdp { +// custom_info +// } // rdp // -// Note: according to the protocol documentation, RDP cookies are optional, i.e. it depends on the client whether -// they are included in the first packet (RDP Connection Request) or not. Besides, no valid RDP CR packet must +// Note: according to the protocol documentation, RDP cookies and tokens are optional, i.e. it depends on the client +// whether they are included in the first packet (RDP Connection Request) or not. Besides, no valid RDP CR packet must // contain cookie_hash ("mstshash") and cookie_ip:cookie_port ("msts") at the same time, i.e. Match will always return // false if cookie_hash and any of cookie_ip and cookie_port are set simultaneously. If this matcher has cookie_hash -// option, but a valid RDP Connection Request packet doesn't have it, Match will return false. If this matcher has -// a set of cookie_ip and cookie_port options, or any of them, but a valid RDP Connection Request packet doesn't -// have them, Match will return false as well. +// option, but a valid RDP CR packet doesn't have it, Match will return false. If this matcher has a set of cookie_ip +// and cookie_port options, or any of them, but a valid RDP CR packet doesn't have them, Match will return false. +// +// There are some RDP clients (e.g. Apache Guacamole) that support any text to be included into an RDP CR packet +// instead of "mstshash" and "msts" cookies for load balancing and/or routing purposes, parsed here as custom_info. +// If this matcher has custom_info option, but a valid RDP CR packet doesn't have it, Match will return false. +// If custom_info option is combined with cookie_hash, cookie_ip or cookie_port, Match will return false as well. func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { _, wrapper := d.Next(), d.Val() // consume wrapper name @@ -397,11 +440,14 @@ func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.ArgErr() } - var hasCookieHash bool + var hasCookieHash, hasCookieIPOrPort, hasCustomInfo bool for nesting := d.Nesting(); d.NextBlock(nesting); { optionName := d.Val() switch optionName { case "cookie_hash": + if hasCookieIPOrPort || hasCustomInfo { + return d.Errf("%s option '%s' can't be combined with other options", wrapper, optionName) + } if hasCookieHash { return d.Errf("duplicate %s option '%s'", wrapper, optionName) } @@ -411,6 +457,9 @@ func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { _, val := d.NextArg(), d.Val() m.CookieHash, hasCookieHash = val[:min(RDPCookieHashBytesMax, uint16(len(val)))], true case "cookie_ip": + if hasCookieHash || hasCustomInfo { + return d.Errf("%s option '%s' can only be combined with 'cookie_port' option", wrapper, optionName) + } if d.CountRemainingArgs() == 0 { return d.ArgErr() } @@ -421,7 +470,11 @@ func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { for _, prefix := range prefixes { m.CookieIPs = append(m.CookieIPs, prefix.String()) } + hasCookieIPOrPort = true case "cookie_port": + if hasCookieHash || hasCustomInfo { + return d.Errf("%s option '%s' can only be combined with 'cookie_ip' option", wrapper, optionName) + } if d.CountRemainingArgs() == 0 { return d.ArgErr() } @@ -433,6 +486,19 @@ func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } m.CookiePorts = append(m.CookiePorts, uint16(num)) } + hasCookieIPOrPort = true + case "custom_info": + if hasCookieHash || hasCookieIPOrPort { + return d.Errf("%s option '%s' can't be combined with other options", wrapper, optionName) + } + if hasCustomInfo { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() != 1 { + return d.ArgErr() + } + _, val := d.NextArg(), d.Val() + m.CustomInfo, hasCustomInfo = val[:min(RDPCustomInfoBytesMax, uint16(len(val)))], true default: return d.ArgErr() } @@ -600,10 +666,10 @@ const ( ASCIIByteCR uint8 = 0x0D ASCIIByteLF uint8 = 0x0A - RDPCookieBytesMax = RDPCookieBytesMin + (RDPCookieHashBytesMax - 1) - RDPCookieBytesMin = uint16(len(RDPCookiePrefix) + 1 + 2) // 2 bytes for CR LF and at least 1 character + RDPCookieBytesMax = uint16(X224CrqLengthMax) - (X224CrqBytesTotal - 1) + RDPCookieBytesMin = uint16(len(RDPCookiePrefix)) + 1 + 2 // 2 bytes for CR LF and at least 1 character RDPCookieBytesStart uint16 = 0 - RDPCookieHashBytesMax uint16 = 9 + RDPCookieHashBytesMax = RDPCookieBytesMax - (RDPCookieBytesMin - 1) RDPCookiePrefix = "Cookie: mstshash=" RDPCorrInfoBytesTotal uint16 = 36 @@ -613,6 +679,12 @@ const ( RDPCorrInfoIdentityF4 uint8 = 0xF4 RDPCorrInfoReserved uint8 = 0x00 + RDPCustomBytesMax = uint16(X224CrqLengthMax) - (X224CrqBytesTotal - 1) + RDPCustomBytesMin uint16 = 1 + 2 // 2 bytes for CR LF and at least 1 character + RDPCustomBytesStart uint16 = 0 + RDPCustomInfoBytesMax = RDPCustomBytesMax - (RDPCustomBytesMin - 1) + RDPCustomInfoBytesStart uint16 = 0 + RDPNegReqBytesTotal uint16 = 8 RDPNegReqType uint8 = 0x01 RDPNegReqFlagAdminMode uint8 = 0x01 @@ -629,7 +701,6 @@ const ( RDPNegReqProtocolsAll = RDPNegReqProtoStandard | RDPNegReqProtoSSL | RDPNegReqProtoHybrid | RDPNegReqProtoRDSTLS | RDPNegReqProtoHybridEx | RDPNegReqProtoRDSAAD - RDPTokenBytesMax = RDPTokenBytesMin + RDPTokenOptionalCookieBytesMax RDPTokenBytesMin uint16 = 11 RDPTokenBytesStart uint16 = 0 RDPTokenVersion uint8 = 0x03 @@ -660,15 +731,14 @@ const ( X224CrqBytesStart = TPKTHeaderBytesStart + TPKTHeaderBytesTotal X224CrqBytesTotal uint16 = 7 + X224CrqLengthMax uint8 = 254 // 255 is reserved for possible extensions X224CrqTypeCredit uint8 = 0xE0 // also known as TPDU code X224CrqDstRef uint16 = 0x0000 X224CrqSrcRef uint16 = 0x0000 X224CrqClassOptions uint8 = 0x00 - RDPConnReqBytesMaxCookie = RDPConnReqBytesMin + RDPCookieBytesMax + 2 + RDPNegReqBytesTotal + RDPCorrInfoBytesTotal // 2 bytes for CR LF - RDPConnReqBytesMaxToken = RDPConnReqBytesMin + RDPTokenBytesMax + RDPNegReqBytesTotal + RDPCorrInfoBytesTotal - RDPConnReqBytesMax = max(RDPConnReqBytesMaxCookie, RDPConnReqBytesMaxToken) - RDPConnReqBytesMin = TPKTHeaderBytesTotal + X224CrqBytesTotal + RDPConnReqBytesMax = TPKTHeaderBytesTotal + uint16(X224CrqLengthMax) + 1 // 1 byte for X224Crq.Length + RDPConnReqBytesMin = TPKTHeaderBytesTotal + X224CrqBytesTotal ) // Variables specific to RDP Connection Request. Packet structure is described in the comments below. @@ -740,7 +810,8 @@ var ( // 0x436F6F6B69653A206D737473686173683D (Cookie: mstshash=) // [IDENTIFIER] // 0x0D0A (CR LF); -// where a username cut to have no more than 9 symbols is commonly used as IDENTIFIER +// where IDENTIFIER can be a "domain/username" string truncated to 9 symbols for a native client (mstsc.exe), +// and an intact "username" string for Apache Guacamole (unless a load balance token/info field is set) // // ref: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/902b090b-9cb3-4efc-92bf-ee13373371e3 // 5. OPTIONAL rdpNegReq (8 bytes): diff --git a/modules/l4rdp/matcher_test.go b/modules/l4rdp/matcher_test.go index 9b4657a..200c977 100644 --- a/modules/l4rdp/matcher_test.go +++ b/modules/l4rdp/matcher_test.go @@ -36,7 +36,7 @@ func assertNoError(t *testing.T, err error) { func Test_MatchRDP_ProcessTPKTHeader(t *testing.T) { p := [][]byte{ packetValid1[0:4], packetValid2[0:4], packetValid3[0:4], packetValid4[0:4], - packetValid5[0:4], packetValid6[0:4], packetValid7[0:4], packetValid8[0:4], + packetValid5[0:4], packetValid6[0:4], packetValid7[0:4], packetValid8[0:4], packetValid9[0:4], } for _, b := range p { func() { @@ -55,7 +55,7 @@ func Test_MatchRDP_ProcessTPKTHeader(t *testing.T) { func Test_MatchRDP_ProcessX224Crq(t *testing.T) { p := [][]byte{ packetValid1[4:11], packetValid2[4:11], packetValid3[4:11], packetValid4[4:11], - packetValid5[4:11], packetValid6[4:11], packetValid7[4:11], packetValid8[4:11], + packetValid5[4:11], packetValid6[4:11], packetValid7[4:11], packetValid8[4:11], packetValid9[4:11], } for _, b := range p { func() { @@ -168,6 +168,7 @@ func Test_MatchRDP_Match(t *testing.T) { {matcher: &MatchRDP{}, data: packetValid6, shouldMatch: true}, {matcher: &MatchRDP{}, data: packetValid7, shouldMatch: true}, {matcher: &MatchRDP{}, data: packetValid8, shouldMatch: true}, + {matcher: &MatchRDP{}, data: packetValid9, shouldMatch: true}, {matcher: &MatchRDP{}, data: packetExtraByte, shouldMatch: false}, // with filtered hash {matcher: &MatchRDP{CookieHash: ""}, data: packetValid3, shouldMatch: true}, @@ -181,6 +182,10 @@ func Test_MatchRDP_Match(t *testing.T) { {matcher: &MatchRDP{CookieIPs: []string{}}, data: packetValid7, shouldMatch: true}, {matcher: &MatchRDP{CookieIPs: []string{"127.0.0.1/8"}}, data: packetValid7, shouldMatch: true}, {matcher: &MatchRDP{CookieIPs: []string{"192.168.0.1/16"}}, data: packetValid7, shouldMatch: false}, + // with filtered info + {matcher: &MatchRDP{CustomInfo: ""}, data: packetValid9, shouldMatch: true}, + {matcher: &MatchRDP{CustomInfo: "anything could be here"}, data: packetValid9, shouldMatch: true}, + {matcher: &MatchRDP{CustomInfo: "arbitrary text"}, data: packetValid9, shouldMatch: false}, } ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) @@ -246,8 +251,8 @@ var packetInvalid4 = []byte{ var packetInvalid5 = []byte{ 0x03, 0x00, 0x00, 0x4F, // TPKTHeader 0x4A, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq - 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x30, // RDPCookie (1/3) - 0x61, 0x30, 0x31, 0x32, 0x33, // RDPCookie (2/3) + 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x3D, // RDPCookie (1/3) + // RDPCookie (2/3) 0x0D, 0x0A, // RDPCookie (3/3) 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) @@ -264,7 +269,7 @@ var packetSemiValid1 = []byte{ // we can't be sure it's RDP 0x03, 0x00, 0x00, 0x0B, // TPKTHeader 0x06, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq } -var packetSemiValid2 = []byte{ // we assume hash must have at least 1 symbol +var packetSemiValid2 = []byte{ // we assume cookie hash must have at least 1 symbol 0x03, 0x00, 0x00, 0x4F, // TPKTHeader 0x4A, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x3D, // RDPCookie (1/3) @@ -275,12 +280,11 @@ var packetSemiValid2 = []byte{ // we assume hash must have at least 1 symbol 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) } -var packetSemiValid3 = []byte{ // we assume hash must have at most 9 symbols - 0x03, 0x00, 0x00, 0x4F, // TPKTHeader - 0x4A, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq - 0x43, 0x6F, 0x6F, 0x6B, 0x69, 0x65, 0x3A, 0x20, 0x6D, 0x73, 0x74, 0x73, 0x68, 0x61, 0x73, 0x68, 0x3D, // RDPCookie (1/3) - 0x61, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, // RDPCookie (2/3) - 0x0D, 0x0A, // RDPCookie (3/3) +var packetSemiValid3 = []byte{ // we assume custom info must have at least 1 symbol + 0x03, 0x00, 0x00, 0x39, // TPKTHeader + 0x34, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + // RDPCustom (1/2) + 0x0D, 0x0A, // RDPCustom (2/2) 0x01, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPNegReq 0x06, 0x00, 0x24, 0x00, // RDPCorrInfo (1/3) 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) @@ -366,6 +370,13 @@ var packetValid8 = []byte{ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (2/3) 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // RDPCorrInfo (3/3) } +var packetValid9 = []byte{ + 0x03, 0x00, 0x00, 0x23, // TPKTHeader + 0x1E, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq + 0x61, 0x6E, 0x79, 0x74, 0x68, 0x69, 0x6E, 0x67, 0x20, 0x63, 0x6F, 0x75, 0x6C, 0x64, 0x20, // RDPCustom (1/3) + 0x62, 0x65, 0x20, 0x68, 0x65, 0x72, 0x65, // RDPCustom (2/3) + 0x0D, 0x0A, // RDPCustom (3/3) +} var packetExtraByte = []byte{ 0x03, 0x00, 0x00, 0x64, // TPKTHeader 0x5F, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, // X224Crq From ed36f3f8a8268d2c7e7eb1503e1a43c62a11c86a Mon Sep 17 00:00:00 2001 From: vnxme <46669194+vnxme@users.noreply.github.com> Date: Mon, 22 Jul 2024 16:10:37 +0300 Subject: [PATCH 3/3] Remote Desktop Protocol (RDP) matcher: allow regexp matching --- modules/l4rdp/matcher.go | 70 +++++++++++++++++++++++++++++++---- modules/l4rdp/matcher_test.go | 6 +++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/modules/l4rdp/matcher.go b/modules/l4rdp/matcher.go index 47e8d87..3be133b 100644 --- a/modules/l4rdp/matcher.go +++ b/modules/l4rdp/matcher.go @@ -21,6 +21,7 @@ import ( "io" "net" "net/netip" + "regexp" "strconv" "strings" @@ -35,12 +36,17 @@ func init() { // MatchRDP is able to match RDP connections. type MatchRDP struct { - CookieHash string `json:"cookie_hash,omitempty"` - CookieIPs []string `json:"cookie_ips,omitempty"` - CookiePorts []uint16 `json:"cookie_ports,omitempty"` - CustomInfo string `json:"custom_info,omitempty"` + CookieHash string `json:"cookie_hash,omitempty"` + CookieHashRegexp string `json:"cookie_hash_regexp,omitempty"` + CookieIPs []string `json:"cookie_ips,omitempty"` + CookiePorts []uint16 `json:"cookie_ports,omitempty"` + CustomInfo string `json:"custom_info,omitempty"` + CustomInfoRegexp string `json:"custom_info_regexp,omitempty"` cookieIPs []netip.Prefix + + cookieHashRegexp *regexp.Regexp + customInfoRegexp *regexp.Regexp } // CaddyModule returns the Caddy module information. @@ -142,16 +148,22 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) repl.Set("l4.rdp.cookie_hash", hash) + // Full match if len(m.CookieHash) > 0 && m.CookieHash != hash { break } + // Regexp match + if len(m.CookieHashRegexp) > 0 && !m.cookieHashRegexp.MatchString(hash) { + break + } + hasValidCookie = true break } // NOTE: we can stop validation because hash hasn't matched - if !hasValidCookie && len(m.CookieHash) > 0 { + if !hasValidCookie && (len(m.CookieHash) > 0 || len(m.CookieHashRegexp) > 0) { return false, nil } @@ -296,16 +308,22 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) repl.Set("l4.rdp.custom_info", info) + // Full match if len(m.CustomInfo) > 0 && m.CustomInfo != info { break } + // Regexp match + if len(m.CustomInfoRegexp) > 0 && !m.customInfoRegexp.MatchString(info) { + break + } + hasValidCustom = true break } // NOTE: we can stop validation because info hasn't matched - if !hasValidCustom && len(m.CustomInfo) > 0 { + if !hasValidCustom && (len(m.CustomInfo) > 0 || len(m.CustomInfoRegexp) > 0) { return false, nil } @@ -398,12 +416,20 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { return true, nil } -// Provision parses m's IP ranges, either from IP or CIDR expressions. +// Provision parses m's IP ranges, either from IP or CIDR expressions, and regular expressions. func (m *MatchRDP) Provision(_ caddy.Context) (err error) { m.cookieIPs, err = layer4.ParseNetworks(m.CookieIPs) if err != nil { return err } + m.cookieHashRegexp, err = regexp.Compile(m.CookieHashRegexp) + if err != nil { + return err + } + m.customInfoRegexp, err = regexp.Compile(m.CustomInfoRegexp) + if err != nil { + return err + } return nil } @@ -413,12 +439,18 @@ func (m *MatchRDP) Provision(_ caddy.Context) (err error) { // cookie_hash // } // rdp { +// cookie_hash_regexp +// } +// rdp { // cookie_ip // cookie_port // } // rdp { // custom_info // } +// rdp { +// custom_info_regexp +// } // rdp // // Note: according to the protocol documentation, RDP cookies and tokens are optional, i.e. it depends on the client @@ -456,6 +488,18 @@ func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } _, val := d.NextArg(), d.Val() m.CookieHash, hasCookieHash = val[:min(RDPCookieHashBytesMax, uint16(len(val)))], true + case "cookie_hash_regexp": + if hasCookieIPOrPort || hasCustomInfo { + return d.Errf("%s option '%s' can't be combined with other options", wrapper, optionName) + } + if hasCookieHash { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() != 1 { + return d.ArgErr() + } + _, val := d.NextArg(), d.Val() + m.CookieHashRegexp, hasCookieHash = val[:min(RDPCookieHashBytesMax, uint16(len(val)))], true case "cookie_ip": if hasCookieHash || hasCustomInfo { return d.Errf("%s option '%s' can only be combined with 'cookie_port' option", wrapper, optionName) @@ -499,6 +543,18 @@ func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } _, val := d.NextArg(), d.Val() m.CustomInfo, hasCustomInfo = val[:min(RDPCustomInfoBytesMax, uint16(len(val)))], true + case "custom_info_regexp": + if hasCookieHash || hasCookieIPOrPort { + return d.Errf("%s option '%s' can't be combined with other options", wrapper, optionName) + } + if hasCustomInfo { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() != 1 { + return d.ArgErr() + } + _, val := d.NextArg(), d.Val() + m.CustomInfoRegexp, hasCustomInfo = val[:min(RDPCustomInfoBytesMax, uint16(len(val)))], true default: return d.ArgErr() } diff --git a/modules/l4rdp/matcher_test.go b/modules/l4rdp/matcher_test.go index 200c977..223d5a9 100644 --- a/modules/l4rdp/matcher_test.go +++ b/modules/l4rdp/matcher_test.go @@ -174,6 +174,9 @@ func Test_MatchRDP_Match(t *testing.T) { {matcher: &MatchRDP{CookieHash: ""}, data: packetValid3, shouldMatch: true}, {matcher: &MatchRDP{CookieHash: "a0123"}, data: packetValid3, shouldMatch: true}, {matcher: &MatchRDP{CookieHash: "admin"}, data: packetValid3, shouldMatch: false}, + {matcher: &MatchRDP{CookieHashRegexp: ""}, data: packetValid3, shouldMatch: true}, + {matcher: &MatchRDP{CookieHashRegexp: "^[a-z]\\d+$"}, data: packetValid3, shouldMatch: true}, + {matcher: &MatchRDP{CookieHashRegexp: "^[A-Z]\\d+$"}, data: packetValid3, shouldMatch: false}, // with filtered port {matcher: &MatchRDP{CookiePorts: []uint16{}}, data: packetValid5, shouldMatch: true}, {matcher: &MatchRDP{CookiePorts: []uint16{3389}}, data: packetValid5, shouldMatch: true}, @@ -186,6 +189,9 @@ func Test_MatchRDP_Match(t *testing.T) { {matcher: &MatchRDP{CustomInfo: ""}, data: packetValid9, shouldMatch: true}, {matcher: &MatchRDP{CustomInfo: "anything could be here"}, data: packetValid9, shouldMatch: true}, {matcher: &MatchRDP{CustomInfo: "arbitrary text"}, data: packetValid9, shouldMatch: false}, + {matcher: &MatchRDP{CustomInfoRegexp: ""}, data: packetValid9, shouldMatch: true}, + {matcher: &MatchRDP{CustomInfoRegexp: "^([A-Za-z0-9 ]+)$"}, data: packetValid9, shouldMatch: true}, + {matcher: &MatchRDP{CustomInfoRegexp: "^\\x00\\x01\\x02\\x03\\x04$"}, data: packetValid9, shouldMatch: false}, } ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})