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 b8acd36d867e6422268c9b88c630de29037ca710 Mon Sep 17 00:00:00 2001 From: Velkris Date: Thu, 27 Aug 2026 15:01:09 -0400 Subject: [PATCH 4/5] Add -CSVersion so profiles load on Cobalt Strike 4.13 Cobalt Strike 4.13 removed the stage.rdll_loader and stage.name malleable C2 options, so every profile SourcePoint generated was rejected by c2lint with 'invalid option for <.stage>'. Rather than dropping the options outright, which would degrade older team servers, -CSVersion selects the target release: 4.13 and newer omit them, 4.12 and older emit them as before. The flag defaults to 4.13. -RdllLoader is still validated regardless of target so a typo remains an error, and the omission is reported on stdout rather than being silent. The spoofed module name is now recovered with a regex before stripping instead of by splitting the clone block on ';' and indexing len-3, which broke once the block lost a directive. Verified with c2lint on a licensed 4.13 team server: a 4.13 profile compiles, a 4.12 profile still fails with the two stage errors. Co-Authored-By: Claude Opus 5 --- Loader/CSVersion.go | 81 ++++++++++++++++++++++++++++++++++ Loader/CSVersion_test.go | 95 ++++++++++++++++++++++++++++++++++++++++ Loader/Loader.go | 37 +++++++++++----- Sample.yaml | 1 + SourcePoint.go | 12 ++++- Struct/Struct.go | 2 +- 6 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 Loader/CSVersion.go create mode 100644 Loader/CSVersion_test.go diff --git a/Loader/CSVersion.go b/Loader/CSVersion.go new file mode 100644 index 0000000..0f4e8a8 --- /dev/null +++ b/Loader/CSVersion.go @@ -0,0 +1,81 @@ +package Loader + +import ( + "log" + "regexp" + "strconv" + "strings" +) + +// DefaultCSVersion is the Cobalt Strike release profiles target when +// -CSVersion is not supplied. +const DefaultCSVersion = "4.13" + +// CSVersion is the team server release a profile is being generated for. +// +// Cobalt Strike removes Malleable C2 options between releases, so the generator +// has to know what it is writing for. 4.13 rejects stage.rdll_loader and +// stage.name, both of which earlier releases accept, and a profile carrying +// either one fails to load with "invalid option for <.stage>". +type CSVersion struct { + Major int + Minor int +} + +// ParseCSVersion accepts "4.13", "4.13+" or "4", and fails loudly on anything +// else rather than silently targeting the wrong release. +func ParseCSVersion(value string) CSVersion { + if value == "" { + value = DefaultCSVersion + } + parts := strings.SplitN(strings.TrimSuffix(strings.TrimSpace(value), "+"), ".", 3) + + major, err := strconv.Atoi(parts[0]) + if err != nil || major < 0 { + log.Fatalf("Error: -CSVersion must look like 4.13, got %q", value) + } + minor := 0 + if len(parts) > 1 { + minor, err = strconv.Atoi(parts[1]) + if err != nil || minor < 0 { + log.Fatalf("Error: -CSVersion must look like 4.13, got %q", value) + } + } + return CSVersion{Major: major, Minor: minor} +} + +// AtLeast reports whether the target release is major.minor or newer. +func (v CSVersion) AtLeast(major, minor int) bool { + if v.Major != major { + return v.Major > major + } + return v.Minor >= minor +} + +func (v CSVersion) String() string { + return strconv.Itoa(v.Major) + "." + strconv.Itoa(v.Minor) +} + +// setNameLine matches the "set name" directive inside a PE clone block. The +// \s+name guard keeps it away from set pipename and set ssh_pipename. +var setNameLine = regexp.MustCompile(`(?m)^.*\bset\s+name\s+"([^"]*)".*$\n?`) + +// PECloneName returns the module name a PE clone block masquerades as. +// +// This used to be recovered by splitting the block on ";" and indexing len-3, +// which breaks the moment the block gains or loses a directive, as it does when +// the name is stripped for 4.13. +func PECloneName(pe string) string { + if m := setNameLine.FindStringSubmatch(pe); m != nil { + return m[1] + } + return "unknown" +} + +// StripPECloneName removes the "set name" directive from a PE clone block. +// Cobalt Strike 4.13 rejects stage.name while still accepting the rest of the +// clone, so the checksum, compile time, entry point, image size and rich header +// all survive; only the spoofed module name is lost. +func StripPECloneName(pe string) string { + return setNameLine.ReplaceAllString(pe, "") +} diff --git a/Loader/CSVersion_test.go b/Loader/CSVersion_test.go new file mode 100644 index 0000000..7565088 --- /dev/null +++ b/Loader/CSVersion_test.go @@ -0,0 +1,95 @@ +package Loader + +import ( + "strings" + "testing" + + "github.com/Tylous/SourcePoint/Struct" +) + +func TestParseCSVersion(t *testing.T) { + cases := []struct { + in string + major int + minor int + }{ + {"", 4, 13}, + {"4.13", 4, 13}, + {"4.13+", 4, 13}, + {"4.12", 4, 12}, + {" 4.9 ", 4, 9}, + {"5", 5, 0}, + } + for _, c := range cases { + got := ParseCSVersion(c.in) + if got.Major != c.major || got.Minor != c.minor { + t.Errorf("ParseCSVersion(%q) = %d.%d, want %d.%d", c.in, got.Major, got.Minor, c.major, c.minor) + } + } +} + +func TestCSVersionAtLeast(t *testing.T) { + cases := []struct { + version string + want bool + }{ + {"4.13", true}, + {"4.14", true}, + {"5.0", true}, + {"4.12", false}, + {"4.9", false}, + {"3.14", false}, + } + for _, c := range cases { + if got := ParseCSVersion(c.version).AtLeast(4, 13); got != c.want { + t.Errorf("ParseCSVersion(%q).AtLeast(4, 13) = %v, want %v", c.version, got, c.want) + } + } +} + +// Every PE clone entry must yield a name, since the summary line reports it and +// the 4.13 path strips the directive that carries it. +func TestPECloneNameReadsEveryEntry(t *testing.T) { + for i, pe := range Struct.Peclone_list { + name := PECloneName(pe) + if name == "" || name == "unknown" { + t.Errorf("Peclone_list[%d]: could not read the module name", i) + } + if !strings.HasSuffix(strings.ToLower(name), ".dll") { + t.Errorf("Peclone_list[%d]: name %q does not look like a module", i, name) + } + } +} + +// 4.13 rejects stage.name, so the directive has to go and nothing else may. +// The entries are not uniform (four of the thirty carry no image_size +// directives), so this compares against each entry rather than against a fixed +// list of directives. +func TestStripPECloneNameRemovesOnlyTheNameDirective(t *testing.T) { + for i, pe := range Struct.Peclone_list { + stripped := StripPECloneName(pe) + if strings.Contains(stripped, "set name") { + t.Errorf("Peclone_list[%d]: set name survived stripping", i) + } + for _, line := range strings.Split(pe, "\n") { + if strings.TrimSpace(line) == "" || strings.Contains(line, "set name") { + continue + } + if !strings.Contains(stripped, line) { + t.Errorf("Peclone_list[%d]: stripping also removed %q", i, strings.TrimSpace(line)) + } + } + } +} + +// set pipename and set ssh_pipename must not be mistaken for set name. +func TestStripPECloneNameLeavesPipenamesAlone(t *testing.T) { + in := "set pipename \"foo\";\nset ssh_pipename \"bar\";\nset name \"baz.dll\";\n" + got := StripPECloneName(in) + if strings.Contains(got, `set name "baz.dll"`) { + t.Error("set name was not stripped") + } + if !strings.Contains(got, "set pipename") || !strings.Contains(got, "set ssh_pipename") { + t.Errorf("a pipename directive was stripped: %q", got) + } +} diff --git a/Loader/Loader.go b/Loader/Loader.go index e969ae0..58a5b97 100644 --- a/Loader/Loader.go +++ b/Loader/Loader.go @@ -94,7 +94,8 @@ func validateNumber(flagName, value string, min, max int) { } } -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, cs_version string) { + csv := ParseCSVersion(cs_version) Beacon_Com := &Beacon_Com{} Beacon_Stage_p1 := &Beacon_Stage_p1{} Beacon_Stage_p2 := &Beacon_Stage_p2{} @@ -110,15 +111,13 @@ func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, custom 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, 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, 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, csv) 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...") Build(custom_cert, cert_password, outFile, Beacon_Com, Beacon_Stage_p1, Beacon_Stage_p2, Beacon_Stage_p3, Process_Inject, Beacon_PostEX, Beacon_GETPOST, Beacon_GETPOST_Profile, Beacon_SSL) fmt.Println(HostStageMessage) - PE := strings.Split(Beacon_Stage_p2.Variables["pe"], `;`) - PE_Name := strings.Split(PE[len(PE)-3], `"`) - fmt.Println("[*] Beacon DLL Spoofed To: " + PE_Name[1]) + fmt.Println("[*] Beacon DLL Spoofed To: " + Beacon_Stage_p2.Variables["pe_name"]) PEX := strings.Split(Beacon_PostEX.Variables["Post_EX_Process_Name"], `sysnative\\`) PEX_Name := PEX[1] fmt.Println("[*] Post-Ex Process Name: " + PEX_Name[:(len(PEX_Name)-3)]) @@ -131,6 +130,9 @@ func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, custom } else { fmt.Println("[!] " + syscall_method + " syscall method selected") } + if csv.AtLeast(4, 13) { + fmt.Println("[!] Targeting Cobalt Strike " + csv.String() + ": stage.rdll_loader and stage.name omitted, both removed in 4.13") + } // 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. @@ -386,7 +388,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, 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, csv CSVersion) (map[string]string, map[string]string, string) { Beacon_Stage_p1 := &Beacon_Stage_p1{} Beacon_Stage_p1.Variables = make(map[string]string) @@ -405,13 +407,19 @@ func GeneratePE(beacon_PE string, syscall_method string, beacongate string, eaf_ } else { log.Fatal("Error: Please provide a valid Syscall Method") } - if rdll_loader == "PrependLoader" { - Beacon_Stage_p1.Variables["rdll_loader"] = "PrependLoader" - } else if rdll_loader == "StompLoader" { - Beacon_Stage_p1.Variables["rdll_loader"] = "StompLoader" - } else { + // The flag is validated regardless of target version, so a typo is still an + // error rather than being silently discarded along with the directive. + if rdll_loader != "PrependLoader" && rdll_loader != "StompLoader" { log.Fatal("Error: Please provide a valid Rdll Loader option") } + if csv.AtLeast(4, 13) { + // Cobalt Strike 4.13 removed stage.rdll_loader entirely: c2lint rejects + // it on both PrependLoader and StompLoader, so this is not the earlier + // stomp loader deprecation. + Beacon_Stage_p1.Variables["rdll_loader"] = "" + } else { + Beacon_Stage_p1.Variables["rdll_loader"] = `set rdll_loader "` + rdll_loader + `";` + } // Set default value for eaf_bypass if eaf_bypass == true { Beacon_Stage_p1.Variables["eaf_bypass"] = "true" @@ -473,6 +481,13 @@ func GeneratePE(beacon_PE string, syscall_method string, beacongate string, eaf_ Beacon_Stage_p2.Variables["pe"] = Struct.Peclone_list[(PE_Num - 1)] } + // Capture the spoofed module name before it is stripped below, so the + // summary line can still report it. + Beacon_Stage_p2.Variables["pe_name"] = PECloneName(Beacon_Stage_p2.Variables["pe"]) + if csv.AtLeast(4, 13) { + Beacon_Stage_p2.Variables["pe"] = StripPECloneName(Beacon_Stage_p2.Variables["pe"]) + } + if beacongate == "" { Beacon_Stage_p1.Variables["beacongate"] = "None;" } else if beacongate == "All" || beacongate == "Comms" || beacongate == "Core" || beacongate == "Cleanup" { diff --git a/Sample.yaml b/Sample.yaml index 3cb37c2..ac15c6a 100644 --- a/Sample.yaml +++ b/Sample.yaml @@ -37,4 +37,5 @@ TransformObfuscate: "lznt1,xor \"32\"" SmartInject: False BeaconGate: "All" SleepMask: False +CSVersion: "4.13" diff --git a/SourcePoint.go b/SourcePoint.go index 4c1c09c..91279c2 100644 --- a/SourcePoint.go +++ b/SourcePoint.go @@ -51,6 +51,7 @@ type FlagOptions struct { transform_obfuscate string smartinject bool sleep_mask bool + cs_version string } type conf struct { @@ -93,6 +94,7 @@ type conf struct { TransformObfuscate string `yaml:"TransformObfuscate"` SmartInject *bool `yaml:"SmartInject"` SleepMask *bool `yaml:"SleepMask"` + CSVersion string `yaml:"CSVersion"` } func (c *conf) getConf(yamlfile string) *conf { @@ -248,8 +250,13 @@ 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") + cs_version := flag.String("CSVersion", Loader.DefaultCSVersion, `Cobalt Strike release the profile is generated for. +4.13 removed the stage.rdll_loader and stage.name options, so profiles built +for 4.13 or newer omit them. Set this lower to target an older team server: +[*] 4.13 (or newer) +[*] 4.12 (or older)`) 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, cs_version: *cs_version} } @@ -307,6 +314,7 @@ func main() { setString(&opt.transform_obfuscate, c.TransformObfuscate) setBool(&opt.smartinject, c.SmartInject) setBool(&opt.sleep_mask, c.SleepMask) + setString(&opt.cs_version, c.CSVersion) } if opt.outFile == "" { @@ -321,5 +329,5 @@ func main() { if (opt.customuriGET != "" && opt.customuriPOST == "") || (opt.customuriGET == "" && opt.customuriPOST != "") { log.Fatal("Error: When using CustomuriGET/CustomuriPOST, both must be sepecified") } - 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.cs_version) } diff --git a/Struct/Struct.go b/Struct/Struct.go index 2f6cde0..995458b 100644 --- a/Struct/Struct.go +++ b/Struct/Struct.go @@ -1349,7 +1349,7 @@ stage { set eaf_bypass "{{.Variables.eaf_bypass}}"; set rdll_use_syscalls "{{.Variables.rdll_use_syscalls}}"; set copy_pe_header "{{.Variables.copy_pe_header}}"; - set rdll_loader "{{.Variables.rdll_loader}}"; + {{.Variables.rdll_loader}} {{.Variables.transform_obfuscate}} ` } From 42363395553cde2d86321939eeace007c4294adb Mon Sep 17 00:00:00 2001 From: Velkris Date: Thu, 27 Aug 2026 15:27:57 -0400 Subject: [PATCH 5/5] Omit PE clone image_size values on 4.13 as well Cobalt Strike requires stage.image_size_x86 and image_size_x64 to be at least the size of the Beacon DLL it stomps into the image, and rejects the profile otherwise with 'must be larger than N bytes'. The values in Peclone_list are the real sizes of the modules being mimicked and the Beacon has outgrown most of them, so on 4.13 only four of the thirty clone entries produce a loadable profile: the four that happen to carry no image_size directives at all. Raising them to a fixed floor would age out again as the Beacon grows each release, so they are dropped for 4.13 and newer and Cobalt Strike sizes the image itself. Verified by generating a profile for every clone entry and confirming none still carries image_size or name. Co-Authored-By: Claude Opus 5 --- Loader/CSVersion.go | 26 ++++++++++++++++++++++++-- Loader/CSVersion_test.go | 38 ++++++++++++++++++++++++++++++++++++++ Loader/Loader.go | 3 ++- SourcePoint.go | 5 +++-- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/Loader/CSVersion.go b/Loader/CSVersion.go index 0f4e8a8..01d3759 100644 --- a/Loader/CSVersion.go +++ b/Loader/CSVersion.go @@ -74,8 +74,30 @@ func PECloneName(pe string) string { // StripPECloneName removes the "set name" directive from a PE clone block. // Cobalt Strike 4.13 rejects stage.name while still accepting the rest of the -// clone, so the checksum, compile time, entry point, image size and rich header -// all survive; only the spoofed module name is lost. +// clone, so the checksum, compile time, entry point and rich header all +// survive; only the spoofed module name is lost. func StripPECloneName(pe string) string { return setNameLine.ReplaceAllString(pe, "") } + +// imageSizeLine matches the image_size_x86 and image_size_x64 directives inside +// a PE clone block. +var imageSizeLine = regexp.MustCompile(`(?m)^.*\bset\s+image_size_x(?:86|64)\s+"[^"]*".*$\n?`) + +// StripPECloneImageSize removes the image_size directives from a PE clone +// block. +// +// Cobalt Strike requires each to be at least the size of the beacon DLL it +// stomps into the image, and rejects the profile otherwise: +// +// [-] .stage.image_size_x86 must be larger than 372736 bytes +// [-] .stage.image_size_x64 must be larger than 462848 bytes +// +// The values in Peclone_list are the real sizes of the modules being mimicked, +// and the beacon has outgrown most of them. Raising them to a fixed floor would +// only age out again as the beacon grows with each release, so they are dropped +// and Cobalt Strike sizes the image itself. Four of the thirty entries already +// carry no image_size directives and load fine, which is what this relies on. +func StripPECloneImageSize(pe string) string { + return imageSizeLine.ReplaceAllString(pe, "") +} diff --git a/Loader/CSVersion_test.go b/Loader/CSVersion_test.go index 7565088..e0f76e6 100644 --- a/Loader/CSVersion_test.go +++ b/Loader/CSVersion_test.go @@ -82,6 +82,44 @@ func TestStripPECloneNameRemovesOnlyTheNameDirective(t *testing.T) { } } +// The beacon has outgrown the image_size values baked into most clone entries, +// so 4.13 rejects them with "must be larger than N bytes". They have to go, and +// nothing else may go with them. +func TestStripPECloneImageSizeRemovesOnlyThoseDirectives(t *testing.T) { + for i, pe := range Struct.Peclone_list { + stripped := StripPECloneImageSize(pe) + if strings.Contains(stripped, "image_size_x86") || strings.Contains(stripped, "image_size_x64") { + t.Errorf("Peclone_list[%d]: an image_size directive survived stripping", i) + } + for _, line := range strings.Split(pe, "\n") { + if strings.TrimSpace(line) == "" || strings.Contains(line, "image_size_x") { + continue + } + if !strings.Contains(stripped, line) { + t.Errorf("Peclone_list[%d]: stripping also removed %q", i, strings.TrimSpace(line)) + } + } + } +} + +// Together, the two strips have to leave a clone block 4.13 accepts: no name, +// no image_size, but the rest of the masquerade intact. +func TestStrippedCloneKeepsTheRemainingMasquerade(t *testing.T) { + for i, pe := range Struct.Peclone_list { + stripped := StripPECloneImageSize(StripPECloneName(pe)) + for _, gone := range []string{"set name", "image_size_x86", "image_size_x64"} { + if strings.Contains(stripped, gone) { + t.Errorf("Peclone_list[%d]: %q survived", i, gone) + } + } + for _, keep := range []string{"set checksum", "set compile_time", "set entry_point", "set rich_header"} { + if !strings.Contains(stripped, keep) { + t.Errorf("Peclone_list[%d]: %q did not survive", i, keep) + } + } + } +} + // set pipename and set ssh_pipename must not be mistaken for set name. func TestStripPECloneNameLeavesPipenamesAlone(t *testing.T) { in := "set pipename \"foo\";\nset ssh_pipename \"bar\";\nset name \"baz.dll\";\n" diff --git a/Loader/Loader.go b/Loader/Loader.go index 58a5b97..666755e 100644 --- a/Loader/Loader.go +++ b/Loader/Loader.go @@ -131,7 +131,7 @@ func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, custom fmt.Println("[!] " + syscall_method + " syscall method selected") } if csv.AtLeast(4, 13) { - fmt.Println("[!] Targeting Cobalt Strike " + csv.String() + ": stage.rdll_loader and stage.name omitted, both removed in 4.13") + fmt.Println("[!] Targeting Cobalt Strike " + csv.String() + ": omitting stage.rdll_loader and stage.name (removed in 4.13) and the PE clone image_size values (smaller than the current beacon)") } // num_Profile holds the resolved profile, including the one picked at // random when -Profile was not supplied. Re-parsing the raw flag here meant @@ -486,6 +486,7 @@ func GeneratePE(beacon_PE string, syscall_method string, beacongate string, eaf_ Beacon_Stage_p2.Variables["pe_name"] = PECloneName(Beacon_Stage_p2.Variables["pe"]) if csv.AtLeast(4, 13) { Beacon_Stage_p2.Variables["pe"] = StripPECloneName(Beacon_Stage_p2.Variables["pe"]) + Beacon_Stage_p2.Variables["pe"] = StripPECloneImageSize(Beacon_Stage_p2.Variables["pe"]) } if beacongate == "" { diff --git a/SourcePoint.go b/SourcePoint.go index 91279c2..ec0da92 100644 --- a/SourcePoint.go +++ b/SourcePoint.go @@ -251,8 +251,9 @@ Example: "lznt1,rc4 \"64\",xor \"32\",base64"`) smartinject := flag.Bool("SmartInject", false, "Enable Smart Inject") sleep_mask := flag.Bool("SleepMask", true, "Enable Sleep Mask") cs_version := flag.String("CSVersion", Loader.DefaultCSVersion, `Cobalt Strike release the profile is generated for. -4.13 removed the stage.rdll_loader and stage.name options, so profiles built -for 4.13 or newer omit them. Set this lower to target an older team server: +4.13 removed the stage.rdll_loader and stage.name options and its Beacon has +outgrown the PE clone image_size values, so profiles built for 4.13 or newer +omit all three. Set this lower to target an older team server: [*] 4.13 (or newer) [*] 4.12 (or older)`) flag.Parse()