From cb585c63b65fe091418261176897406f65ff92ef Mon Sep 17 00:00:00 2001 From: Velkris Date: Wed, 26 Aug 2026 18:00:58 -0400 Subject: [PATCH 1/2] Add DNS staging with generated labels The dns-beacon block shipped commented out in Struct.go carrying Cobalt Strike's documentation example values (doc.bc., doc.1a., doc.tx., doc.md., doc-stg-sh. and the rest) under a note telling operators to add them manually. Those labels are among the most heavily signatured DNS C2 indicators there are, so following that instruction produces the most detectable configuration available. The block is now generated behind an opt-in -DNS flag, with all eight staging and request labels randomized per profile and the get_/put_ prefixes guaranteed distinct. dns_idle is exposed as -DNSIdle instead of being randomized, since it is the no-tasks sentinel and must not collide with a real answer. The numeric tuning values are unchanged. Co-Authored-By: Claude Opus 5 --- Loader/DNS.go | 137 +++++++++++++++++++++++++++++++++++++++++++++ Loader/DNS_test.go | 103 ++++++++++++++++++++++++++++++++++ Loader/Loader.go | 6 +- Sample.yaml | 2 + SourcePoint.go | 14 ++++- Struct/Struct.go | 20 +------ 6 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 Loader/DNS.go create mode 100644 Loader/DNS_test.go diff --git a/Loader/DNS.go b/Loader/DNS.go new file mode 100644 index 0000000..d11364b --- /dev/null +++ b/Loader/DNS.go @@ -0,0 +1,137 @@ +package Loader + +import ( + crand "crypto/rand" + "encoding/binary" + "log" + "math/rand" + "net" + "strings" + "time" +) + +const ( + dnsLabelFirstChars = "abcdefghijklmnopqrstuvwxyz" + dnsLabelChars = "abcdefghijklmnopqrstuvwxyz0123456789" + + // dnsPadWidth aligns the generated directives the way the block was + // originally written out by hand. + dnsPadWidth = 21 +) + +// dnsRNG is seeded once from crypto/rand so that labels do not repeat between +// profiles generated in quick succession. +var dnsRNG = rand.New(rand.NewSource(dnsSeed())) + +func dnsSeed() int64 { + var b [8]byte + if _, err := crand.Read(b[:]); err != nil { + return time.Now().UnixNano() + } + return int64(binary.LittleEndian.Uint64(b[:])) +} + +// dnsLabel returns a lowercase DNS label of length n. The first character is +// always a letter: RFC 1123 permits a leading digit, but a leading letter is +// handled more predictably across resolvers. +func dnsLabel(n int) string { + if n < 1 { + n = 1 + } + b := make([]byte, n) + b[0] = dnsLabelFirstChars[dnsRNG.Intn(len(dnsLabelFirstChars))] + for i := 1; i < n; i++ { + b[i] = dnsLabelChars[dnsRNG.Intn(len(dnsLabelChars))] + } + return string(b) +} + +// dnsDistinctLabels returns count distinct labels, starting at length n. The +// get_*/put_* prefixes are how the teamserver tells one request type from +// another, so a collision between any two would break the channel rather than +// merely look wrong. A collision widens the label space instead of spinning. +func dnsDistinctLabels(count, n int) []string { + out := make([]string, 0, count) + seen := make(map[string]bool, count) + for len(out) < count { + label := dnsLabel(n) + if seen[label] { + n++ + continue + } + seen[label] = true + out = append(out, label) + } + return out +} + +func dnsPad(key string) string { + if len(key) >= dnsPadWidth { + return " " + } + return strings.Repeat(" ", dnsPadWidth-len(key)) +} + +// GenerateDNSBeacon renders the dns-beacon block for the generated profile, or +// an empty string when DNS was not requested. +// +// The block previously shipped commented out in Struct.go, carrying Cobalt +// Strike's documentation example values ("doc.bc.", "doc.1a.", "doc.tx.", +// "doc.md.", "doc-stg-sh." and the rest) under a note telling operators to add +// them manually. Those exact labels are the most heavily signatured DNS C2 +// indicators there are, so pasting them in unchanged is the worst possible +// starting point. Every label is now generated per profile. +// +// The numeric tuning values are deliberately left as they were written. +// dns_max_txt, dns_sleep, dns_ttl and maxdns affect throughput and reliability +// rather than signature, and choosing them is an operator decision. +func GenerateDNSBeacon(enabled bool, dnsIdle string) string { + if !enabled { + return "" + } + if dnsIdle == "" { + // Cobalt Strike's own default. dns_idle is the "no tasks" sentinel, so + // it must not collide with an address the operator's domain genuinely + // serves, which makes it an operator choice rather than something to + // randomize. + dnsIdle = "0.0.0.0" + } + if ip := net.ParseIP(dnsIdle); ip == nil || ip.To4() == nil { + log.Fatalf("Error: -DNSIdle must be an IPv4 address, got %q", dnsIdle) + } + + // The documentation values share a first label and vary the second + // ("doc.bc.", "doc.tx."). Keeping that shape keeps the queries short. + base := dnsLabel(3 + dnsRNG.Intn(3)) + labels := dnsDistinctLabels(7, 2) + + directives := []struct{ key, value string }{ + {"dns_idle", dnsIdle}, + {"dns_max_txt", "199"}, + {"dns_sleep", "1"}, + {"dns_ttl", "5"}, + {"maxdns", "200"}, + {"dns_stager_prepend", dnsLabel(6 + dnsRNG.Intn(5))}, + {"dns_stager_subhost", base + "." + labels[6] + "."}, + {"", ""}, + {"beacon", base + "." + labels[0] + "."}, + {"get_A", base + "." + labels[1] + "."}, + {"get_AAAA", base + "." + labels[2] + "."}, + {"get_TXT", base + "." + labels[3] + "."}, + {"put_metadata", base + "." + labels[4] + "."}, + {"put_output", base + "." + labels[5] + "."}, + {"ns_response", "zero"}, + } + + var b strings.Builder + b.WriteString("\ndns-beacon {\n") + for _, d := range directives { + if d.key == "" { + b.WriteString("\n") + continue + } + b.WriteString(" set " + d.key + dnsPad(d.key) + "\"" + d.value + "\";\n") + } + b.WriteString("}\n") + return b.String() +} diff --git a/Loader/DNS_test.go b/Loader/DNS_test.go new file mode 100644 index 0000000..20ab140 --- /dev/null +++ b/Loader/DNS_test.go @@ -0,0 +1,103 @@ +package Loader + +import ( + "regexp" + "strings" + "testing" +) + +var dnsDirective = regexp.MustCompile(`set\s+(\S+)\s+"([^"]*)";`) + +func dnsValues(t *testing.T, block string) map[string]string { + t.Helper() + out := make(map[string]string) + for _, m := range dnsDirective.FindAllStringSubmatch(block, -1) { + out[m[1]] = m[2] + } + return out +} + +// DNS is opt-in, so a profile generated without it must be unchanged. +func TestDNSBeaconOmittedWhenDisabled(t *testing.T) { + if got := GenerateDNSBeacon(false, ""); got != "" { + t.Errorf("expected no dns-beacon block when DNS is off, got %q", got) + } +} + +// The block shipped carrying Cobalt Strike's documentation example values, +// which are the most heavily signatured DNS C2 labels in existence. None of +// them may survive into a generated profile. +func TestDNSBeaconDropsDocumentationValues(t *testing.T) { + block := GenerateDNSBeacon(true, "") + docValues := []string{ + "doc.bc.", "doc.1a.", "doc.4a.", "doc.tx.", "doc.md.", "doc.po.", + "doc-stg-prepend", "doc-stg-sh.", + } + for _, v := range docValues { + if strings.Contains(block, v) { + t.Errorf("block still carries the documentation value %q", v) + } + } +} + +// The get_*/put_* prefixes are how the teamserver distinguishes request types. +// Two sharing a value would break the channel, not just look wrong. +func TestDNSBeaconLabelsAreDistinct(t *testing.T) { + keys := []string{ + "beacon", "get_A", "get_AAAA", "get_TXT", + "put_metadata", "put_output", "dns_stager_subhost", + } + for i := 0; i < 50; i++ { + v := dnsValues(t, GenerateDNSBeacon(true, "")) + owner := make(map[string]string, len(keys)) + for _, k := range keys { + got := v[k] + if got == "" { + t.Fatalf("%s was not set", k) + } + if prev, dup := owner[got]; dup { + t.Fatalf("%s and %s share the prefix %q", prev, k, got) + } + owner[got] = k + } + } +} + +func TestDNSBeaconVariesBetweenProfiles(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 25; i++ { + v := dnsValues(t, GenerateDNSBeacon(true, "")) + if seen[v["beacon"]] { + t.Fatalf("beacon prefix repeated across profiles: %q", v["beacon"]) + } + seen[v["beacon"]] = true + } +} + +// dns_idle is the "no tasks" sentinel, so it stays an operator decision rather +// than something the generator randomizes. +func TestDNSIdleDefaultsAndHonoursOperatorValue(t *testing.T) { + if got := dnsValues(t, GenerateDNSBeacon(true, ""))["dns_idle"]; got != "0.0.0.0" { + t.Errorf("dns_idle default = %q, want 0.0.0.0", got) + } + if got := dnsValues(t, GenerateDNSBeacon(true, "8.8.4.4"))["dns_idle"]; got != "8.8.4.4" { + t.Errorf("dns_idle = %q, want 8.8.4.4", got) + } +} + +// Every label must be a syntactically valid lowercase DNS label sequence. +func TestDNSBeaconLabelsAreValid(t *testing.T) { + valid := regexp.MustCompile(`^([a-z][a-z0-9]*\.)+$`) + keys := []string{ + "beacon", "get_A", "get_AAAA", "get_TXT", + "put_metadata", "put_output", "dns_stager_subhost", + } + for i := 0; i < 50; i++ { + v := dnsValues(t, GenerateDNSBeacon(true, "")) + for _, k := range keys { + if !valid.MatchString(v[k]) { + t.Errorf("%s = %q is not a valid dotted label sequence", k, v[k]) + } + } + } +} diff --git a/Loader/Loader.go b/Loader/Loader.go index 7c1579a..5b68f05 100644 --- a/Loader/Loader.go +++ b/Loader/Loader.go @@ -75,7 +75,7 @@ type Beacon_SSL struct { var num_Profile int var Post bool -func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, customuriGET, customuriPOST, beacon_PE, processinject_min_alloc, Post_EX_Process_Name, metadata, injector, Host, Profile, ProfilePath, outFile, custom_cert, cert_password, CDN, CDN_Value, datajitter, Keylogger string, Forwarder bool, tasks_max_size string, tasks_proxy_max_size string, tasks_dns_proxy_max_size string, syscall_method string, httplib string, ThreadSpoof bool, beacongate string, eaf_bypass bool, rdll_use_syscalls bool, copy_pe_header bool, rdll_loader string, transform_obfuscate string, smartinject bool, sleep_mask bool) { +func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, customuriGET, customuriPOST, beacon_PE, processinject_min_alloc, Post_EX_Process_Name, metadata, injector, Host, Profile, ProfilePath, outFile, custom_cert, cert_password, CDN, CDN_Value, datajitter, Keylogger string, Forwarder bool, tasks_max_size string, tasks_proxy_max_size string, tasks_dns_proxy_max_size string, syscall_method string, httplib string, ThreadSpoof bool, beacongate string, eaf_bypass bool, rdll_use_syscalls bool, copy_pe_header bool, rdll_loader string, transform_obfuscate string, smartinject bool, sleep_mask bool, dns bool, dns_idle string) { Beacon_Com := &Beacon_Com{} Beacon_Stage_p1 := &Beacon_Stage_p1{} Beacon_Stage_p2 := &Beacon_Stage_p2{} @@ -89,6 +89,7 @@ func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, custom fmt.Println("[*] Preparing Varibles...") HostStageMessage, Beacon_Com.Variables = GenerateComunication(stage, sleeptime, jitter, useragent, datajitter, tasks_max_size, tasks_proxy_max_size, tasks_dns_proxy_max_size, httplib) + Beacon_Com.Variables["dns_beacon"] = GenerateDNSBeacon(dns, dns_idle) Beacon_PostEX.Variables = GeneratePostProcessName(Post_EX_Process_Name, Keylogger, ThreadSpoof) Beacon_GETPOST.Variables = GenerateHTTPVaribles(Host, metadata, uri, customuri, customuriGET, customuriPOST, CDN, CDN_Value, Profile, Forwarder) Beacon_Stage_p1.Variables, Beacon_Stage_p2.Variables, syscall_method = GeneratePE(beacon_PE, syscall_method, beacongate, eaf_bypass, rdll_use_syscalls, copy_pe_header, rdll_loader, transform_obfuscate, smartinject, sleep_mask) @@ -112,6 +113,9 @@ func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, custom } else { fmt.Println("[!] " + syscall_method + " syscall method selected") } + if dns { + fmt.Println("[!] DNS Beacon Block Added With Randomized Labels") + } Name, _ := strconv.Atoi(Profile) fmt.Println("[*] Seleted Profile: " + Struct.Profile_Names[Name]) fmt.Println("[+] Profile Generated: " + outFile) diff --git a/Sample.yaml b/Sample.yaml index 8ce7fc3..082648e 100644 --- a/Sample.yaml +++ b/Sample.yaml @@ -38,4 +38,6 @@ TransformObfuscate: "lznt1,xor \"32\"" SmartInject: False BeaconGate: "All" SleepMask: False +DNS: False +DNSIdle: "0.0.0.0" diff --git a/SourcePoint.go b/SourcePoint.go index 591407a..3b0769c 100644 --- a/SourcePoint.go +++ b/SourcePoint.go @@ -51,6 +51,8 @@ type FlagOptions struct { transform_obfuscate string smartinject bool sleep_mask bool + dns bool + dns_idle string } type conf struct { @@ -93,6 +95,8 @@ type conf struct { TransformObfuscate string `yaml:"TransformObfuscate"` SmartInject bool `yaml:"SmartInject"` SleepMask bool `yaml:"SleepMask"` + DNS bool `yaml:"DNS"` + DNSIdle string `yaml:"DNSIdle"` } func (c *conf) getConf(yamlfile string) *conf { @@ -227,8 +231,10 @@ func options() *FlagOptions { Example: "lznt1,rc4 \"64\",xor \"32\",base64"`) smartinject := flag.Bool("SmartInject", false, "Enable Smart Inject") sleep_mask := flag.Bool("SleepMask", true, "Enable Sleep Mask") + dns := flag.Bool("DNS", false, "Add a dns-beacon block for C2 over DNS, with all staging and request labels randomly generated") + dns_idle := flag.String("DNSIdle", "0.0.0.0", "IPv4 address returned when the beacon has no tasks (only used with -DNS)") flag.Parse() - return &FlagOptions{stage: *stage, sleeptime: *sleeptime, jitter: *jitter, useragent: *useragent, uri: *uri, customuri: *customuri, customuriGET: *customuriGET, customuriPOST: *customuriPOST, beacon_PE: *beacon_PE, processinject_min_alloc: *processinject_min_alloc, Post_EX_Process_Name: *Post_EX_Process_Name, metadata: *metadata, injector: *injector, Host: *Host, Profile: *Profile, ProfilePath: *ProfilePath, outFile: *outFile, custom_cert: *custom_cert, cert_password: *cert_password, CDN: *CDN, CDN_Value: *CDN_Value, Yaml: *Yaml, Datajitter: *Datajitter, Keylogger: *Keylogger, Forwarder: *Forwarder, tasks_max_size: *tasks_max_size, tasks_proxy_max_size: *tasks_proxy_max_size, tasks_dns_proxy_max_size: *tasks_dns_proxy_max_size, syscall_method: *syscall_method, httplib: *httplib, threadspoof: *threadspoof, beacongate: *beacongate, eaf_bypass: *eaf_bypass, rdll_use_syscalls: *rdll_use_syscalls, copy_pe_header: *copy_pe_header, rdll_loader: *rdll_loader, transform_obfuscate: *transform_obfuscate, smartinject: *smartinject, sleep_mask: *sleep_mask} + return &FlagOptions{stage: *stage, sleeptime: *sleeptime, jitter: *jitter, useragent: *useragent, uri: *uri, customuri: *customuri, customuriGET: *customuriGET, customuriPOST: *customuriPOST, beacon_PE: *beacon_PE, processinject_min_alloc: *processinject_min_alloc, Post_EX_Process_Name: *Post_EX_Process_Name, metadata: *metadata, injector: *injector, Host: *Host, Profile: *Profile, ProfilePath: *ProfilePath, outFile: *outFile, custom_cert: *custom_cert, cert_password: *cert_password, CDN: *CDN, CDN_Value: *CDN_Value, Yaml: *Yaml, Datajitter: *Datajitter, Keylogger: *Keylogger, Forwarder: *Forwarder, tasks_max_size: *tasks_max_size, tasks_proxy_max_size: *tasks_proxy_max_size, tasks_dns_proxy_max_size: *tasks_dns_proxy_max_size, syscall_method: *syscall_method, httplib: *httplib, threadspoof: *threadspoof, beacongate: *beacongate, eaf_bypass: *eaf_bypass, rdll_use_syscalls: *rdll_use_syscalls, copy_pe_header: *copy_pe_header, rdll_loader: *rdll_loader, transform_obfuscate: *transform_obfuscate, smartinject: *smartinject, sleep_mask: *sleep_mask, dns: *dns, dns_idle: *dns_idle} } @@ -283,6 +289,10 @@ func main() { opt.transform_obfuscate = c.TransformObfuscate opt.smartinject = c.SmartInject opt.sleep_mask = c.SleepMask + opt.dns = c.DNS + if c.DNSIdle != "" { + opt.dns_idle = c.DNSIdle + } } if opt.outFile == "" { @@ -298,5 +308,5 @@ func main() { log.Fatal("Error: When using CustomuriGET/CustomuriPOST, both must be sepecified") } fmt.Println(c.TasksMaxSize) - Loader.GenerateOptions(opt.stage, opt.sleeptime, opt.jitter, opt.useragent, opt.uri, opt.customuri, opt.customuriGET, opt.customuriPOST, opt.beacon_PE, opt.processinject_min_alloc, opt.Post_EX_Process_Name, opt.metadata, opt.injector, opt.Host, opt.Profile, opt.ProfilePath, opt.outFile, opt.custom_cert, opt.cert_password, opt.CDN, opt.CDN_Value, opt.Datajitter, opt.Keylogger, opt.Forwarder, opt.tasks_max_size, opt.tasks_proxy_max_size, opt.tasks_dns_proxy_max_size, opt.syscall_method, opt.httplib, opt.threadspoof, opt.beacongate, opt.eaf_bypass, opt.rdll_use_syscalls, opt.copy_pe_header, opt.rdll_loader, opt.transform_obfuscate, opt.smartinject, opt.sleep_mask) + Loader.GenerateOptions(opt.stage, opt.sleeptime, opt.jitter, opt.useragent, opt.uri, opt.customuri, opt.customuriGET, opt.customuriPOST, opt.beacon_PE, opt.processinject_min_alloc, opt.Post_EX_Process_Name, opt.metadata, opt.injector, opt.Host, opt.Profile, opt.ProfilePath, opt.outFile, opt.custom_cert, opt.cert_password, opt.CDN, opt.CDN_Value, opt.Datajitter, opt.Keylogger, opt.Forwarder, opt.tasks_max_size, opt.tasks_proxy_max_size, opt.tasks_dns_proxy_max_size, opt.syscall_method, opt.httplib, opt.threadspoof, opt.beacongate, opt.eaf_bypass, opt.rdll_use_syscalls, opt.copy_pe_header, opt.rdll_loader, opt.transform_obfuscate, opt.smartinject, opt.sleep_mask, opt.dns, opt.dns_idle) } diff --git a/Struct/Struct.go b/Struct/Struct.go index aedafd8..a5eadb5 100644 --- a/Struct/Struct.go +++ b/Struct/Struct.go @@ -1304,25 +1304,7 @@ set tcp_frame_header ""; set ssh_banner "{{.Variables.SSH_Banner}}"; set ssh_pipename "{{.Variables.SSH_pipename}}##"; -####Manaully add these if your doing C2 over DNS (Future Release)#### -##dns-beacon { -# set dns_idle "1.2.3.4"; -# set dns_max_txt "199"; -# set dns_sleep "1"; -# set dns_ttl "5"; -# set maxdns "200"; -# set dns_stager_prepend "doc-stg-prepend"; -# set dns_stager_subhost "doc-stg-sh."; - -# set beacon "doc.bc."; -# set get_A "doc.1a."; -# set get_AAAA "doc.4a."; -# set get_TXT "doc.tx."; -# set put_metadata "doc.md."; -# set put_output "doc.po."; -# set ns_response "zero"; - -#} +{{.Variables.dns_beacon}} ` } From 36ff3023c81e60bd749bf56c6f883bdfc6f13b9c Mon Sep 17 00:00:00 2001 From: Velkris Date: Thu, 27 Aug 2026 15:15:01 -0400 Subject: [PATCH 2/2] Fix dns_max_txt and keep DNS labels within the recommended length c2lint rejects a profile whose dns_max_txt is not divisible by four, and the commented-out block this feature replaced carried 199, so it now uses the documented default of 252. c2lint also warns when a dns-beacon prefix runs past eight characters, since every indicator character is data space lost from each query. Two things caused that: the base label could reach five characters, and dnsDistinctLabels widened the label length on a collision, which made every label after the first collision one character longer. The base is now capped at four and collisions are redrawn at the same length. Tests cover the divisibility rule, the length limit, and all seven prefixes sharing a length. None of this surfaced earlier because c2lint stops at the stage block errors from #32 before reaching dns-beacon. Co-Authored-By: Claude Opus 5 --- Loader/DNS.go | 26 ++++++++++++++++++------ Loader/DNS_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Loader/DNS.go b/Loader/DNS.go index d11364b..bf06580 100644 --- a/Loader/DNS.go +++ b/Loader/DNS.go @@ -46,17 +46,23 @@ func dnsLabel(n int) string { return string(b) } -// dnsDistinctLabels returns count distinct labels, starting at length n. The -// get_*/put_* prefixes are how the teamserver tells one request type from +// dnsDistinctLabels returns count distinct labels, every one of length n. +// +// The get_*/put_* prefixes are how the teamserver tells one request type from // another, so a collision between any two would break the channel rather than -// merely look wrong. A collision widens the label space instead of spinning. +// merely look wrong. Colliding candidates are redrawn at the same length: an +// earlier version widened n on a collision, which quietly made every label +// after the first collision one character longer and pushed the resulting +// prefix past the eight character maximum c2lint recommends. +// +// There are 26 * 36^(n-1) possible labels, which for the values used here +// exceeds count by orders of magnitude, so the redraw terminates. func dnsDistinctLabels(count, n int) []string { out := make([]string, 0, count) seen := make(map[string]bool, count) for len(out) < count { label := dnsLabel(n) if seen[label] { - n++ continue } seen[label] = true @@ -102,12 +108,20 @@ func GenerateDNSBeacon(enabled bool, dnsIdle string) string { // The documentation values share a first label and vary the second // ("doc.bc.", "doc.tx."). Keeping that shape keeps the queries short. - base := dnsLabel(3 + dnsRNG.Intn(3)) + // + // The base is capped at 4 characters so the full prefix lands at 7 or 8, + // within the 8 character maximum c2lint recommends. Every indicator + // character is data space lost from each query, so overshooting costs DNS + // throughput on every request the beacon makes. + base := dnsLabel(3 + dnsRNG.Intn(2)) labels := dnsDistinctLabels(7, 2) directives := []struct{ key, value string }{ {"dns_idle", dnsIdle}, - {"dns_max_txt", "199"}, + // Cobalt Strike requires dns_max_txt to be divisible by four and + // rejects the profile otherwise. The commented-out block carried 199, + // which is not, so this uses the documented default of 252. + {"dns_max_txt", "252"}, {"dns_sleep", "1"}, {"dns_ttl", "5"}, {"maxdns", "200"}, diff --git a/Loader/DNS_test.go b/Loader/DNS_test.go index 20ab140..86eb712 100644 --- a/Loader/DNS_test.go +++ b/Loader/DNS_test.go @@ -2,6 +2,7 @@ package Loader import ( "regexp" + "strconv" "strings" "testing" ) @@ -42,6 +43,24 @@ func TestDNSBeaconDropsDocumentationValues(t *testing.T) { // The get_*/put_* prefixes are how the teamserver distinguishes request types. // Two sharing a value would break the channel, not just look wrong. +// All seven prefixes have to share a length. Redrawing a collision at a wider +// length made every label after the first collision one character longer. +func TestDNSBeaconLabelsShareALength(t *testing.T) { + keys := []string{ + "beacon", "get_A", "get_AAAA", "get_TXT", + "put_metadata", "put_output", "dns_stager_subhost", + } + for i := 0; i < 200; i++ { + v := dnsValues(t, GenerateDNSBeacon(true, "")) + want := len(v["beacon"]) + for _, k := range keys { + if len(v[k]) != want { + t.Fatalf("%s = %q is %d characters, but beacon is %d", k, v[k], len(v[k]), want) + } + } + } +} + func TestDNSBeaconLabelsAreDistinct(t *testing.T) { keys := []string{ "beacon", "get_A", "get_AAAA", "get_TXT", @@ -85,6 +104,37 @@ func TestDNSIdleDefaultsAndHonoursOperatorValue(t *testing.T) { } } +// Cobalt Strike rejects the profile outright unless dns_max_txt is divisible +// by four. The commented-out block this feature replaced carried 199, which is +// not, so the value has to be checked rather than inherited. +func TestDNSMaxTXTIsDivisibleByFour(t *testing.T) { + got := dnsValues(t, GenerateDNSBeacon(true, ""))["dns_max_txt"] + n, err := strconv.Atoi(got) + if err != nil { + t.Fatalf("dns_max_txt = %q, which is not a number", got) + } + if n%4 != 0 { + t.Errorf("dns_max_txt = %d, which Cobalt Strike rejects because it is not divisible by four", n) + } +} + +// c2lint warns when a prefix runs past eight characters, because every +// indicator character is data space lost from each query. +func TestDNSLabelsStayWithinTheRecommendedLength(t *testing.T) { + keys := []string{ + "beacon", "get_A", "get_AAAA", "get_TXT", + "put_metadata", "put_output", "dns_stager_subhost", + } + for i := 0; i < 100; i++ { + v := dnsValues(t, GenerateDNSBeacon(true, "")) + for _, k := range keys { + if len(v[k]) > 8 { + t.Fatalf("%s = %q is %d characters, over the 8 character maximum", k, v[k], len(v[k])) + } + } + } +} + // Every label must be a syntactically valid lowercase DNS label sequence. func TestDNSBeaconLabelsAreValid(t *testing.T) { valid := regexp.MustCompile(`^([a-z][a-z0-9]*\.)+$`)