Added dns detector based on miekg/dns
This commit is contained in:
151
protocol/detect/dns/detect.go
Normal file
151
protocol/detect/dns/detect.go
Normal file
@@ -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
|
||||||
|
}
|
115
protocol/detect/dns/detect_test.go
Normal file
115
protocol/detect/dns/detect_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
19
protocol/detect/dns/go.mod
Normal file
19
protocol/detect/dns/go.mod
Normal file
@@ -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
|
||||||
|
)
|
16
protocol/detect/dns/go.sum
Normal file
16
protocol/detect/dns/go.sum
Normal file
@@ -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=
|
Reference in New Issue
Block a user