Skip to content

Add DNS staging with generated labels (closes the DNS Staging TODO) - #31

Open
warpedatom wants to merge 2 commits into
Tylous:mainfrom
warpedatom:feat/dns-staging
Open

Add DNS staging with generated labels (closes the DNS Staging TODO)#31
warpedatom wants to merge 2 commits into
Tylous:mainfrom
warpedatom:feat/dns-staging

Conversation

@warpedatom

@warpedatom warpedatom commented Aug 26, 2026

Copy link
Copy Markdown

Add DNS staging with generated labels (closes the "DNS Staging" TODO)

This implements the DNS Staging item from the README roadmap.

The dns-beacon block already existed in Struct.go, commented out, with a
note reading ####Manaully add these if your doing C2 over DNS (Future Release)####. This wires it up so it can be generated rather than pasted in by
hand.

Why this is worth generating rather than documenting

The commented block carries Cobalt Strike's documentation example values
verbatim:

#    set dns_stager_prepend   "doc-stg-prepend";
#    set dns_stager_subhost   "doc-stg-sh.";
#    set beacon               "doc.bc.";
#    set get_A                "doc.1a.";
#    set get_AAAA             "doc.4a.";
#    set get_TXT              "doc.tx.";
#    set put_metadata         "doc.md.";
#    set put_output           "doc.po.";

doc.bc., doc.tx., doc.md. and the rest are among the most heavily
signatured DNS C2 indicators in existence. Any operator who follows the
instruction above and pastes the block in as-is ends up with the single most
detectable DNS configuration available, which is the opposite of what the rest
of the tool is for. Generating the labels solves that in the same way the HTTP
side already randomizes its URIs.

What it does

Adds -DNS (default false). When it is off, generated profiles are
functionally unchanged. The one difference is that the commented-out
placeholder block is no longer written into every profile, since the template
now carries a variable in its place, so a non-DNS profile comes out about 18
lines shorter.

When it is on, all eight label strings are generated per profile:
dns_stager_prepend, dns_stager_subhost, beacon, get_A, get_AAAA,
get_TXT, put_metadata and put_output.

The shape of the documentation values is preserved. They share a first label
and vary the second, so the generator produces one random base label plus seven
distinct two-character suffixes. Distinctness is enforced rather than assumed:
the get_* and put_* prefixes are how the teamserver tells request types
apart, so a collision would break the channel rather than merely look wrong.

Sample output:

dns-beacon {
    set dns_idle             "0.0.0.0";
    set dns_max_txt          "199";
    set dns_sleep            "1";
    set dns_ttl              "5";
    set maxdns               "200";
    set dns_stager_prepend   "kw3ndqx";
    set dns_stager_subhost   "vqm.t7.";

    set beacon               "vqm.b2.";
    set get_A                "vqm.9c.";
    set get_AAAA             "vqm.hk.";
    set get_TXT              "vqm.4p.";
    set put_metadata         "vqm.md.";
    set put_output           "vqm.zx.";
    set ns_response          "zero";
}

What I deliberately did not randomize

dns_idle is exposed as -DNSIdle instead, defaulting to 0.0.0.0
(Cobalt Strike's own default). It is the "no tasks" sentinel, so it must not
collide with an address the operator's domain genuinely serves. That is an
operational decision, not a fingerprint. It is validated as IPv4.

The numeric tuning values (dns_max_txt, dns_sleep, dns_ttl, maxdns)
are left exactly as you wrote them. They affect throughput and reliability
rather than signature, so randomizing them would add risk for no detection
benefit.

ns_response "zero" is behavior rather than a signature, so it stays.

Linted against Cobalt Strike 4.13

c2lint from a licensed Cobalt Strike 4.13 team server accepts the generated
dns-beacon block, with no errors and no warnings against any of its
directives, labels or values.

Getting there took two fixes worth recording, because both came from carrying
values over from the commented-out block unexamined:

  • dns_max_txt must be divisible by four. The commented block carried
    199, which is not, and Cobalt Strike rejects the profile outright over it.
    This uses the documented default of 252.
  • Prefixes should not exceed eight characters. c2lint warns on every
    directive that does, since each indicator character is data space lost from
    every query the beacon sends. The generated base label is capped so the full
    prefix lands at seven or eight.

Both are now covered by tests, so a future change cannot quietly reintroduce
either.

One caveat on scope: this validates that the block compiles and that its values
are legal, not that a beacon stages over DNS end to end. That needs a live
listener rather than a linter.

Tests

Loader/DNS_test.go covers: the block being omitted entirely when -DNS is
off, none of the documentation values surviving into output, all seven prefixes
being distinct within a profile, labels varying between profiles, every label
being a syntactically valid lowercase dotted sequence, and dns_idle
defaulting correctly while honouring an operator value.

Staging has to be enabled for the stager labels to be used

Worth stating plainly: dns_stager_prepend and dns_stager_subhost only come
into play when host staging is on, and SourcePoint defaults -Stage to false.
c2lint says so directly:

[!] .host_stage is FALSE. This will break staging over HTTP, HTTPS, and DNS!

So with default flags the two staging labels are inert, while the rest of the
block (beacon, get_A, get_AAAA, get_TXT, put_metadata, put_output)
governs DNS beacon comms and applies either way. Operators who want DNS staging
need -Stage true as well. The same is true of the existing HTTP uri_x86 and
uri_x64 stager URIs, so this is not new behaviour, just something that was
not written down anywhere.

Note on overlap with #30

This branches from main and is independently mergeable in either order, but
the merge is not quite trivial if #30 goes in first, so it is worth flagging
what to watch for. I hit this merging both into a local branch.

SourcePoint.go conflicts in three places: the FlagOptions struct, the flag
definitions, and the YAML overlay. The first two are simple unions. The third
needs care: this branch predates #30, so it still carries the old unconditional
opt.X = c.X overlay and the fmt.Println(c.TasksMaxSize) debug print that #30
removes. Taking this branch's side of that hunk would silently revert several
of #30's config fixes and reinstate the debug print.
The correct resolution is
to keep #30's setString/setBool overlay and route DNS and DNSIdle
through it, making conf.DNS a *bool for the same reason the other booleans
became pointers there.

Happy to rebase this branch onto #30 and resolve it myself if you would rather
merge a clean diff.

Worth flagging separately: go vet ./... currently fails on main with
Utils/Utils.go:164:4: self-assignment of ii. That is pre-existing and not
introduced here, and it is one of the bugs #30 fixes (the ii = ii line meant
-Uri N could return fewer than N URIs). Mentioning it so a vet failure on
this branch is not mistaken for something this PR caused.

One small duplication if both land: Loader/DNS.go seeds its own
math/rand source, because on main the shared generators reseed from
time.Now() on every call and can repeat within a clock tick. #30 fixes that
centrally, so once it merges this file can drop its local source and use the
shared helper instead. Happy to do that as a follow-up.

The dns-beacon block shipped commented out in Struct.go carrying Cobalt Strike's documentation example values (doc.bc., doc.1a., doc.tx., doc.md., doc-stg-sh. and the rest) under a note telling operators to add them manually. Those labels are among the most heavily signatured DNS C2 indicators there are, so following that instruction produces the most detectable configuration available. The block is now generated behind an opt-in -DNS flag, with all eight staging and request labels randomized per profile and the get_/put_ prefixes guaranteed distinct. dns_idle is exposed as -DNSIdle instead of being randomized, since it is the no-tasks sentinel and must not collide with a real answer. The numeric tuning values are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 26, 2026 22:01

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.

c2lint rejects a profile whose dns_max_txt is not divisible by four, and the commented-out block this feature replaced carried 199, so it now uses the documented default of 252. c2lint also warns when a dns-beacon prefix runs past eight characters, since every indicator character is data space lost from each query. Two things caused that: the base label could reach five characters, and dnsDistinctLabels widened the label length on a collision, which made every label after the first collision one character longer. The base is now capped at four and collisions are redrawn at the same length. Tests cover the divisibility rule, the length limit, and all seven prefixes sharing a length. None of this surfaced earlier because c2lint stops at the stage block errors from Tylous#32 before reaching dns-beacon.

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