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