Files
2026-04-01 10:58:34 -06:00

753 lines
29 KiB
Go

package analysis
import (
"context"
"fmt"
"net"
"strconv"
"time"
zlog "github.com/activecm/rita/v5/logger"
"github.com/activecm/rita/v5/progressbar"
"github.com/activecm/rita/v5/util"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
)
type AnalysisResult struct {
// Unique connections
Hash util.FixedString `ch:"hash"`
Src net.IP `ch:"src"`
SrcNUID uuid.UUID `ch:"src_nuid"`
Dst net.IP `ch:"dst"`
DstNUID uuid.UUID `ch:"dst_nuid"`
FQDN string `ch:"fqdn"`
BeaconType string `ch:"beacon_type"` // (sni, ip, dns)
Count uint64 `ch:"count"`
ProxyCount uint64 `ch:"proxy_count"`
OpenCount uint64 `ch:"open_count"`
TSUnique uint64 `ch:"ts_unique"` // number of unique timestamps
TSList []uint32 `ch:"ts_list"`
TotalDuration float64 `ch:"total_duration"`
OpenTotalDuration float64 `ch:"open_total_duration"`
BytesList []float64 `ch:"bytes"` //TODO: do we need to change this since bytes are now uint64?
TotalBytes uint64 `ch:"total_bytes"`
PortProtoService []string `ch:"port_proto_service"`
FirstSeenHistorical time.Time `ch:"first_seen_historical"`
LastSeen time.Time `ch:"last_seen"`
ServerIPs []net.IP `ch:"server_ips"` // array of unique destination IPs for SNI conns
ProxyIPs []net.IP `ch:"proxy_ips"` // array of unique proxy (destination IPs) for SNI conns
MissingHostCount uint64 `ch:"missing_host_count"`
// C2 OVER DNS Connection Info
HasC2OverDNSDirectConnectionsModifier bool `ch:"has_c2_direct_conns_mod"`
// Prevalence
PrevalenceTotal uint64 `ch:"prevalence_total"`
Prevalence float64 `ch:"prevalence"`
// C2 over DNS
TLD string `ch:"tld"`
SubdomainCount uint64 `ch:"subdomain_count"`
// Threat Intel
OnThreatIntel bool `ch:"on_threat_intel"`
}
func (analyzer *Analyzer) Spagoop(ctx context.Context) error {
logger := zlog.GetLogger()
// record start time
start := time.Now()
queryGroup, ctx := errgroup.WithContext(ctx)
// create progress bars
bars := progressbar.New(ctx, []*progressbar.ProgressBar{
progressbar.NewBar("SNI Connection Analysis", 1, progress.New(progress.WithDefaultGradient())),
progressbar.NewBar("IP Connection Analysis ", 2, progress.New(progress.WithDefaultGradient())),
progressbar.NewBar("DNS Analysis ", 3, progress.New(progress.WithDefaultGradient())),
}, []progressbar.Spinner{})
// if !analyzer.minTS.IsZero() && !analyzer.maxTS.IsZero() {
logger.Debug().Msg("Starting to get unique SNI connections")
queryGroup.Go(func() error {
// get the unique connections from the database
err := analyzer.ScoopSNIConns(ctx, bars)
// record end time
end := time.Since(start)
// print the time it took to finish
logger.Debug().Str("elapsed", fmt.Sprintf("%1.2fs", end.Seconds())).Msg("FINISHED SNI BEACON QUERY")
return err
})
logger.Debug().Msg("Starting to get unique IP connections")
queryGroup.Go(func() error {
// get the unique connections from the database
err := analyzer.ScoopIPConns(ctx, bars)
// record end time
end := time.Since(start)
// log the time it took to finish
logger.Debug().Str("elapsed", fmt.Sprintf("%1.2fs", end.Seconds())).Msg("FINISHED IP BEACON QUERY")
return err
})
// }
logger.Debug().Msg("Starting to get DNS connections")
queryGroup.Go(func() error {
// get the unique connections from the database
err := analyzer.ScoopDNS(ctx, bars)
// record end time
end := time.Since(start)
// print the time it took to finish
logger.Debug().Str("elapsed", fmt.Sprintf("%1.2fs", end.Seconds())).Msg("FINISHED EXPLODED DNS QUERY")
return err
})
queryGroup.Go(func() error {
_, err := bars.Run()
if err != nil {
logger.Error().Err(err).Msg("error running program")
}
return err
})
// // wait for the uconn queries and check if any exited with an error
// // Note: If any of the g.Go routines return an error, then the context will be cancelled
// // and other goroutines can exit if they listen for the context cancellation (ctx.Done())
if err := queryGroup.Wait(); err != nil {
close(analyzer.UconnChan)
logger.Error().Err(err).Msg("could not perform uconn spagoop")
return err
}
// close uconn channel to signal that all uconns have been sent
close(analyzer.UconnChan)
logger.Debug().Msg("Finished getting uconns")
return nil
}
func (analyzer *Analyzer) ScoopSNIConns(ctx context.Context, bars *tea.Program) error {
logger := zlog.GetLogger()
// initialize progress bar variables
var totalSNI uint64
// get total number of unique hashes between sni and opensni
err := analyzer.Database.Conn.QueryRow(analyzer.Database.GetContext(), `
SELECT count() FROM (
SELECT DISTINCT hash FROM sniconn_tmp
UNION DISTINCT
SELECT DISTINCT hash FROM opensniconn_tmp
)
`).Scan(&totalSNI)
if err != nil {
return err
}
// use context to pass a call back for progress and profile info
chCtx := clickhouse.Context(analyzer.Database.GetContext(), clickhouse.WithParameters(clickhouse.Parameters{
// use minTSBeacon because all SNI conns have a matching conn entry and openconn data is not limited by the hour since the tables are truncated before each import
"min_ts": fmt.Sprintf("%d", analyzer.minTSBeacon.UTC().Unix()),
"unique_connection_threshold": fmt.Sprint(analyzer.Config.Scoring.Beacon.UniqueConnectionThreshold),
"network_size": fmt.Sprint(analyzer.networkSize),
// historical first seen is used for rolling dbs, but if the db is >24hrs old it must use the first seen from the current import due to the ttl on the historical_first_seen table
"use_historical": strconv.FormatBool(analyzer.Database.Rolling && analyzer.useCurrentTime),
}))
rows, err := analyzer.Database.Conn.Query(chCtx, `--sql
WITH unique_sni AS (
SELECT DISTINCT hash FROM sniconn_tmp
),
prevalence_counts AS (
SELECT fqdn, count() as prevalence_total FROM (
SELECT DISTINCT fqdn, src FROM usni
WHERE src_local AND hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
UNION DISTINCT
SELECT DISTINCT fqdn, dst AS src FROM usni
WHERE dst_local AND hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
UNION DISTINCT
SELECT DISTINCT host as fqdn, src FROM openhttp
WHERE src_local
UNION DISTINCT
SELECT DISTINCT host as fqdn, dst AS src FROM openhttp
WHERE dst_local
UNION DISTINCT
SELECT DISTINCT server_name as fqdn, src FROM openssl
WHERE src_local
UNION DISTINCT
SELECT DISTINCT server_name as fqdn, dst AS src FROM openssl
WHERE dst_local
UNION DISTINCT
SELECT DISTINCT fqdn, src FROM udns
WHERE src_local AND hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
UNION DISTINCT
SELECT DISTINCT fqdn, dst AS src FROM udns
WHERE dst_local AND hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
)
GROUP BY fqdn
),
sniconns AS (
-- Get SNI connections (HTTP + SSL for a source IP -> destination FQDN pair)
SELECT hash, src, src_nuid, fqdn,
countMerge(count) AS conn_count,
countMerge(proxy_count) AS proxy_count,
0 as open_count,
sumMerge(total_duration) AS total_duration,
0 AS open_duration,
uniqExactMerge(unique_ts_count) AS ts_unique,
arraySort(groupArrayMerge(86400)(ts_list)) AS ts_list,
arraySort(groupArrayMerge(86400)(src_ip_bytes_list)) AS bytes,
sumMerge(total_ip_bytes) as total_bytes,
groupUniqArrayMerge(10)(server_ips) AS server_ips,
groupUniqArrayMerge(10)(proxy_ips) AS proxy_ips,
maxMerge(last_seen) AS last_seen,
minMerge(first_seen) as first_seen
FROM usni
-- Limit query to the last 24 hours of data
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64})) AND
hash IN (SELECT hash FROM unique_sni)
GROUP BY hash, src, src_nuid, fqdn, proxy
UNION ALL
-- Get Open HTTP connections
SELECT hash, src, src_nuid, host as fqdn,
0 as conn_count, -- openhttp uses open_count
countIf(method = 'CONNECT') as proxy_count,
countIf(multi_request = false) as open_count,
0 as total_duration,
sum(duration) as open_duration,
0 as ts_unique, -- set following to zero/empty since openhttp is not included in beaconing
[] as ts_list,
[] as bytes,
sum(src_ip_bytes + dst_ip_bytes) as total_bytes,
groupUniqArrayIf(10)(dst, method != 'CONNECT') as server_ips,
groupUniqArrayIf(10)(dst, method = 'CONNECT') as proxy_ips,
max(ts) AS last_seen,
min(ts) AS first_seen
FROM openhttp
-- ignore missing hosts for openhttp, this is automatically handled in usni via MV
WHERE host != ''
-- Right join unique HTTP hashes to limit analysis to just the connections that updated in this import
GROUP BY hash, src, src_nuid, fqdn
UNION ALL
-- Get Open SSL connections
SELECT hash, src, src_nuid, server_name as fqdn,
0 as conn_count, -- openssl uses open_count
0 as proxy_count,
count() as open_count,
0 as total_duration, -- openssl uses open_duration
sum(duration) as open_duration,
0 as ts_unique, -- set following to zero/empty since openssl is not included in beaconing
[] as ts_list,
[] as bytes,
sum(src_ip_bytes + dst_ip_bytes) as total_bytes,
groupUniqArray(10)(dst) as server_ips,
[] as proxy_ips,
max(ts) AS last_seen,
min(ts) AS first_seen
FROM openssl
GROUP BY hash, src, src_nuid, fqdn
),
historical AS (
SELECT min(first_seen) AS first_seen, fqdn
FROM metadatabase.historical_first_seen
WHERE fqdn IN (SELECT fqdn FROM sniconns)
GROUP BY fqdn
),
port_proto AS (
SELECT hash, groupUniqArray(20)(port_proto_service) AS port_proto_service FROM (
SELECT DISTINCT hash, concat(po.dst_port, ':', po.proto, ':', po.service) as port_proto_service
FROM port_info po
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64})) AND
hash IN (SELECT hash FROM sniconns)
UNION DISTINCT
SELECT DISTINCT hash, concat(dst_port, ':', proto, ':', service) FROM openhttp
UNION DISTINCT
SELECT DISTINCT hash, concat(dst_port, ':', proto, ':', service) FROM openssl
)
GROUP BY hash
),
-- Aggregate data between all union groups into final structure
totaled_sniconns AS (
SELECT s.hash AS hash, s.src AS src, s.src_nuid AS src_nuid, s.fqdn AS fqdn,
sum(conn_count) AS count,
sum(open_count) AS open_count,
sum(proxy_count) AS proxy_count,
sum(total_duration + open_duration) AS total_duration,
sum(open_duration) AS open_total_duration,
max(ts_unique) AS ts_unique,
groupArrayArray(86400)(ts_list) AS ts_list,
groupArrayArray(86400)(bytes) AS bytes,
sum(total_bytes) AS total_bytes,
groupUniqArrayArray(10)(server_ips) AS server_ips,
groupUniqArrayArray(10)(proxy_ips) AS proxy_ips,
max(s.last_seen) AS last_seen,
min(s.first_seen) AS first_seen
FROM sniconns s
GROUP BY s.hash, s.src, s.src_nuid, s.fqdn
)
SELECT s.hash AS hash, s.src AS src, s.src_nuid AS src_nuid, s.fqdn AS fqdn,
if(t.fqdn != '', true, false) AS on_threat_intel,
prevalence_total,
prevalence_total / {network_size:UInt64} AS prevalence,
if({use_historical:Bool}, h.first_seen, s.first_seen) AS first_seen_historical,
'sni' AS beacon_type,
count,
open_count,
proxy_count,
total_duration,
open_total_duration,
ts_unique,
ts_list,
bytes,
total_bytes,
server_ips,
proxy_ips,
last_seen,
po.port_proto_service as port_proto_service
FROM totaled_sniconns s
LEFT JOIN prevalence_counts USING fqdn
LEFT JOIN metadatabase.threat_intel t ON s.fqdn = t.fqdn
LEFT JOIN historical h ON h.fqdn = s.fqdn
LEFT JOIN port_proto po ON s.hash = po.hash
`)
if err != nil {
// return error and cancel all uconn analysis
return fmt.Errorf("could not retrieve unique SNI connections for analysis: %w", err)
}
logger.Debug().Msg("successfully retrieved SNI connections")
i := uint64(0)
// loop over the rows
for rows.Next() {
select {
// abort this function if the context was cancelled
case <-ctx.Done():
logger.Warn().Msg("cancelling SNI uconns query for analysis")
rows.Close()
return ctx.Err()
default:
var res AnalysisResult
if err := rows.ScanStruct(&res); err != nil {
// return error and cancel all uconn analysis
return fmt.Errorf("could not read unique SNI connection during analysis: %w", err)
}
// send the unique sni connections to the uconn analysis channel
analyzer.UconnChan <- res
if i%1000 == 0 {
bars.Send(progressbar.ProgressMsg{ID: 1, Percent: float64(i / totalSNI)})
}
i++
}
}
rows.Close()
bars.Send(progressbar.ProgressMsg{ID: 1, Percent: 1})
return nil
}
func (analyzer *Analyzer) ScoopIPConns(ctx context.Context, bars *tea.Program) error {
logger := zlog.GetLogger()
totalRows := uint64(0)
hasSetTotal := false
chCtx := clickhouse.Context(analyzer.Database.GetContext(), clickhouse.WithProgress(func(p *clickhouse.Progress) {
// set the total rows for the progress bar
if !hasSetTotal {
totalRows = p.Rows
if totalRows == 0 {
bars.Send(progressbar.ProgressMsg{ID: 2, Percent: 1})
}
hasSetTotal = true
} else {
// update the progress bar
if totalRows > 0 {
bars.Send(progressbar.ProgressMsg{ID: 2, Percent: float64((totalRows - p.Rows) / totalRows)})
}
bars.Send(progressbar.ProgressMsg{ID: 2, Percent: 1})
}
}), clickhouse.WithParameters(clickhouse.Parameters{
// use minTSBeacon because all entries in conn are used in beaconing and openconn data is not limited by the hour since the tables are truncated before each import
"min_ts": fmt.Sprintf("%d", analyzer.minTSBeacon.UTC().Unix()),
"unique_connection_threshold": fmt.Sprint(analyzer.Config.Scoring.Beacon.UniqueConnectionThreshold),
"network_size": fmt.Sprint(analyzer.networkSize),
// historical first seen is used for rolling dbs, but if the db is >24hrs old it must use the first seen from the current import due to the ttl on the historical_first_seen table
"use_historical": strconv.FormatBool(analyzer.Database.Rolling && analyzer.useCurrentTime),
"long_connection_base_thresh": fmt.Sprintf("%f", float64(analyzer.Config.Scoring.LongConnectionScoreThresholds.Base)),
}))
query := `--sql
WITH unique_http AS (
SELECT DISTINCT hash FROM sniconn_tmp
WHERE conn_type = 'http'
),
prevalence_counts AS (
SELECT ip, count() as prevalence_total FROM (
SELECT DISTINCT if(src_local, dst, src) as ip, if(src_local, src, dst) as internal FROM uconn
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
UNION DISTINCT
SELECT DISTINCT if(src_local, dst, src) as ip, if(src_local, src, dst) as internal FROM openconn
)
GROUP BY ip
),
sniconns AS ( -- usni connections that will be beacons or long connections in this import
SELECT hash, uniqExactMerge(u.unique_ts_count) AS unique_count, countMerge(u.count) AS total_count, sumMerge(total_duration) AS duration
FROM usni u
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
AND hash IN (SELECT hash FROM sniconn_tmp)
GROUP BY hash
-- is beacon or longconn
HAVING (unique_count >= {unique_connection_threshold:UInt64} AND total_count < 86400) OR
duration >= {long_connection_base_thresh:Float64}
), uid_list AS ( -- list of unique Zeek UID's used by SNI beacons in this import
SELECT DISTINCT zeek_uid FROM sniconn_tmp
INNER JOIN sniconns USING hash
UNION DISTINCT
-- open conns don't need to be joined on the potential beacons list bc open conns aren't used in beaconing
SELECT DISTINCT zeek_uid from opensniconn_tmp
), filtered_hashes AS ( -- list of unique hashes for uconns that were not used by SNI beacons in this import
SELECT DISTINCT hash FROM uconn_tmp u
-- this is used instead of an anti join because we need to query hashes that aren't associated with any zeek_uids from SNI
LEFT JOIN uid_list ui ON u.zeek_uid = ui.zeek_uid
GROUP BY hash
HAVING countIf(u.zeek_uid = ui.zeek_uid) = 0
UNION DISTINCT
SELECT DISTINCT hash FROM openconnhash_tmp o
LEFT JOIN uid_list oi ON o.zeek_uid = oi.zeek_uid
GROUP BY hash
HAVING countIf(o.zeek_uid = oi.zeek_uid) = 0
),
ip_conns AS (
-- Get IP connections
SELECT hash, src, src_nuid, dst, dst_nuid, src_local, dst_local,
countMerge(missing_host_header_count) AS missing_host_count,
countMerge(count) as conn_count,
0 as open_count, -- only used in openconn/openhttp
0 as proxy_count, -- only used in sni/openhttp
sumMerge(total_duration) as total_duration,
toFloat64(0) as open_duration, -- only used for openconn/openhttp
arraySort(groupArrayMerge(86400)(ts_list)) as ts_list,
uniqExactMerge(unique_ts_count) as ts_unique, -- gets unique timestamp count for uconns
arraySort(groupArrayMerge(86400)(src_ip_bytes_list)) as bytes,
sumMerge(total_ip_bytes) as total_bytes,
maxMerge(last_seen) as last_seen,
minMerge(first_seen) as first_seen
FROM uconn
-- Limit query to the last 24 hours of data
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
-- Limit IP connections to just connections not used by a SNI beacon
AND hash IN (SELECT hash FROM filtered_hashes)
GROUP BY hash, src, src_nuid, dst, dst_nuid, src_local, dst_local
UNION ALL
-- Get open connections
SELECT hash, src, src_nuid, dst, dst_nuid, src_local, dst_local,
countIf(missing_host_header = true) AS missing_host_count,
0 as conn_count, -- open connections use open_count
count() as open_count,
0 as proxy_count,
toFloat64(0) as total_duration, -- open connections use open_duration
sum(duration) as open_duration,
[] as ts_list, -- set to zero/empty since we aren't using open connections for beaconing
0 as ts_unique,
[] as bytes,
sum(src_ip_bytes + dst_ip_bytes) as total_bytes,
min(ts) AS first_seen,
max(ts) AS last_seen
FROM openconn
WHERE hash IN (SELECT hash FROM filtered_hashes) -- exclude SNI connections
GROUP BY hash, src, src_nuid, dst, dst_nuid, src_local, dst_local
),
-- Aggregate data between all union groups
totaled_ipconns AS (
SELECT hash, src, src_nuid, dst, dst_nuid, src_local, dst_local,
multiIf(src_local = true, dst, dst_local = true, src, dst) as target_ip,
sum(missing_host_count) as missing_host_count,
sum(conn_count) as count,
sum(open_count) as open_count,
sum(proxy_count) as proxy_count,
sum(total_duration + open_duration) as total_duration,
sum(open_duration) as open_total_duration,
groupArrayArray(86400)(ts_list) as ts_list,
-- since the uniqExact AggregateFunctions are defined on uconn and usni (2 separate materialized views),
-- the unique ts count doesn't represent the unique set between both uconn and usni, so we must take the max of these two
-- and as long as that value is greater than the unique_connection_threshold (checked when we loop through the results),
-- we will send it to the beacon analysis workers
max(ts_unique) as ts_unique,
groupArrayArray(86400)(bytes) as bytes,
sum(total_bytes) as total_bytes,
max(last_seen) as last_seen,
min(first_seen) as first_seen
-- any(po.port_proto_service) as port_proto_service
FROM ip_conns
GROUP BY hash, src, src_nuid, dst, dst_nuid, src_local, dst_local
),
-- historical and port_proto are split out here instead of just being joined on at the end in order to avoid
-- multiplying the results (cartesian product)
historical AS (
SELECT min(first_seen) AS first_seen, ip
FROM metadatabase.historical_first_seen h
WHERE ip IN (SELECT target_ip FROM totaled_ipconns)
GROUP BY ip
),
port_proto AS (
SELECT hash, groupUniqArray(20)(port_proto_service) AS port_proto_service FROM (
SELECT DISTINCT hash, if(po.proto = 'icmp', concat(po.proto, ':', po.icmp_type, '/', po.icmp_code), concat(po.dst_port, ':', po.proto, ':', po.service)) as port_proto_service
FROM port_info po
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
AND hash IN (SELECT hash FROM totaled_ipconns)
UNION DISTINCT
SELECT DISTINCT hash, if(proto = 'icmp', concat(proto, ':', src_port, '/', dst_port), concat(dst_port, ':', proto, ':', service)) as port_proto_service
FROM openconn
WHERE missing_host_header = false
)
GROUP BY hash
)
SELECT i.hash AS hash, i.src as src, i.src_nuid as src_nuid, i.dst as dst, i.dst_nuid as dst_nuid,
'ip' AS beacon_type,
missing_host_count,
count,
open_count,
proxy_count,
total_duration,
open_total_duration,
ts_list,
ts_unique,
bytes,
total_bytes,
last_seen,
if(t.ip != '::', true, false) AS on_threat_intel,
prevalence_total,
prevalence_total / {network_size:UInt64} AS prevalence,
if({use_historical:Bool}, h.first_seen, i.first_seen) AS first_seen_historical,
po.port_proto_service as port_proto_service
FROM totaled_ipconns i
LEFT JOIN prevalence_counts p ON if(src_local = true, i.dst, i.src) = p.ip
LEFT JOIN metadatabase.threat_intel t ON i.target_ip = t.ip
LEFT JOIN port_proto po ON i.hash = po.hash
LEFT JOIN historical h ON i.target_ip = h.ip
`
rows, err := analyzer.Database.Conn.Query(chCtx, query)
if err != nil {
// return error and cancel all uconn analysis
return fmt.Errorf("could not retrieve unique IP connections for analysis: %w", err)
}
logger.Debug().Msg("successsfully retrieved IP connections")
// loop over the rows
for rows.Next() {
select {
// abort this function if the context was cancelled
case <-ctx.Done():
logger.Warn().Msg("cancelling IP uconns query for analysis")
rows.Close()
return ctx.Err()
default:
var res AnalysisResult
if err := rows.ScanStruct(&res); err != nil {
// return error and cancel all uconn analysis
return fmt.Errorf("could not read IP connection during analysis: %w", err)
}
// send the unique ip connection to the uconn analysis channel
analyzer.UconnChan <- res
}
}
rows.Close()
return nil
}
func (analyzer *Analyzer) ScoopDNS(ctx context.Context, bars *tea.Program) error {
logger := zlog.GetLogger()
totalRows := uint64(0)
hasSetTotal := false
// use context to pass a call back for progress and profile info
chCtx := clickhouse.Context(analyzer.Database.GetContext(), clickhouse.WithProgress(func(p *clickhouse.Progress) {
// set the total rows for the progress bar
if !hasSetTotal {
totalRows = p.Rows
if totalRows == 0 {
bars.Send(progressbar.ProgressMsg{ID: 3, Percent: 1})
}
hasSetTotal = true
} else {
// update the progress bar
if totalRows > 0 {
bars.Send(progressbar.ProgressMsg{ID: 3, Percent: float64((totalRows - p.Rows) / totalRows)})
}
bars.Send(progressbar.ProgressMsg{ID: 3, Percent: 1})
}
}), clickhouse.WithParameters(clickhouse.Parameters{
// use minTS (not minTSBeacon) because DNS logs don't get correlated with conn logs
"min_ts": fmt.Sprintf("%d", analyzer.minTS.UTC().Unix()),
"subdomain_threshold": fmt.Sprint(analyzer.Config.Scoring.C2ScoreThresholds.Base),
// historical first seen is used for rolling dbs, but if the db is >24hrs old it must use the first seen from the current import due to the ttl on the historical_first_seen table
"use_historical": strconv.FormatBool(analyzer.Database.Rolling && analyzer.useCurrentTime),
"network_size": fmt.Sprint(analyzer.networkSize),
}))
rows, err := analyzer.Database.Conn.Query(chCtx, `--sql
-- use only the domains from this import to reduce computation cost
WITH unique_tld AS (
SELECT DISTINCT tld FROM dns_tmp
),
prevalence_counts AS (
SELECT tld, count() AS prevalence_total FROM (
SELECT DISTINCT cutToFirstSignificantSubdomain(fqdn) as tld, src FROM usni
WHERE src_local AND hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
UNION DISTINCT
SELECT DISTINCT cutToFirstSignificantSubdomain(fqdn) as tld, dst AS src FROM usni
WHERE dst_local AND hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
UNION DISTINCT
SELECT DISTINCT cutToFirstSignificantSubdomain(host) as tld, src FROM openhttp
WHERE src_local
UNION DISTINCT
SELECT DISTINCT cutToFirstSignificantSubdomain(host) as tld, dst AS src FROM openhttp
WHERE dst_local
UNION DISTINCT
SELECT DISTINCT cutToFirstSignificantSubdomain(server_name) as tld, src FROM openssl
WHERE src_local
UNION DISTINCT
SELECT DISTINCT cutToFirstSignificantSubdomain(server_name) as tld, dst AS src FROM openssl
WHERE dst_local
UNION DISTINCT
SELECT DISTINCT tld, src FROM udns
WHERE src_local AND hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
UNION DISTINCT
SELECT DISTINCT tld, dst AS src FROM udns
WHERE dst_local AND hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
)
GROUP BY tld
),
-- grab the last seen dates for the domains from this import
unique_dns AS (
SELECT tld, maxMerge(last_seen) as last_seen, minMerge(first_seen) as first_seen from udns
-- limiting the scope to just the domains in this import here
-- has significant performance benefits as opposed to doing it later in the query
WHERE tld IN (SELECT tld FROM unique_tld)
GROUP BY tld
),
sussy_subdomains AS (
-- get all tlds with more than 100 subdomains
SELECT tld, uniqExactMerge(subdomains) as subdomain_count FROM exploded_dns
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
GROUP BY tld
HAVING subdomain_count >= 100
-- get all the resolved ips for the tld
), resolved_ips AS (
SELECT DISTINCT resolved_ip, tld FROM pdns
RIGHT JOIN sussy_subdomains USING tld
WHERE day >= toStartOfDay(fromUnixTimestamp({min_ts:Int64}))
-- get all source ips that made a connection to the resolved ips
), direct_connections AS (
SELECT tld, src as direct_conn FROM uconn u
RIGHT JOIN resolved_ips r ON u.dst = r.resolved_ip
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
-- get all systems that performed a dns query to the tld
), queried_by AS (
SELECT tld, src as queried FROM udns
INNER JOIN sussy_subdomains USING tld
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
-- keep tlds which had zero non-dns-server ips in direct connections
), queried_by_count AS (
SELECT tld, count() as qcount FROM queried_by
GROUP BY tld
), direct_conns_modifier AS (
-- tld has modifier if queried count == 0 or if
SELECT ddx.tld AS tld, greatest(if(qc.qcount > 0, 0, 1), 1 - inverse_has_mod) AS has_mod FROM (
-- direct conns mod checks if there are any IPs in queried that are not also in direct_conns
-- if there is an IP in queried that isn't in direct_conns, then q.queried is empty
-- max will return 1 if there was at least 1 ip that wasn't in direct conns
SELECT d.tld AS tld, max(empty(q.queried)) AS inverse_has_mod FROM direct_connections d
LEFT JOIN queried_by q ON d.tld = q.tld AND d.direct_conn = q.queried
GROUP BY tld
) ddx
LEFT JOIN queried_by_count qc ON qc.tld = ddx.tld
),
totaled_exploded AS (
SELECT tld, uniqExactMerge(subdomains) AS subdomain_count
FROM exploded_dns
WHERE hour >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
GROUP BY tld
HAVING subdomain_count >= {subdomain_threshold:Int32}
),
historical AS (
SELECT min(first_seen) AS first_seen, cutToFirstSignificantSubdomain(fqdn) as tld
-- SELECT minMerge(first_seen) AS first_seen, cutToFirstSignificantSubdomain(fqdn) as tld
FROM metadatabase.historical_first_seen
INNER JOIN totaled_exploded USING tld
GROUP BY tld
)
-- get the subdomain counts and the last seen count for each tld
SELECT e.tld AS tld, e.subdomain_count as subdomain_count,
'dns' AS beacon_type,
u.last_seen as last_seen,
prevalence_total,
if(dm.has_mod > 0, true, false) as has_c2_direct_conns_mod,
prevalence_total / {network_size:UInt64} AS prevalence,
-- use the historical first seen value if this dataset is rolling and <= 24 hours old
if({use_historical:Bool}, h.first_seen, u.first_seen) AS first_seen_historical,
if(cutToFirstSignificantSubdomain(t.fqdn) != '', true, false) AS on_threat_intel
FROM totaled_exploded e
INNER JOIN unique_dns u ON e.tld = u.tld
LEFT JOIN prevalence_counts p ON e.tld = p.tld
LEFT JOIN historical h ON e.tld = h.tld
LEFT JOIN direct_conns_modifier dm ON e.tld = dm.tld
LEFT JOIN metadatabase.threat_intel t ON e.tld = cutToFirstSignificantSubdomain(t.fqdn)
`)
if err != nil {
// return error and cancel all uconn analysis
return fmt.Errorf("could not retrieve unique exploded domains for analysis: %w", err)
}
logger.Debug().Msg("successfully retrieved exploded dns")
// loop over the rows
for rows.Next() {
select {
// abort this function if the context was cancelled
case <-ctx.Done():
logger.Warn().Msg("cancelling exploded dns query for analysis")
rows.Close()
return ctx.Err()
default:
var res AnalysisResult
if err := rows.ScanStruct(&res); err != nil {
// return error and cancel all uconn analysis
return fmt.Errorf("could not read exploded dns during analysis: %w", err)
}
// send the unique ip connection to the uconn analysis channel
analyzer.UconnChan <- res
}
}
rows.Close()
return nil
}