Fix DNS canary embedding and detection (#2432)

* Preserve DNS canaries in obfuscated implants

* Refactor canary regression assertions for lint

* Fix DNS canary detection and trigger persistence
This commit is contained in:
Joe authored and GitHub committed 2026-11-05 09:42:05 -08:00
1 parent 6cf3ad78bd
commit 9f8f1d11be
14 files changed
+922 -74

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
package generate
import (
"strconv"
"testing"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
func TestCanariesCommandRegistered(t *testing.T) {
root := &cobra.Command{Use: "sliver"}
root.AddCommand(Commands(&console.SliverClient{})...)
cmd, _, err := root.Find([]string{consts.CanariesStr})
if err != nil {
t.Fatalf("find canaries command: %v", err)
}
if cmd.Parent() != root {
t.Fatal("canaries command is not registered at the root")
}
if cmd.Run == nil {
t.Fatal("canaries command has no handler")
}
if cmd.GroupID != consts.PayloadsHelpGroup {
t.Errorf("canaries group = %q, want %q", cmd.GroupID, consts.PayloadsHelpGroup)
}
for _, want := range []struct {
name string
shorthand string
valueType string
defaultValue string
}{
{name: "burned", shorthand: "b", valueType: "bool", defaultValue: "false"},
{name: "timeout", shorthand: "t", valueType: "int64", defaultValue: strconv.Itoa(flags.DefaultTimeout)},
} {
flag := cmd.Flags().Lookup(want.name)
if flag == nil {
t.Fatalf("canaries command is missing --%s", want.name)
}
if flag.Shorthand != want.shorthand {
t.Errorf("--%s shorthand = %q, want %q", want.name, flag.Shorthand, want.shorthand)
}
if flag.Value.Type() != want.valueType {
t.Errorf("--%s type = %q, want %q", want.name, flag.Value.Type(), want.valueType)
}
if flag.DefValue != want.defaultValue {
t.Errorf("--%s default = %q, want %q", want.name, flag.DefValue, want.defaultValue)
}
}
}
+1 -1
View File
@@ -313,7 +313,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd}
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd, canariesCmd}
}
// coreImplantFlags binds all flags common to all sliver implant types.
+87 -18
View File
@@ -27,16 +27,70 @@ fi
SKIP_GENERATE=0
UNIT_ONLY=0
for arg in "$@"; do
if [ "$arg" = "--skip-generate" ]; then
GENERATE_ONLY=0
GENERATE_RUN=""
usage() {
cat <<'EOF'
Usage: ./go-tests.sh [--skip-generate | --unit-only | --generate-only] [--run REGEX]
--skip-generate Skip implant generation tests.
--unit-only Skip implant generation and C2 end-to-end tests.
--generate-only Run only the server/generate package tests.
--run REGEX Select generation tests by Go test name regex; requires --generate-only.
--help Show this help.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--skip-generate)
SKIP_GENERATE=1
fi
if [ "$arg" = "--unit-only" ]; then
;;
--unit-only)
UNIT_ONLY=1
SKIP_GENERATE=1
fi
;;
--generate-only)
GENERATE_ONLY=1
;;
--run)
if [ "$#" -lt 2 ] || [ -z "$2" ] || [[ "$2" == --* ]]; then
echo "--run requires a non-empty regex" >&2
exit 2
fi
GENERATE_RUN="$2"
shift
;;
--run=*)
GENERATE_RUN="${1#--run=}"
if [ -z "$GENERATE_RUN" ]; then
echo "--run requires a non-empty regex" >&2
exit 2
fi
;;
--help | -h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
if [ "$GENERATE_ONLY" -eq 1 ] && [ "$SKIP_GENERATE" -eq 1 ]; then
echo "--generate-only cannot be combined with --skip-generate or --unit-only" >&2
exit 2
fi
if [ -n "$GENERATE_RUN" ] && [ "$GENERATE_ONLY" -eq 0 ]; then
echo "--run requires --generate-only" >&2
exit 2
fi
echo "----------------------------------------------------------------"
echo "WARNING: Running unit tests on slow systems can take a LONG time"
echo " Recommended to only run on 16+ CPU cores and 32Gb+ RAM"
@@ -175,8 +229,35 @@ unpack_server_assets() {
"$HOST_GO" run -tags=server,go_sqlite ./server unpack --force
}
run_generate_tests() {
# Reclaim the host build cache before generation so long CI runs do not
# exhaust the runner's disk while retaining the implant compiler cache.
run_test_cmd "clean Go build cache" "$HOST_GO" clean -cache || return 1
export GOPROXY=off
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
# parallelism conservative by default to avoid OOM kills on smaller systems.
local generate_gomaxprocs="${SLIVER_GENERATE_GOMAXPROCS:-2}"
local generate_go_p="${SLIVER_GENERATE_GO_P:-1}"
local generate_test_parallel="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
local generate_test_cmd=(
"$HOST_GO" test -timeout 6h -p "$generate_go_p" -parallel "$generate_test_parallel" -tags="server,$TAGS"
)
if [ -n "$GENERATE_RUN" ]; then
# Keep test selection out of GOFLAGS, which implant compiler children inherit.
generate_test_cmd+=(-run "$GENERATE_RUN" -count=1 -v)
fi
generate_test_cmd+=(./server/generate)
run_test_cmd "./server/generate" \
env GOMAXPROCS="$generate_gomaxprocs" "${generate_test_cmd[@]}"
}
run_test_cmd "unpack server assets" unpack_server_assets || exit 1
if [ "$GENERATE_ONLY" -eq 1 ]; then
run_generate_tests || exit 1
exit 0
fi
TEST_DIRS=()
while IFS= read -r test_dir; do
TEST_DIRS+=("$test_dir")
@@ -279,19 +360,7 @@ fi
## Server generate
if [ "$SKIP_GENERATE" -eq 0 ]; then
# The package tests above no longer need their Go build cache. Reclaim it
# before the cross-platform generation matrix starts so long CI runs do not
# exhaust the runner's disk while retaining the implant compiler cache.
run_test_cmd "clean Go build cache" "$HOST_GO" clean -cache || exit 1
export GOPROXY=off
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
# parallelism conservative by default to avoid OOM kills on smaller systems.
GENERATE_GOMAXPROCS="${SLIVER_GENERATE_GOMAXPROCS:-2}"
GENERATE_GO_P="${SLIVER_GENERATE_GO_P:-1}"
GENERATE_TEST_PARALLEL="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
run_test_cmd "./server/generate" \
env GOMAXPROCS="$GENERATE_GOMAXPROCS" \
"$HOST_GO" test -timeout 6h -p "$GENERATE_GO_P" -parallel "$GENERATE_TEST_PARALLEL" -tags="server,$TAGS" ./server/generate || exit 1
run_generate_tests || exit 1
else
echo
if [ "$UNIT_ONLY" -eq 1 ]; then
+10 -3
View File
@@ -25,10 +25,17 @@ var (
SliverName = `{{.Name}}`
)
// Message - Fake message for embedding canaries
// Message carries canaries in struct tags, which remain plaintext under Garble.
type Message struct {
Command string `c2:"[[GenerateCanary]]"`
// {{if .Config.IncludeMTLS}}
MTLS string `c2:"[[GenerateCanary]]"`
// {{end}}
// {{if .Config.IncludeWG}}
WireGuard string `c2:"[[GenerateCanary]]"`
// {{end}}
}
// never obfuscate the Message type
var _ = reflect.TypeOf(Message{})
// Access a field at runtime to retain the type metadata and its canary tags.
// A discarded TypeOf call alone is optimized away, removing the canaries.
var _ = reflect.TypeOf(Message{}).Field(0)
+1 -1
View File
@@ -189,7 +189,7 @@ func ReadEnvelope(r io.Reader) (*pb.Envelope, error) {
rawSigBuf := make([]byte, cryptography.RawSigSize)
dataLengthBuf := make([]byte, 4) // Size of uint32
if len(rawSigBuf) == 0 || len(dataLengthBuf) == 0 || r == nil {
panic("[[GenerateCanary]]")
panic("invalid envelope reader")
}
n, err := io.ReadFull(r, rawSigBuf)
@@ -182,7 +182,7 @@ func ReadEnvelope(connection net.Conn) (*pb.Envelope, error) {
rawSigBuf := make([]byte, cryptography.RawSigSize)
dataLengthBuf := make([]byte, 4) // Size of uint32
if len(rawSigBuf) == 0 || len(dataLengthBuf) == 0 || connection == nil {
panic("[[GenerateCanary]]")
panic("invalid envelope connection")
}
n, err := io.ReadFull(connection, rawSigBuf)
+40 -41
View File
@@ -46,6 +46,7 @@ import (
"time"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/dnspb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/server/core"
@@ -576,15 +577,7 @@ func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writ
return
}
var resp *dns.Msg
isC2, domain := s.isC2SubDomain(domains, req.Question[0].Name)
if isC2 {
dnsLog.Debugf("'%s' is subdomain of c2 parent '%s'", req.Question[0].Name, domain)
resp = s.handleC2(domain, req)
} else if canaries {
dnsLog.Debugf("checking '%s' for DNS canary matches", req.Question[0].Name)
resp = s.handleCanary(req)
}
resp := s.handleDNSQuery(domains, canaries, req)
if resp != nil {
// These responses often have near-max-length QNAMEs. Enable compression to
// keep UDP responses under the minimum DNS message size limits.
@@ -595,6 +588,27 @@ func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writ
}
}
func (s *SliverDNSServer) handleDNSQuery(domains []string, canaries bool, req *dns.Msg) *dns.Msg {
if canaries {
canary, err := db.CanaryByDomain(req.Question[0].Name)
if err == nil && canary != nil {
return s.handleCanary(req, canary)
}
if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
dnsLog.Errorf("Failed to look up DNS canary: %s", err)
}
}
if isC2, domain := s.isC2SubDomain(domains, req.Question[0].Name); isC2 {
dnsLog.Debugf("'%s' is subdomain of c2 parent '%s'", req.Question[0].Name, domain)
return s.handleC2(domain, req)
}
if canaries {
return s.nameErrorResp(req)
}
return nil
}
// Returns true if the requested domain is a c2 subdomain, and the domain it matched with
func (s *SliverDNSServer) isC2SubDomain(domains []string, reqDomain string) (bool, string) {
for _, parentDomain := range domains {
@@ -1142,39 +1156,24 @@ func (s *SliverDNSServer) handleNOP(domain string, msg *dnspb.DNSMessage, checks
// Canary Record Handler
// ---------------------------
// Canary -> valid? -> trigger alert event
func (s *SliverDNSServer) handleCanary(req *dns.Msg) *dns.Msg {
// Don't block, return error as fast as possible
go func() {
defer recoverAndLogPanic(dnsLog.Errorf, "dns handleCanary")
func (s *SliverDNSServer) handleCanary(req *dns.Msg, canary *clientpb.DNSCanary) *dns.Msg {
firstTrigger, err := generate.TriggerCanary(canary)
if err != nil {
dnsLog.Errorf("Failed to record DNS canary trigger for '%s': %s", canary.Domain, err)
return s.nameErrorResp(req)
}
reqDomain := strings.ToLower(req.Question[0].Name)
if !strings.HasSuffix(reqDomain, ".") {
reqDomain += "." // Ensure we have the FQDN
}
canary, err := db.CanaryByDomain(reqDomain)
if err != nil {
dnsLog.Errorf("Failed to find canary: %s", err)
return
}
if canary != nil {
dnsLog.Warnf("DNS canary tripped for '%s'", canary.ImplantName)
if !canary.Triggered {
// Defer publishing the event until we're sure the db is sync'd
defer core.EventBroker.Publish(core.Event{
Session: &core.Session{
Name: canary.ImplantName,
},
Data: []byte(canary.Domain),
EventType: consts.CanaryEvent,
})
canary.Triggered = true
canary.FirstTriggered = time.Now().Format(time.RFC1123)
}
canary.LatestTrigger = time.Now().Format(time.RFC1123)
canary.Count++
generate.UpdateCanary(canary)
}
}()
dnsLog.Warnf("DNS canary tripped for '%s'", canary.ImplantName)
if firstTrigger {
// Publish only after the trigger transaction commits successfully.
core.EventBroker.Publish(core.Event{
Session: &core.Session{
Name: canary.ImplantName,
},
Data: []byte(canary.Domain),
EventType: consts.CanaryEvent,
})
}
return s.nameErrorResp(req)
}
+297
View File
@@ -0,0 +1,297 @@
package c2
import (
"encoding/binary"
"errors"
"hash/crc32"
"net"
"strings"
"sync/atomic"
"testing"
"time"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/dnspb"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"github.com/miekg/dns"
"google.golang.org/protobuf/proto"
"gorm.io/gorm"
)
func TestDNSCanarySameParentTriggersOnce(t *testing.T) {
const parent = "canary-parent.invalid."
// Legacy records can contain mixed-case parent names, and DNS queries are
// case-insensitive independently of how the registered name was spelled.
canary := createDNSCanaryRecord(t, models.NewUUID().String()+".CaNaRy-PaReNt.InVaLiD.")
events := subscribeDNSCanaryEvents(t)
lookups := countDNSCanaryLookups(t)
server := newTestDNSServer()
response := queryDNSCanary(t, server, parent, strings.ToUpper(canary.Domain), true)
assertDNSCanaryResponse(t, response)
if got := lookups.Load(); got != 1 {
t.Fatalf("first query performed %d canary lookups, want 1", got)
}
first := assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
lookups.Store(0)
response = queryDNSCanary(t, server, parent, strings.ToLower(canary.Domain), true)
assertDNSCanaryResponse(t, response)
if got := lookups.Load(); got != 1 {
t.Fatalf("repeat query performed %d canary lookups, want 1", got)
}
second := assertDNSCanaryRecord(t, canary, 2)
if !second.FirstTrigger.Equal(first.FirstTrigger) || second.LatestTrigger.Before(first.LatestTrigger) {
t.Error("repeat query changed the first trigger or moved the latest trigger backwards")
}
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
}
func TestDNSCanaryC2Routing(t *testing.T) {
tests := []struct {
name string
registered bool
enabled bool
}{
{name: "unknown_name", enabled: true},
{name: "disabled_detection", registered: true},
{name: "registered_name_precedes_c2", registered: true, enabled: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server, parent, domain, checksum := newDNSCanaryNOP(t)
var canary *models.DNSCanary
if tt.registered {
canary = createDNSCanaryRecord(t, domain)
}
events := subscribeDNSCanaryEvents(t)
lookups := countDNSCanaryLookups(t)
response := queryDNSCanary(t, server, parent, domain, tt.enabled)
wantLookups := int64(0)
if tt.enabled {
wantLookups = 1
}
if got := lookups.Load(); got != wantLookups {
t.Fatalf("performed %d canary lookups, want %d", got, wantLookups)
}
if tt.registered && tt.enabled {
assertDNSCanaryResponse(t, response)
assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
return
}
assertDNSCanaryNOP(t, response, checksum)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
if tt.registered {
assertDNSCanaryRecord(t, canary, 0)
}
})
}
}
func TestDNSCanaryFailedTriggerDoesNotPublish(t *testing.T) {
const parent = "canary-failure.invalid."
canary := createDNSCanaryRecord(t, models.NewUUID().String()+"."+parent)
events := subscribeDNSCanaryEvents(t)
reject := rejectDNSCanaryUpdates(t)
server := newTestDNSServer()
assertDNSCanaryResponse(t, queryDNSCanary(t, server, parent, canary.Domain, true))
assertDNSCanaryRecord(t, canary, 0)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
reject.Store(false)
assertDNSCanaryResponse(t, queryDNSCanary(t, server, parent, canary.Domain, true))
assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
}
func createDNSCanaryRecord(t *testing.T, domain string) *models.DNSCanary {
t.Helper()
canary := &models.DNSCanary{ImplantName: "dns-canary-" + models.NewUUID().String(), Domain: domain}
if err := db.Session().Create(canary).Error; err != nil {
t.Fatalf("create canary: %v", err)
}
t.Cleanup(func() {
if err := db.Session().Where("id = ?", canary.ID).Delete(&models.DNSCanary{}).Error; err != nil {
t.Errorf("delete canary: %v", err)
}
})
return canary
}
func assertDNSCanaryRecord(t *testing.T, canary *models.DNSCanary, count uint32) models.DNSCanary {
t.Helper()
var stored models.DNSCanary
if err := db.Session().Where("id = ?", canary.ID).First(&stored).Error; err != nil {
t.Fatalf("reload canary: %v", err)
}
if stored.Count != count || stored.Triggered != (count > 0) {
t.Fatalf("stored count=%d triggered=%v, want count=%d triggered=%v", stored.Count, stored.Triggered, count, count > 0)
}
if stored.FirstTrigger.IsZero() != (count == 0) || stored.LatestTrigger.IsZero() != (count == 0) {
t.Error("trigger timestamps do not match the persisted count")
}
return stored
}
func countDNSCanaryLookups(t *testing.T) *atomic.Int64 {
t.Helper()
var count atomic.Int64
name := "test:count-dns-canary-lookups-" + models.NewUUID().String()
if err := db.Client.Callback().Query().Before("gorm:query").Register(name, func(tx *gorm.DB) {
if tx.Statement.Table == "dns_canaries" {
count.Add(1)
}
}); err != nil {
t.Fatalf("register canary query counter: %v", err)
}
t.Cleanup(func() { _ = db.Client.Callback().Query().Remove(name) })
return &count
}
func rejectDNSCanaryUpdates(t *testing.T) *atomic.Bool {
t.Helper()
var reject atomic.Bool
reject.Store(true)
name := "test:reject-dns-canary-updates-" + models.NewUUID().String()
if err := db.Client.Callback().Update().Before("gorm:update").Register(name, func(tx *gorm.DB) {
if reject.Load() && tx.Statement.Table == "dns_canaries" {
_ = tx.AddError(errors.New("injected DNS canary update failure"))
}
}); err != nil {
t.Fatalf("register canary update failure: %v", err)
}
t.Cleanup(func() { _ = db.Client.Callback().Update().Remove(name) })
return &reject
}
func subscribeDNSCanaryEvents(t *testing.T) chan core.Event {
t.Helper()
events := core.EventBroker.Subscribe()
t.Cleanup(func() { core.EventBroker.Unsubscribe(events) })
// Subscription registration is asynchronous. Complete a broker round trip
// before sending requests so the first canary event cannot be missed.
collectDNSCanaryEvents(t, events, true)
return events
}
func collectDNSCanaryEvents(t *testing.T, events <-chan core.Event, subscribing bool) []core.Event {
t.Helper()
marker := core.Event{EventType: "dns-canary-test-barrier", Data: []byte(models.NewUUID().String())}
core.EventBroker.Publish(marker)
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
var canaries []core.Event
for {
select {
case event := <-events:
if event.EventType == marker.EventType && string(event.Data) == string(marker.Data) {
return canaries
}
if event.EventType == consts.CanaryEvent {
canaries = append(canaries, event)
}
case <-ticker.C:
if subscribing {
marker.Data = []byte(models.NewUUID().String())
core.EventBroker.Publish(marker)
}
case <-timer.C:
t.Fatal("timed out waiting for DNS canary event barrier")
}
}
}
func assertDNSCanaryEvents(t *testing.T, events []core.Event, canary *models.DNSCanary, count int) {
t.Helper()
if len(events) != count {
t.Fatalf("received %d canary events, want %d", len(events), count)
}
for _, event := range events {
if event.Session == nil || event.Session.Name != canary.ImplantName || string(event.Data) != canary.Domain {
t.Errorf("unexpected canary event: %+v", event)
}
}
}
func newDNSCanaryNOP(t *testing.T) (*SliverDNSServer, string, string, uint32) {
t.Helper()
server := newTestDNSServer()
const sessionID = uint32(0x123456)
server.sessions.Store(sessionID, newTestDNSSession(sessionID))
parent := models.NewUUID().String() + ".canary-c2.invalid."
msg := &dnspb.DNSMessage{Type: dnspb.DNSMessageType_NOP, ID: (1 << 24) | sessionID, Data: []byte("canary-routing")}
raw, err := proto.Marshal(msg)
if err != nil {
t.Fatalf("marshal DNS NOP: %v", err)
}
domain, err := encodeMessageDomain("."+parent, msg)
if err != nil {
t.Fatalf("encode DNS NOP: %v", err)
}
return server, parent, domain, crc32.ChecksumIEEE(raw)
}
func queryDNSCanary(t *testing.T, server *SliverDNSServer, parent, domain string, enabled bool) *dns.Msg {
t.Helper()
req := new(dns.Msg)
req.SetQuestion(domain, dns.TypeA)
writer := &canaryDNSResponseWriter{}
server.HandleDNSRequest([]string{parent}, enabled, writer, req)
if writer.response == nil {
t.Fatal("DNS handler did not write a response")
}
return writer.response
}
func assertDNSCanaryResponse(t *testing.T, response *dns.Msg) {
t.Helper()
if response.Rcode != dns.RcodeNameError || !response.Authoritative {
t.Fatalf("canary response rcode=%d authoritative=%v, want authoritative NXDOMAIN", response.Rcode, response.Authoritative)
}
}
func assertDNSCanaryNOP(t *testing.T, response *dns.Msg, checksum uint32) {
t.Helper()
if response.Rcode != dns.RcodeSuccess || len(response.Answer) != 1 {
t.Fatalf("NOP response rcode=%d answers=%d, want success with one answer", response.Rcode, len(response.Answer))
}
answer, ok := response.Answer[0].(*dns.A)
if !ok || len(answer.A) != 4 {
t.Fatalf("unexpected DNS NOP answer: %v", response.Answer[0])
}
if got := binary.LittleEndian.Uint32(answer.A); got != checksum {
t.Fatalf("NOP checksum=%d, want %d", got, checksum)
}
}
type canaryDNSResponseWriter struct {
response *dns.Msg
}
func (w *canaryDNSResponseWriter) LocalAddr() net.Addr { return &net.UDPAddr{} }
func (w *canaryDNSResponseWriter) RemoteAddr() net.Addr { return &net.UDPAddr{} }
func (w *canaryDNSResponseWriter) Close() error { return nil }
func (w *canaryDNSResponseWriter) TsigStatus() error { return nil }
func (w *canaryDNSResponseWriter) TsigTimersOnly(bool) {}
func (w *canaryDNSResponseWriter) Hijack() {}
func (w *canaryDNSResponseWriter) WriteMsg(msg *dns.Msg) error {
w.response = msg.Copy()
return nil
}
func (w *canaryDNSResponseWriter) Write(data []byte) (int, error) {
msg := new(dns.Msg)
if err := msg.Unpack(data); err != nil {
return 0, err
}
w.response = msg
return len(data), nil
}
+52
View File
@@ -0,0 +1,52 @@
package db
import (
"errors"
"strings"
"testing"
"github.com/bishopfox/sliver/server/db/models"
)
func TestCanaryByDomain(t *testing.T) {
canary := &models.DNSCanary{
ImplantName: "canary_lookup_" + models.NewUUID().String(),
Domain: "lookup-" + models.NewUUID().String() + ".Canary.Example.",
}
if err := Session().Create(canary).Error; err != nil {
t.Fatalf("create canary: %v", err)
}
t.Cleanup(func() {
if err := Session().Delete(canary).Error; err != nil {
t.Errorf("delete canary: %v", err)
}
})
for _, domain := range []string{
canary.Domain,
strings.ToLower(canary.Domain),
strings.ToUpper(canary.Domain),
" " + strings.TrimSuffix(canary.Domain, ".") + " ",
} {
t.Run(domain, func(t *testing.T) {
found, err := CanaryByDomain(domain)
if err != nil {
t.Fatalf("lookup canary: %v", err)
}
if found.ID != canary.ID.String() || found.Domain != canary.Domain {
t.Errorf("lookup returned %v, want ID %s and domain %s", found, canary.ID, canary.Domain)
}
})
}
}
func TestCanaryByDomainNotFound(t *testing.T) {
for _, domain := range []string{"", " \t", "missing-" + models.NewUUID().String() + ".invalid."} {
t.Run(domain, func(t *testing.T) {
found, err := CanaryByDomain(domain)
if !errors.Is(err, ErrRecordNotFound) || found != nil {
t.Fatalf("lookup returned (%v, %v), want (nil, record not found)", found, err)
}
})
}
}
+16 -3
View File
@@ -1,3 +1,4 @@
// Package db provides persistence and queries for Sliver server state.
package db
/*
@@ -742,13 +743,25 @@ func ListCanaries() ([]*clientpb.DNSCanary, error) {
// CanaryByDomain - Check if a canary exists
func CanaryByDomain(domain string) (*clientpb.DNSCanary, error) {
if len(domain) < 1 {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, ErrRecordNotFound
}
if !strings.HasSuffix(domain, ".") {
domain += "."
}
dbSession := Session()
canary := models.DNSCanary{}
err := dbSession.Where(&models.DNSCanary{Domain: domain}).First(&canary).Error
return canary.ToProtobuf(), err
// Older builds may have stored mixed-case parent domains. DNS names are
// case-insensitive, so normalize both sides without rewriting those records.
result := dbSession.Where("LOWER(domain) = ?", domain).Limit(1).Find(&canary)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, ErrRecordNotFound
}
return canary.ToProtobuf(), nil
}
// WebsiteByName - Get website by name
+1
View File
@@ -48,6 +48,7 @@ func (c *DNSCanary) BeforeCreate(tx *gorm.DB) (err error) {
// ToProtobuf - Converts to protobuf object
func (c *DNSCanary) ToProtobuf() *clientpb.DNSCanary {
return &clientpb.DNSCanary{
ID: c.ID.String(),
ImplantName: c.ImplantName,
Domain: c.Domain,
Triggered: c.Triggered,
+39 -6
View File
@@ -21,11 +21,13 @@ package generate
import (
"fmt"
"strings"
"time"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"github.com/bishopfox/sliver/util"
"gorm.io/gorm"
)
const (
@@ -50,11 +52,42 @@ func canarySubDomain() string {
return string(subdomain)
}
// UpdateCanary - Update an existing canary
func UpdateCanary(canary *clientpb.DNSCanary) error {
dbSession := db.Session()
result := dbSession.Save(&canary)
return result.Error
// TriggerCanary records a DNS query and reports whether it first triggered the
// existing canary. The caller's status may be stale; only its ID is used.
func TriggerCanary(canary *clientpb.DNSCanary) (bool, error) {
if canary == nil || canary.ID == "" {
return false, fmt.Errorf("missing canary ID")
}
id, err := models.ParseUUID(canary.ID)
if err != nil || id == models.NilUUID() {
return false, fmt.Errorf("invalid canary ID %q", canary.ID)
}
firstTrigger := false
err = db.Session().Transaction(func(tx *gorm.DB) error {
count := tx.Model(&models.DNSCanary{}).Where("id = ?", id).
UpdateColumn("count", gorm.Expr("count + ?", 1))
if count.Error != nil {
return count.Error
}
if count.RowsAffected != 1 {
return db.ErrRecordNotFound
}
// The increment locks the existing row before timestamps are sampled,
// so waiting queries cannot move LatestTrigger backwards.
first := tx.Model(&models.DNSCanary{}).
Where("id = ? AND triggered = ?", id, false).
Updates(models.DNSCanary{Triggered: true, FirstTrigger: time.Now()})
if first.Error != nil {
return first.Error
}
firstTrigger = first.RowsAffected == 1
return tx.Model(&models.DNSCanary{}).Where("id = ?", id).
Updates(models.DNSCanary{LatestTrigger: time.Now()}).Error
})
return firstTrigger && err == nil, err
}
// CanaryGenerator - Holds data related to canary generation
@@ -75,7 +108,7 @@ func (g *CanaryGenerator) GenerateCanary() string {
// Don't need secure random here
index := util.Intn(len(g.ParentDomains))
parentDomain := g.ParentDomains[index]
parentDomain := strings.ToLower(strings.TrimSpace(g.ParentDomains[index]))
parentDomain = strings.TrimPrefix(parentDomain, ".")
if !strings.HasSuffix(parentDomain, ".") {
parentDomain += "." // Ensure we have the FQDN
+124
View File
@@ -0,0 +1,124 @@
package generate
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/certs"
"github.com/bishopfox/sliver/server/configs"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
)
// Canary domains must remain visible to a binary scanner after the production
// compiler and obfuscator run. Checking rendered source alone misses both
// literal obfuscation and linker removal of unused type metadata.
func TestCanariesSurviveObfuscation(t *testing.T) {
const parentDomain = "canary-regression.invalid."
certs.SetupCAs()
tests := []struct {
name string
goos string
protocol string
beacon bool
wantCanaries int
}{
{name: "windows_http_session", goos: "windows", protocol: "http", wantCanaries: 1},
{name: "linux_http_beacon", goos: "linux", protocol: "http", beacon: true, wantCanaries: 1},
{name: "windows_mtls_session", goos: "windows", protocol: "mtls", wantCanaries: 2},
{name: "linux_wireguard_session", goos: "linux", protocol: "wg", wantCanaries: 2},
{name: "linux_http_without_canaries", goos: "linux", protocol: "http"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name := "canary_" + strings.ReplaceAll(models.NewUUID().String(), "-", "")
config := &clientpb.ImplantConfig{
GOOS: tt.goos,
GOARCH: "amd64",
Debug: false,
ObfuscateSymbols: true,
NetGoEnabled: true,
IsBeacon: tt.beacon,
C2: []*clientpb.ImplantC2{
{URL: tt.protocol + "://c2.invalid"},
},
}
if tt.wantCanaries > 0 {
config.CanaryDomains = []string{parentDomain}
}
switch tt.protocol {
case "http":
config.IncludeHTTP = true
case "mtls":
config.IncludeMTLS = true
case "wg":
config.IncludeWG = true
config.WGKeyExchangePort = 1234
config.WGTcpCommsPort = 5678
certs.SetupWGKeys()
}
// Register cleanup before generation so failed builds do not leave
// source trees or canary records behind.
projectDir := filepath.Join(GetSliversDir(), config.GOOS, config.GOARCH, name)
t.Cleanup(func() {
if err := os.RemoveAll(projectDir); err != nil {
t.Errorf("remove generated project: %v", err)
}
if err := db.Session().Where("implant_name = ?", name).Delete(&models.DNSCanary{}).Error; err != nil {
t.Errorf("remove generated canaries: %v", err)
}
})
build, err := GenerateConfig(name, config)
if err != nil {
t.Fatalf("generate implant config: %v", err)
}
httpConfig := configs.GenerateDefaultHTTPC2Config()
binPath, err := SliverExecutable(name, build, config, httpConfig.ImplantConfig)
if err != nil {
t.Fatalf("build obfuscated implant: %v", err)
}
binary, err := os.ReadFile(binPath)
if err != nil {
t.Fatalf("read obfuscated implant: %v", err)
}
var canaries []models.DNSCanary
if err := db.Session().Where("implant_name = ?", name).Find(&canaries).Error; err != nil {
t.Fatalf("read generated canaries: %v", err)
}
assertCanaryBinary(t, binary, canaries, parentDomain, tt.wantCanaries)
t.Logf("checked %d registered canaries in %s/%s obfuscated binary (%d bytes)", len(canaries), config.GOOS, config.GOARCH, len(binary))
})
}
}
func assertCanaryBinary(t *testing.T, binary []byte, canaries []models.DNSCanary, parentDomain string, wantCanaries int) {
t.Helper()
if bytes.Contains(binary, []byte("[[GenerateCanary]]")) {
t.Error("unexpanded canary placeholder remains in binary")
}
if len(canaries) != wantCanaries {
t.Fatalf("registered %d canaries, want %d", len(canaries), wantCanaries)
}
if wantCanaries == 0 {
if bytes.Contains(binary, []byte(parentDomain)) {
t.Error("build without canaries contains the canary parent domain")
}
return
}
for _, canary := range canaries {
if canary.Domain == "" || !strings.HasSuffix(canary.Domain, "."+parentDomain) {
t.Errorf("unexpected generated canary domain %q", canary.Domain)
continue
}
if !bytes.Contains(binary, []byte(canary.Domain)) {
t.Errorf("registered canary %q is absent from the obfuscated binary", canary.Domain)
}
}
}
+199
View File
@@ -0,0 +1,199 @@
package generate
import (
"errors"
"strings"
"sync/atomic"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"gorm.io/gorm"
)
func createCanaryStateFixture(t *testing.T) *models.DNSCanary {
t.Helper()
canary := &models.DNSCanary{
ImplantName: "canary_state_" + models.NewUUID().String(),
Domain: models.NewUUID().String() + ".canary.invalid.",
}
if err := db.Session().Create(canary).Error; err != nil {
t.Fatalf("create canary: %v", err)
}
t.Cleanup(func() {
if err := db.Session().Delete(canary).Error; err != nil {
t.Errorf("delete canary: %v", err)
}
})
return canary
}
func readCanaryState(t *testing.T, id models.UUID) models.DNSCanary {
t.Helper()
var canary models.DNSCanary
if err := db.Session().First(&canary, "id = ?", id).Error; err != nil {
t.Fatalf("read canary: %v", err)
}
return canary
}
func assertCanaryTriggerCount(t *testing.T, canary models.DNSCanary, count uint32) {
t.Helper()
if !canary.Triggered || canary.Count != count {
t.Errorf("triggered/count = %v/%d, want true/%d", canary.Triggered, canary.Count, count)
}
if canary.FirstTrigger.IsZero() || canary.LatestTrigger.Before(canary.FirstTrigger) {
t.Errorf("invalid trigger times: first=%v latest=%v", canary.FirstTrigger, canary.LatestTrigger)
}
}
func TestTriggerCanaryPersistsRepeatedQueries(t *testing.T) {
canary := createCanaryStateFixture(t)
// Reuse a stale caller snapshot; persistence must use the database state.
snapshot := &clientpb.DNSCanary{ID: canary.ID.String(), Count: 99}
first, err := TriggerCanary(snapshot)
if err != nil || !first {
t.Fatalf("first trigger = (%v, %v), want (true, nil)", first, err)
}
initial := readCanaryState(t, canary.ID)
assertCanaryTriggerCount(t, initial, 1)
first, err = TriggerCanary(snapshot)
if err != nil || first {
t.Fatalf("repeat trigger = (%v, %v), want (false, nil)", first, err)
}
latest := readCanaryState(t, canary.ID)
assertCanaryTriggerCount(t, latest, 2)
if !latest.FirstTrigger.Equal(initial.FirstTrigger) || latest.LatestTrigger.Before(initial.LatestTrigger) {
t.Error("repeat query changed first trigger time or moved latest trigger backwards")
}
if latest.Domain != canary.Domain || latest.ImplantName != canary.ImplantName {
t.Error("trigger overwrote canary identity from the caller snapshot")
}
}
func TestTriggerCanaryConcurrentQueries(t *testing.T) {
const queries = 12
canary := createCanaryStateFixture(t)
snapshot := &clientpb.DNSCanary{ID: canary.ID.String()}
type result struct {
first bool
err error
}
results := make(chan result, queries)
start := make(chan struct{})
for range queries {
go func() {
<-start
first, err := TriggerCanary(snapshot)
results <- result{first: first, err: err}
}()
}
close(start)
firstTriggers := 0
for range queries {
got := <-results
if got.err != nil {
t.Errorf("concurrent trigger: %v", got.err)
}
if got.first {
firstTriggers++
}
}
if firstTriggers != 1 {
t.Errorf("first triggers = %d, want 1", firstTriggers)
}
assertCanaryTriggerCount(t, readCanaryState(t, canary.ID), queries)
}
func TestTriggerCanaryRejectsMissingIdentity(t *testing.T) {
canary := createCanaryStateFixture(t)
var before int64
if err := db.Session().Model(&models.DNSCanary{}).Count(&before).Error; err != nil {
t.Fatalf("count canaries: %v", err)
}
for _, snapshot := range []*clientpb.DNSCanary{
nil,
{},
{ID: "not-a-uuid"},
{ID: models.NilUUID().String()},
{ID: models.NewUUID().String()},
} {
first, err := TriggerCanary(snapshot)
if err == nil || first {
t.Errorf("trigger for %v = (%v, %v), want (false, error)", snapshot, first, err)
}
}
var after int64
if err := db.Session().Model(&models.DNSCanary{}).Count(&after).Error; err != nil {
t.Fatalf("count canaries: %v", err)
}
if after != before {
t.Errorf("invalid identity inserted canaries: count changed from %d to %d", before, after)
}
unchanged := readCanaryState(t, canary.ID)
if unchanged.Triggered || unchanged.Count != 0 {
t.Error("invalid identity changed an existing canary")
}
}
func TestTriggerCanaryRollsBackWriteFailure(t *testing.T) {
canary := createCanaryStateFixture(t)
injectedErr := errors.New("injected canary timestamp write failure")
callbackName := "test:canary-trigger-write-" + canary.ID.String()
var rejectWrite atomic.Bool
rejectWrite.Store(true)
if err := db.Client.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
update, ok := tx.Statement.Dest.(models.DNSCanary)
if rejectWrite.Load() && ok && !update.LatestTrigger.IsZero() {
_ = tx.AddError(injectedErr)
}
}); err != nil {
t.Fatalf("register write failure: %v", err)
}
t.Cleanup(func() {
rejectWrite.Store(false)
if err := db.Client.Callback().Update().Remove(callbackName); err != nil {
t.Errorf("remove write failure callback: %v", err)
}
})
first, err := TriggerCanary(&clientpb.DNSCanary{ID: canary.ID.String()})
if first || !errors.Is(err, injectedErr) {
t.Fatalf("failed trigger = (%v, %v), want (false, injected error)", first, err)
}
unchanged := readCanaryState(t, canary.ID)
if unchanged.Triggered || unchanged.Count != 0 || !unchanged.FirstTrigger.IsZero() || !unchanged.LatestTrigger.IsZero() {
t.Error("failed trigger did not roll back count and trigger timestamps")
}
rejectWrite.Store(false)
first, err = TriggerCanary(&clientpb.DNSCanary{ID: canary.ID.String()})
if err != nil || !first {
t.Fatalf("trigger after rollback = (%v, %v), want (true, nil)", first, err)
}
assertCanaryTriggerCount(t, readCanaryState(t, canary.ID), 1)
}
func TestGenerateCanaryNormalizesParent(t *testing.T) {
for _, parent := range []string{"MiXeD.Example", "MIXED.EXAMPLE.", " \t.MiXeD.Example \t"} {
t.Run(parent, func(t *testing.T) {
name := "canary_normalization_" + models.NewUUID().String()
t.Cleanup(func() {
if err := db.Session().Where("implant_name = ?", name).Delete(&models.DNSCanary{}).Error; err != nil {
t.Errorf("delete generated canary: %v", err)
}
})
generator := CanaryGenerator{ImplantName: name, ParentDomains: []string{parent}}
domain := generator.GenerateCanary()
if !strings.HasSuffix(domain, ".mixed.example.") || domain != strings.ToLower(domain) {
t.Fatalf("generated domain %q was not normalized", domain)
}
canary, err := db.CanaryByDomain(domain)
if err != nil {
t.Fatalf("look up normalized canary: %v", err)
}
if canary.Domain != domain || canary.ImplantName != name {
t.Errorf("stored canary = %v, want domain %s and implant %s", canary, domain, name)
}
})
}
}