Skip to content

Fix profile-entropy bugs, YAML config handling, and crash-on-bad-input - #30

Open
warpedatom wants to merge 5 commits into
Tylous:mainfrom
warpedatom:fix/profile-entropy-and-config-handling
Open

Fix profile-entropy bugs, YAML config handling, and crash-on-bad-input#30
warpedatom wants to merge 5 commits into
Tylous:mainfrom
warpedatom:fix/profile-entropy-and-config-handling

Conversation

@warpedatom

@warpedatom warpedatom commented Aug 26, 2026

Copy link
Copy Markdown

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 c2lint from accepting any generated profile at all on Cobalt Strike
4.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.go shipped these as literals rather than templates:

set uri_x86 "/messages/DALBNSf25";
set uri_x64 "/messages/DALBNSF25";

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 UValue is also used in the beacon's own check-in traffic (the U= /
REF=ID= prepends and the wla42 cookie). Reusing one token across both meant
the staging request and the check-ins shared a unique correlatable value.

Both now use stager_x86 / stager_x64, generated independently per run, per
architecture, and separately from UValue, with varied length so the segment
isn't a fixed-width tell either. Reuse of UValue within check-in traffic is
left alone, since a session identifier recurring across requests is realistic
and worth keeping.

Outlook.Live is untouched: number86 / number64 were already independently
random, 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 of max, but every caller passed
len(list)-1, so the final entry of each lookup table could never be selected.
One of them (SSH_Banner) was much further off:

Table old picker reachable table size never selected
SSH_Banner GenerateNumer(0, 4) 0-3 8 4 of 8
Pipename_list GenerateNumer(0, 7) 0-6 8 1
Thread_list GenerateNumer(0, 8) 0-7 9 1
Magic_PE GenerateNumer(0, 6) 0-5 7 1
Useragent_list GenerateNumer(0, 64) 0-63 65 1
Post_EX_Process_Name GenerateNumer(0, 14) 0-13 15 1 (svchost.exe)

Replaced with Utils.RandIndex(len(Struct.X)), so the bound is derived from the
table and stays correct when you add entries.

The named -Useragent bands (Win10Chrome to (0,9), Win10Edge to (9,16),
and so on up to Mac at (51,65)) are already correct and cover all 65
entries. Only the "no -Useragent supplied" default path was wrong, so I left
the bands alone.

3. -Uri N could return fewer than N URIs

GenerateURIValues rejects a random path segment that starts with -, but the
rejection branch was a no-op:

if strings.HasPrefix(value, "-") {
    ii = ii          // intended to retry; actually just drops the URI
} else {
    uri += baseuri + value + " "
}

alpha contains -, so roughly 1 in 63 candidates was silently dropped and
set uri ended up short. Now it retries, and also de-duplicates so one profile
can't list the same URI twice.

4. Re-seeding math/rand on every call

Every generator in Utils opened with rand.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. GenerateURIValues called it inside its
loop, which is the most likely place to see it.

Replaced with a single *rand.Rand seeded once from crypto/rand (with a
time.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_Value was never copied out of the config. Every other field is
    assigned in main, but opt.CDN_Value = c.CDN_Value is missing, so an
    AzureEdge profile driven from a YAML file emitted Cookie: <name>= with an
    empty value.
  • An unreadable -Yaml path only logged and continued, producing a profile
    built entirely from defaults. Now fatal.
  • The config overlay clobbered flag defaults. Every key was assigned
    unconditionally, so a config that omits a key overwrote the default with ""
    or false. In practice a YAML file without SleepMask: / ThreadSpoof:
    turned both off, even though both default to true; Metadata, Httplib
    and RdllLoader lost base64url / winhttp / PrependLoader. Booleans are
    now *bool so an absent key is distinguishable from an explicit false.

6. Crashes and silent misconfiguration on bad input

These all ended in panic: index out of range rather than an error message:

input before
-PE_Clone 0 panic (Peclone_list[-1])
-PE_Clone abc panic (Atoi error discarded, giving -1)
-PostEX_Name 0 / -PostEX_Name 99 panic
-Profile 0 / -Profile abc panic
-Profile 9 panic on Profile_Names[9]

Each now reports the valid range. Related fixes:

  • -Injector was silently mandatory. It defaults to "", and
    GenerateProcessInject has no empty case, so the final else fired and
    killed the run:

    $ ./SourcePoint -Host acme-email.com -Outfile test.profile -Uri 8 -Profile 2
    [*] Preparing Varibles...
    Error: Please provide a valid Process Injector option
    

    Every other optional flag defaults or randomizes when blank, and nothing
    documents -Injector as required, so this now defaults to VirtualAllocEx
    (NtMapViewOfSection fails over to it anyway, per the flag's own help text).

  • Numeric flags weren't validated at all. -Sleep abc produced
    set sleeptime "abc000", and -Jitter 150 produced a jitter percentage
    outside 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, -Allocation and the three
    -Tasks*MaxSize flags are now checked up front. (-Allocation also discarded
    its Atoi error, so abc parsed as 0 and reported the misleading
    "needs to be greater than 4096".)

  • An invalid -Keylogger value hit an empty else {} branch and left the
    keylogger unset, producing a profile Cobalt Strike rejects at load time. Now
    an error.

  • The AzureEdge profile checks -CDN but not -CDN-Value, so a
    half-configured cookie got through.

7. Smaller things

  • fmt.Println(c.TasksMaxSize) in main was a leftover debug print.
  • Without -Profile, the summary printed [*] Seleted Profile: with an empty
    name, because it re-parsed the raw flag instead of using the resolved
    num_Profile. (Also fixed the "Seleted" typo.)
  • -Useragent help text was missing Win10Firefox, which the code accepts.
  • Sample.yaml declared Customuri: twice.

8. Two reasons profiles were rejected by c2lint

Verified against a licensed Cobalt Strike 4.13 team server.

smartinject was set in the stage block, where it is not a valid option:

Error: invalid option for <.stage> at line 53
       smartinject

It is a post-ex option, and SourcePoint already sets it there, hardcoded to
"true". So -SmartInject was driving the invalid copy in stage and had no
effect on the block that was meant to carry it. Moved to post-ex and driven
by the flag, which makes -SmartInject do something for the first time.

sleep_mask was emitted without quotes, unlike every other boolean in the
block:

set sleep_mask {{.Variables.sleep_mask}};    // renders: set sleep_mask true;
Error: Unknown statement in <.stage> at line 65

Both are covered by tests asserting against the template text, so neither can
regress silently.

c2lint surfaces roughly two errors per run. With these fixed it proceeds
further into the block and rejects stage.rdll_loader and stage.name, which
are 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.go and
Struct/Struct_test.go covering the regressions above: full table coverage,
exact -Uri N count, URI uniqueness, per-profile base paths, stager URIs
varying per run and per architecture and staying independent of UValue,
empty/inverted range handling, and the two stage block errors from section 8.
Also added a GitHub Actions workflow running gofmt -l, go vet, go test
and go build.

Note on the roadmap

GenerateURIValues now takes its path prefix/suffix from a uriBase switch, so
adding a profile for the "Add More Profiles" TODO is a matter of appending
to Profile_Names / HTTP_GET_POST_list / Cert and adding one case. The
len()-derived pickers above mean the new entry is reachable immediately. I
didn'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.

-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>
Copilot AI lite review requested due to automatic review settings August 26, 2026 21:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants