diff --git a/go.mod b/go.mod
index 02122c1e..fdbd1f80 100644
--- a/go.mod
+++ b/go.mod
@@ -3,10 +3,11 @@ module github.com/projectdiscovery/interactsh
go 1.24.13
require (
- git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a
github.com/Mzack9999/goimpacket v0.0.0-20260420131935-a9fe473cda7d
github.com/caddyserver/certmagic v0.25.0
github.com/docker/go-units v0.5.0
+ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
+ github.com/emersion/go-smtp v0.24.0
github.com/goburrow/cache v0.1.4
github.com/google/uuid v1.6.0
github.com/json-iterator/go v1.1.12
diff --git a/go.sum b/go.sum
index 3e821750..5de50f1f 100644
--- a/go.sum
+++ b/go.sum
@@ -19,8 +19,6 @@ cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a h1:3i+FJ7IpSZHL+VAjtpQeZCRhrpP0odl5XfoLBY4fxJ8=
-git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a/go.mod h1:C7hXLmFmPYPjIDGfQl1clsmQ5TMEQfmzWTrJk475bUs=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -126,6 +124,10 @@ github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdf
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
+github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
+github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
+github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
diff --git a/pkg/server/smtp_backend.go b/pkg/server/smtp_backend.go
new file mode 100644
index 00000000..7b940d57
--- /dev/null
+++ b/pkg/server/smtp_backend.go
@@ -0,0 +1,127 @@
+package server
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net"
+ "sync/atomic"
+ "time"
+
+ "github.com/emersion/go-sasl"
+ "github.com/emersion/go-smtp"
+ "github.com/projectdiscovery/gologger"
+)
+
+// interactshBackend is the emersion/go-smtp Backend for collaborator SMTP.
+type interactshBackend struct {
+ srv *SMTPServer
+}
+
+func (b *interactshBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
+ return &interactshSession{
+ srv: b.srv,
+ remote: c.Conn().RemoteAddr(),
+ }, nil
+}
+
+// interactshSession accepts all mail and auth, then forwards message bodies
+// to the existing interaction storage path.
+type interactshSession struct {
+ srv *SMTPServer
+ remote net.Addr
+ from string
+ to []string
+}
+
+func (s *interactshSession) AuthMechanisms() []string {
+ return []string{sasl.Plain, sasl.Login}
+}
+
+func (s *interactshSession) Auth(mech string) (sasl.Server, error) {
+ switch mech {
+ case sasl.Plain:
+ return sasl.NewPlainServer(func(identity, username, password string) error {
+ return nil
+ }), nil
+ case sasl.Login:
+ return &acceptLoginServer{}, nil
+ default:
+ return nil, smtp.ErrAuthUnknownMechanism
+ }
+}
+
+func (s *interactshSession) Mail(from string, _ *smtp.MailOptions) error {
+ s.from = from
+ return nil
+}
+
+func (s *interactshSession) Rcpt(to string, _ *smtp.RcptOptions) error {
+ s.to = append(s.to, to)
+ return nil
+}
+
+func (s *interactshSession) Data(r io.Reader) error {
+ body, err := io.ReadAll(r)
+ if err != nil {
+ return err
+ }
+ return s.srv.deliverSMTP(s.remote, s.from, s.to, body)
+}
+
+func (s *interactshSession) Reset() {
+ s.from = ""
+ s.to = nil
+}
+
+func (s *interactshSession) Logout() error {
+ return nil
+}
+
+// acceptLoginServer implements LOGIN auth and always succeeds, matching the
+// previous interactsh behavior of accepting any credentials.
+type acceptLoginServer struct {
+ step int
+}
+
+func (a *acceptLoginServer) Next(response []byte) (challenge []byte, done bool, err error) {
+ a.step++
+ switch a.step {
+ case 1:
+ return []byte("Password:"), false, nil
+ case 2:
+ return nil, true, nil
+ default:
+ return nil, true, nil
+ }
+}
+
+func (h *SMTPServer) deliverSMTP(remoteAddr net.Addr, from string, to []string, body []byte) error {
+ atomic.AddUint64(&h.options.Stats.Smtp, 1)
+
+ buf := bytes.NewBuffer(makeSMTPHeaders(remoteAddr, h.options.Domains[0], to))
+ buf.Write(body)
+ dataString := buf.String()
+
+ gologger.Debug().Msgf("New SMTP request: %s %s %v %s\n", remoteAddr, from, to, dataString)
+ h.storeSMTPRecipients(remoteAddr, from, dataString, to)
+ return nil
+}
+
+// makeSMTPHeaders mirrors the Received header the previous smtpd integration
+// attached before the message body.
+func makeSMTPHeaders(remoteAddr net.Addr, hostname string, to []string) []byte {
+ if len(to) == 0 {
+ return nil
+ }
+ host, _, _ := net.SplitHostPort(remoteAddr.String())
+ if host == "" {
+ host = remoteAddr.String()
+ }
+ now := time.Now().Format("Mon, _2 Jan 2006 15:04:05 -0700 (MST)")
+ var buffer bytes.Buffer
+ fmt.Fprintf(&buffer, "Received: from %s ([%s])\r\n", host, host)
+ fmt.Fprintf(&buffer, " by %s (interactsh) with SMTP\r\n", hostname)
+ fmt.Fprintf(&buffer, " for <%s>; %s\r\n", to[0], now)
+ return buffer.Bytes()
+}
diff --git a/pkg/server/smtp_server.go b/pkg/server/smtp_server.go
index f7cb2a2d..74d2afe3 100644
--- a/pkg/server/smtp_server.go
+++ b/pkg/server/smtp_server.go
@@ -4,10 +4,9 @@ import (
"crypto/tls"
"net"
"strings"
- "sync/atomic"
"time"
- "git.mills.io/prologic/smtpd"
+ "github.com/emersion/go-smtp"
"github.com/projectdiscovery/gologger"
stringsutil "github.com/projectdiscovery/utils/strings"
)
@@ -15,56 +14,45 @@ import (
// SMTPServer is a smtp server instance that listens both
// TLS and Non-TLS based servers.
type SMTPServer struct {
- options *Options
- smtpServer smtpd.Server
- smtpsServer smtpd.Server
+ options *Options
+ backend *interactshBackend
+ smtpServer *smtp.Server
+ smtpsServer *smtp.Server
+ smtpAutoTLSServer *smtp.Server
}
// NewSMTPServer returns a new TLS & Non-TLS SMTP server.
func NewSMTPServer(options *Options) (*SMTPServer, error) {
server := &SMTPServer{options: options}
+ server.backend = &interactshBackend{srv: server}
- authHandler := func(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
- return true, nil
- }
- rcptHandler := func(remoteAddr net.Addr, from string, to string) bool {
- return true
- }
- server.smtpServer = smtpd.Server{
- Addr: formatAddress(options.ListenIP, options.SmtpPort),
- AuthHandler: authHandler,
- HandlerRcpt: rcptHandler,
- Hostname: options.Domains[0],
- Appname: "interactsh",
- Handler: smtpd.Handler(server.defaultHandler),
- }
- server.smtpsServer = smtpd.Server{
- Addr: formatAddress(options.ListenIP, options.SmtpsPort),
- AuthHandler: authHandler,
- HandlerRcpt: rcptHandler,
- Hostname: options.Domains[0],
- Appname: "interactsh",
- Handler: smtpd.Handler(server.defaultHandler),
+ newEmersionServer := func(addr string, tlsConfig *tls.Config) *smtp.Server {
+ s := smtp.NewServer(server.backend)
+ s.Addr = addr
+ s.Domain = options.Domains[0]
+ s.AllowInsecureAuth = true
+ s.TLSConfig = tlsConfig
+ return s
}
+
+ server.smtpServer = newEmersionServer(formatAddress(options.ListenIP, options.SmtpPort), nil)
+ server.smtpsServer = newEmersionServer(formatAddress(options.ListenIP, options.SmtpsPort), nil)
+ server.smtpAutoTLSServer = newEmersionServer(formatAddress(options.ListenIP, options.SmtpAutoTLSPort), nil)
return server, nil
}
// ListenAndServe listens on smtp and/or smtps ports for the server.
func (h *SMTPServer) ListenAndServe(tlsConfig *tls.Config, smtpAlive, smtpsAlive chan bool) {
- go func() {
- if tlsConfig == nil {
- return
- }
- srv := &smtpd.Server{Addr: formatAddress(h.options.ListenIP, h.options.SmtpAutoTLSPort), Handler: h.defaultHandler, Appname: "interactsh", Hostname: h.options.Domains[0]}
- srv.TLSConfig = tlsConfig
-
- smtpsAlive <- true
- err := srv.ListenAndServe()
- if err != nil {
- gologger.Error().Msgf("Could not serve smtp with tls on port %d: %s\n", h.options.SmtpAutoTLSPort, err)
- smtpsAlive <- false
- }
- }()
+ if tlsConfig != nil {
+ h.smtpAutoTLSServer.TLSConfig = tlsConfig
+ go func() {
+ smtpsAlive <- true
+ if err := h.smtpAutoTLSServer.ListenAndServeTLS(); err != nil {
+ gologger.Error().Msgf("Could not serve smtp with tls on port %d: %s\n", h.options.SmtpAutoTLSPort, err)
+ smtpsAlive <- false
+ }
+ }()
+ }
smtpAlive <- true
go func() {
@@ -75,21 +63,23 @@ func (h *SMTPServer) ListenAndServe(tlsConfig *tls.Config, smtpAlive, smtpsAlive
}()
if err := h.smtpsServer.ListenAndServe(); err != nil {
gologger.Error().Msgf("Could not serve smtp on port %d: %s\n", h.options.SmtpsPort, err)
- smtpAlive <- false
+ smtpsAlive <- false
}
}
-// defaultHandler is a handler for default collaborator requests
+// defaultHandler is kept for unit tests that exercise storage without
+// standing up a TCP listener.
func (h *SMTPServer) defaultHandler(remoteAddr net.Addr, from string, to []string, data []byte) error {
- atomic.AddUint64(&h.options.Stats.Smtp, 1)
-
- dataString := string(data)
- gologger.Debug().Msgf("New SMTP request: %s %s %s %s\n", remoteAddr, from, to, dataString)
+ return h.deliverSMTP(remoteAddr, from, to, data)
+}
+// storeSMTPRecipients persists SMTP interactions for correlation ids found
+// in the recipient addresses.
+func (h *SMTPServer) storeSMTPRecipients(remoteAddr net.Addr, from, rawRequest string, recipients []string) {
host, _, _ := net.SplitHostPort(remoteAddr.String())
if h.options.RootTLD {
- for _, addr := range to {
+ for _, addr := range recipients {
for _, domain := range h.options.Domains {
if !stringsutil.HasSuffixI(addr, domain) {
continue
@@ -99,7 +89,7 @@ func (h *SMTPServer) defaultHandler(remoteAddr net.Addr, from string, to []strin
Protocol: "smtp",
UniqueID: address,
FullId: address,
- RawRequest: dataString,
+ RawRequest: rawRequest,
SMTPFrom: from,
RemoteAddress: host,
Timestamp: time.Now(),
@@ -112,7 +102,7 @@ func (h *SMTPServer) defaultHandler(remoteAddr net.Addr, from string, to []strin
// 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 {
+ for _, addr := range recipients {
if len(addr) <= h.options.GetIdLength() || !strings.Contains(addr, "@") {
continue
}
@@ -130,12 +120,11 @@ func (h *SMTPServer) defaultHandler(remoteAddr net.Addr, from string, to []strin
Protocol: "smtp",
UniqueID: normalizedPart,
FullId: fullID,
- RawRequest: dataString,
+ RawRequest: rawRequest,
SMTPFrom: from,
RemoteAddress: host,
Timestamp: time.Now(),
}, normalizedPart[:h.options.CorrelationIdLength])
}
}
- return nil
}
diff --git a/pkg/server/smtp_vrfy_test.go b/pkg/server/smtp_vrfy_test.go
new file mode 100644
index 00000000..08e204ef
--- /dev/null
+++ b/pkg/server/smtp_vrfy_test.go
@@ -0,0 +1,105 @@
+package server
+
+import (
+ "bufio"
+ "fmt"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestSMTPVRFYOnlyReturns252 reproduces the curl flow from issue #991: EHLO,
+// VRFY, then further commands. Before the fix VRFY returned 502 and clients
+// aborted before MAIL; here we assert 252 and that the session stays usable.
+func TestSMTPVRFYOnlyReturns252(t *testing.T) {
+ opts := &Options{
+ Domains: []string{"oast.fun"},
+ ListenIP: "127.0.0.1",
+ Stats: &Metrics{},
+ }
+ smtpSrv, err := NewSMTPServer(opts)
+ require.NoError(t, err)
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = ln.Close() })
+
+ go func() { _ = smtpSrv.smtpServer.Serve(ln) }()
+
+ conn, err := net.Dial("tcp", ln.Addr().String())
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = conn.Close() })
+
+ br := bufio.NewReader(conn)
+ _, err = br.ReadString('\n')
+ require.NoError(t, err)
+
+ require.Equal(t, "250", smtpCmdCode(t, conn, "EHLO skMac2-5"))
+ require.Equal(t, "252", smtpCmdCode(t, conn, "VRFY b@abcybn.oast.fun"))
+ // Session must remain open for later SMTP commands (curl aborted here on 502).
+ require.Equal(t, "250", smtpCmdCode(t, conn, "MAIL FROM:"))
+ require.Equal(t, "221", smtpCmdCode(t, conn, "QUIT"))
+}
+
+// Regression for https://github.com/projectdiscovery/interactsh/issues/991:
+// VRFY must not return 502 so clients (e.g. curl) can continue to MAIL/DATA.
+func TestSMTPVRFYCommandReturns252(t *testing.T) {
+ store := newTestStorage(t)
+ registerTestKey(t, store, "ab")
+
+ opts := &Options{
+ Domains: []string{"example.com"},
+ ListenIP: "127.0.0.1",
+ Storage: store,
+ CorrelationIdLength: 2,
+ CorrelationIdNonceLength: 1,
+ Stats: &Metrics{},
+ }
+ smtpSrv, err := NewSMTPServer(opts)
+ require.NoError(t, err)
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = ln.Close() })
+
+ go func() { _ = smtpSrv.smtpServer.Serve(ln) }()
+
+ conn, err := net.Dial("tcp", ln.Addr().String())
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = conn.Close() })
+
+ br := bufio.NewReader(conn)
+ _, err = br.ReadString('\n') // banner
+ require.NoError(t, err)
+
+ require.Equal(t, "250", smtpCmdCode(t, conn, "EHLO testclient"))
+ require.Equal(t, "252", smtpCmdCode(t, conn, "VRFY victim@aby.example.com"))
+ require.Equal(t, "250", smtpCmdCode(t, conn, "MAIL FROM:"))
+ require.Equal(t, "250", smtpCmdCode(t, conn, "RCPT TO:"))
+ require.Equal(t, "354", smtpCmdCode(t, conn, "DATA"))
+ require.Equal(t, "250", smtpCmdCode(t, conn, "Subject: test\r\n\r\nbody\r\n."))
+ require.Equal(t, "221", smtpCmdCode(t, conn, "QUIT"))
+
+ interactions, _, err := store.GetInteractions("ab", "secret")
+ require.NoError(t, err)
+ require.NotEmpty(t, interactions, "completed mail transaction must be stored")
+}
+
+func smtpCmdCode(t *testing.T, conn net.Conn, cmd string) string {
+ t.Helper()
+ _, err := fmt.Fprintf(conn, "%s\r\n", cmd)
+ require.NoError(t, err)
+ _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ br := bufio.NewReader(conn)
+ for {
+ resp, err := br.ReadString('\n')
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, len(resp), 3)
+ // SMTP multiline replies use "250-..." until the final "250 ..." line.
+ if len(resp) >= 4 && resp[3] == ' ' {
+ return resp[0:3]
+ }
+ }
+}