Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
103 changes: 103 additions & 0 deletions Loader/CSVersion.go
Original file line number Diff line number Diff line change
@@ -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, "")
}
133 changes: 133 additions & 0 deletions Loader/CSVersion_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading