diff --git a/detection/dga/lms.go b/detection/dga/lms.go index d827e546..50773a5f 100644 --- a/detection/dga/lms.go +++ b/detection/dga/lms.go @@ -4,16 +4,13 @@ import ( "strings" ) -// LmsScoreOfDomain calculates the mean longest meaningful substring of a domain. It follows some special rules to increase accuracy. It returns a value between 0 and 100, representing the length-based percentage of the meaningful substring. +// LmsScoreOfDomain calculates the mean longest meaningful substring of a domain. +// It follows some special rules to increase accuracy. It returns a value between +// 0 and 100, representing the length-based percentage of the meaningful substring. func LmsScoreOfDomain(domain string) float64 { var totalScore float64 domain = strings.ToLower(domain) subjects := strings.Split(domain, ".") - // ignore the last two parts - if len(subjects) <= 3 { - return 100 - } - subjects = subjects[:len(subjects)-3] var totalLength int for _, subject := range subjects { totalLength += len(subject) @@ -27,7 +24,9 @@ func LmsScoreOfDomain(domain string) float64 { return totalScore } -// LmsScore calculates the longest meaningful substring of a domain. It returns a value between 0 and 100, representing the length-based percentage of the meaningful substring. +// LmsScore calculates the longest meaningful substring of a domain. It returns a +// value between 0 and 100, representing the length-based percentage of the +// meaningful substring. func LmsScore(subject string) float64 { lmsStart := -1 lmsStop := -1 diff --git a/detection/dga/lms_test.go b/detection/dga/lms_test.go index c4799f7e..9421550b 100644 --- a/detection/dga/lms_test.go +++ b/detection/dga/lms_test.go @@ -5,8 +5,8 @@ import "testing" func TestLmsScoreOfDomain(t *testing.T) { testDomain(t, "g.symcd.com.", 100, 100) testDomain(t, "www.google.com.", 100, 100) - testDomain(t, "55ttt5.12abc3.test.com.", 50, 50) - testDomain(t, "mbtq6opnuodp34gcrma65fxacgxv5ukr7lq6xuhr4mhoibe7.yvqptrozfbnqyemchpovw3q5xwjibuxfsgb72mix3znhpfhc.i2n7jh2gadqaadck3zs3vg3hbv5pkmwzeay4gc75etyettbb.isi5mhmowtfriu33uxzmgvjur5g2p3tloynwohfrggee6fkn.meop7kqyd5gwxxa3.er.spotify.com.", 0, 30) + testDomain(t, "55ttt5.12abc3.test.com.", 68, 69) + testDomain(t, "mbtq6opnuodp34gcrma65fxacgxv5ukr7lq6xuhr4mhoibe7.yvqptrozfbnqyemchpovw3q5xwjibuxfsgb72mix3znhpfhc.i2n7jh2gadqaadck3zs3vg3hbv5pkmwzeay4gc75etyettbb.isi5mhmowtfriu33uxzmgvjur5g2p3tloynwohfrggee6fkn.meop7kqyd5gwxxa3.er.spotify.com.", 0, 31) } func testDomain(t *testing.T, domain string, min, max float64) { diff --git a/firewall/master.go b/firewall/master.go index 7f0eb18b..a662663c 100644 --- a/firewall/master.go +++ b/firewall/master.go @@ -9,6 +9,7 @@ import ( "github.com/safing/portmaster/detection/dga" "github.com/safing/portmaster/netenv" + "golang.org/x/net/publicsuffix" "github.com/safing/portbase/log" "github.com/safing/portmaster/network" @@ -59,7 +60,7 @@ func DecideOnConnection(ctx context.Context, conn *network.Connection, pkt packe checkBypassPrevention, checkFilterLists, checkInbound, - checkLMSScore, + checkDomainHeuristics, checkDefaultPermit, checkAutoPermitRelated, checkDefaultAction, @@ -283,19 +284,63 @@ func checkFilterLists(ctx context.Context, conn *network.Connection, pkt packet. return false } -func checkLMSScore(ctx context.Context, conn *network.Connection, _ packet.Packet) bool { +func checkDomainHeuristics(ctx context.Context, conn *network.Connection, _ packet.Packet) bool { + p := conn.Process().Profile() + + if !p.DomainHeuristics() { + return false + } + if conn.Entity.Domain == "" { return false } - // check for possible DNS tunneling / data transmission - lms := dga.LmsScoreOfDomain(conn.Entity.Domain) - if lms < 10 { - log.Tracer(ctx).Warningf("nameserver: possible data tunnel by %s: %s has lms score of %f, returning nxdomain", conn.Process(), conn.Entity.Domain, lms) - conn.BlockWithContext("Possible data tunnel", conn.ReasonContext) + trimmedDomain := strings.TrimRight(conn.Entity.Domain, ".") + etld1, err := publicsuffix.EffectiveTLDPlusOne(trimmedDomain) + if err != nil { + // we don't apply any checks here and let the request through + // because a malformed domain-name will likely be dropped by + // checks better suited for that. + log.Tracer(ctx).Warningf("nameserver: failed to get eTLD+1: %s", err) + return false + } + domainToCheck := strings.Split(etld1, ".")[0] + score := dga.LmsScore(domainToCheck) + if score < 5 { + log.Tracer(ctx).Warningf( + "nameserver: possible data tunnel by %s in eTLD+1 %s: %s has an lms score of %.2f, returning nxdomain", + conn.Process(), + etld1, + domainToCheck, + score, + ) + conn.Block("Possible data tunnel") return true } + log.Tracer(ctx).Infof("LMS score of eTLD+1 %s is %.2f", etld1, score) + + // 100 is a somewhat arbitrary threshold to ensure we don't mess + // around with CDN domain names to early. They use short second-level + // domains that would trigger LMS checks but are to small to actually + // exfiltrate data. + if len(conn.Entity.Domain) > len(etld1)+100 { + domainToCheck = trimmedDomain[0:len(etld1)] + score := dga.LmsScoreOfDomain(domainToCheck) + if score < 10 { + log.Tracer(ctx).Warningf( + "nameserver: possible data tunnel by %s in subdomain %s: %s has an lms score of %.2f, returning nxdomain", + conn.Process(), + conn.Entity.Domain, + domainToCheck, + score, + ) + conn.Block("Possible data tunnel") + return true + } + log.Tracer(ctx).Infof("LMS score of entire domain is %.2f", score) + } + return false } diff --git a/profile/config.go b/profile/config.go index b9516ce5..2095a893 100644 --- a/profile/config.go +++ b/profile/config.go @@ -80,6 +80,10 @@ var ( cfgOptionRemoveBlockedDNS config.IntOption // security level option cfgOptionRemoveBlockedDNSOrder = 113 + CfgOptionDomainHeuristicsKey = "filter/domainHeuristics" + cfgOptionDomainHeuristics config.IntOption // security level option + cfgOptionDomainHeuristicsOrder = 114 + // Permanent Verdicts Order = 128 ) @@ -378,6 +382,24 @@ Examples: cfgOptionRemoveBlockedDNS = config.Concurrent.GetAsInt(CfgOptionRemoveBlockedDNSKey, int64(status.SecurityLevelsAll)) cfgIntOptions[CfgOptionRemoveBlockedDNSKey] = cfgOptionRemoveBlockedDNS + // Domain heuristics + err = config.Register(&config.Option{ + Name: "Enable Domain Heuristics", + Key: CfgOptionDomainHeuristicsKey, + Description: "Domain Heuristics checks for suspicious looking domain names and blocks them. Ths option currently targets domains generated by malware and DNS data tunnels.", + Order: cfgOptionDomainHeuristicsOrder, + OptType: config.OptTypeInt, + ExpertiseLevel: config.ExpertiseLevelExpert, + ExternalOptType: "security level", + DefaultValue: status.SecurityLevelsAll, + ValidationRegex: "^(0|4|6|7)$", + }) + if err != nil { + return err + } + cfgOptionDomainHeuristics = config.Concurrent.GetAsInt(CfgOptionDomainHeuristicsKey, int64(status.SecurityLevelsAll)) + + // Bypass prevention err = config.Register(&config.Option{ Name: "Prevent Bypassing", Key: CfgOptionPreventBypassingKey, diff --git a/profile/profile-layered.go b/profile/profile-layered.go index ab0335a2..9a5e0d50 100644 --- a/profile/profile-layered.go +++ b/profile/profile-layered.go @@ -45,6 +45,7 @@ type LayeredProfile struct { FilterSubDomains config.BoolOption FilterCNAMEs config.BoolOption PreventBypassing config.BoolOption + DomainHeuristics config.BoolOption } // NewLayeredProfile returns a new layered profile based on the given local profile. @@ -108,6 +109,10 @@ func NewLayeredProfile(localProfile *Profile) *LayeredProfile { CfgOptionPreventBypassingKey, cfgOptionPreventBypassing, ) + new.DomainHeuristics = new.wrapSecurityLevelOption( + CfgOptionDomainHeuristicsKey, + cfgOptionDomainHeuristics, + ) // TODO: load linked profiles.