Fix profile-entropy bugs, YAML config handling, and crash-on-bad-input - #30
Open
warpedatom wants to merge 5 commits into
Open
Fix profile-entropy bugs, YAML config handling, and crash-on-bad-input#30warpedatom wants to merge 5 commits into
warpedatom wants to merge 5 commits into
Conversation
-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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The Slack profile appends __ar_v4 to the Cookie header in both directions, but the http-post append was missing its leading semicolon. A POST request emitted _ga=GA1.2.875__ar_v4=%8867UMDGS643 as a single mangled cookie value while the http-get request emitted the same fragment correctly, so GET and POST from the same host disagreed on their own cookie format. Found by reading the traffic sample c2lint prints for a compiled profile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 27, 2026
The Outlook.Live http-stager response declared Server: nginx while its http-get and http-post responses declared Microsoft-IIS/10.0. One host cannot be both, and real Outlook Web Access is IIS, so a defender comparing responses from the same origin gets a free correlation. Found by reading the transaction sample c2lint prints for a compiled profile. The accompanying test asserts that no profile declares more than one Server value, which catches this class rather than this instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix profile-entropy bugs, YAML config handling, and crash-on-bad-input
Hi. SourcePoint is great, and this PR is a set of correctness fixes I found
while reading through the generator. Nothing here changes what a generated
profile does; it fixes cases where the generator was producing less variety
than intended, silently ignoring config, or panicking instead of erroring.
Happy to split this into smaller PRs if you'd prefer, since the groups below are
independent.
Section 8 is the one to read first if you only read one: two of these were
stopping
c2lintfrom accepting any generated profile at all on Cobalt Strike4.13. Two further causes of that are version compatibility questions rather
than bugs, so I have reported those separately in #32 instead of guessing at a
fix.
1. The Slack profile's stager URIs were hardcoded
Struct.goshipped these as literals rather than templates:Every Slack profile SourcePoint has ever generated carries those two exact
paths, so a single signature matches all of them, which cuts against the point
of the tool. The two also differ only by the case of one character.
The GoToMeeting profile had a subtler version of the same problem: both stager
URIs were
/Meeting/{{.Variables.UValue}}/, so x86 and x64 were byte-identical,and
UValueis also used in the beacon's own check-in traffic (theU=/REF=ID=prepends and thewla42cookie). Reusing one token across both meantthe staging request and the check-ins shared a unique correlatable value.
Both now use
stager_x86/stager_x64, generated independently per run, perarchitecture, and separately from
UValue, with varied length so the segmentisn't a fixed-width tell either. Reuse of
UValuewithin check-in traffic isleft alone, since a session identifier recurring across requests is realistic
and worth keeping.
Outlook.Live is untouched:
number86/number64were already independentlyrandom, and its
/rpc/<number>shape is more realistic than a token would be.2. Random table selection was excluding the last entry of every table
Utils.GenerateNumer(min, max)is exclusive ofmax, but every caller passedlen(list)-1, so the final entry of each lookup table could never be selected.One of them (
SSH_Banner) was much further off:SSH_BannerGenerateNumer(0, 4)Pipename_listGenerateNumer(0, 7)Thread_listGenerateNumer(0, 8)Magic_PEGenerateNumer(0, 6)Useragent_listGenerateNumer(0, 64)Post_EX_Process_NameGenerateNumer(0, 14)svchost.exe)Replaced with
Utils.RandIndex(len(Struct.X)), so the bound is derived from thetable and stays correct when you add entries.
The named
-Useragentbands (Win10Chrometo(0,9),Win10Edgeto(9,16),and so on up to
Macat(51,65)) are already correct and cover all 65entries. Only the "no
-Useragentsupplied" default path was wrong, so I leftthe bands alone.
3.
-Uri Ncould return fewer than N URIsGenerateURIValuesrejects a random path segment that starts with-, but therejection branch was a no-op:
alphacontains-, so roughly 1 in 63 candidates was silently dropped andset uriended up short. Now it retries, and also de-duplicates so one profilecan't list the same URI twice.
4. Re-seeding
math/randon every callEvery generator in
Utilsopened withrand.Seed(time.Now().UnixNano()).That is deprecated as of go1.20, and it works against the goal of the tool:
calls that land inside the same clock tick reseed the global source to the same
state and return byte-identical values.
GenerateURIValuescalled it inside itsloop, which is the most likely place to see it.
Replaced with a single
*rand.Randseeded once fromcrypto/rand(with atime.Now()fallback). No deprecated API, still go1.15-compatible.Also removed
generateRandomBytes, which was unused and, despite the name,read from
math/rand.5. YAML config handling
CDN_Valuewas never copied out of the config. Every other field isassigned in
main, butopt.CDN_Value = c.CDN_Valueis missing, so anAzureEdge profile driven from a YAML file emitted
Cookie: <name>=with anempty value.
-Yamlpath only logged and continued, producing a profilebuilt entirely from defaults. Now fatal.
unconditionally, so a config that omits a key overwrote the default with
""or
false. In practice a YAML file withoutSleepMask:/ThreadSpoof:turned both off, even though both default to
true;Metadata,Httpliband
RdllLoaderlostbase64url/winhttp/PrependLoader. Booleans arenow
*boolso an absent key is distinguishable from an explicitfalse.6. Crashes and silent misconfiguration on bad input
These all ended in
panic: index out of rangerather than an error message:-PE_Clone 0Peclone_list[-1])-PE_Clone abcAtoierror discarded, giving-1)-PostEX_Name 0/-PostEX_Name 99-Profile 0/-Profile abc-Profile 9Profile_Names[9]Each now reports the valid range. Related fixes:
-Injectorwas silently mandatory. It defaults to"", andGenerateProcessInjecthas no empty case, so the finalelsefired andkilled the run:
Every other optional flag defaults or randomizes when blank, and nothing
documents
-Injectoras required, so this now defaults toVirtualAllocEx(
NtMapViewOfSectionfails over to it anyway, per the flag's own help text).Numeric flags weren't validated at all.
-Sleep abcproducedset sleeptime "abc000", and-Jitter 150produced a jitter percentageoutside the permitted 0-99. Neither failed in SourcePoint: they failed when
the teamserver refused to load the profile, which is the worst time to find
out.
-Sleep,-Jitter,-Datajitter,-Allocationand the three-Tasks*MaxSizeflags are now checked up front. (-Allocationalso discardedits
Atoierror, soabcparsed as0and reported the misleading"needs to be greater than 4096".)
An invalid
-Keyloggervalue hit an emptyelse {}branch and left thekeylogger unset, producing a profile Cobalt Strike rejects at load time. Now
an error.
The AzureEdge profile checks
-CDNbut not-CDN-Value, so ahalf-configured cookie got through.
7. Smaller things
fmt.Println(c.TasksMaxSize)inmainwas a leftover debug print.-Profile, the summary printed[*] Seleted Profile:with an emptyname, because it re-parsed the raw flag instead of using the resolved
num_Profile. (Also fixed the "Seleted" typo.)-Useragenthelp text was missingWin10Firefox, which the code accepts.Sample.yamldeclaredCustomuri:twice.8. Two reasons profiles were rejected by c2lint
Verified against a licensed Cobalt Strike 4.13 team server.
smartinjectwas set in thestageblock, where it is not a valid option:It is a
post-exoption, and SourcePoint already sets it there, hardcoded to"true". So-SmartInjectwas driving the invalid copy instageand had noeffect on the block that was meant to carry it. Moved to
post-exand drivenby the flag, which makes
-SmartInjectdo something for the first time.sleep_maskwas emitted without quotes, unlike every other boolean in theblock:
Both are covered by tests asserting against the template text, so neither can
regress silently.
c2lintsurfaces roughly two errors per run. With these fixed it proceedsfurther into the block and rejects
stage.rdll_loaderandstage.name, whichare 4.13 compatibility questions rather than bugs. Details, including which
stage options 4.13 does and does not accept, are in #32.
Tests and CI
The repo had no tests. Added
Utils/Utils_test.go,Loader/Loader_test.goandStruct/Struct_test.gocovering the regressions above: full table coverage,exact
-Uri Ncount, URI uniqueness, per-profile base paths, stager URIsvarying per run and per architecture and staying independent of
UValue,empty/inverted range handling, and the two
stageblock errors from section 8.Also added a GitHub Actions workflow running
gofmt -l,go vet,go testand
go build.Note on the roadmap
GenerateURIValuesnow takes its path prefix/suffix from auriBaseswitch, soadding a profile for the "Add More Profiles" TODO is a matter of appending
to
Profile_Names/HTTP_GET_POST_list/Certand adding onecase. Thelen()-derived pickers above mean the new entry is reachable immediately. Ididn't add any profiles here because a good one needs real capture-derived
headers rather than invented ones; happy to follow up if you have traffic
samples you want covered.