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] + } + } +}