diff --git a/README.md b/README.md index 6f31f0cc..de7c6eb4 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ interactsh-client | notify ## Interactsh Web Client -[Interactsh-web](https://github.com/projectdiscovery/interactsh-web) is a free and open-source web client that displays Interactsh interactions in a well-managed dashboard in your browser. It uses the browser's local storage to store and display all incoming interactions. By default, the web client is configured to use **interact.sh** as default interactsh server, and supports other self-hosted public/authencaited interactsh servers as well. +[Interactsh-web](https://github.com/projectdiscovery/interactsh-web) is a free and open-source web client that displays Interactsh interactions in a well-managed dashboard in your browser. It uses the browser's local storage to store and display all incoming interactions. By default, the web client is configured to use **oast.fun** as default interactsh server, and supports other self-hosted public/authencaited interactsh servers as well. A hosted instance of **interactsh-web** client is available at https://app.interactsh.com @@ -277,9 +277,9 @@ $ docker run projectdiscovery/interactsh-client:latest [INF] c59e3crp82ke7bcnedq0cfjqdpeyyyyyn.oast.pro ``` -## Burp Suite Extension +## Burp Suite Original Extension -[interactsh-collaborator](https://github.com/wdahlenburg/interactsh-collaborator) is Burp Suite extension developed and maintained by [@wdahlenb](https://twitter.com/wdahlenb) +[interactsh-collaborator](https://github.com/wdahlenburg/interactsh-collaborator) is an original Burp Suite interactsh extension developed and maintained by [@wdahlenb](https://twitter.com/wdahlenb) - Download latest JAR file from [releases](https://github.com/wdahlenburg/interactsh-collaborator/releases) page. - Open Burp Suite → Extender → Add → Java → Select JAR file → Next @@ -288,9 +288,20 @@ $ docker run projectdiscovery/interactsh-client:latest burp -## OWASP ZAP Add-On +## Burp Suite Revised Extension -Interactsh can be used with OWASP ZAP via the [OAST add-on for ZAP](https://www.zaproxy.org/docs/desktop/addons/oast-support/). With ZAP's scripting capabilities, you can create powerful out-of-band scan rules that leverage Interactsh's features. A standalone script template has been provided as an example (it is added automatically when you install the add-on). +[interactsh-collaborator-rev](https://github.com/TheArqsz/interactsh-collaborator-rev) is a revised version of the original Burp Suite interactsh extension and is developed and maintained by [@Arqsz](https://arqsz.net/) + +- Download latest JAR file from [releases](https://github.com/TheArqsz/interactsh-collaborator-rev/releases) page. +- Open Burp Suite → Extender → Add → Java → Select JAR file → Next +- New tab named **Interactsh** will be appeared upon successful installation. +- See the [interactsh-collaborator-rev](https://github.com/TheArqsz/interactsh-collaborator-rev) project for more info. + +burp + +## ZAP Add-On + +Interactsh can be used with ZAP via the [OAST add-on for ZAP](https://www.zaproxy.org/docs/desktop/addons/oast-support/). With ZAP's scripting capabilities, you can create powerful out-of-band scan rules that leverage Interactsh's features. A standalone script template has been provided as an example (it is added automatically when you install the add-on). - Install the OAST add-on from the [ZAP Marketplace](https://www.zaproxy.org/addons/). - Go to Tools → Options → OAST and select **Interactsh**. @@ -303,9 +314,6 @@ Interactsh can be used with OWASP ZAP via the [OAST add-on for ZAP](https://www. ![zap](https://user-images.githubusercontent.com/16446369/135211920-ed24ba5a-5547-4cd4-b6d8-656af9592c20.png) *Interactsh in ZAP* -![Options > OAST > General](https://github.com/hahwul/interactsh/assets/13212227/005bb527-3f60-4822-8b76-f9a3fd06df83) -*`Options` > `OAST` > `General`* - ## Caido Extension [quickssrf](https://github.com/caido-community/quickssrf) is Caido extension developed and maintained which allows using Interactsh from within Caido Proxy. @@ -871,8 +879,8 @@ Currently supported metadata services: Example: -* **aws.interact.sh** points to 169.254.169.254 -* **alibaba.interact.sh** points to 100.100.100.200 +* **aws.oast.fun** points to 169.254.169.254 +* **alibaba.oast.fun** points to 100.100.100.200 ----- diff --git a/cmd/interactsh-server/example-custom-records.yaml b/cmd/interactsh-server/example-custom-records.yaml index 1e21e915..47df405e 100644 --- a/cmd/interactsh-server/example-custom-records.yaml +++ b/cmd/interactsh-server/example-custom-records.yaml @@ -1,8 +1,93 @@ # This is a reference custom DNS records file +# +# Custom DNS records can be specified in two formats: +# +# 1. Standard format (supports multiple record types): +# subdomain: +# - type: RECORD_TYPE +# value: "record_value" +# ttl: 3600 # optional, defaults to server TTL +# priority: 10 # optional, for MX records only +# +# 2. LEGACY FORMAT (Simple key-value, assumes A records): +# subdomain: "ip_address" +# +# Supported record types: A, AAAA, CNAME, MX, TXT, NS -# The default custom records can be specified using this YAML -# file using the below declaration. -aws: "169.254.169.254" -alibaba: "100.100.100.200" -localhost: "127.0.0.1" -oracle: "192.0.0.192" +# ============================================ +# STANDARD FORMAT EXAMPLES +# ============================================ + +# A record example +api: + - type: A + value: "192.0.2.1" + ttl: 3600 + +# Multiple A records for the same subdomain +webserver: + - type: A + value: "203.0.113.10" + - type: A + value: "203.0.113.11" + +# AAAA record (IPv6) +ipv6: + - type: AAAA + value: "2001:db8::1" + +# CNAME record +cdn: + - type: CNAME + value: "example.cdn.net" + +# MX record (mail server) +mail: + - type: MX + value: "mail.example.com" + priority: 10 + ttl: 7200 + +# Multiple MX records with different priorities +mailserver: + - type: MX + value: "mail1.example.com" + priority: 10 + - type: MX + value: "mail2.example.com" + priority: 20 + +# TXT record (useful for SPF, DKIM, verification, etc.) +spf: + - type: TXT + value: "v=spf1 include:_spf.example.com ~all" + +# Multi-level subdomain example (DKIM key) +something._domainkey: + - type: TXT + value: "v=DKIM1; k=rsa; p=MIGfMA0GCS..." + +# NS record (nameserver) +custom-ns: + - type: NS + value: "ns1.example.com" + - type: NS + value: "ns2.example.com" + +# Mixed record types for the same subdomain +multi: + - type: A + value: "198.51.100.1" + - type: AAAA + value: "2001:db8::2" + - type: TXT + value: "v=spf1 mx ~all" + +# ============================================ +# LEGACY FORMAT EXAMPLES (backwards compatible) +# ============================================ +# These simple entries will be interpreted as A records +# aws: "169.254.169.254" +# alibaba: "100.100.100.200" +# localhost: "127.0.0.1" +# oracle: "192.0.0.192" diff --git a/pkg/server/dns_server.go b/pkg/server/dns_server.go index 21388c67..ec93ba30 100644 --- a/pkg/server/dns_server.go +++ b/pkg/server/dns_server.go @@ -9,7 +9,6 @@ import ( "strings" "sync/atomic" "time" - "unicode" jsoniter "github.com/json-iterator/go" "github.com/miekg/dns" @@ -54,7 +53,7 @@ func NewDNSServer(network string, options *Options) *DNSServer { mxDomains: mxDomains, nsDomains: nsDomains, timeToLive: 3600, - customRecords: newCustomDNSRecordsServer(options.CustomRecords), + customRecords: newCustomDNSRecordsServer(options.CustomRecords, options.Domains), } server.server = &dns.Server{ Addr: formatAddress(options.ListenIP, options.DnsPort), @@ -156,17 +155,26 @@ func (h *DNSServer) handleACMETXTChallenge(zone string, m *dns.Msg) error { // handleACNAMEANY handles A, CNAME or ANY queries for DNS server func (h *DNSServer) handleACNAMEANY(zone string, m *dns.Msg) { - nsHeader := dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: h.timeToLive} - - // If we have a custom record serve it, or default IPs - record := h.customRecords.checkCustomResponse(zone) - answerIPs := uniqueIPs(parseIPList(record)) - if len(answerIPs) == 0 { - answerIPs = h.ipAddresses - } - if len(answerIPs) > 0 { - h.resultFunction(nsHeader, zone, answerIPs, m) + // Determine the query type from the question + var qtype = dns.TypeA + if len(m.Question) > 0 { + qtype = m.Question[0].Qtype + } + + // Check for custom records + customRecords := h.customRecords.checkCustomResponse(zone, qtype) + if len(customRecords) > 0 { + for _, record := range customRecords { + if err := h.addCustomRecordToMessage(record, zone, m); err != nil { + gologger.Warning().Msgf("Could not add custom %s record for %s: %s", record.Type, zone, err) + } + } + return } + + // No custom records, use default IP + nsHeader := dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: h.timeToLive} + h.resultFunction(nsHeader, zone, h.ipAddresses, m) } func (h *DNSServer) resultFunction(nsHeader dns.RR_Header, zone string, ipAddresses []net.IP, m *dns.Msg) { @@ -194,8 +202,19 @@ func (h *DNSServer) resultFunction(nsHeader dns.RR_Header, zone string, ipAddres } func (h *DNSServer) handleMX(zone string, m *dns.Msg) { - nsHdr := dns.RR_Header{Name: zone, Rrtype: dns.TypeMX, Class: dns.ClassINET, Ttl: h.timeToLive} + // Check for custom MX records first + customRecords := h.customRecords.checkCustomResponse(zone, dns.TypeMX) + if len(customRecords) > 0 { + for _, record := range customRecords { + if err := h.addCustomRecordToMessage(record, zone, m); err != nil { + gologger.Warning().Msgf("Could not add custom %s record for %s: %s", record.Type, zone, err) + } + } + return + } + // Fall back to default MX records + nsHdr := dns.RR_Header{Name: zone, Rrtype: dns.TypeMX, Class: dns.ClassINET, Ttl: h.timeToLive} dotDomains := []string{zone, dns.Fqdn(h.options.Domains[0])} for _, dotDomain := range dotDomains { if mxdomain, ok := h.mxDomains[dotDomain]; ok { @@ -206,8 +225,19 @@ func (h *DNSServer) handleMX(zone string, m *dns.Msg) { } func (h *DNSServer) handleNS(zone string, m *dns.Msg) { - nsHeader := dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: h.timeToLive} + // Check for custom NS records first + customRecords := h.customRecords.checkCustomResponse(zone, dns.TypeNS) + if len(customRecords) > 0 { + for _, record := range customRecords { + if err := h.addCustomRecordToMessage(record, zone, m); err != nil { + gologger.Warning().Msgf("Could not add custom %s record for %s: %s", record.Type, zone, err) + } + } + return + } + // Fall back to default NS records + nsHeader := dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: h.timeToLive} dotDomains := []string{zone, dns.Fqdn(h.options.Domains[0])} for _, dotDomain := range dotDomains { if nsDomains, ok := h.nsDomains[dotDomain]; ok { @@ -233,6 +263,18 @@ func (h *DNSServer) handleSOA(zone string, m *dns.Msg) { } func (h *DNSServer) handleTXT(zone string, m *dns.Msg) { + // Check for custom TXT records first + customRecords := h.customRecords.checkCustomResponse(zone, dns.TypeTXT) + if len(customRecords) > 0 { + for _, record := range customRecords { + if err := h.addCustomRecordToMessage(record, zone, m); err != nil { + gologger.Warning().Msgf("Could not add custom %s record for %s: %s", record.Type, zone, err) + } + } + return + } + + // Fall back to default TXT record m.Answer = append(m.Answer, &dns.TXT{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 0}, Txt: []string{h.TxtRecord}}) } @@ -360,6 +402,17 @@ func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dn } } +// CustomRecordConfig represents a custom DNS record configuration +type CustomRecordConfig struct { + Type string `yaml:"type"` + Value string `yaml:"value"` + TTL uint32 `yaml:"ttl,omitempty"` + Priority uint16 `yaml:"priority,omitempty"` // for MX records +} + +// DNSRecordsConfig represents the structured DNS records configuration (YAML format) +type DNSRecordsConfig map[string][]CustomRecordConfig + // appendAnswerRecord appends an A/AAAA record to the DNS message based on the // provided IP address. func (h *DNSServer) appendAnswerRecord(zone string, ip net.IP, m *dns.Msg) bool { @@ -394,33 +447,6 @@ func (h *DNSServer) appendGlueRecords(nsDomain string, m *dns.Msg) { } } -// parseIPList parses a string containing multiple IP addresses separated by -// commas, semicolons, or whitespace and returns a slice of [net.IP] objects. -func parseIPList(value string) []net.IP { - if strings.TrimSpace(value) == "" { - return nil - } - - parts := strings.FieldsFunc(value, func(r rune) bool { - return r == ',' || r == ';' || unicode.IsSpace(r) - }) - - var ips []net.IP - - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed == "" { - continue - } - - if ip := net.ParseIP(trimmed); ip != nil { - ips = append(ips, ip) - } - } - - return ips -} - // uniqueIPs deduplicates a slice of [net.IP] objects and returns a new slice // containing only unique IP addresses. func uniqueIPs(ips []net.IP) []net.IP { @@ -450,7 +476,8 @@ func uniqueIPs(ips []net.IP) []net.IP { // customDNSRecords is a server for custom dns records type customDNSRecords struct { - records map[string]string + records map[string][]CustomRecordConfig + domains []string } // defaultCustomRecords is the list of default custom DNS records @@ -461,10 +488,16 @@ var defaultCustomRecords = map[string]string{ "oracle": "192.0.0.192", } -func newCustomDNSRecordsServer(input string) *customDNSRecords { - server := &customDNSRecords{records: make(map[string]string)} +func newCustomDNSRecordsServer(input string, domains []string) *customDNSRecords { + server := &customDNSRecords{ + records: make(map[string][]CustomRecordConfig), + domains: domains, + } + // Add default records as A records for k, v := range defaultCustomRecords { - server.records[k] = v + server.records[k] = []CustomRecordConfig{ + {Type: "A", Value: v}, + } } if input != "" { if err := server.readRecordsFromFile(input); err != nil { @@ -475,33 +508,175 @@ func newCustomDNSRecordsServer(input string) *customDNSRecords { } func (c *customDNSRecords) readRecordsFromFile(input string) error { - file, err := os.Open(input) + // Read the entire file once + data, err := os.ReadFile(input) if err != nil { - return errors.Wrap(err, "could not open file") - } - defer func() { - if err := file.Close(); err != nil { - gologger.Error().Msgf("Could not close file: %s", err) + return errors.Wrap(err, "could not read file") + } + + // Try to parse as structured format first + var structuredData DNSRecordsConfig + if err := yaml.Unmarshal(data, &structuredData); err == nil && len(structuredData) > 0 { + // Successfully parsed as structured format + for subdomain, entries := range structuredData { + subdomainLower := strings.ToLower(subdomain) + for _, entry := range entries { + if entry.Type == "" { + return errors.New("record type is required") + } + if entry.Value == "" { + return errors.New("record value is required") + } + + // Normalize type to uppercase + entry.Type = strings.ToUpper(entry.Type) + c.records[subdomainLower] = append(c.records[subdomainLower], entry) + } } - }() + return nil + } - var data map[string]string - if err := yaml.NewDecoder(file).Decode(&data); err != nil { - return errors.Wrap(err, "could not decode file") + // If structured format failed, try legacy format (backwards compatibility) + var legacyData map[string]string + if err := yaml.Unmarshal(data, &legacyData); err != nil { + return errors.Wrap(err, "could not decode file as structured or legacy format") } - for k, v := range data { - c.records[strings.ToLower(k)] = v + + // Convert legacy format to CustomRecordConfig (assume A records) + for k, v := range legacyData { + c.records[strings.ToLower(k)] = []CustomRecordConfig{ + {Type: "A", Value: v}, + } } return nil } -func (c *customDNSRecords) checkCustomResponse(zone string) string { - parts := strings.SplitN(zone, ".", 2) - if len(parts) != 2 { - return "" +// checkCustomResponse returns custom DNS records for the given zone and record type +func (c *customDNSRecords) checkCustomResponse(zone string, recordType uint16) []CustomRecordConfig { + // Normalize zone (remove trailing dot if present) + zone = strings.TrimSuffix(zone, ".") + zoneLower := strings.ToLower(zone) + + // Try to find which base domain this zone belongs to and extract the subdomain + var subdomain string + for _, domain := range c.domains { + domainLower := strings.ToLower(domain) + // Check if zone ends with .domain or is exactly domain + if zoneLower == domainLower { + // It's the base domain itself, no custom subdomain + continue + } + suffix := "." + domainLower + if strings.HasSuffix(zoneLower, suffix) { + // Extract the subdomain part (everything before .domain) + subdomain = zoneLower[:len(zoneLower)-len(suffix)] + break + } + } + + if subdomain == "" { + return nil } - if value, ok := c.records[strings.ToLower(parts[0])]; ok { - return value + + configs, ok := c.records[subdomain] + if !ok { + return nil } - return "" + + // Filter by record type + var filtered []CustomRecordConfig + for _, config := range configs { + // Match the requested type + switch recordType { + case dns.TypeA: + if config.Type == "A" { + filtered = append(filtered, config) + } + case dns.TypeAAAA: + if config.Type == "AAAA" { + filtered = append(filtered, config) + } + case dns.TypeCNAME: + if config.Type == "CNAME" { + filtered = append(filtered, config) + } + case dns.TypeMX: + if config.Type == "MX" { + filtered = append(filtered, config) + } + case dns.TypeTXT: + if config.Type == "TXT" { + filtered = append(filtered, config) + } + case dns.TypeNS: + if config.Type == "NS" { + filtered = append(filtered, config) + } + case dns.TypeANY: + // Return all records for ANY query + filtered = append(filtered, config) + } + } + + return filtered +} + +// addCustomRecordToMessage adds a custom DNS record to the DNS message +func (h *DNSServer) addCustomRecordToMessage(record CustomRecordConfig, zone string, m *dns.Msg) error { + // Determine TTL (use custom if set, otherwise use server default) + ttl := h.timeToLive + if record.TTL > 0 { + ttl = record.TTL + } + + // Create the appropriate DNS record based on type + switch record.Type { + case "A": + ip := net.ParseIP(record.Value) + if ip == nil { + return fmt.Errorf("invalid IPv4 address for A record: %s", record.Value) + } + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl}, + A: ip.To4(), + }) + case "AAAA": + ip := net.ParseIP(record.Value) + if ip == nil { + return fmt.Errorf("invalid IPv6 address for AAAA record: %s", record.Value) + } + m.Answer = append(m.Answer, &dns.AAAA{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl}, + AAAA: ip.To16(), + }) + case "CNAME": + m.Answer = append(m.Answer, &dns.CNAME{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: ttl}, + Target: dns.Fqdn(record.Value), + }) + case "MX": + priority := record.Priority + if priority == 0 { + priority = 10 // default priority if not specified + } + m.Answer = append(m.Answer, &dns.MX{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeMX, Class: dns.ClassINET, Ttl: ttl}, + Mx: dns.Fqdn(record.Value), + Preference: priority, + }) + case "TXT": + m.Answer = append(m.Answer, &dns.TXT{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: ttl}, + Txt: []string{record.Value}, + }) + case "NS": + m.Answer = append(m.Answer, &dns.NS{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: ttl}, + Ns: dns.Fqdn(record.Value), + }) + default: + return fmt.Errorf("unsupported record type: %s", record.Type) + } + + return nil } diff --git a/pkg/server/dns_server_test.go b/pkg/server/dns_server_test.go index 942c3692..83c1390b 100644 --- a/pkg/server/dns_server_test.go +++ b/pkg/server/dns_server_test.go @@ -35,9 +35,10 @@ func TestDNSServerIPv6OnlyResponses(t *testing.T) { func TestDNSServerCustomIPv6Record(t *testing.T) { opts := newTestOptions([]string{"192.0.2.1", "2001:db8::1"}, "127.0.0.1") dnsServer := NewDNSServer("udp", opts) - dnsServer.customRecords.records["ipv6"] = "2001:db8::dead" + dnsServer.customRecords.records["ipv6"] = []CustomRecordConfig{{Type: "AAAA", Value: "2001:db8::dead"}} msg := new(dns.Msg) + msg.Question = []dns.Question{{Name: dns.Fqdn("ipv6.example.com"), Qtype: dns.TypeAAAA}} dnsServer.handleACNAMEANY(dns.Fqdn("ipv6.example.com"), msg) require.True(t, hasRecord(msg.Answer, dns.TypeAAAA, "2001:db8::dead"), "expected custom AAAA record") @@ -79,9 +80,10 @@ func TestDNSServerDefaultIPv4Answer(t *testing.T) { func TestDNSServerCustomIPv4Record(t *testing.T) { opts := newTestOptions([]string{"192.0.2.50"}, "127.0.0.1") dnsServer := NewDNSServer("udp", opts) - dnsServer.customRecords.records["app"] = "198.51.100.5" + dnsServer.customRecords.records["app"] = []CustomRecordConfig{{Type: "A", Value: "198.51.100.5"}} msg := new(dns.Msg) + msg.Question = []dns.Question{{Name: dns.Fqdn("app.example.com"), Qtype: dns.TypeA}} dnsServer.handleACNAMEANY(dns.Fqdn("app.example.com"), msg) require.True(t, hasRecord(msg.Answer, dns.TypeA, "198.51.100.5"), "expected custom IPv4 answer")