From cea9fa52217745ab2022c268ef424a41771c391a Mon Sep 17 00:00:00 2001 From: Velkris Date: Wed, 26 Aug 2026 17:35:15 -0400 Subject: [PATCH 1/5] Fix profile-entropy bugs, YAML config handling, and crash-on-bad-input -Injector defaulted to an empty string with no empty case in GenerateProcessInject, so it was silently mandatory and no profile could be generated without it. Random table pickers used GenerateNumer(0, len-1), which is exclusive of its upper bound, making the last entry of every lookup table unreachable (4 of 8 SSH banners). GenerateURIValues dropped rejected URIs instead of retrying, so -Uri N returned fewer than N. Re-seeding math/rand from time.Now() on every call is deprecated and returns identical values within a clock tick. CDN_Value was never read from the YAML config, the config overlay clobbered flag defaults, and out-of-range numeric flags panicked instead of erroring. Adds unit tests and a build workflow. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 34 ++++++ Loader/Loader.go | 54 ++++++--- Sample.yaml | 5 +- SourcePoint.go | 115 ++++++++++-------- Utils/Utils.go | 224 +++++++++++++++++++----------------- Utils/Utils_test.go | 149 ++++++++++++++++++++++++ 6 files changed, 410 insertions(+), 171 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 Utils/Utils_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5a2839f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: build + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go build + run: go build ./... diff --git a/Loader/Loader.go b/Loader/Loader.go index 7c1579a..a940120 100644 --- a/Loader/Loader.go +++ b/Loader/Loader.go @@ -112,8 +112,10 @@ func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, custom } else { fmt.Println("[!] " + syscall_method + " syscall method selected") } - Name, _ := strconv.Atoi(Profile) - fmt.Println("[*] Seleted Profile: " + Struct.Profile_Names[Name]) + // num_Profile holds the resolved profile, including the one picked at + // random when -Profile was not supplied. Re-parsing the raw flag here meant + // a randomly selected profile always printed as an empty name. + fmt.Println("[*] Selected Profile: " + Struct.Profile_Names[num_Profile]) fmt.Println("[+] Profile Generated: " + outFile) fmt.Println("[+] Happy Hacking") } @@ -162,10 +164,10 @@ func GenerateComunication(stage, sleeptime, jitter, useragent, datajitter string } else { Beacon_Com.Variables["tasks_dns_proxy_max_size"] = "71680" } - SSH_Numb, _ := strconv.Atoi(Utils.GenerateNumer(0, 4)) + SSH_Numb := Utils.RandIndex(len(Struct.SSH_Banner)) Beacon_Com.Variables["SSH_Banner"] = Struct.SSH_Banner[SSH_Numb] - pipe_number, _ := strconv.Atoi(Utils.GenerateNumer(0, 7)) + pipe_number := Utils.RandIndex(len(Struct.Pipename_list)) Beacon_Com.Variables["pipename"] = Struct.Pipename_list[pipe_number] + Utils.GenerateNumer(3000, 9000) Beacon_Com.Variables["pipename_stager"] = Struct.Pipename_list[pipe_number] + Utils.GenerateNumer(1000, 9000) Beacon_Com.Variables["SSH_pipename"] = Struct.Pipename_list[pipe_number] @@ -204,7 +206,7 @@ func GenerateComunication(stage, sleeptime, jitter, useragent, datajitter string } } if useragent == "" { - num_agent, _ := strconv.Atoi(Utils.GenerateNumer(0, 64)) + num_agent := Utils.RandIndex(len(Struct.Useragent_list)) Beacon_Com.Variables["useragent"] = Struct.Useragent_list[num_agent] } @@ -222,11 +224,14 @@ func GeneratePostProcessName(Post_EX_Process_Name, Keylogger string, ThreadSpoof Beacon_PostEX := &Beacon_PostEX{} Beacon_PostEX.Variables = make(map[string]string) if Post_EX_Process_Name != "" { - num_PSPN, _ := strconv.Atoi(Post_EX_Process_Name) + num_PSPN, err := strconv.Atoi(Post_EX_Process_Name) + if err != nil || num_PSPN < 1 || num_PSPN > len(Struct.Post_EX_Process_Name) { + log.Fatalf("Error: PostEX_Name must be a number between 1 and %d", len(Struct.Post_EX_Process_Name)) + } Beacon_PostEX.Variables["Post_EX_Process_Name"] = Struct.Post_EX_Process_Name[(num_PSPN - 1)] } if Post_EX_Process_Name == "" { - num_Post_EX_Process_Name, _ := strconv.Atoi(Utils.GenerateNumer(0, 14)) + num_Post_EX_Process_Name := Utils.RandIndex(len(Struct.Post_EX_Process_Name)) Beacon_PostEX.Variables["Post_EX_Process_Name"] = Struct.Post_EX_Process_Name[num_Post_EX_Process_Name] } if Keylogger == "GetAsyncKeyState" || Keylogger == "SetWindowsHookEx" { @@ -234,10 +239,13 @@ func GeneratePostProcessName(Post_EX_Process_Name, Keylogger string, ThreadSpoof } else if Keylogger == "" { Beacon_PostEX.Variables["Keylogger"] = "SetWindowsHookEx" } else { + // Previously an empty branch, which left the keylogger unset and + // emitted a profile Cobalt Strike rejects at load time. + log.Fatal("Error: Keylogger must be either GetAsyncKeyState or SetWindowsHookEx") } if ThreadSpoof == true { - threadhint_num, _ := strconv.Atoi(Utils.GenerateNumer(0, 8)) + threadhint_num := Utils.RandIndex(len(Struct.Thread_list)) Beacon_PostEX.Variables["thread_hint"] = "set thread_hint \"" + Struct.Thread_list[(threadhint_num)] + Utils.GenHex() + "\";" } else { Beacon_PostEX.Variables["thread_hint"] = "" @@ -251,9 +259,15 @@ func GenerateHTTPVaribles(Host, metadata, uri, customuri, customuriGET, customur Beacon_GETPOST.Variables = make(map[string]string) Beacon_GETPOST.Variables["Host"] = Host if Profile == "" { + // Profiles 5-7 need a keystore/CDN and 8 needs a ProfilePath, so the + // random pick stays within the self-contained profiles. num_Profile, _ = strconv.Atoi(Utils.GenerateNumer(1, 5)) } else { - num_Profile, _ = strconv.Atoi(Profile) + var err error + num_Profile, err = strconv.Atoi(Profile) + if err != nil || num_Profile < 1 || num_Profile >= len(Struct.Profile_Names) { + log.Fatalf("Error: Profile must be a number between 1 and %d", len(Struct.Profile_Names)-1) + } } if metadata == "base64" { Beacon_GETPOST.Variables["metadata_mode"] = metadata @@ -381,7 +395,7 @@ func GeneratePE(beacon_PE string, syscall_method string, beacongate string, eaf_ } else { Beacon_Stage_p1.Variables["sleep_mask"] = "false" } - gen_number, _ := strconv.Atoi(Utils.GenerateNumer(0, 6)) + gen_number := Utils.RandIndex(len(Struct.Magic_PE)) Beacon_Stage_p1.Variables["magic_mz_x64"] = Struct.Magic_PE[gen_number] Beacon_Stage_p1.Variables["magic_pe"] = strings.ToUpper(Utils.GenerateSingleValue(2)) @@ -410,13 +424,13 @@ func GeneratePE(beacon_PE string, syscall_method string, beacongate string, eaf_ } if beacon_PE == "" { - PE_Num, _ := strconv.Atoi(Utils.GenerateNumer(0, 30)) + PE_Num := Utils.RandIndex(len(Struct.Peclone_list)) Beacon_Stage_p2.Variables["pe"] = Struct.Peclone_list[PE_Num] } if beacon_PE != "" { - PE_Num, _ := strconv.Atoi(beacon_PE) - if PE_Num > 30 { - log.Fatal("Error: Please provide a valid PE number less the 31 option") + PE_Num, err := strconv.Atoi(beacon_PE) + if err != nil || PE_Num < 1 || PE_Num > len(Struct.Peclone_list) { + log.Fatalf("Error: PE_Clone must be a number between 1 and %d", len(Struct.Peclone_list)) } Beacon_Stage_p2.Variables["pe"] = Struct.Peclone_list[(PE_Num - 1)] } @@ -476,6 +490,13 @@ func GenerateProcessInject(processinject_min_alloc, injector string) map[string] } Process_Inject.Variables["ThreadStartNum"] = Utils.GenerateNumer(500, 2500) Process_Inject.Variables["ThreadStartNumv2"] = Utils.GenerateNumer(500, 2500) + if injector == "" { + // Every other optional flag either defaults or picks at random when + // left blank. Without a default here the else branch below fires and + // SourcePoint cannot generate a profile at all unless -Injector is + // passed, even though nothing documents it as required. + injector = "VirtualAllocEx" + } if injector == "NtMapViewOfSection" { Process_Inject.Variables["injector"] = injector } else if injector == "VirtualAllocEx" { @@ -508,7 +529,10 @@ func GenerateProfile(Profile, CDN, CDN_Value, cert_password, custom_cert, Profil fmt.Println("[!] Self Signed SSL Cerificate Used") } else if num_Profile == 6 { if CDN == "" { - log.Fatal("Error: Please provide a CDN value in order to use AzureEdge profiles") + log.Fatal("Error: Please provide a CDN cookie name (-CDN) in order to use AzureEdge profiles") + } + if CDN_Value == "" { + log.Fatal("Error: Please provide a CDN cookie value (-CDN-Value) in order to use AzureEdge profiles") } if cert_password == "" { log.Fatal("Error: Please provide a Password value to use this profile") diff --git a/Sample.yaml b/Sample.yaml index 8ce7fc3..3cb37c2 100644 --- a/Sample.yaml +++ b/Sample.yaml @@ -21,10 +21,9 @@ CDN: CDN_Value: ProfilePath: Syscall_method: -Httplib: +Httplib: ThreadSpoof: True -Customuri: -CustomuriGET: +CustomuriGET: CustomuriPOST: Forwarder: False TasksMaxSize: diff --git a/SourcePoint.go b/SourcePoint.go index 591407a..4c1c09c 100644 --- a/SourcePoint.go +++ b/SourcePoint.go @@ -78,28 +78,30 @@ type conf struct { Useragent string `yaml:"Useragent"` Datajitter string `yaml:"Datajitter"` Keylogger string `yaml:"Keylogger"` - Forwarder bool `yaml:"Forwarder"` + Forwarder *bool `yaml:"Forwarder"` TasksMaxSize string `yaml:"TasksMaxSize"` TasksProxyMaxSize string `yaml:"TasksProxyMaxSize"` TasksDnsProxyMaxSize string `yaml:"TasksDnsProxyMaxSize"` Syscall_method string `yaml:"Syscall_method"` Httplib string `yaml:"Httplib"` - Threadspoof bool `yaml:"ThreadSpoof"` + Threadspoof *bool `yaml:"ThreadSpoof"` BeaconGate string `yaml:"BeaconGate"` - EafBypass bool `yaml:"EafBypass"` - RdllUseSyscalls bool `yaml:"RdllUseSyscalls"` - Copy_PE_Header bool `yaml:"CopyPEHeader"` + EafBypass *bool `yaml:"EafBypass"` + RdllUseSyscalls *bool `yaml:"RdllUseSyscalls"` + Copy_PE_Header *bool `yaml:"CopyPEHeader"` RdllLoader string `yaml:"RdllLoader"` TransformObfuscate string `yaml:"TransformObfuscate"` - SmartInject bool `yaml:"SmartInject"` - SleepMask bool `yaml:"SleepMask"` + SmartInject *bool `yaml:"SmartInject"` + SleepMask *bool `yaml:"SleepMask"` } func (c *conf) getConf(yamlfile string) *conf { yamlFile, err := ioutil.ReadFile(yamlfile) if err != nil { - log.Printf("yamlFile.Get err #%v ", err) + // Previously only logged, so a typo in -Yaml silently produced a + // profile built entirely from defaults. + log.Fatalf("Error: unable to read %s: %v", yamlfile, err) } err = yaml.Unmarshal(yamlFile, c) if err != nil { @@ -109,6 +111,24 @@ func (c *conf) getConf(yamlfile string) *conf { return c } +// setString applies a YAML value only when it is present. Assigning +// unconditionally overwrote the flag defaults ("base64url", "winhttp", +// "PrependLoader") with empty strings for every key a config file omits. +func setString(dst *string, src string) { + if src != "" { + *dst = src + } +} + +// setBool applies a YAML boolean only when the key is present, so a config file +// that omits ThreadSpoof or SleepMask keeps their `true` defaults instead of +// silently turning them off. +func setBool(dst *bool, src *bool) { + if src != nil { + *dst = *src + } +} + func options() *FlagOptions { sleeptime := flag.String("Sleep", "", "Initial beacon sleep time") stage := flag.String("Stage", "false", "Disable host staging (Default: False)") @@ -117,6 +137,7 @@ func options() *FlagOptions { [*] Win10Chrome [*] Win10Edge [*] Win10IE +[*] Win10Firefox [*] Win10 [*] Win6.3 [*] Linux @@ -246,43 +267,46 @@ func main() { var c conf if opt.Yaml != "" { c.getConf(opt.Yaml) - opt.stage = c.Stage - opt.Post_EX_Process_Name = c.Post_EX_Process_Name - opt.Host = c.Host - opt.custom_cert = c.Keystore - opt.cert_password = c.Password - opt.metadata = c.Metadata - opt.outFile = c.Outfile - opt.beacon_PE = c.PE_Clone - opt.Profile = c.Profile - opt.processinject_min_alloc = c.Allocation - opt.jitter = c.Jitter - opt.sleeptime = c.Sleep - opt.uri = c.Uri - opt.customuri = c.Customuri - opt.customuriGET = c.CustomuriGET - opt.customuriPOST = c.CustomuriPOST - opt.CDN = c.CDN - opt.useragent = c.Useragent - opt.ProfilePath = c.ProfilePath - opt.injector = c.Injector - opt.Datajitter = c.Datajitter - opt.Keylogger = c.Keylogger - opt.Forwarder = c.Forwarder - opt.tasks_max_size = c.TasksMaxSize - opt.tasks_proxy_max_size = c.TasksProxyMaxSize - opt.tasks_dns_proxy_max_size = c.TasksDnsProxyMaxSize - opt.syscall_method = c.Syscall_method - opt.httplib = c.Httplib - opt.threadspoof = c.Threadspoof - opt.beacongate = c.BeaconGate - opt.eaf_bypass = c.EafBypass - opt.rdll_use_syscalls = c.RdllUseSyscalls - opt.copy_pe_header = c.Copy_PE_Header - opt.rdll_loader = c.RdllLoader - opt.transform_obfuscate = c.TransformObfuscate - opt.smartinject = c.SmartInject - opt.sleep_mask = c.SleepMask + setString(&opt.stage, c.Stage) + setString(&opt.Post_EX_Process_Name, c.Post_EX_Process_Name) + setString(&opt.Host, c.Host) + setString(&opt.custom_cert, c.Keystore) + setString(&opt.cert_password, c.Password) + setString(&opt.metadata, c.Metadata) + setString(&opt.outFile, c.Outfile) + setString(&opt.beacon_PE, c.PE_Clone) + setString(&opt.Profile, c.Profile) + setString(&opt.processinject_min_alloc, c.Allocation) + setString(&opt.jitter, c.Jitter) + setString(&opt.sleeptime, c.Sleep) + setString(&opt.uri, c.Uri) + setString(&opt.customuri, c.Customuri) + setString(&opt.customuriGET, c.CustomuriGET) + setString(&opt.customuriPOST, c.CustomuriPOST) + setString(&opt.CDN, c.CDN) + // CDN_Value was never copied out of the config, so AzureEdge profiles + // driven from a YAML file emitted an empty cookie value. + setString(&opt.CDN_Value, c.CDN_Value) + setString(&opt.useragent, c.Useragent) + setString(&opt.ProfilePath, c.ProfilePath) + setString(&opt.injector, c.Injector) + setString(&opt.Datajitter, c.Datajitter) + setString(&opt.Keylogger, c.Keylogger) + setBool(&opt.Forwarder, c.Forwarder) + setString(&opt.tasks_max_size, c.TasksMaxSize) + setString(&opt.tasks_proxy_max_size, c.TasksProxyMaxSize) + setString(&opt.tasks_dns_proxy_max_size, c.TasksDnsProxyMaxSize) + setString(&opt.syscall_method, c.Syscall_method) + setString(&opt.httplib, c.Httplib) + setBool(&opt.threadspoof, c.Threadspoof) + setString(&opt.beacongate, c.BeaconGate) + setBool(&opt.eaf_bypass, c.EafBypass) + setBool(&opt.rdll_use_syscalls, c.RdllUseSyscalls) + setBool(&opt.copy_pe_header, c.Copy_PE_Header) + setString(&opt.rdll_loader, c.RdllLoader) + setString(&opt.transform_obfuscate, c.TransformObfuscate) + setBool(&opt.smartinject, c.SmartInject) + setBool(&opt.sleep_mask, c.SleepMask) } if opt.outFile == "" { @@ -297,6 +321,5 @@ func main() { if (opt.customuriGET != "" && opt.customuriPOST == "") || (opt.customuriGET == "" && opt.customuriPOST != "") { 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) } diff --git a/Utils/Utils.go b/Utils/Utils.go index 22211e5..0ef6a9e 100644 --- a/Utils/Utils.go +++ b/Utils/Utils.go @@ -1,6 +1,8 @@ package Utils import ( + crand "crypto/rand" + "encoding/binary" "fmt" "log" "math/rand" @@ -17,6 +19,56 @@ const alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-" const alphanum = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" const lowercasealpha = "abcdefghijklmnopqrstuvwxyz" +// rng is seeded once, from crypto/rand, and reused for the life of the process. +// +// Every generator in this file used to call rand.Seed(time.Now().UnixNano()) +// on entry. That is deprecated as of go1.20, and it actively works against a +// polymorphic generator: consecutive calls that land inside the same clock tick +// reseed the global source to the same state and hand back byte-identical +// "random" values. That is how a single profile ends up with duplicate URIs. +var rng = rand.New(rand.NewSource(seed())) + +func seed() int64 { + var b [8]byte + if _, err := crand.Read(b[:]); err != nil { + return time.Now().UnixNano() + } + return int64(binary.LittleEndian.Uint64(b[:])) +} + +// randRange returns a random int in [min, max). An empty or inverted range +// returns min rather than panicking inside rand.Intn. +func randRange(min, max int) int { + if max <= min { + return min + } + return rng.Intn(max-min) + min +} + +// RandIndex returns a random index into a slice of length n, i.e. [0, n). +// +// Callers used to hand-write GenerateNumer(0, len(list)-1), which is exclusive +// of its upper bound and so made the last entry of every lookup table +// unreachable. Deriving the bound from len() keeps the tables and the picker in +// sync when entries are added. +func RandIndex(n int) int { + if n <= 0 { + return 0 + } + return rng.Intn(n) +} + +func randomString(n int, charset string) string { + if n <= 0 { + return "" + } + b := make([]byte, n) + for i := range b { + b[i] = charset[rng.Intn(len(charset))] + } + return string(b) +} + func check(e error) { if e != nil { panic(e) @@ -39,138 +91,96 @@ func Writefile(outFile, result string) { check(err) } -func generateRandomBytes(n int) ([]byte, error) { - b := make([]byte, n) - _, err := rand.Read(b) - if err != nil { - return nil, err - } - - return b, nil -} - func RandStringBytes(n int) string { - b := make([]byte, n) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - - } - return string(b) + return randomString(n, letters) } func VarNumberLength(min, max int) string { - var r string - rand.Seed(time.Now().UnixNano()) - num := rand.Intn(max-min) + min - n := num - r = RandStringBytes(n) - return r + return randomString(randRange(min, max), letters) } func GenerateNumer(min, max int) string { - - rand.Seed(time.Now().UnixNano()) - num := rand.Intn(max-min) + min - number := strconv.Itoa(num) - return number - + return strconv.Itoa(randRange(min, max)) } func GenerateValue(min, max int) string { - rand.Seed(time.Now().UnixNano()) - num := rand.Intn(max-min) + min - n := num - b := make([]byte, n) - for i := range b { - b[i] = alpha[rand.Intn(len(alpha))] - } - return string(b) + return randomString(randRange(min, max), alpha) } func GenerateSingleValue(num int) string { - n := num - b := make([]byte, n) - for i := range b { - b[i] = alphanum[rand.Intn(len(alphanum))] - } - return string(b) + return randomString(num, alphanum) } func GenHex() string { - rand.Seed(time.Now().UnixNano()) - - // Generate a random number and convert it to a hexadecimal string - hexString := fmt.Sprintf("%x", rand.Intn(4096)) // 4096 is 16^3, ensuring up to 3 hex characters - return hexString + // Up to 3 hex characters (16^3 == 4096). + return fmt.Sprintf("%x", randRange(0, 4096)) } -func GenerateURIValues(numb int, profile_type int, Post bool, customuri string) string { - var uri string - var baseuri string - var enduri string - var num int - if profile_type == 1 { - baseuri = "/c/msdownload/update/others/2021/10/" - } - if profile_type == 2 { - baseuri = "/messages/" - } - if profile_type == 3 { - if Post == false { - baseuri = "/functionalStatus/" - } else if Post == true { - baseuri = "/rest/2/meetings" +// uriBase returns the path prefix and suffix used by a given profile. Splitting +// this out of GenerateURIValues keeps the retry loop below readable. +func uriBase(profileType int, post bool, customuri string) (prefix, suffix string) { + switch profileType { + case 1: + return "/c/msdownload/update/others/2021/10/", "" + case 2: + return "/messages/", "" + case 3: + if post { + return "/rest/2/meetings", "" } - } - if profile_type == 4 { - baseuri = "/owa/" - } - if profile_type == 5 { - baseuri = "/safebrowsing/" + GenerateValue(4, 10) + "/" - } - if profile_type == 6 { - baseuri = "/chat/" - } - if profile_type == 7 { - if Post == false { - baseuri = "/s/" - enduri = "/field-keywords/" - } else if Post == true { - baseuri = "/n" - enduri = "/avp/amznussraps/" + return "/functionalStatus/", "" + case 4: + return "/owa/", "" + case 5: + return "/safebrowsing/" + GenerateValue(4, 10) + "/", "" + case 6: + return "/chat/", "" + case 7: + if post { + return "/n", "/avp/amznussraps/" } + return "/s/", "/field-keywords/" + default: + // Profiles 8 and 9 are operator-supplied; anything else has already + // been rejected by the caller. + return customuri, "" } - if profile_type == 8 { - baseuri = "" + customuri + "" - } - if profile_type == 9 { - baseuri = "" + customuri + "" - } - uri = "set uri \"" - for ii := 1; ii <= numb; ii++ { - rand.Seed(time.Now().UnixNano()) +} + +func GenerateURIValues(numb int, profile_type int, Post bool, customuri string) string { + baseuri, enduri := uriBase(profile_type, Post, customuri) + + var sb strings.Builder + sb.WriteString("set uri \"") + + seen := make(map[string]bool, numb) + // maxAttempts stops a pathological retry loop; with 14+ character segments + // the rejection paths below are hit vanishingly rarely. + maxAttempts := numb*100 + 100 + for generated, attempts := 0, 0; generated < numb && attempts < maxAttempts; attempts++ { + // Segment length: 14-29 for Windows Update, 20-35 for everything else. + // Preserved from the original rand.Intn(30-14)+14 / +20 expressions. + min, max := 20, 36 if profile_type == 1 { - num = rand.Intn(30-14) + 14 - } else { - num = rand.Intn(30-14) + 20 + min, max = 14, 30 } - n := num - b := make([]byte, n) - for i := range b { - b[i] = alpha[rand.Intn(len(alpha))] - } - value := string(b) + value := randomString(randRange(min, max), alpha) + + // A path segment starting with '-' stands out, so it is rejected. The + // original loop dropped the URI outright instead of retrying, so + // -Uri 8 could quietly emit as few as one URI. if strings.HasPrefix(value, "-") { - ii = ii - } else { - if enduri != "" { - uri += baseuri + value + enduri + " " - } else { - uri += baseuri + value + " " - } + continue + } + uri := baseuri + value + enduri + if seen[uri] { + continue } + seen[uri] = true + sb.WriteString(uri + " ") + generated++ } - uri += "\";\n" - return uri + sb.WriteString("\";\n") + return sb.String() } diff --git a/Utils/Utils_test.go b/Utils/Utils_test.go new file mode 100644 index 0000000..a1443c5 --- /dev/null +++ b/Utils/Utils_test.go @@ -0,0 +1,149 @@ +package Utils + +import ( + "strconv" + "strings" + "testing" +) + +// RandIndex must be able to return every index of a table, including the last +// one. The hand-written GenerateNumer(0, len(list)-1) calls it replaces were +// exclusive of their upper bound, so the final entry of every lookup table in +// Struct was unreachable. +func TestRandIndexCoversEveryEntry(t *testing.T) { + for _, n := range []int{1, 5, 8, 9, 15, 30, 65} { + seen := make(map[int]bool, n) + for i := 0; i < n*400; i++ { + idx := RandIndex(n) + if idx < 0 || idx >= n { + t.Fatalf("RandIndex(%d) returned out-of-range index %d", n, idx) + } + seen[idx] = true + } + if len(seen) != n { + t.Errorf("RandIndex(%d) only ever produced %d distinct indices; last entry unreachable", n, len(seen)) + } + } +} + +func TestRandIndexHandlesEmptyTable(t *testing.T) { + if got := RandIndex(0); got != 0 { + t.Errorf("RandIndex(0) = %d, want 0", got) + } + if got := RandIndex(-3); got != 0 { + t.Errorf("RandIndex(-3) = %d, want 0", got) + } +} + +// An inverted or empty range used to panic inside rand.Intn. +func TestRandRangeDoesNotPanicOnEmptyRange(t *testing.T) { + if got := randRange(10, 10); got != 10 { + t.Errorf("randRange(10, 10) = %d, want 10", got) + } + if got := randRange(10, 4); got != 10 { + t.Errorf("randRange(10, 4) = %d, want 10", got) + } +} + +func TestGenerateNumerStaysInRange(t *testing.T) { + for i := 0; i < 1000; i++ { + n, err := strconv.Atoi(GenerateNumer(30, 75)) + if err != nil { + t.Fatalf("GenerateNumer returned a non-number: %v", err) + } + if n < 30 || n >= 75 { + t.Fatalf("GenerateNumer(30, 75) = %d, outside [30, 75)", n) + } + } +} + +func parseURIs(t *testing.T, set string) []string { + t.Helper() + if !strings.HasPrefix(set, `set uri "`) || !strings.HasSuffix(set, "\";\n") { + t.Fatalf("malformed uri statement: %q", set) + } + body := strings.TrimSuffix(strings.TrimPrefix(set, `set uri "`), "\";\n") + return strings.Fields(body) +} + +// GenerateURIValues must emit exactly as many URIs as the operator asked for. +// The original loop dropped any candidate whose random segment started with +// "-" instead of retrying, so `-Uri 8` regularly produced fewer than 8. +func TestGenerateURIValuesReturnsRequestedCount(t *testing.T) { + for _, profile := range []int{1, 2, 3, 4, 5, 6, 7} { + for _, want := range []int{1, 3, 8, 20} { + for _, post := range []bool{false, true} { + got := parseURIs(t, GenerateURIValues(want, profile, post, "")) + if len(got) != want { + t.Errorf("profile %d post=%v: asked for %d URIs, got %d", profile, post, want, len(got)) + } + } + } + } +} + +// A profile that repeats the same URI is a free clustering signal, and the +// per-call time-based reseeding made repeats likely on coarse clocks. +func TestGenerateURIValuesAreUnique(t *testing.T) { + uris := parseURIs(t, GenerateURIValues(50, 2, false, "")) + seen := make(map[string]bool, len(uris)) + for _, u := range uris { + if seen[u] { + t.Fatalf("duplicate URI generated: %s", u) + } + seen[u] = true + } +} + +func TestGenerateURIValuesUsesProfileBasePath(t *testing.T) { + cases := []struct { + profile int + post bool + prefix string + }{ + {1, false, "/c/msdownload/update/others/2021/10/"}, + {2, false, "/messages/"}, + {3, false, "/functionalStatus/"}, + {3, true, "/rest/2/meetings"}, + {4, false, "/owa/"}, + {6, false, "/chat/"}, + {7, false, "/s/"}, + {7, true, "/n"}, + } + for _, c := range cases { + for _, u := range parseURIs(t, GenerateURIValues(5, c.profile, c.post, "")) { + if !strings.HasPrefix(u, c.prefix) { + t.Errorf("profile %d post=%v: %q does not start with %q", c.profile, c.post, u, c.prefix) + } + } + } + for _, u := range parseURIs(t, GenerateURIValues(5, 8, false, "/api/v2/")) { + if !strings.HasPrefix(u, "/api/v2/") { + t.Errorf("custom profile: %q does not use the supplied base URI", u) + } + } +} + +// Segments beginning with "-" stand out in traffic and the generator has always +// meant to reject them. +func TestGenerateURIValuesNeverStartASegmentWithDash(t *testing.T) { + for _, u := range parseURIs(t, GenerateURIValues(100, 8, false, "/x/")) { + if strings.HasPrefix(strings.TrimPrefix(u, "/x/"), "-") { + t.Errorf("URI segment starts with '-': %s", u) + } + } +} + +// Two profiles generated back to back must not be identical. Re-seeding the +// global source from time.Now() on every call meant that calls landing in the +// same clock tick returned the same "random" value. +func TestGeneratorsDoNotRepeatWithinAClockTick(t *testing.T) { + const draws = 200 + values := make(map[string]bool, draws) + for i := 0; i < draws; i++ { + values[GenerateValue(6, 15)] = true + } + if len(values) < draws*9/10 { + t.Errorf("GenerateValue produced only %d distinct values out of %d draws", len(values), draws) + } +} From ee72aa2ad442fd7962cde253f373ef7533c36799 Mon Sep 17 00:00:00 2001 From: Velkris Date: Wed, 26 Aug 2026 17:47:55 -0400 Subject: [PATCH 2/5] Randomize stager URIs and validate numeric flags The Slack profile hardcoded its http-stager URIs as /messages/DALBNSf25 and /messages/DALBNSF25, so every profile generated from that template shared the same two paths. GoToMeeting derived both from UValue, making the x86 and x64 stager URIs identical and tying the staging request to the beacon check-ins, which reuse UValue in their prepends and cookie. Both now use independent per-architecture stager tokens of varied length. Outlook.Live already randomized correctly and is unchanged. Separately, -Sleep, -Jitter, -Datajitter, -Allocation and the three -Tasks*MaxSize flags were written into the profile unvalidated, so bad values only surfaced when the teamserver refused to load it. Co-Authored-By: Claude Opus 5 --- Loader/Loader.go | 44 ++++++++++++++++++++++++++++++----- Loader/Loader_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++ Struct/Struct.go | 8 +++---- 3 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 Loader/Loader_test.go diff --git a/Loader/Loader.go b/Loader/Loader.go index a940120..514d6c6 100644 --- a/Loader/Loader.go +++ b/Loader/Loader.go @@ -75,6 +75,25 @@ type Beacon_SSL struct { var num_Profile int var Post bool +// validateNumber checks an operator-supplied numeric flag before it reaches the +// profile. These values were written out unchecked, so "-Sleep abc" emitted +// `set sleeptime "abc000"` and "-Jitter 150" emitted a jitter percentage +// outside the permitted 0-99 range. Neither failed here: they failed when the +// teamserver refused to load the profile, which is the worst time to find out. +// A max of 0 means the flag has no meaningful upper bound. +func validateNumber(flagName, value string, min, max int) { + n, err := strconv.Atoi(value) + if err != nil { + log.Fatalf("Error: %s must be a whole number, got %q", flagName, value) + } + if n < min { + log.Fatalf("Error: %s must be %d or greater, got %d", flagName, min, n) + } + if max > 0 && n > max { + log.Fatalf("Error: %s must be between %d and %d, got %d", flagName, min, max, n) + } +} + 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) { Beacon_Com := &Beacon_Com{} Beacon_Stage_p1 := &Beacon_Stage_p1{} @@ -132,17 +151,21 @@ func GenerateComunication(stage, sleeptime, jitter, useragent, datajitter string HostStageMessage = "[!] Host Staging Is Enabled - Staged Payloads Are Available But Your Beacon Payload Is Available To Anyone That Connects To Your Server To Request It" } if sleeptime != "" { + validateNumber("-Sleep", sleeptime, 0, 0) Beacon_Com.Variables["sleep"] = sleeptime + "000" } else if sleeptime == "" { Beacon_Com.Variables["sleep"] = Utils.GenerateNumer(30, 75) + "000" } if jitter != "" { + // Cobalt Strike requires jitter to be a percentage in the range 0-99. + validateNumber("-Jitter", jitter, 0, 99) Beacon_Com.Variables["jitter"] = jitter } if jitter == "" { Beacon_Com.Variables["jitter"] = Utils.GenerateNumer(10, 40) } if datajitter != "" { + validateNumber("-Datajitter", datajitter, 0, 0) Beacon_Com.Variables["datajitter"] = datajitter } if datajitter == "" { @@ -150,16 +173,19 @@ func GenerateComunication(stage, sleeptime, jitter, useragent, datajitter string } if tasks_max_size != "" { + validateNumber("-TasksMaxSize", tasks_max_size, 1, 0) Beacon_Com.Variables["tasks_max_size"] = tasks_max_size } else { Beacon_Com.Variables["tasks_max_size"] = "1048576" } if tasks_proxy_max_size != "" { + validateNumber("-TasksProxyMaxSize", tasks_proxy_max_size, 1, 0) Beacon_Com.Variables["tasks_proxy_max_size"] = tasks_proxy_max_size } else { Beacon_Com.Variables["tasks_proxy_max_size"] = "921600" } if tasks_dns_proxy_max_size != "" { + validateNumber("-TasksDnsProxyMaxSize", tasks_dns_proxy_max_size, 1, 0) Beacon_Com.Variables["tasks_dns_proxy_max_size"] = tasks_dns_proxy_max_size } else { Beacon_Com.Variables["tasks_dns_proxy_max_size"] = "71680" @@ -333,6 +359,14 @@ func GenerateHTTPVaribles(Host, metadata, uri, customuri, customuriGET, customur Beacon_GETPOST.Variables["UValue"] = Utils.GenerateValue(6, 15) Beacon_GETPOST.Variables["CSMValue"] = Utils.GenerateValue(6, 15) + // Stager URIs are generated per architecture, and deliberately not from + // UValue: UValue also appears in the beacon's own check-in traffic (the + // "U="/"REF=ID=" prepends and the wla42 cookie), so reusing it here would + // tie the staging request and the check-ins together with one shared + // token. Length is varied so the segment isn't a fixed-width tell. + Beacon_GETPOST.Variables["stager_x86"] = Utils.GenerateSingleValue(8 + Utils.RandIndex(5)) + Beacon_GETPOST.Variables["stager_x64"] = Utils.GenerateSingleValue(8 + Utils.RandIndex(5)) + //needs to be put stacic if Forwarder == true { Beacon_GETPOST.Variables["forward"] = "true" @@ -481,12 +515,10 @@ func GenerateProcessInject(processinject_min_alloc, injector string) map[string] Process_Inject.Variables["processinject_min_alloc"] = Utils.GenerateNumer(4096, 57841) } if processinject_min_alloc != "" { - processinject_min_alloc_int, _ := strconv.Atoi(processinject_min_alloc) - if processinject_min_alloc_int < 4096 { - log.Fatal("Error: Minimum amount of memory to request for injected content needs to be greater than 4096") - } else { - Process_Inject.Variables["processinject_min_alloc"] = processinject_min_alloc - } + // The Atoi error was discarded here, so "-Allocation abc" parsed as 0 + // and reported the misleading "needs to be greater than 4096". + validateNumber("-Allocation", processinject_min_alloc, 4096, 0) + Process_Inject.Variables["processinject_min_alloc"] = processinject_min_alloc } Process_Inject.Variables["ThreadStartNum"] = Utils.GenerateNumer(500, 2500) Process_Inject.Variables["ThreadStartNumv2"] = Utils.GenerateNumer(500, 2500) diff --git a/Loader/Loader_test.go b/Loader/Loader_test.go new file mode 100644 index 0000000..349207d --- /dev/null +++ b/Loader/Loader_test.go @@ -0,0 +1,54 @@ +package Loader + +import "testing" + +func httpVars(t *testing.T, profile string) map[string]string { + t.Helper() + return GenerateHTTPVaribles("acme-email.com", "base64url", "", "", "", "", "", "", profile, false) +} + +// The Slack profile hardcoded its stager URIs ("/messages/DALBNSf25" and +// "/messages/DALBNSF25"), so every profile SourcePoint ever produced for that +// template carried the same two paths. They must now vary per run. +func TestStagerURIsVaryBetweenProfiles(t *testing.T) { + const runs = 50 + seen := make(map[string]bool, runs*2) + for i := 0; i < runs; i++ { + v := httpVars(t, "2") + for _, key := range []string{"stager_x86", "stager_x64"} { + got := v[key] + if got == "" { + t.Fatalf("%s was not generated", key) + } + if seen[got] { + t.Errorf("%s repeated across generated profiles: %q", key, got) + } + seen[got] = true + } + } +} + +// The GoToMeeting profile derived both stager URIs from a single value, so the +// x86 and x64 staging paths were byte-identical. +func TestStagerURIsDifferPerArchitecture(t *testing.T) { + for i := 0; i < 50; i++ { + v := httpVars(t, "3") + if v["stager_x86"] == v["stager_x64"] { + t.Fatalf("stager URIs identical across architectures: %q", v["stager_x86"]) + } + } +} + +// UValue appears in the beacon's own check-in traffic (the "U="/"REF=ID=" +// prepends and the wla42 cookie). Deriving a stager URI from it linked the +// staging request and the check-ins by a shared unique token. +func TestStagerURIsAreIndependentOfCheckinToken(t *testing.T) { + for i := 0; i < 50; i++ { + v := httpVars(t, "3") + for _, key := range []string{"stager_x86", "stager_x64"} { + if v[key] == v["UValue"] { + t.Fatalf("%s reuses UValue, which also appears in check-in traffic: %q", key, v[key]) + } + } + } +} diff --git a/Struct/Struct.go b/Struct/Struct.go index aedafd8..164626a 100644 --- a/Struct/Struct.go +++ b/Struct/Struct.go @@ -659,8 +659,8 @@ header "X-Via" "haproxy-www-6g1x"; http-stager { -set uri_x86 "/messages/DALBNSf25"; -set uri_x64 "/messages/DALBNSF25"; +set uri_x86 "/messages/{{.Variables.stager_x86}}"; +set uri_x64 "/messages/{{.Variables.stager_x64}}"; client { header "Accept" "*/*"; @@ -819,8 +819,8 @@ server { http-stager { -set uri_x86 "/Meeting/{{.Variables.UValue}}/"; -set uri_x64 "/Meeting/{{.Variables.UValue}}/"; +set uri_x86 "/Meeting/{{.Variables.stager_x86}}/"; +set uri_x64 "/Meeting/{{.Variables.stager_x64}}/"; client { header "Host" "{{.Variables.Host}}"; From 45d4d3610a28c7250b6240a1f9ae474b0355523f Mon Sep 17 00:00:00 2001 From: Velkris Date: Wed, 26 Aug 2026 19:43:52 -0400 Subject: [PATCH 3/5] Move smartinject to post-ex and quote sleep_mask c2lint on Cobalt Strike 4.13 rejects every profile SourcePoint generates. Two of the causes are unambiguous. smartinject is a post-ex option, but the stage block set it too, which c2lint reports as 'invalid option for <.stage>'; the post-ex copy was hardcoded to true, so -SmartInject drove the invalid one and never affected the profile it was meant to. sleep_mask was emitted without quotes around its value, unlike every other boolean in the block, which c2lint reports as 'Unknown statement in <.stage>'. Both are fixed and covered by tests against the template text. Two further causes, stage.rdll_loader and stage.name, are version compatibility questions rather than bugs and are reported separately. Co-Authored-By: Claude Opus 5 --- Loader/Loader.go | 22 +++++++++++++--------- Struct/Struct.go | 5 ++--- Struct/Struct_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 Struct/Struct_test.go diff --git a/Loader/Loader.go b/Loader/Loader.go index 514d6c6..e969ae0 100644 --- a/Loader/Loader.go +++ b/Loader/Loader.go @@ -108,9 +108,9 @@ 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_PostEX.Variables = GeneratePostProcessName(Post_EX_Process_Name, Keylogger, ThreadSpoof) + Beacon_PostEX.Variables = GeneratePostProcessName(Post_EX_Process_Name, Keylogger, ThreadSpoof, smartinject) 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) + 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, sleep_mask) Process_Inject.Variables = GenerateProcessInject(processinject_min_alloc, injector) Beacon_GETPOST_Profile.Variables, Beacon_SSL.Variables = GenerateProfile(Profile, CDN, CDN_Value, cert_password, custom_cert, ProfilePath, Host) fmt.Println("[*] Building Profile...") @@ -246,9 +246,18 @@ func GenerateComunication(stage, sleeptime, jitter, useragent, datajitter string return HostStageMessage, Beacon_Com.Variables } -func GeneratePostProcessName(Post_EX_Process_Name, Keylogger string, ThreadSpoof bool) map[string]string { +func GeneratePostProcessName(Post_EX_Process_Name, Keylogger string, ThreadSpoof bool, smartinject bool) map[string]string { Beacon_PostEX := &Beacon_PostEX{} Beacon_PostEX.Variables = make(map[string]string) + // smartinject is a post-ex option. It used to be emitted into the stage + // block instead, where Cobalt Strike rejects it, while the post-ex copy was + // hardcoded to "true" - so -SmartInject drove the invalid one and had no + // effect on the profile that was actually meant to carry it. + if smartinject { + Beacon_PostEX.Variables["smartinject"] = "true" + } else { + Beacon_PostEX.Variables["smartinject"] = "false" + } if Post_EX_Process_Name != "" { num_PSPN, err := strconv.Atoi(Post_EX_Process_Name) if err != nil || num_PSPN < 1 || num_PSPN > len(Struct.Post_EX_Process_Name) { @@ -377,7 +386,7 @@ func GenerateHTTPVaribles(Host, metadata, uri, customuri, customuriGET, customur return Beacon_GETPOST.Variables } -func GeneratePE(beacon_PE string, syscall_method string, beacongate string, eaf_bypass bool, rdll_use_syscalls bool, copy_pe_header bool, rdll_loader string, transform_obfuscate string, smartinject bool, sleep_mask bool) (map[string]string, map[string]string, string) { +func GeneratePE(beacon_PE string, syscall_method string, beacongate string, eaf_bypass bool, rdll_use_syscalls bool, copy_pe_header bool, rdll_loader string, transform_obfuscate string, sleep_mask bool) (map[string]string, map[string]string, string) { Beacon_Stage_p1 := &Beacon_Stage_p1{} Beacon_Stage_p1.Variables = make(map[string]string) @@ -419,11 +428,6 @@ func GeneratePE(beacon_PE string, syscall_method string, beacongate string, eaf_ } else { Beacon_Stage_p1.Variables["copy_pe_header"] = "false" } - if smartinject == true { - Beacon_Stage_p1.Variables["smartinject"] = "true" - } else { - Beacon_Stage_p1.Variables["smartinject"] = "false" - } if sleep_mask == true { Beacon_Stage_p1.Variables["sleep_mask"] = "true" } else { diff --git a/Struct/Struct.go b/Struct/Struct.go index 164626a..2f6cde0 100644 --- a/Struct/Struct.go +++ b/Struct/Struct.go @@ -1334,7 +1334,6 @@ stage { set stomppe "true"; set cleanup "true"; set userwx "false"; - set smartinject "{{.Variables.smartinject}}"; beacon_gate { {{.Variables.beacongate}} } @@ -1346,7 +1345,7 @@ stage { #TCP and SMB beacons will obfuscate themselves while they wait for a new connection. #They will also obfuscate themselves while they wait to read information from their parent Beacon. - set sleep_mask {{.Variables.sleep_mask}}; + set sleep_mask "{{.Variables.sleep_mask}}"; set eaf_bypass "{{.Variables.eaf_bypass}}"; set rdll_use_syscalls "{{.Variables.rdll_use_syscalls}}"; set copy_pe_header "{{.Variables.copy_pe_header}}"; @@ -1624,7 +1623,7 @@ post-ex { {{.Variables.thread_hint}} # pass key function pointers from Beacon to its child jobs - set smartinject "true"; + set smartinject "{{.Variables.smartinject}}"; # disable AMSI in powerpick, execute-assembly, and psinject set amsi_disable "false"; diff --git a/Struct/Struct_test.go b/Struct/Struct_test.go new file mode 100644 index 0000000..7316508 --- /dev/null +++ b/Struct/Struct_test.go @@ -0,0 +1,33 @@ +package Struct + +import ( + "strings" + "testing" +) + +// smartinject is a post-ex option. Emitting it inside the stage block made +// Cobalt Strike reject every generated profile with +// "invalid option for <.stage>". +func TestStageBlockDoesNotSetSmartinject(t *testing.T) { + if strings.Contains(Beacon_Stage_Struct_p1(), "smartinject") { + t.Error("the stage block sets smartinject, which is a post-ex option") + } +} + +// Every value in the stage block has to be quoted. An unquoted boolean made +// Cobalt Strike reject the profile with "Unknown statement in <.stage>". +func TestStageSleepMaskIsQuoted(t *testing.T) { + want := `set sleep_mask "{{.Variables.sleep_mask}}";` + if !strings.Contains(Beacon_Stage_Struct_p1(), want) { + t.Errorf("stage block does not contain %s", want) + } +} + +// post-ex is where smartinject belongs, and it has to be driven by the +// -SmartInject flag rather than hardcoded to "true". +func TestPostExSmartinjectIsTemplated(t *testing.T) { + want := `set smartinject "{{.Variables.smartinject}}";` + if !strings.Contains(Beacon_PostEX_Struct(), want) { + t.Errorf("post-ex block does not contain %s", want) + } +} From 8731080291ff8ab073136e45cafee24e5c16cd47 Mon Sep 17 00:00:00 2001 From: Velkris Date: Thu, 27 Aug 2026 15:03:11 -0400 Subject: [PATCH 4/5] Add the missing cookie separator in the Slack http-post block The Slack profile appends __ar_v4 to the Cookie header in both directions, but the http-post append was missing its leading semicolon. A POST request emitted _ga=GA1.2.875__ar_v4=%8867UMDGS643 as a single mangled cookie value while the http-get request emitted the same fragment correctly, so GET and POST from the same host disagreed on their own cookie format. Found by reading the traffic sample c2lint prints for a compiled profile. Co-Authored-By: Claude Opus 5 --- Struct/Struct.go | 2 +- Struct/Struct_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Struct/Struct.go b/Struct/Struct.go index 2f6cde0..92a82d2 100644 --- a/Struct/Struct.go +++ b/Struct/Struct.go @@ -612,7 +612,7 @@ header "Accept-Language" "en-US"; {{.Variables.metadata_mode}}; append ";_ga=GA1.2.875"; - append "__ar_v4=%8867UMDGS643"; + append ";__ar_v4=%8867UMDGS643"; prepend "d="; prepend "_ga=GA1.2.875;"; prepend "b=.12vPkW22o;"; diff --git a/Struct/Struct_test.go b/Struct/Struct_test.go index 7316508..458a9fe 100644 --- a/Struct/Struct_test.go +++ b/Struct/Struct_test.go @@ -31,3 +31,15 @@ func TestPostExSmartinjectIsTemplated(t *testing.T) { t.Errorf("post-ex block does not contain %s", want) } } + +// The Slack profile appends __ar_v4 to the Cookie header in both directions. +// The http-post side was missing the ";" separator, so the request emitted +// _ga=GA1.2.875__ar_v4=... as one mangled cookie value while http-get emitted +// it correctly, leaving GET and POST from the same host visibly inconsistent. +func TestCookieFragmentsKeepTheirSeparator(t *testing.T) { + for i, profile := range HTTP_GET_POST_list { + if strings.Contains(profile, `append "__ar_v4=`) { + t.Errorf("HTTP_GET_POST_list[%d]: __ar_v4 is appended with no leading separator, which mangles the Cookie value", i) + } + } +} From 7d9fef657dbb70a657408ccefdbe5e8b858ab851 Mon Sep 17 00:00:00 2001 From: Velkris Date: Thu, 27 Aug 2026 17:21:37 -0400 Subject: [PATCH 5/5] Make the Outlook profile agree with itself about its server The Outlook.Live http-stager response declared Server: nginx while its http-get and http-post responses declared Microsoft-IIS/10.0. One host cannot be both, and real Outlook Web Access is IIS, so a defender comparing responses from the same origin gets a free correlation. Found by reading the transaction sample c2lint prints for a compiled profile. The accompanying test asserts that no profile declares more than one Server value, which catches this class rather than this instance. Co-Authored-By: Claude Opus 5 --- Struct/Struct.go | 2 +- Struct/Struct_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Struct/Struct.go b/Struct/Struct.go index 92a82d2..8f1af7e 100644 --- a/Struct/Struct.go +++ b/Struct/Struct.go @@ -960,7 +960,7 @@ header "Accept" "*/*"; } server { - header "Server" "nginx"; + header "Server" "Microsoft-IIS/10.0"; } diff --git a/Struct/Struct_test.go b/Struct/Struct_test.go index 458a9fe..f9811a0 100644 --- a/Struct/Struct_test.go +++ b/Struct/Struct_test.go @@ -1,6 +1,8 @@ package Struct import ( + "regexp" + "sort" "strings" "testing" ) @@ -36,6 +38,29 @@ func TestPostExSmartinjectIsTemplated(t *testing.T) { // The http-post side was missing the ";" separator, so the request emitted // _ga=GA1.2.875__ar_v4=... as one mangled cookie value while http-get emitted // it correctly, leaving GET and POST from the same host visibly inconsistent. +// A profile impersonates one origin, so its responses must not disagree about +// what is serving them. The Outlook.Live http-stager response declared +// Server: nginx while its beacon traffic declared Microsoft-IIS/10.0, which +// hands a defender a free correlation between two responses from the same host. +// Real Outlook Web Access is IIS. +func TestServerHeaderIsConsistentWithinEachProfile(t *testing.T) { + serverHeader := regexp.MustCompile(`header "Server" "([^"]*)"`) + for i, profile := range HTTP_GET_POST_list { + seen := make(map[string]bool) + for _, m := range serverHeader.FindAllStringSubmatch(profile, -1) { + seen[m[1]] = true + } + if len(seen) > 1 { + values := make([]string, 0, len(seen)) + for v := range seen { + values = append(values, v) + } + sort.Strings(values) + t.Errorf("HTTP_GET_POST_list[%d] declares more than one Server value: %v", i, values) + } + } +} + func TestCookieFragmentsKeepTheirSeparator(t *testing.T) { for i, profile := range HTTP_GET_POST_list { if strings.Contains(profile, `append "__ar_v4=`) {