From 13dc5a5d50cfb2fed66920bafc59ec9f04539e77 Mon Sep 17 00:00:00 2001 From: maze Date: Thu, 9 Oct 2025 15:37:30 +0200 Subject: [PATCH] Added dns detector based on miekg/dns --- protocol/detect/dns/detect.go | 151 +++++++++++++++++++++++++++++ protocol/detect/dns/detect_test.go | 115 ++++++++++++++++++++++ protocol/detect/dns/go.mod | 19 ++++ protocol/detect/dns/go.sum | 16 +++ 4 files changed, 301 insertions(+) create mode 100644 protocol/detect/dns/detect.go create mode 100644 protocol/detect/dns/detect_test.go create mode 100644 protocol/detect/dns/go.mod create mode 100644 protocol/detect/dns/go.sum diff --git a/protocol/detect/dns/detect.go b/protocol/detect/dns/detect.go new file mode 100644 index 0000000..23bd96e --- /dev/null +++ b/protocol/detect/dns/detect.go @@ -0,0 +1,151 @@ +package dns + +import ( + "encoding/hex" + "log" + "math" + "strings" + + "github.com/miekg/dns" + + "git.maze.io/go/dpi/protocol" +) + +// Name is the DNS protocol name. +const Name = "dns" + +var ( + classTypeScoreUnknown = -.15 + classTypeScore = map[uint16]float64{ + dns.ClassINET: .1, + dns.ClassCHAOS: .05, + } + recordTypeScoreUnknown = -.15 + recordTypeScore = map[uint16]float64{ + // Common types + dns.TypeA: .2, + dns.TypeAAAA: .2, + dns.TypeCNAME: .2, + dns.TypeMX: .2, + dns.TypeNS: .2, + dns.TypePTR: .2, + dns.TypeSOA: .15, + dns.TypeTXT: .15, + dns.TypeSRV: .15, + dns.TypeCAA: .15, + // Common types related to DNSSEC + dns.TypeDNSKEY: .1, + dns.TypeDS: .1, + dns.TypeNSEC: .1, + dns.TypeNSEC3: .1, + // Rare/obsolete + dns.TypeHINFO: -.1, + dns.TypeNULL: -.1, + } +) + +func init() { + // Every DNS packet (query or answer) has a 12-byte header. + log.Println("register DetectDNS") + protocol.Register(protocol.Both, "????????????", DetectDNS) +} + +func DetectDNS(dir protocol.Direction, data []byte, srcPort, dstPort int) (proto *protocol.Protocol, confidence float64) { + log.Printf("detect dns: %q", hex.EncodeToString(data)) + + // Parsing using miekg/dns + msg := new(dns.Msg) + if msg.Unpack(data) != nil { + return nil, 0 + } + + if srcPort == 53 || dstPort == 53 { + confidence = .1 + } + + // Base confidence for a DNS packet; a lot of things may look like DNS, so our initial + // confidence isn't very high. + confidence += .45 + + if msg.Opcode == dns.OpcodeQuery { + confidence += .1 + } else { + confidence -= .2 + } + + switch msg.Rcode { + case dns.RcodeSuccess: // NOERROR + confidence += .1 + case dns.RcodeNameError: // NXDOMAIN + confidence += .05 + default: + confidence -= .05 + } + + if len(msg.Question) == 1 { + // Contains exactly one question, this is most common. + confidence += .2 + + if score, ok := classTypeScore[msg.Question[0].Qclass]; ok { + confidence += score + } else { + confidence += classTypeScoreUnknown + } + + if score, ok := recordTypeScore[msg.Question[0].Qtype]; ok { + confidence += score + } else { + confidence += recordTypeScoreUnknown + } + } else { + // Contains zero or more than one question, this is very uncommon. + confidence -= .2 + } + + if msg.Response { + if msg.Authoritative { + confidence += .05 + } + + if len(msg.Question) == 1 && len(msg.Answer) > 0 { + var ( + question = msg.Question[0].Name + answer = msg.Answer[0].Header().Name + ) + if strings.EqualFold(question, answer) { + confidence += .1 + } else { + var isCNAME bool + for _, rr := range msg.Answer { + if isCNAME = rr.Header().Rrtype == dns.TypeCNAME; isCNAME { + break + } + } + if isCNAME { + confidence += .05 + } else { + confidence -= .1 + } + } + } + } else { + if len(msg.Answer) > 0 || len(msg.Extra) > 0 { + // This wasn't a reply but we have an answer section anyway + confidence -= .2 + } else { + confidence += .1 + } + } + + // Clip the confidence between [0 .. 0.99]. + confidence = math.Max(confidence, 0) + confidence = math.Min(confidence, .99) + + // We don't have a lower threshold for confidence capping in this function, because + // that is really up to the caller to determine if this wasn't some kind of attempt + // to exfiltrate data using malicious queries, etc. + + return &protocol.Protocol{ + Name: Name, + }, confidence +} diff --git a/protocol/detect/dns/detect_test.go b/protocol/detect/dns/detect_test.go new file mode 100644 index 0000000..f0a1177 --- /dev/null +++ b/protocol/detect/dns/detect_test.go @@ -0,0 +1,115 @@ +package dns + +import ( + "testing" + + "git.maze.io/go/dpi/protocol" +) + +func TestDetectDNS(t *testing.T) { + // A valid DNS query for "www.google.com" (A record) + dnsQuery := []byte{ + 0x12, 0x34, // Transaction ID + 0x01, 0x00, // Flags (Standard Query, Recursion Desired) + 0x00, 0x01, // Questions: 1 + 0x00, 0x00, // Answer RRs: 0 + 0x00, 0x00, // Authority RRs: 0 + 0x00, 0x00, // Additional RRs: 0 + 0x03, 'w', 'w', 'w', // Question section: + 0x06, 'g', 'o', 'o', 'g', 'l', 'e', // + 0x03, 'c', 'o', 'm', // + 0x00, // Null terminator for the name + 0x00, 0x01, // Type: A (Host Address) + 0x00, 0x01, // Class: IN (Internet) + } + + // A valid DNS reply for the above query + dnsReply := []byte{ + 0x12, 0x34, // Transaction ID (matches query) + 0x81, 0x80, // Flags (Response, Recursion Desired, Recursion Available) + 0x00, 0x01, // Questions: 1 + 0x00, 0x01, // Answer RRs: 1 + 0x00, 0x00, // Authority RRs: 0 + 0x00, 0x00, // Additional RRs: 0 + 0x03, 'w', 'w', 'w', // Question section (same as query) + 0x06, 'g', 'o', 'o', 'g', 'l', 'e', // + 0x03, 'c', 'o', 'm', // + 0x00, // + 0x00, 0x01, // + 0x00, 0x01, // + 0xc0, 0x0c, // Answer section. Name: Pointer to the question name at offset 12 (0x0c) + 0x00, 0x01, // Type: A + 0x00, 0x01, // Class: IN + 0x00, 0x00, 0x01, 0x2c, // Time to Live (TTL): 300 seconds + 0x00, 0x04, // Data Length: 4 bytes + 0x08, 0x08, 0x08, 0x08, // RDATA: IP Address 8.8.8.8 + } + + // This is just the first 12 bytes of a DNS query with a single question. + headerWithQuestionPromise := []byte{ + 0x12, 0x34, // Transaction ID + 0x01, 0x00, // Flags (Standard Query) + 0x00, 0x01, // Questions: 1 <-- This promises more data! + 0x00, 0x00, // Answer RRs: 0 + 0x00, 0x00, // Authority RRs: 0 + 0x00, 0x00, // Additional RRs: 0 + } + + // This is a valid, self-contained "empty" DNS message. + headerWithNoPromise := []byte{ + 0x56, 0x78, // Transaction ID + 0x01, 0x00, // Flags (Standard Query) + 0x00, 0x00, // Questions: 0 <-- This promises NO more data. + 0x00, 0x00, // Answer RRs: 0 + 0x00, 0x00, // Authority RRs: 0 + 0x00, 0x00, // Additional RRs: 0 + } + + t.Run("query", func(t *testing.T) { + p, c, err := protocol.Detect(protocol.Both, dnsQuery, 1234, 53) + if err != nil { + t.Fatalf("unexpected error: %v", err) + return + } + t.Logf("detected %s confidence %g%%", p.Name, c*100) + if p.Name != Name { + t.Errorf("expected %q protocol, got %q", Name, p.Name) + } + }) + + t.Run("answer", func(t *testing.T) { + p, c, err := protocol.Detect(protocol.Both, dnsReply, 53, 1234) + if err != nil { + t.Fatalf("unexpected error: %v", err) + return + } + t.Logf("detected %s confidence %g%%", p.Name, c*100) + if p.Name != Name { + t.Errorf("expected %q protocol, got %q", Name, p.Name) + } + }) + + t.Run("header with no promise", func(t *testing.T) { + p, c, err := protocol.Detect(protocol.Both, headerWithNoPromise, 1234, 53) + if err != nil { + t.Fatalf("unexpected error: %v", err) + return + } + t.Logf("detected %s confidence %g%%", p.Name, c*100) + if p.Name != Name { + t.Errorf("expected %q protocol, got %q", Name, p.Name) + } + }) + + t.Run("header with question promise", func(t *testing.T) { + p, c, err := protocol.Detect(protocol.Both, headerWithQuestionPromise, 1234, 53) + if err != nil { + t.Fatalf("unexpected error: %v", err) + return + } + t.Logf("detected %s confidence %g%%", p.Name, c*100) + if p.Name != Name { + t.Errorf("expected %q protocol, got %q", Name, p.Name) + } + }) +} diff --git a/protocol/detect/dns/go.mod b/protocol/detect/dns/go.mod new file mode 100644 index 0000000..32f8ac7 --- /dev/null +++ b/protocol/detect/dns/go.mod @@ -0,0 +1,19 @@ +module git.maze.io/go/dpi/protocol/detect/dns + +go 1.25.0 + +replace git.maze.io/go/dpi => ../../.. + +require ( + git.maze.io/go/dpi v0.0.0-20251009103949-31774b961d0d + github.com/miekg/dns v1.1.68 + golang.org/x/crypto v0.42.0 +) + +require ( + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/tools v0.33.0 // indirect +) diff --git a/protocol/detect/dns/go.sum b/protocol/detect/dns/go.sum new file mode 100644 index 0000000..2301f51 --- /dev/null +++ b/protocol/detect/dns/go.sum @@ -0,0 +1,16 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=