diff --git a/modules/dns_proxy/dns_proxy.go b/modules/dns_proxy/dns_proxy.go new file mode 100644 index 00000000..e737504b --- /dev/null +++ b/modules/dns_proxy/dns_proxy.go @@ -0,0 +1,124 @@ +package dns_proxy + +import ( + "github.com/bettercap/bettercap/v2/session" +) + +type DnsProxy struct { + session.SessionModule + proxy *DNSProxy +} + +func (mod *DnsProxy) Author() string { + return "Yarwin Kolff <@buffermet>" +} + +func (mod *DnsProxy) Configure() error { + var err error + var address string + var dnsPort int + var doRedirect bool + var nameserver string + var netProtocol string + var proxyPort int + var scriptPath string + + if mod.Running() { + return session.ErrAlreadyStarted(mod.Name()) + } else if err, dnsPort = mod.IntParam("dns.port"); err != nil { + return err + } else if err, address = mod.StringParam("dns.proxy.address"); err != nil { + return err + } else if err, nameserver = mod.StringParam("dns.proxy.nameserver"); err != nil { + return err + } else if err, netProtocol = mod.StringParam("dns.proxy.networkprotocol"); err != nil { + return err + } else if err, proxyPort = mod.IntParam("dns.proxy.port"); err != nil { + return err + } else if err, doRedirect = mod.BoolParam("dns.proxy.redirect"); err != nil { + return err + } else if err, scriptPath = mod.StringParam("dns.proxy.script"); err != nil { + return err + } + + error := mod.proxy.Configure(address, dnsPort, doRedirect, nameserver, netProtocol, proxyPort, scriptPath) + + return error +} + +func (mod *DnsProxy) Description() string { + return "A full featured DNS proxy that can be used to manipulate DNS traffic." +} + +func (mod *DnsProxy) Name() string { + return "dns.proxy" +} + +func NewDnsProxy(s *session.Session) *DnsProxy { + mod := &DnsProxy{ + SessionModule: session.NewSessionModule("dns.proxy", s), + proxy: NewDNSProxy(s, "dns.proxy"), + } + + mod.AddParam(session.NewIntParameter("dns.port", + "53", + "DNS port to redirect when the proxy is activated.")) + + mod.AddParam(session.NewStringParameter("dns.proxy.address", + session.ParamIfaceAddress, + session.IPv4Validator, + "Address to bind the DNS proxy to.")) + + mod.AddParam(session.NewStringParameter("dns.proxy.nameserver", + "1.1.1.1", + session.IPv4Validator, + "DNS resolver address.")) + + mod.AddParam(session.NewStringParameter("dns.proxy.networkprotocol", + "udp", + "^(udp|tcp|tcp-tls)$", + "Network protocol for the DNS proxy server to use. Accepted values: udp, tcp, tcp-tls")) + + mod.AddParam(session.NewIntParameter("dns.proxy.port", + "8053", + "Port to bind the DNS proxy to.")) + + mod.AddParam(session.NewBoolParameter("dns.proxy.redirect", + "true", + "Enable or disable port redirection with iptables.")) + + mod.AddParam(session.NewStringParameter("dns.proxy.script", + "", + "", + "Path of a JS proxy script.")) + + mod.AddHandler(session.NewModuleHandler("dns.proxy on", "", + "Start the DNS proxy.", + func(args []string) error { + return mod.Start() + })) + + mod.AddHandler(session.NewModuleHandler("dns.proxy off", "", + "Stop the DNS proxy.", + func(args []string) error { + return mod.Stop() + })) + + return mod +} + +func (mod *DnsProxy) Start() error { + if err := mod.Configure(); err != nil { + return err + } + + return mod.SetRunning(true, func() { + mod.proxy.Start() + }) +} + +func (mod *DnsProxy) Stop() error { + return mod.SetRunning(false, func() { + mod.proxy.Stop() + }) +} diff --git a/modules/dns_proxy/dns_proxy_base.go b/modules/dns_proxy/dns_proxy_base.go new file mode 100644 index 00000000..1c619e95 --- /dev/null +++ b/modules/dns_proxy/dns_proxy_base.go @@ -0,0 +1,194 @@ +package dns_proxy + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/bettercap/bettercap/v2/firewall" + "github.com/bettercap/bettercap/v2/session" + "github.com/evilsocket/islazy/log" + + "github.com/miekg/dns" +) + +const ( + dialTimeout = 2 * time.Second + readTimeout = 2 * time.Second + writeTimeout = 2 * time.Second +) + +type DNSProxy struct { + Address string + Name string + Nameserver string + NetProtocol string + Redirection *firewall.Redirection + Script *DnsProxyScript + Server *dns.Server + Sess *session.Session + + doRedirect bool + isRunning bool + tag string +} + +func (p *DNSProxy) Configure(address string, dnsPort int, doRedirect bool, nameserver string, netProtocol string, proxyPort int, scriptPath string) error { + var err error + + p.Address = address + p.doRedirect = doRedirect + + if scriptPath != "" { + if err, p.Script = LoadDnsProxyScript(scriptPath, p.Sess); err != nil { + return err + } else { + p.Debug("proxy script %s loaded.", scriptPath) + } + } else { + p.Script = nil + } + + dnsClient := dns.Client{ + DialTimeout: dialTimeout, + Net: netProtocol, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + } + + resolverAddr := fmt.Sprintf("%s:%d", nameserver, dnsPort) + + handler := dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + m.SetReply(req) + + clientIP := strings.Split(w.RemoteAddr().String(), ":")[0] + + req, res := p.onRequestFilter(req, clientIP) + if res == nil { + // unused var is time til res + res, _, err := dnsClient.Exchange(req, resolverAddr) + if err != nil { + p.Debug("error while resolving DNS query: %s", err.Error()) + m.SetRcode(req, dns.RcodeServerFailure) + w.WriteMsg(m) + return + } + res = p.onResponseFilter(req, res, clientIP) + if res == nil { + p.Error("response filter returned nil") + m.SetRcode(req, dns.RcodeServerFailure) + w.WriteMsg(m) + } else { + if err := w.WriteMsg(res); err != nil { + p.Error("Error writing response: %s", err) + } + } + } else { + if err := w.WriteMsg(res); err != nil { + p.Error("Error writing response: %s", err) + } + } + }) + + p.Server = &dns.Server{ + Addr: fmt.Sprintf("%s:%d", address, proxyPort), + Net: netProtocol, + Handler: handler, + } + + if p.doRedirect { + if !p.Sess.Firewall.IsForwardingEnabled() { + p.Info("enabling forwarding.") + p.Sess.Firewall.EnableForwarding(true) + } + + p.Redirection = firewall.NewRedirection(p.Sess.Interface.Name(), + netProtocol, + dnsPort, + p.Address, + proxyPort) + + if err := p.Sess.Firewall.EnableRedirection(p.Redirection, true); err != nil { + return err + } + + p.Debug("applied redirection %s", p.Redirection.String()) + } else { + p.Warning("port redirection disabled, the proxy must be set manually to work") + } + + p.Sess.UnkCmdCallback = func(cmd string) bool { + if p.Script != nil { + return p.Script.OnCommand(cmd) + } + return false + } + return nil +} + +func (p *DNSProxy) dnsWorker() error { + p.isRunning = true + return p.Server.ListenAndServe() +} + +func (p *DNSProxy) Debug(format string, args ...interface{}) { + p.Sess.Events.Log(log.DEBUG, p.tag+format, args...) +} + +func (p *DNSProxy) Info(format string, args ...interface{}) { + p.Sess.Events.Log(log.INFO, p.tag+format, args...) +} + +func (p *DNSProxy) Warning(format string, args ...interface{}) { + p.Sess.Events.Log(log.WARNING, p.tag+format, args...) +} + +func (p *DNSProxy) Error(format string, args ...interface{}) { + p.Sess.Events.Log(log.ERROR, p.tag+format, args...) +} + +func (p *DNSProxy) Fatal(format string, args ...interface{}) { + p.Sess.Events.Log(log.FATAL, p.tag+format, args...) +} + +func NewDNSProxy(s *session.Session, tag string) *DNSProxy { + p := &DNSProxy{ + Name: "dns.proxy", + Sess: s, + Server: nil, + doRedirect: true, + tag: session.AsTag(tag), + } + + return p +} + +func (p *DNSProxy) Start() { + go func() { + p.Info("started on %s", p.Server.Addr) + + err := p.dnsWorker() + // TODO: check the dns server closed error + if err != nil && err.Error() != "dns: Server closed" { + p.Fatal("%s", err) + } + }() +} + +func (p *DNSProxy) Stop() error { + if p.doRedirect && p.Redirection != nil { + p.Debug("disabling redirection %s", p.Redirection.String()) + if err := p.Sess.Firewall.EnableRedirection(p.Redirection, false); err != nil { + return err + } + p.Redirection = nil + } + + p.Sess.UnkCmdCallback = nil + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return p.Server.ShutdownContext(ctx) +} diff --git a/modules/dns_proxy/dns_proxy_base_filters.go b/modules/dns_proxy/dns_proxy_base_filters.go new file mode 100644 index 00000000..5255b608 --- /dev/null +++ b/modules/dns_proxy/dns_proxy_base_filters.go @@ -0,0 +1,86 @@ +package dns_proxy + +import ( + "strings" + + "github.com/miekg/dns" +) + +func shortenResourceRecords(records []string) []string { + shorterRecords := []string{} + for _, record := range records { + shorterRecord := strings.ReplaceAll(record, "\t", " ") + shorterRecords = append(shorterRecords, shorterRecord) + } + return shorterRecords +} + +func (p *DNSProxy) logRequestAction(j *JSQuery) { + p.Sess.Events.Add(p.Name+".spoofed-request", struct { + Client string + Questions []string + }{ + j.Client["IP"], + shortenResourceRecords(j.Questions), + }) +} + +func (p *DNSProxy) logResponseAction(j *JSQuery) { + p.Sess.Events.Add(p.Name+".spoofed-response", struct { + client string + Extras []string + Answers []string + Nameservers []string + Questions []string + }{ + j.Client["IP"], + shortenResourceRecords(j.Extras), + shortenResourceRecords(j.Answers), + shortenResourceRecords(j.Nameservers), + shortenResourceRecords(j.Questions), + }) +} + +func (p *DNSProxy) onRequestFilter(query *dns.Msg, clientIP string) (req, res *dns.Msg) { + p.Debug("< %s %s", clientIP, query.Question) + + // do we have a proxy script? + if p.Script == nil { + return query, nil + } + + // run the module OnRequest callback if defined + jsreq, jsres := p.Script.OnRequest(query, clientIP) + if jsreq != nil { + // the request has been changed by the script + p.logRequestAction(jsreq) + return jsreq.ToQuery(), nil + } else if jsres != nil { + // a fake response has been returned by the script + p.logResponseAction(jsres) + return query, jsres.ToQuery() + } + + return query, nil +} + +func (p *DNSProxy) onResponseFilter(req, res *dns.Msg, clientIP string) *dns.Msg { + // sometimes it happens ¯\_(ツ)_/¯ + if res == nil { + return nil + } + + p.Debug("> %s %s", clientIP, res.Answer) + + // do we have a proxy script? + if p.Script != nil { + _, jsres := p.Script.OnResponse(req, res, clientIP) + if jsres != nil { + // the response has been changed by the script + p.logResponseAction(jsres) + return jsres.ToQuery() + } + } + + return res +} diff --git a/modules/dns_proxy/dns_proxy_js_query.go b/modules/dns_proxy/dns_proxy_js_query.go new file mode 100644 index 00000000..a13a6ef6 --- /dev/null +++ b/modules/dns_proxy/dns_proxy_js_query.go @@ -0,0 +1,231 @@ +package dns_proxy + +import ( + "fmt" + "regexp" + "strings" + + "github.com/bettercap/bettercap/v2/log" + "github.com/bettercap/bettercap/v2/session" + + "github.com/miekg/dns" +) + +var whiteSpaceRegexp = regexp.MustCompile(`\s+`) +var stripWhiteSpaceRegexp = regexp.MustCompile(`^\s*(.*?)\s*$`) + +type JSQuery struct { + Answers []string + Client map[string]string + Compress bool `json:"-"` + Extras []string + Header *JSQueryHeader + Nameservers []string + Questions []string + + refHash string +} + +type JSQueryHeader struct { + AuthenticatedData bool + Authoritative bool + CheckingDisabled bool + Id uint16 + Opcode int + Rcode int + RecursionAvailable bool + RecursionDesired bool + Response bool + Truncated bool + Zero bool +} + +func (j *JSQuery) NewHash() string { + headerHash := fmt.Sprintf("%t.%t.%t.%d.%d.%d.%t.%t.%t.%t.%t", + j.Header.AuthenticatedData, + j.Header.Authoritative, + j.Header.CheckingDisabled, + j.Header.Id, + j.Header.Opcode, + j.Header.Rcode, + j.Header.RecursionAvailable, + j.Header.RecursionDesired, + j.Header.Response, + j.Header.Truncated, + j.Header.Zero) + hash := fmt.Sprintf("%s.%s.%t.%s.%s.%s.%s", + strings.Join(j.Answers, ""), + j.Client["IP"], + j.Compress, + strings.Join(j.Extras, ""), + headerHash, + strings.Join(j.Nameservers, ""), + strings.Join(j.Questions, "")) + return hash +} + +func NewJSQuery(query *dns.Msg, clientIP string) *JSQuery { + answers := []string{} + extras := []string{} + nameservers := []string{} + questions := []string{} + + header := &JSQueryHeader{ + AuthenticatedData: query.MsgHdr.AuthenticatedData, + Authoritative: query.MsgHdr.Authoritative, + CheckingDisabled: query.MsgHdr.CheckingDisabled, + Id: query.MsgHdr.Id, + Opcode: query.MsgHdr.Opcode, + Rcode: query.MsgHdr.Rcode, + RecursionAvailable: query.MsgHdr.RecursionAvailable, + RecursionDesired: query.MsgHdr.RecursionDesired, + Response: query.MsgHdr.Response, + Truncated: query.MsgHdr.Truncated, + Zero: query.MsgHdr.Zero, + } + + for _, rr := range query.Answer { + answers = append(answers, rr.String()) + } + for _, rr := range query.Extra { + extras = append(extras, rr.String()) + } + for _, rr := range query.Ns { + nameservers = append(nameservers, rr.String()) + } + for _, q := range query.Question { + qType := dns.Type(q.Qtype).String() + qClass := dns.Class(q.Qclass).String() + questions = append(questions, fmt.Sprintf("%s\t%s\t%s", q.Name, qClass, qType)) + } + + clientMAC := "" + clientAlias := "" + if endpoint := session.I.Lan.GetByIp(clientIP); endpoint != nil { + clientMAC = endpoint.HwAddress + clientAlias = endpoint.Alias + } + client := map[string]string{"IP": clientIP, "MAC": clientMAC, "Alias": clientAlias} + + jsquery := &JSQuery{ + Answers: answers, + Client: client, + Compress: query.Compress, + Extras: extras, + Header: header, + Nameservers: nameservers, + Questions: questions, + } + jsquery.UpdateHash() + + return jsquery +} + +func stringToClass(s string) (uint16, error) { + for i, dnsClass := range dns.ClassToString { + if s == dnsClass { + return i, nil + } + } + return 0, fmt.Errorf("unkown DNS class (got %s)", s) +} + +func stringToType(s string) (uint16, error) { + for i, dnsType := range dns.TypeToString { + if s == dnsType { + return i, nil + } + } + return 0, fmt.Errorf("unkown DNS type (got %s)", s) +} + +func (j *JSQuery) ToQuery() *dns.Msg { + var answers []dns.RR + var extras []dns.RR + var nameservers []dns.RR + var questions []dns.Question + + for _, s := range j.Answers { + rr, err := dns.NewRR(s) + if err != nil { + log.Error("error parsing DNS answer resource record: %s", err.Error()) + return nil + } else { + answers = append(answers, rr) + } + } + for _, s := range j.Extras { + rr, err := dns.NewRR(s) + if err != nil { + log.Error("error parsing DNS extra resource record: %s", err.Error()) + return nil + } else { + extras = append(extras, rr) + } + } + for _, s := range j.Nameservers { + rr, err := dns.NewRR(s) + if err != nil { + log.Error("error parsing DNS nameserver resource record: %s", err.Error()) + return nil + } else { + nameservers = append(nameservers, rr) + } + } + + for _, s := range j.Questions { + qStripped := stripWhiteSpaceRegexp.FindStringSubmatch(s) + qParts := whiteSpaceRegexp.Split(qStripped[1], -1) + + if len(qParts) != 3 { + log.Error("invalid DNS question format: (got %s)", s) + return nil + } + + qName := dns.Fqdn(qParts[0]) + qClass, err := stringToClass(qParts[1]) + if err != nil { + log.Error("error parsing DNS question class: %s", err.Error()) + return nil + } + qType, err := stringToType(qParts[2]) + if err != nil { + log.Error("error parsing DNS question type: %s", err.Error()) + return nil + } + + questions = append(questions, dns.Question{qName, qType, qClass}) + } + + query := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: j.Header.Id, + Response: j.Header.Response, + Opcode: j.Header.Opcode, + Authoritative: j.Header.Authoritative, + Truncated: j.Header.Truncated, + RecursionDesired: j.Header.RecursionDesired, + RecursionAvailable: j.Header.RecursionAvailable, + Zero: j.Header.Zero, + AuthenticatedData: j.Header.AuthenticatedData, + CheckingDisabled: j.Header.CheckingDisabled, + Rcode: j.Header.Rcode, + }, + Compress: j.Compress, + Question: questions, + Answer: answers, + Ns: nameservers, + Extra: extras, + } + + return query +} + +func (j *JSQuery) UpdateHash() { + j.refHash = j.NewHash() +} + +func (j *JSQuery) WasModified() bool { + // check if any of the fields has been changed + return j.NewHash() != j.refHash +} diff --git a/modules/dns_proxy/dns_proxy_script.go b/modules/dns_proxy/dns_proxy_script.go new file mode 100644 index 00000000..aa2c9d5f --- /dev/null +++ b/modules/dns_proxy/dns_proxy_script.go @@ -0,0 +1,99 @@ +package dns_proxy + +import ( + "github.com/bettercap/bettercap/v2/log" + "github.com/bettercap/bettercap/v2/session" + "github.com/evilsocket/islazy/plugin" + + "github.com/miekg/dns" + + "github.com/robertkrimen/otto" +) + +type DnsProxyScript struct { + *plugin.Plugin + + doOnRequest bool + doOnResponse bool + doOnCommand bool +} + +func LoadDnsProxyScript(path string, sess *session.Session) (err error, s *DnsProxyScript) { + log.Debug("loading proxy script %s ...", path) + + plug, err := plugin.Load(path) + if err != nil { + return + } + + // define session pointer + if err = plug.Set("env", sess.Env.Data); err != nil { + log.Error("Error while defining environment: %+v", err) + return + } + + // run onLoad if defined + if plug.HasFunc("onLoad") { + if _, err = plug.Call("onLoad"); err != nil { + log.Error("Error while executing onLoad callback: %s", "\nTraceback:\n "+err.(*otto.Error).String()) + return + } + } + + s = &DnsProxyScript{ + Plugin: plug, + doOnRequest: plug.HasFunc("onRequest"), + doOnResponse: plug.HasFunc("onResponse"), + doOnCommand: plug.HasFunc("onCommand"), + } + return +} + +func (s *DnsProxyScript) OnRequest(req *dns.Msg, clientIP string) (jsreq, jsres *JSQuery) { + if s.doOnRequest { + jsreq := NewJSQuery(req, clientIP) + jsres := NewJSQuery(req, clientIP) + + if _, err := s.Call("onRequest", jsreq, jsres); err != nil { + log.Error("%s", err) + return nil, nil + } else if jsreq.WasModified() { + jsreq.UpdateHash() + return jsreq, nil + } else if jsres.WasModified() { + jsres.UpdateHash() + return nil, jsres + } + } + + return nil, nil +} + +func (s *DnsProxyScript) OnResponse(req, res *dns.Msg, clientIP string) (jsreq, jsres *JSQuery) { + if s.doOnResponse { + jsreq := NewJSQuery(req, clientIP) + jsres := NewJSQuery(res, clientIP) + + if _, err := s.Call("onResponse", jsreq, jsres); err != nil { + log.Error("%s", err) + return nil, nil + } else if jsres.WasModified() { + jsres.UpdateHash() + return nil, jsres + } + } + + return nil, nil +} + +func (s *DnsProxyScript) OnCommand(cmd string) bool { + if s.doOnCommand { + if ret, err := s.Call("onCommand", cmd); err != nil { + log.Error("Error while executing onCommand callback: %+v", err) + return false + } else if v, ok := ret.(bool); ok { + return v + } + } + return false +} diff --git a/modules/modules.go b/modules/modules.go index 8af00309..984cb95f 100644 --- a/modules/modules.go +++ b/modules/modules.go @@ -9,6 +9,7 @@ import ( "github.com/bettercap/bettercap/v2/modules/can" "github.com/bettercap/bettercap/v2/modules/caplets" "github.com/bettercap/bettercap/v2/modules/dhcp6_spoof" + "github.com/bettercap/bettercap/v2/modules/dns_proxy" "github.com/bettercap/bettercap/v2/modules/dns_spoof" "github.com/bettercap/bettercap/v2/modules/events_stream" "github.com/bettercap/bettercap/v2/modules/gps" @@ -45,6 +46,7 @@ func LoadModules(sess *session.Session) { sess.Register(can.NewCanModule(sess)) sess.Register(dhcp6_spoof.NewDHCP6Spoofer(sess)) sess.Register(net_recon.NewDiscovery(sess)) + sess.Register(dns_proxy.NewDnsProxy(sess)) sess.Register(dns_spoof.NewDNSSpoofer(sess)) sess.Register(events_stream.NewEventsStream(sess)) sess.Register(gps.NewGPS(sess))