Move LMS scoring under new Domain Heuristics

This commit is contained in:
Patrick Pacher 2020-08-11 10:27:16 +02:00
parent 85e4beafa1
commit 3b896ee892
No known key found for this signature in database
GPG key ID: E8CD2DA160925A6D
5 changed files with 87 additions and 16 deletions

View file

@ -4,16 +4,13 @@ import (
"strings" "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 { func LmsScoreOfDomain(domain string) float64 {
var totalScore float64 var totalScore float64
domain = strings.ToLower(domain) domain = strings.ToLower(domain)
subjects := strings.Split(domain, ".") subjects := strings.Split(domain, ".")
// ignore the last two parts
if len(subjects) <= 3 {
return 100
}
subjects = subjects[:len(subjects)-3]
var totalLength int var totalLength int
for _, subject := range subjects { for _, subject := range subjects {
totalLength += len(subject) totalLength += len(subject)
@ -27,7 +24,9 @@ func LmsScoreOfDomain(domain string) float64 {
return totalScore 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 { func LmsScore(subject string) float64 {
lmsStart := -1 lmsStart := -1
lmsStop := -1 lmsStop := -1

View file

@ -5,8 +5,8 @@ import "testing"
func TestLmsScoreOfDomain(t *testing.T) { func TestLmsScoreOfDomain(t *testing.T) {
testDomain(t, "g.symcd.com.", 100, 100) testDomain(t, "g.symcd.com.", 100, 100)
testDomain(t, "www.google.com.", 100, 100) testDomain(t, "www.google.com.", 100, 100)
testDomain(t, "55ttt5.12abc3.test.com.", 50, 50) testDomain(t, "55ttt5.12abc3.test.com.", 68, 69)
testDomain(t, "mbtq6opnuodp34gcrma65fxacgxv5ukr7lq6xuhr4mhoibe7.yvqptrozfbnqyemchpovw3q5xwjibuxfsgb72mix3znhpfhc.i2n7jh2gadqaadck3zs3vg3hbv5pkmwzeay4gc75etyettbb.isi5mhmowtfriu33uxzmgvjur5g2p3tloynwohfrggee6fkn.meop7kqyd5gwxxa3.er.spotify.com.", 0, 30) testDomain(t, "mbtq6opnuodp34gcrma65fxacgxv5ukr7lq6xuhr4mhoibe7.yvqptrozfbnqyemchpovw3q5xwjibuxfsgb72mix3znhpfhc.i2n7jh2gadqaadck3zs3vg3hbv5pkmwzeay4gc75etyettbb.isi5mhmowtfriu33uxzmgvjur5g2p3tloynwohfrggee6fkn.meop7kqyd5gwxxa3.er.spotify.com.", 0, 31)
} }
func testDomain(t *testing.T, domain string, min, max float64) { func testDomain(t *testing.T, domain string, min, max float64) {

View file

@ -9,6 +9,7 @@ import (
"github.com/safing/portmaster/detection/dga" "github.com/safing/portmaster/detection/dga"
"github.com/safing/portmaster/netenv" "github.com/safing/portmaster/netenv"
"golang.org/x/net/publicsuffix"
"github.com/safing/portbase/log" "github.com/safing/portbase/log"
"github.com/safing/portmaster/network" "github.com/safing/portmaster/network"
@ -59,7 +60,7 @@ func DecideOnConnection(ctx context.Context, conn *network.Connection, pkt packe
checkBypassPrevention, checkBypassPrevention,
checkFilterLists, checkFilterLists,
checkInbound, checkInbound,
checkLMSScore, checkDomainHeuristics,
checkDefaultPermit, checkDefaultPermit,
checkAutoPermitRelated, checkAutoPermitRelated,
checkDefaultAction, checkDefaultAction,
@ -283,19 +284,63 @@ func checkFilterLists(ctx context.Context, conn *network.Connection, pkt packet.
return false 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 == "" { if conn.Entity.Domain == "" {
return false return false
} }
// check for possible DNS tunneling / data transmission trimmedDomain := strings.TrimRight(conn.Entity.Domain, ".")
lms := dga.LmsScoreOfDomain(conn.Entity.Domain) etld1, err := publicsuffix.EffectiveTLDPlusOne(trimmedDomain)
if lms < 10 { if err != nil {
log.Tracer(ctx).Warningf("nameserver: possible data tunnel by %s: %s has lms score of %f, returning nxdomain", conn.Process(), conn.Entity.Domain, lms) // we don't apply any checks here and let the request through
conn.BlockWithContext("Possible data tunnel", conn.ReasonContext) // 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 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 return false
} }

View file

@ -80,6 +80,10 @@ var (
cfgOptionRemoveBlockedDNS config.IntOption // security level option cfgOptionRemoveBlockedDNS config.IntOption // security level option
cfgOptionRemoveBlockedDNSOrder = 113 cfgOptionRemoveBlockedDNSOrder = 113
CfgOptionDomainHeuristicsKey = "filter/domainHeuristics"
cfgOptionDomainHeuristics config.IntOption // security level option
cfgOptionDomainHeuristicsOrder = 114
// Permanent Verdicts Order = 128 // Permanent Verdicts Order = 128
) )
@ -378,6 +382,24 @@ Examples:
cfgOptionRemoveBlockedDNS = config.Concurrent.GetAsInt(CfgOptionRemoveBlockedDNSKey, int64(status.SecurityLevelsAll)) cfgOptionRemoveBlockedDNS = config.Concurrent.GetAsInt(CfgOptionRemoveBlockedDNSKey, int64(status.SecurityLevelsAll))
cfgIntOptions[CfgOptionRemoveBlockedDNSKey] = cfgOptionRemoveBlockedDNS 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{ err = config.Register(&config.Option{
Name: "Prevent Bypassing", Name: "Prevent Bypassing",
Key: CfgOptionPreventBypassingKey, Key: CfgOptionPreventBypassingKey,

View file

@ -45,6 +45,7 @@ type LayeredProfile struct {
FilterSubDomains config.BoolOption FilterSubDomains config.BoolOption
FilterCNAMEs config.BoolOption FilterCNAMEs config.BoolOption
PreventBypassing config.BoolOption PreventBypassing config.BoolOption
DomainHeuristics config.BoolOption
} }
// NewLayeredProfile returns a new layered profile based on the given local profile. // NewLayeredProfile returns a new layered profile based on the given local profile.
@ -108,6 +109,10 @@ func NewLayeredProfile(localProfile *Profile) *LayeredProfile {
CfgOptionPreventBypassingKey, CfgOptionPreventBypassingKey,
cfgOptionPreventBypassing, cfgOptionPreventBypassing,
) )
new.DomainHeuristics = new.wrapSecurityLevelOption(
CfgOptionDomainHeuristicsKey,
cfgOptionDomainHeuristics,
)
// TODO: load linked profiles. // TODO: load linked profiles.