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
-## 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.
+
+
+
+## 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.

*Interactsh in ZAP*
-
-*`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")