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/CSVersion.go b/Loader/CSVersion.go new file mode 100644 index 0000000..01d3759 --- /dev/null +++ b/Loader/CSVersion.go @@ -0,0 +1,103 @@ +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 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 new file mode 100644 index 0000000..e0f76e6 --- /dev/null +++ b/Loader/CSVersion_test.go @@ -0,0 +1,133 @@ +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)) + } + } + } +} + +// 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" + 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 7c1579a..666755e 100644 --- a/Loader/Loader.go +++ b/Loader/Loader.go @@ -75,7 +75,27 @@ type Beacon_SSL struct { var num_Profile int var Post bool -func GenerateOptions(stage, sleeptime, jitter, useragent, uri, customuri, customuriGET, customuriPOST, beacon_PE, processinject_min_alloc, Post_EX_Process_Name, metadata, injector, Host, Profile, ProfilePath, outFile, custom_cert, cert_password, CDN, CDN_Value, datajitter, Keylogger string, Forwarder bool, tasks_max_size string, tasks_proxy_max_size string, tasks_dns_proxy_max_size string, syscall_method string, httplib string, ThreadSpoof bool, beacongate string, eaf_bypass bool, rdll_use_syscalls bool, copy_pe_header bool, rdll_loader string, transform_obfuscate string, smartinject bool, sleep_mask bool) { +// 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, cs_version string) { + csv := ParseCSVersion(cs_version) Beacon_Com := &Beacon_Com{} Beacon_Stage_p1 := &Beacon_Stage_p1{} Beacon_Stage_p2 := &Beacon_Stage_p2{} @@ -89,17 +109,15 @@ 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, 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)]) @@ -112,8 +130,13 @@ 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]) + if csv.AtLeast(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 + // 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") } @@ -130,17 +153,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 == "" { @@ -148,24 +175,27 @@ 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" } - 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 +234,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] } @@ -218,15 +248,27 @@ 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, _ := 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 +276,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 +296,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 @@ -319,6 +370,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" @@ -329,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, 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, csv CSVersion) (map[string]string, map[string]string, string) { Beacon_Stage_p1 := &Beacon_Stage_p1{} Beacon_Stage_p1.Variables = make(map[string]string) @@ -348,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" @@ -371,17 +436,12 @@ 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 { 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,17 +470,25 @@ 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)] } + // 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"]) + Beacon_Stage_p2.Variables["pe"] = StripPECloneImageSize(Beacon_Stage_p2.Variables["pe"]) + } + if beacongate == "" { Beacon_Stage_p1.Variables["beacongate"] = "None;" } else if beacongate == "All" || beacongate == "Comms" || beacongate == "Core" || beacongate == "Cleanup" { @@ -467,15 +535,20 @@ 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) + 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 +581,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/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/Sample.yaml b/Sample.yaml index 8ce7fc3..ac15c6a 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: @@ -38,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 591407a..ec0da92 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 { @@ -78,28 +79,31 @@ 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"` + CSVersion string `yaml:"CSVersion"` } 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 +113,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 +139,7 @@ func options() *FlagOptions { [*] Win10Chrome [*] Win10Edge [*] Win10IE +[*] Win10Firefox [*] Win10 [*] Win6.3 [*] Linux @@ -227,8 +250,14 @@ 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 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() - 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} } @@ -246,43 +275,47 @@ 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) + setString(&opt.cs_version, c.CSVersion) } if opt.outFile == "" { @@ -297,6 +330,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) + 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 aedafd8..995458b 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}}"; @@ -1334,7 +1334,6 @@ stage { set stomppe "true"; set cleanup "true"; set userwx "false"; - set smartinject "{{.Variables.smartinject}}"; beacon_gate { {{.Variables.beacongate}} } @@ -1346,11 +1345,11 @@ 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}}"; - set rdll_loader "{{.Variables.rdll_loader}}"; + {{.Variables.rdll_loader}} {{.Variables.transform_obfuscate}} ` } @@ -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) + } +} 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) + } +}