From f8433efe48948b2940783fc8279378407f2d9ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fan=20Can=20Bak=C4=B1r?= Date: Wed, 13 May 2026 14:20:47 +0300 Subject: [PATCH 1/5] test: dns callback regression for short cidl/cidn (#1362) --- pkg/server/dns_server_interaction_test.go | 131 ++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 pkg/server/dns_server_interaction_test.go diff --git a/pkg/server/dns_server_interaction_test.go b/pkg/server/dns_server_interaction_test.go new file mode 100644 index 00000000..3bb21dd9 --- /dev/null +++ b/pkg/server/dns_server_interaction_test.go @@ -0,0 +1,131 @@ +package server + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/projectdiscovery/interactsh/pkg/storage" + "github.com/stretchr/testify/require" +) + +// Regression for https://github.com/projectdiscovery/interactsh/issues/1362: +// short --cidl / --cidn must still record DNS callbacks. A parent label +// longer than cidl+cidn (e.g. "example") historically caused the legitimate +// correlation id to be overwritten by a false-positive slide window. +func TestDNSInteractionStored_ShortCorrelationIDs(t *testing.T) { + const cidl, cidn = 3, 3 + const correlationID, nonce = "d82", "yyy" + const parentDomain = "example.com" + + store := newTestStorage(t) + registerTestKey(t, store, correlationID) + + port := freeUDPPort(t) + startTestDNSServer(t, &Options{ + Domains: []string{parentDomain}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ListenIP: "127.0.0.1", + DnsPort: port, + Storage: store, + CorrelationIdLength: cidl, + CorrelationIdNonceLength: cidn, + Stats: &Metrics{}, + }) + + sendDNSQuery(t, port, fmt.Sprintf("%s%s.%s.", correlationID, nonce, parentDomain)) + + interactions, _, err := store.GetInteractions(correlationID, "secret") + require.NoError(t, err) + require.Len(t, interactions, 1, "DNS interaction should be recorded when cidl+cidn is shorter than the parent label") +} + +func TestDNSInteractionStored_DefaultCorrelationIDs(t *testing.T) { + const cidl, cidn = 20, 13 + const correlationID = "abcdefghijklmnopqrst" + const nonce = "uvwxyz0123456" + const parentDomain = "example.com" + + store := newTestStorage(t) + registerTestKey(t, store, correlationID) + + port := freeUDPPort(t) + startTestDNSServer(t, &Options{ + Domains: []string{parentDomain}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ListenIP: "127.0.0.1", + DnsPort: port, + Storage: store, + CorrelationIdLength: cidl, + CorrelationIdNonceLength: cidn, + Stats: &Metrics{}, + }) + + sendDNSQuery(t, port, fmt.Sprintf("%s%s.%s.", correlationID, nonce, parentDomain)) + + interactions, _, err := store.GetInteractions(correlationID, "secret") + require.NoError(t, err) + require.Len(t, interactions, 1) +} + +func freeUDPPort(t *testing.T) int { + t.Helper() + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + require.NoError(t, err) + conn, err := net.ListenUDP("udp", addr) + require.NoError(t, err) + port := conn.LocalAddr().(*net.UDPAddr).Port + require.NoError(t, conn.Close()) + return port +} + +func newTestStorage(t *testing.T) storage.Storage { + t.Helper() + s, err := storage.New(&storage.Options{EvictionTTL: time.Hour}) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func registerTestKey(t *testing.T, store storage.Storage, correlationID string) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + require.NoError(t, err) + pemEncoded := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubBytes}) + require.NoError(t, store.SetIDPublicKey(correlationID, "secret", base64.StdEncoding.EncodeToString(pemEncoded))) +} + +func startTestDNSServer(t *testing.T, opts *Options) { + t.Helper() + server := NewDNSServer("udp", opts) + alive := make(chan bool, 2) + go server.ListenAndServe(alive) + <-alive + require.Eventually(t, func() bool { + c, err := net.DialTimeout("udp", fmt.Sprintf("127.0.0.1:%d", opts.DnsPort), 100*time.Millisecond) + if err != nil { + return false + } + _ = c.Close() + return true + }, time.Second, 20*time.Millisecond) + t.Cleanup(func() { _ = server.server.Shutdown() }) +} + +func sendDNSQuery(t *testing.T, port int, name string) { + t.Helper() + msg := new(dns.Msg) + msg.SetQuestion(name, dns.TypeA) + client := &dns.Client{Net: "udp", Timeout: 2 * time.Second} + _, _, err := client.Exchange(msg, fmt.Sprintf("127.0.0.1:%d", port)) + require.NoError(t, err) +} From df0c821cf8f333e23dd7661229d5479e53814c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fan=20Can=20Bak=C4=B1r?= Date: Wed, 13 May 2026 14:29:50 +0300 Subject: [PATCH 2/5] fix(server): restrict isCorrelationID to xid + zbase32 alphabets (#1362) --- pkg/server/dns_server_interaction_test.go | 4 +- pkg/server/util.go | 50 ++++++++++++++++++----- pkg/server/util_test.go | 38 +++++++++++++++++ 3 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 pkg/server/util_test.go diff --git a/pkg/server/dns_server_interaction_test.go b/pkg/server/dns_server_interaction_test.go index 3bb21dd9..7f12ad81 100644 --- a/pkg/server/dns_server_interaction_test.go +++ b/pkg/server/dns_server_interaction_test.go @@ -49,8 +49,8 @@ func TestDNSInteractionStored_ShortCorrelationIDs(t *testing.T) { func TestDNSInteractionStored_DefaultCorrelationIDs(t *testing.T) { const cidl, cidn = 20, 13 - const correlationID = "abcdefghijklmnopqrst" - const nonce = "uvwxyz0123456" + const correlationID = "c6rj61aciaeutn2ae680" + const nonce = "cg5ugboyyyyyn" const parentDomain = "example.com" store := newTestStorage(t) diff --git a/pkg/server/util.go b/pkg/server/util.go index 172a9acb..5abdba0e 100644 --- a/pkg/server/util.go +++ b/pkg/server/util.go @@ -3,22 +3,50 @@ package server import ( "net" "strconv" - "strings" +) - "github.com/asaskevich/govalidator" - "github.com/rs/xid" +// Correlation ids are produced from xid (cidl prefix) and zbase32 (cidn suffix), +// so detection is restricted to those alphabets to avoid false-positive matches +// inside ordinary domain labels (see issue #1362). +const ( + xidAlphabet = "0123456789abcdefghijklmnopqrstuv" + zbase32Alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769" ) -func (options *Options) isCorrelationID(s string) bool { - if len(s) == options.GetIdLength() && govalidator.IsAlphanumeric(s) { - // xid should be 12 - if options.CorrelationIdLength != 12 { - return true - } else if _, err := xid.FromString(strings.ToLower(s[:options.CorrelationIdLength])); err == nil { - return true +var ( + xidAlphabetTable = newAlphabetTable(xidAlphabet) + zbase32AlphabetTable = newAlphabetTable(zbase32Alphabet) +) + +func newAlphabetTable(alphabet string) [256]bool { + var table [256]bool + for i := 0; i < len(alphabet); i++ { + c := alphabet[i] + table[c] = true + if c >= 'a' && c <= 'z' { + table[c-32] = true } } - return false + return table +} + +func inAlphabet(table [256]bool, s string) bool { + for i := 0; i < len(s); i++ { + if !table[s[i]] { + return false + } + } + return true +} + +func (options *Options) isCorrelationID(s string) bool { + if len(s) != options.GetIdLength() { + return false + } + if !inAlphabet(xidAlphabetTable, s[:options.CorrelationIdLength]) { + return false + } + return inAlphabet(zbase32AlphabetTable, s[options.CorrelationIdLength:]) } func formatAddress(host string, port int) string { diff --git a/pkg/server/util_test.go b/pkg/server/util_test.go new file mode 100644 index 00000000..4ad13ff8 --- /dev/null +++ b/pkg/server/util_test.go @@ -0,0 +1,38 @@ +package server + +import ( + "testing" + + "github.com/projectdiscovery/interactsh/pkg/settings" + "github.com/stretchr/testify/require" +) + +func TestIsCorrelationID_DefaultLengths(t *testing.T) { + opts := &Options{ + CorrelationIdLength: settings.CorrelationIdLengthDefault, + CorrelationIdNonceLength: settings.CorrelationIdNonceLengthDefault, + } + + require.True(t, opts.isCorrelationID("c6rj61aciaeutn2ae680cg5ugboyyyyyn")) + require.False(t, opts.isCorrelationID("too-short")) + require.False(t, opts.isCorrelationID("c6rj61aciaeutn2ae680cg5ugboyyyy!!")) +} + +func TestIsCorrelationID_ShortLengthsRejectDomainLabelFalsePositives(t *testing.T) { + opts := &Options{CorrelationIdLength: 3, CorrelationIdNonceLength: 3} + + const exampleLabel = "example" + candidates := []string{exampleLabel[:6], exampleLabel[1:7], "google"} + for _, candidate := range candidates { + require.False(t, opts.isCorrelationID(candidate), "%q must not be treated as a correlation id", candidate) + } + + require.True(t, opts.isCorrelationID("d82yyy"), "valid xid+zbase32 preamble must still match") +} + +func TestIsCorrelationID_AlphabetBoundaries(t *testing.T) { + opts := &Options{CorrelationIdLength: 3, CorrelationIdNonceLength: 3} + + require.False(t, opts.isCorrelationID("xyzbnd"), "xid prefix must reject chars outside 0-9a-v") + require.False(t, opts.isCorrelationID("abclmv"), "zbase32 suffix must reject chars outside its alphabet") +} From 4bc29fc0cbb6cceffc747a02b398c1dae8a350df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fan=20Can=20Bak=C4=B1r?= Date: Wed, 13 May 2026 14:32:39 +0300 Subject: [PATCH 3/5] fix(dns): store each correlation id match independently (#1362) --- pkg/server/dns_server.go | 145 +++++++++++----------- pkg/server/dns_server_interaction_test.go | 31 +++++ 2 files changed, 105 insertions(+), 71 deletions(-) diff --git a/pkg/server/dns_server.go b/pkg/server/dns_server.go index c724795c..98b19870 100644 --- a/pkg/server/dns_server.go +++ b/pkg/server/dns_server.go @@ -301,8 +301,6 @@ func toQType(ttype uint16) (rtype string) { // handleInteraction handles an interaction for the DNS server func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dns.Msg, m *dns.Msg) { - var uniqueID, fullID string - requestMsg := r.String() responseMsg := m.String() @@ -317,87 +315,92 @@ func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dn } } - // if root-tld is enabled stores any interaction towards the main domain if h.options.RootTLD && foundDomain != "" { - correlationID := foundDomain - host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) - interaction := &Interaction{ - Protocol: "dns", - UniqueID: domain, - FullId: domain, - QType: toQType(r.Question[0].Qtype), - RawRequest: requestMsg, - RawResponse: responseMsg, - RemoteAddress: host, - Timestamp: time.Now(), - } + h.storeRootTLDInteraction(foundDomain, domain, requestMsg, responseMsg, w, r) + } - if nil != h.options.OnResult { - h.options.OnResult(interaction) - } + if foundDomain == "" { + return + } - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode root tld dns interaction: %s\n", err) - } else { - gologger.Debug().Msgf("Root TLD DNS Interaction: \n%s\n", string(data)) - if err := h.options.Storage.AddInteractionWithId(correlationID, data); err != nil { - gologger.Warning().Msgf("Could not store dns interaction: %s\n", err) + if h.options.ScanEverywhere { + chunks := stringsutil.SplitAny(requestMsg, ".\n\t\"'") + for _, chunk := range chunks { + for part := range stringsutil.SlideWithLength(chunk, h.options.GetIdLength()) { + normalizedPart := strings.ToLower(part) + if h.options.isCorrelationID(normalizedPart) { + h.storeMatchedInteraction(normalizedPart, part, requestMsg, responseMsg, w, r) + } } } + return } - if foundDomain != "" { - if h.options.ScanEverywhere { - chunks := stringsutil.SplitAny(requestMsg, ".\n\t\"'") - for _, chunk := range chunks { - for part := range stringsutil.SlideWithLength(chunk, h.options.GetIdLength()) { - normalizedPart := strings.ToLower(part) - if h.options.isCorrelationID(normalizedPart) { - uniqueID = normalizedPart - fullID = part - } - } + parts := strings.Split(domain, ".") + for i, part := range parts { + for partChunk := range stringsutil.SlideWithLength(part, h.options.GetIdLength()) { + normalizedPartChunk := strings.ToLower(partChunk) + if !h.options.isCorrelationID(normalizedPartChunk) { + continue } - } else { - parts := strings.Split(domain, ".") - for i, part := range parts { - for partChunk := range stringsutil.SlideWithLength(part, h.options.GetIdLength()) { - normalizedPartChunk := strings.ToLower(partChunk) - if h.options.isCorrelationID(normalizedPartChunk) { - fullID = part - if i+1 <= len(parts) { - fullID = strings.Join(parts[:i+1], ".") - } - uniqueID = normalizedPartChunk - } - } + fullID := part + if i+1 <= len(parts) { + fullID = strings.Join(parts[:i+1], ".") } + h.storeMatchedInteraction(normalizedPartChunk, fullID, requestMsg, responseMsg, w, r) } } +} - if uniqueID != "" { - correlationID := uniqueID[:h.options.CorrelationIdLength] - host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) - interaction := &Interaction{ - Protocol: "dns", - UniqueID: uniqueID, - FullId: fullID, - QType: toQType(r.Question[0].Qtype), - RawRequest: requestMsg, - RawResponse: responseMsg, - RemoteAddress: host, - Timestamp: time.Now(), - } - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode dns interaction: %s\n", err) - } else { - gologger.Debug().Msgf("DNS Interaction: \n%s\n", string(data)) - if err := h.options.Storage.AddInteraction(correlationID, data); err != nil { - gologger.Warning().Msgf("Could not store dns interaction: %s\n", err) - } - } +func (h *DNSServer) storeRootTLDInteraction(rootDomain, fqdn, requestMsg, responseMsg string, w dns.ResponseWriter, r *dns.Msg) { + host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) + interaction := &Interaction{ + Protocol: "dns", + UniqueID: fqdn, + FullId: fqdn, + QType: toQType(r.Question[0].Qtype), + RawRequest: requestMsg, + RawResponse: responseMsg, + RemoteAddress: host, + Timestamp: time.Now(), + } + + if h.options.OnResult != nil { + h.options.OnResult(interaction) + } + + data, err := jsoniter.Marshal(interaction) + if err != nil { + gologger.Warning().Msgf("Could not encode root tld dns interaction: %s\n", err) + return + } + gologger.Debug().Msgf("Root TLD DNS Interaction: \n%s\n", string(data)) + if err := h.options.Storage.AddInteractionWithId(rootDomain, data); err != nil { + gologger.Warning().Msgf("Could not store dns interaction: %s\n", err) + } +} + +func (h *DNSServer) storeMatchedInteraction(uniqueID, fullID, requestMsg, responseMsg string, w dns.ResponseWriter, r *dns.Msg) { + correlationID := uniqueID[:h.options.CorrelationIdLength] + host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) + interaction := &Interaction{ + Protocol: "dns", + UniqueID: uniqueID, + FullId: fullID, + QType: toQType(r.Question[0].Qtype), + RawRequest: requestMsg, + RawResponse: responseMsg, + RemoteAddress: host, + Timestamp: time.Now(), + } + data, err := jsoniter.Marshal(interaction) + if err != nil { + gologger.Warning().Msgf("Could not encode dns interaction: %s\n", err) + return + } + gologger.Debug().Msgf("DNS Interaction: \n%s\n", string(data)) + if err := h.options.Storage.AddInteraction(correlationID, data); err != nil { + gologger.Warning().Msgf("Could not store dns interaction: %s\n", err) } } diff --git a/pkg/server/dns_server_interaction_test.go b/pkg/server/dns_server_interaction_test.go index 7f12ad81..bf5c400f 100644 --- a/pkg/server/dns_server_interaction_test.go +++ b/pkg/server/dns_server_interaction_test.go @@ -47,6 +47,37 @@ func TestDNSInteractionStored_ShortCorrelationIDs(t *testing.T) { require.Len(t, interactions, 1, "DNS interaction should be recorded when cidl+cidn is shorter than the parent label") } +// Even with strict alphabet validation, common parent labels like "online" +// satisfy the xid+zbase32 alphabets. The DNS handler must therefore store +// each match independently instead of relying on a single trailing write +// that the parent label can overwrite. +func TestDNSInteractionStored_ShortCorrelationIDsWithAlphabetCompatibleParentLabel(t *testing.T) { + const cidl, cidn = 3, 3 + const correlationID, nonce = "d82", "yyy" + const parentDomain = "oast.online" + + store := newTestStorage(t) + registerTestKey(t, store, correlationID) + + port := freeUDPPort(t) + startTestDNSServer(t, &Options{ + Domains: []string{parentDomain}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ListenIP: "127.0.0.1", + DnsPort: port, + Storage: store, + CorrelationIdLength: cidl, + CorrelationIdNonceLength: cidn, + Stats: &Metrics{}, + }) + + sendDNSQuery(t, port, fmt.Sprintf("%s%s.%s.", correlationID, nonce, parentDomain)) + + interactions, _, err := store.GetInteractions(correlationID, "secret") + require.NoError(t, err) + require.Len(t, interactions, 1, "DNS interaction must be recorded even when a parent label passes the alphabet check") +} + func TestDNSInteractionStored_DefaultCorrelationIDs(t *testing.T) { const cidl, cidn = 20, 13 const correlationID = "c6rj61aciaeutn2ae680" From 1a8d15e9f173bd8c548dab90fe637e969e04d9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fan=20Can=20Bak=C4=B1r?= Date: Wed, 13 May 2026 16:00:43 +0300 Subject: [PATCH 4/5] test: add table-driven scenarios for cidl/cidn (#1362) --- pkg/server/dns_server_interaction_test.go | 177 +++++++++++++--------- 1 file changed, 106 insertions(+), 71 deletions(-) diff --git a/pkg/server/dns_server_interaction_test.go b/pkg/server/dns_server_interaction_test.go index bf5c400f..1dcba12e 100644 --- a/pkg/server/dns_server_interaction_test.go +++ b/pkg/server/dns_server_interaction_test.go @@ -8,6 +8,7 @@ import ( "encoding/pem" "fmt" "net" + "strings" "testing" "time" @@ -16,94 +17,128 @@ import ( "github.com/stretchr/testify/require" ) -// Regression for https://github.com/projectdiscovery/interactsh/issues/1362: -// short --cidl / --cidn must still record DNS callbacks. A parent label -// longer than cidl+cidn (e.g. "example") historically caused the legitimate -// correlation id to be overwritten by a false-positive slide window. -func TestDNSInteractionStored_ShortCorrelationIDs(t *testing.T) { - const cidl, cidn = 3, 3 - const correlationID, nonce = "d82", "yyy" - const parentDomain = "example.com" - - store := newTestStorage(t) - registerTestKey(t, store, correlationID) - - port := freeUDPPort(t) - startTestDNSServer(t, &Options{ - Domains: []string{parentDomain}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - ListenIP: "127.0.0.1", - DnsPort: port, - Storage: store, - CorrelationIdLength: cidl, - CorrelationIdNonceLength: cidn, - Stats: &Metrics{}, - }) - - sendDNSQuery(t, port, fmt.Sprintf("%s%s.%s.", correlationID, nonce, parentDomain)) - - interactions, _, err := store.GetInteractions(correlationID, "secret") - require.NoError(t, err) - require.Len(t, interactions, 1, "DNS interaction should be recorded when cidl+cidn is shorter than the parent label") +// Regression suite for https://github.com/projectdiscovery/interactsh/issues/1362. +// Each row exercises a different shape of correlation id and parent domain to +// guard against false-positive matches in ordinary domain labels overwriting +// legitimate ones, and against alphabet mismatches. +func TestDNSInteractionStored(t *testing.T) { + cases := []struct { + name string + cidl, cidn int + correlationID string + nonce string + parentDomain string + queryName func(preamble, parent string) string + expectStored int + }{ + { + name: "short ids with parent label rejected by alphabet (example.com)", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + expectStored: 1, + }, + { + name: "short ids with alphabet-compatible parent label (oast.online)", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "oast.online", + expectStored: 1, + }, + { + name: "readme example cidl=4 cidn=6", + cidl: 4, cidn: 6, + correlationID: "abcd", nonce: "ybndrf", + parentDomain: "hackwithautomation.com", + expectStored: 1, + }, + { + name: "default lengths", + cidl: 20, cidn: 13, + correlationID: "c6rj61aciaeutn2ae680", nonce: "cg5ugboyyyyyn", + parentDomain: "example.com", + expectStored: 1, + }, + { + name: "uppercase query is normalized", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + queryName: func(preamble, parent string) string { + return strings.ToUpper(preamble) + "." + parent + "." + }, + expectStored: 1, + }, + { + name: "multi-level subdomain prefix", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + queryName: func(preamble, parent string) string { + return "extra." + preamble + "." + parent + "." + }, + expectStored: 1, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + store := newTestStorage(t) + registerTestKey(t, store, tc.correlationID) + + port := freeUDPPort(t) + startTestDNSServer(t, &Options{ + Domains: []string{tc.parentDomain}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ListenIP: "127.0.0.1", + DnsPort: port, + Storage: store, + CorrelationIdLength: tc.cidl, + CorrelationIdNonceLength: tc.cidn, + Stats: &Metrics{}, + }) + + query := defaultQueryName(tc.correlationID+tc.nonce, tc.parentDomain) + if tc.queryName != nil { + query = tc.queryName(tc.correlationID+tc.nonce, tc.parentDomain) + } + sendDNSQuery(t, port, query) + + interactions, _, err := store.GetInteractions(tc.correlationID, "secret") + require.NoError(t, err) + require.Len(t, interactions, tc.expectStored) + }) + } } -// Even with strict alphabet validation, common parent labels like "online" -// satisfy the xid+zbase32 alphabets. The DNS handler must therefore store -// each match independently instead of relying on a single trailing write -// that the parent label can overwrite. -func TestDNSInteractionStored_ShortCorrelationIDsWithAlphabetCompatibleParentLabel(t *testing.T) { - const cidl, cidn = 3, 3 - const correlationID, nonce = "d82", "yyy" - const parentDomain = "oast.online" - +// Unregistered preambles must not produce stored interactions, even though +// they pass the alphabet check. +func TestDNSInteractionNotStored_UnregisteredPreamble(t *testing.T) { store := newTestStorage(t) - registerTestKey(t, store, correlationID) + registerTestKey(t, store, "d82") port := freeUDPPort(t) startTestDNSServer(t, &Options{ - Domains: []string{parentDomain}, + Domains: []string{"example.com"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, ListenIP: "127.0.0.1", DnsPort: port, Storage: store, - CorrelationIdLength: cidl, - CorrelationIdNonceLength: cidn, + CorrelationIdLength: 3, + CorrelationIdNonceLength: 3, Stats: &Metrics{}, }) - sendDNSQuery(t, port, fmt.Sprintf("%s%s.%s.", correlationID, nonce, parentDomain)) + sendDNSQuery(t, port, "abcybn.example.com.") - interactions, _, err := store.GetInteractions(correlationID, "secret") + interactions, _, err := store.GetInteractions("d82", "secret") require.NoError(t, err) - require.Len(t, interactions, 1, "DNS interaction must be recorded even when a parent label passes the alphabet check") + require.Empty(t, interactions, "queries that do not target a registered preamble must not be stored under another id") } -func TestDNSInteractionStored_DefaultCorrelationIDs(t *testing.T) { - const cidl, cidn = 20, 13 - const correlationID = "c6rj61aciaeutn2ae680" - const nonce = "cg5ugboyyyyyn" - const parentDomain = "example.com" - - store := newTestStorage(t) - registerTestKey(t, store, correlationID) - - port := freeUDPPort(t) - startTestDNSServer(t, &Options{ - Domains: []string{parentDomain}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - ListenIP: "127.0.0.1", - DnsPort: port, - Storage: store, - CorrelationIdLength: cidl, - CorrelationIdNonceLength: cidn, - Stats: &Metrics{}, - }) - - sendDNSQuery(t, port, fmt.Sprintf("%s%s.%s.", correlationID, nonce, parentDomain)) - - interactions, _, err := store.GetInteractions(correlationID, "secret") - require.NoError(t, err) - require.Len(t, interactions, 1) +func defaultQueryName(preamble, parent string) string { + return preamble + "." + parent + "." } func freeUDPPort(t *testing.T) int { From fd13189039b3f81ff17a4e0a07f0f93e16ffbf83 Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Wed, 13 May 2026 20:53:47 +0200 Subject: [PATCH 5/5] adding to smtp --- pkg/server/dns_server.go | 87 +++++--------- pkg/server/smtp_server.go | 100 +++++++---------- pkg/server/smtp_server_interaction_test.go | 125 +++++++++++++++++++++ pkg/server/util.go | 33 ++++++ 4 files changed, 229 insertions(+), 116 deletions(-) create mode 100644 pkg/server/smtp_server_interaction_test.go diff --git a/pkg/server/dns_server.go b/pkg/server/dns_server.go index 98b19870..c49c0943 100644 --- a/pkg/server/dns_server.go +++ b/pkg/server/dns_server.go @@ -9,7 +9,6 @@ import ( "sync/atomic" "time" - jsoniter "github.com/json-iterator/go" "github.com/miekg/dns" "github.com/pkg/errors" "github.com/projectdiscovery/gologger" @@ -315,21 +314,49 @@ func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dn } } + host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) + if h.options.RootTLD && foundDomain != "" { - h.storeRootTLDInteraction(foundDomain, domain, requestMsg, responseMsg, w, r) + interaction := &Interaction{ + Protocol: "dns", + UniqueID: domain, + FullId: domain, + QType: toQType(r.Question[0].Qtype), + RawRequest: requestMsg, + RawResponse: responseMsg, + RemoteAddress: host, + Timestamp: time.Now(), + } + if h.options.OnResult != nil { + h.options.OnResult(interaction) + } + h.options.storeRootTLDInteraction(interaction, foundDomain) } if foundDomain == "" { return } + storeMatch := func(uniqueID, fullID string) { + h.options.storeInteraction(&Interaction{ + Protocol: "dns", + UniqueID: uniqueID, + FullId: fullID, + QType: toQType(r.Question[0].Qtype), + RawRequest: requestMsg, + RawResponse: responseMsg, + RemoteAddress: host, + Timestamp: time.Now(), + }, uniqueID[:h.options.CorrelationIdLength]) + } + if h.options.ScanEverywhere { chunks := stringsutil.SplitAny(requestMsg, ".\n\t\"'") for _, chunk := range chunks { for part := range stringsutil.SlideWithLength(chunk, h.options.GetIdLength()) { normalizedPart := strings.ToLower(part) if h.options.isCorrelationID(normalizedPart) { - h.storeMatchedInteraction(normalizedPart, part, requestMsg, responseMsg, w, r) + storeMatch(normalizedPart, part) } } } @@ -347,63 +374,11 @@ func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dn if i+1 <= len(parts) { fullID = strings.Join(parts[:i+1], ".") } - h.storeMatchedInteraction(normalizedPartChunk, fullID, requestMsg, responseMsg, w, r) + storeMatch(normalizedPartChunk, fullID) } } } -func (h *DNSServer) storeRootTLDInteraction(rootDomain, fqdn, requestMsg, responseMsg string, w dns.ResponseWriter, r *dns.Msg) { - host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) - interaction := &Interaction{ - Protocol: "dns", - UniqueID: fqdn, - FullId: fqdn, - QType: toQType(r.Question[0].Qtype), - RawRequest: requestMsg, - RawResponse: responseMsg, - RemoteAddress: host, - Timestamp: time.Now(), - } - - if h.options.OnResult != nil { - h.options.OnResult(interaction) - } - - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode root tld dns interaction: %s\n", err) - return - } - gologger.Debug().Msgf("Root TLD DNS Interaction: \n%s\n", string(data)) - if err := h.options.Storage.AddInteractionWithId(rootDomain, data); err != nil { - gologger.Warning().Msgf("Could not store dns interaction: %s\n", err) - } -} - -func (h *DNSServer) storeMatchedInteraction(uniqueID, fullID, requestMsg, responseMsg string, w dns.ResponseWriter, r *dns.Msg) { - correlationID := uniqueID[:h.options.CorrelationIdLength] - host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) - interaction := &Interaction{ - Protocol: "dns", - UniqueID: uniqueID, - FullId: fullID, - QType: toQType(r.Question[0].Qtype), - RawRequest: requestMsg, - RawResponse: responseMsg, - RemoteAddress: host, - Timestamp: time.Now(), - } - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode dns interaction: %s\n", err) - return - } - gologger.Debug().Msgf("DNS Interaction: \n%s\n", string(data)) - if err := h.options.Storage.AddInteraction(correlationID, data); err != nil { - gologger.Warning().Msgf("Could not store dns interaction: %s\n", err) - } -} - // CustomRecordConfig represents a custom DNS record configuration type CustomRecordConfig struct { Type string `yaml:"type"` diff --git a/pkg/server/smtp_server.go b/pkg/server/smtp_server.go index 6c88ca06..f7cb2a2d 100644 --- a/pkg/server/smtp_server.go +++ b/pkg/server/smtp_server.go @@ -8,7 +8,6 @@ import ( "time" "git.mills.io/prologic/smtpd" - jsoniter "github.com/json-iterator/go" "github.com/projectdiscovery/gologger" stringsutil "github.com/projectdiscovery/utils/strings" ) @@ -84,77 +83,58 @@ func (h *SMTPServer) ListenAndServe(tlsConfig *tls.Config, smtpAlive, smtpsAlive func (h *SMTPServer) defaultHandler(remoteAddr net.Addr, from string, to []string, data []byte) error { atomic.AddUint64(&h.options.Stats.Smtp, 1) - var uniqueID, fullID string - dataString := string(data) gologger.Debug().Msgf("New SMTP request: %s %s %s %s\n", remoteAddr, from, to, dataString) - // if root-tld is enabled stores any interaction towards the main domain - for _, addr := range to { - if h.options.RootTLD { + host, _, _ := net.SplitHostPort(remoteAddr.String()) + + if h.options.RootTLD { + for _, addr := range to { for _, domain := range h.options.Domains { - if stringsutil.HasSuffixI(addr, domain) { - ID := domain - host, _, _ := net.SplitHostPort(remoteAddr.String()) - address := addr[strings.LastIndex(addr, "@"):] - interaction := &Interaction{ - Protocol: "smtp", - UniqueID: address, - FullId: address, - RawRequest: dataString, - SMTPFrom: from, - RemoteAddress: host, - Timestamp: time.Now(), - } - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode root tld SMTP interaction: %s\n", err) - } else { - gologger.Debug().Msgf("Root TLD SMTP Interaction: \n%s\n", string(data)) - if err := h.options.Storage.AddInteractionWithId(ID, data); err != nil { - gologger.Warning().Msgf("Could not store root tld smtp interaction: %s\n", err) - } - } + if !stringsutil.HasSuffixI(addr, domain) { + continue } + address := addr[strings.LastIndex(addr, "@"):] + h.options.storeRootTLDInteraction(&Interaction{ + Protocol: "smtp", + UniqueID: address, + FullId: address, + RawRequest: dataString, + SMTPFrom: from, + RemoteAddress: host, + Timestamp: time.Now(), + }, domain) } } } + // Each matched correlation id is stored independently; previously the loop + // captured a single uniqueID/fullID and stored it once after the loop, so + // later false-positive labels could overwrite the legitimate match and + // the interaction would be persisted under an unregistered id (issue #1362). for _, addr := range to { - if len(addr) > h.options.GetIdLength() && strings.Contains(addr, "@") { - parts := strings.Split(addr[strings.LastIndex(addr, "@")+1:], ".") - for i, part := range parts { - if h.options.isCorrelationID(part) { - uniqueID = part - fullID = part - if i+1 <= len(parts) { - fullID = strings.Join(parts[:i+1], ".") - } - } - } + if len(addr) <= h.options.GetIdLength() || !strings.Contains(addr, "@") { + continue } - } - if uniqueID != "" { - host, _, _ := net.SplitHostPort(remoteAddr.String()) - - correlationID := uniqueID[:h.options.CorrelationIdLength] - interaction := &Interaction{ - Protocol: "smtp", - UniqueID: uniqueID, - FullId: fullID, - RawRequest: dataString, - SMTPFrom: from, - RemoteAddress: host, - Timestamp: time.Now(), - } - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode smtp interaction: %s\n", err) - } else { - gologger.Debug().Msgf("%s\n", string(data)) - if err := h.options.Storage.AddInteraction(correlationID, data); err != nil { - gologger.Warning().Msgf("Could not store smtp interaction: %s\n", err) + parts := strings.Split(addr[strings.LastIndex(addr, "@")+1:], ".") + for i, part := range parts { + normalizedPart := strings.ToLower(part) + if !h.options.isCorrelationID(normalizedPart) { + continue + } + fullID := part + if i+1 <= len(parts) { + fullID = strings.Join(parts[:i+1], ".") } + h.options.storeInteraction(&Interaction{ + Protocol: "smtp", + UniqueID: normalizedPart, + FullId: fullID, + RawRequest: dataString, + SMTPFrom: from, + RemoteAddress: host, + Timestamp: time.Now(), + }, normalizedPart[:h.options.CorrelationIdLength]) } } return nil diff --git a/pkg/server/smtp_server_interaction_test.go b/pkg/server/smtp_server_interaction_test.go new file mode 100644 index 00000000..c7b48afd --- /dev/null +++ b/pkg/server/smtp_server_interaction_test.go @@ -0,0 +1,125 @@ +package server + +import ( + "net" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Regression suite for https://github.com/projectdiscovery/interactsh/issues/1362 +// covering the SMTP path. Each row exercises a different shape of correlation +// id and parent domain to guard against false-positive matches in ordinary +// domain labels overwriting legitimate ones, and against alphabet mismatches. +func TestSMTPInteractionStored(t *testing.T) { + cases := []struct { + name string + cidl, cidn int + correlationID string + nonce string + parentDomain string + toAddress func(preamble, parent string) string + expectStored int + }{ + { + name: "short ids with parent label rejected by alphabet (example.com)", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + expectStored: 1, + }, + { + name: "short ids with alphabet-compatible parent label (oast.online)", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "oast.online", + expectStored: 1, + }, + { + name: "default lengths", + cidl: 20, cidn: 13, + correlationID: "c6rj61aciaeutn2ae680", nonce: "cg5ugboyyyyyn", + parentDomain: "example.com", + expectStored: 1, + }, + { + name: "uppercase recipient is normalized", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + toAddress: func(preamble, parent string) string { + return "victim@" + strings.ToUpper(preamble) + "." + parent + }, + expectStored: 1, + }, + { + name: "multi-level subdomain prefix", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + toAddress: func(preamble, parent string) string { + return "victim@extra." + preamble + "." + parent + }, + expectStored: 1, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + store := newTestStorage(t) + registerTestKey(t, store, tc.correlationID) + + opts := &Options{ + Domains: []string{tc.parentDomain}, + ListenIP: "127.0.0.1", + Storage: store, + CorrelationIdLength: tc.cidl, + CorrelationIdNonceLength: tc.cidn, + Stats: &Metrics{}, + } + srv := &SMTPServer{options: opts} + + addr := defaultRecipient(tc.correlationID+tc.nonce, tc.parentDomain) + if tc.toAddress != nil { + addr = tc.toAddress(tc.correlationID+tc.nonce, tc.parentDomain) + } + remote := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345} + require.NoError(t, srv.defaultHandler(remote, "attacker@example.org", []string{addr}, []byte("DATA"))) + + interactions, _, err := store.GetInteractions(tc.correlationID, "secret") + require.NoError(t, err) + require.Len(t, interactions, tc.expectStored) + }) + } +} + +// Unregistered preambles must not produce stored interactions, even though +// they pass the alphabet check. This is the structural defense against the +// previous "last match wins" overwrite bug. +func TestSMTPInteractionNotStored_UnregisteredPreamble(t *testing.T) { + store := newTestStorage(t) + registerTestKey(t, store, "d82") + + opts := &Options{ + Domains: []string{"example.com"}, + ListenIP: "127.0.0.1", + Storage: store, + CorrelationIdLength: 3, + CorrelationIdNonceLength: 3, + Stats: &Metrics{}, + } + srv := &SMTPServer{options: opts} + + remote := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345} + require.NoError(t, srv.defaultHandler(remote, "attacker@example.org", []string{"victim@abcybn.example.com"}, []byte("DATA"))) + + interactions, _, err := store.GetInteractions("d82", "secret") + require.NoError(t, err) + require.Empty(t, interactions, "recipients that do not target a registered preamble must not be stored under another id") +} + +func defaultRecipient(preamble, parent string) string { + return "victim@" + preamble + "." + parent +} diff --git a/pkg/server/util.go b/pkg/server/util.go index 5abdba0e..c91259c6 100644 --- a/pkg/server/util.go +++ b/pkg/server/util.go @@ -3,6 +3,10 @@ package server import ( "net" "strconv" + "strings" + + jsoniter "github.com/json-iterator/go" + "github.com/projectdiscovery/gologger" ) // Correlation ids are produced from xid (cidl prefix) and zbase32 (cidn suffix), @@ -52,3 +56,32 @@ func (options *Options) isCorrelationID(s string) bool { func formatAddress(host string, port int) string { return net.JoinHostPort(host, strconv.Itoa(port)) } + +// storeInteraction marshals interaction and persists it under correlationID via Storage.AddInteraction. +// Each protocol handler builds its own protocol-specific Interaction and delegates the marshal/log/store +// step here so every match is stored independently (see issue #1362). +func (options *Options) storeInteraction(interaction *Interaction, correlationID string) { + data, err := jsoniter.Marshal(interaction) + if err != nil { + gologger.Warning().Msgf("Could not encode %s interaction: %s\n", interaction.Protocol, err) + return + } + gologger.Debug().Msgf("%s Interaction: \n%s\n", strings.ToUpper(interaction.Protocol), string(data)) + if err := options.Storage.AddInteraction(correlationID, data); err != nil { + gologger.Warning().Msgf("Could not store %s interaction: %s\n", interaction.Protocol, err) + } +} + +// storeRootTLDInteraction marshals interaction and persists it under id via Storage.AddInteractionWithId. +// Used when RootTLD is enabled and the request targets a configured parent domain directly. +func (options *Options) storeRootTLDInteraction(interaction *Interaction, id string) { + data, err := jsoniter.Marshal(interaction) + if err != nil { + gologger.Warning().Msgf("Could not encode root tld %s interaction: %s\n", interaction.Protocol, err) + return + } + gologger.Debug().Msgf("Root TLD %s Interaction: \n%s\n", strings.ToUpper(interaction.Protocol), string(data)) + if err := options.Storage.AddInteractionWithId(id, data); err != nil { + gologger.Warning().Msgf("Could not store root tld %s interaction: %s\n", interaction.Protocol, err) + } +}