Threat Hunting
- Beaconing
- DNS Tunnelling
- Rare Things
- Stacking with Judgement
- Enriching Mid-hunt
- Keeping What You Find
- Practical Notes
Hunting is iteration. You have a hypothesis, you look, the data says something you did not expect, you change the question. Anything that adds friction between those steps costs you hunts.
BabySOARus removes the packaging step entirely. Edit the snippet, press enter.
Beaconing
Command-and-control traffic is regular. Human traffic is not. The measure is the variability of the gaps between requests.
index=babysoarus_demo sourcetype=babysoarus:proxy
| sort 0 src_host, dest_host, _time
| streamstats current=f last(_time) as previous by src_host, dest_host
| eval gap = _time - previous
| where isnotnull(gap)
| stats count as intervals avg(gap) as mean_gap stdev(gap) as stdev_gap
by src_host, dest_host
| where intervals >= 8
| exec inline="
for e in events:
mean = float(e['mean_gap'])
stdev = float(e.get('stdev_gap') or 0)
if mean <= 0:
continue
jitter = stdev / mean
e['interval_seconds'] = round(mean)
e['jitter'] = round(jitter, 3)
e['beacon_score'] = round(max(0.0, 1 - jitter) * 100)
e['assessment'] = (
'machine-regular, investigate' if jitter < 0.1 else
'semi-regular, worth a look' if jitter < 0.35 else
'human-like'
)"
| sort - beacon_score
| table src_host dest_host intervals interval_seconds jitter beacon_score assessment
Two details worth copying. The interval statistics are computed in SPL because
stats list() caps at 100 values, so gathering raw timestamps would silently
work on a truncated sample. And the sort happens in SPL after the command,
because a snippet only ever sees one micro-batch.
![]()
The result is unambiguous. One destination scores 98 with a jitter of 0.018 and a 180-second interval. Everything legitimate sits at 20 or below, because people browse irregularly and machines do not.
This is roughly forty lines of streamstats and eventstats in SPL, and the
SPL version is harder to tune.
DNS Tunnelling
Data smuggled through DNS produces long, high-entropy labels and a lot of TXT queries.
index=babysoarus_demo sourcetype=babysoarus:dns
| stats count avg(eval(len(query))) as avg_len by src_ip, query_type
| exec inline="for e in events:
e['avg_query_length'] = round(float(e['avg_len']), 1)
e['tunnelling_likely'] = float(e['avg_len']) > 40 and e['query_type'] == 'TXT'
del e['avg_len']"
| sort - avg_query_length

Rare Things
"Show me what is unusual for this host" is the most productive hunting question there is, and it needs per-key state.
index=babysoarus_demo sourcetype=babysoarus:proxy earliest=-7d
| stats count by host, dest_host
| exec inline="
from collections import defaultdict
by_host = defaultdict(dict)
for e in events:
by_host[e['host']][e['dest_host']] = int(e['count'])
out = []
for host, destinations in by_host.items():
total = sum(destinations.values())
for domain, count in destinations.items():
share = count / total
if share < 0.001 and count <= 3:
out.append({
'host': host,
'dest_host': domain,
'requests': count,
'share_of_host_traffic': f'{share * 100:.4f}%',
'why': 'rare for this host',
})
events = sorted(out, key=lambda e: e['requests'])"
Stacking with Judgement
Frequency analysis is standard practice. What SPL cannot easily do is apply judgement to the long tail.
index=babysoarus_demo sourcetype=babysoarus:proxy
| stats count values(host) as hosts by http_user_agent
| exec inline="import re
BROWSER = re.compile(r'(Mozilla|Chrome|Safari|Firefox|Edge)/[\d.]+')
TOOLING = re.compile(r'(curl|wget|python-requests|powershell|Go-http-client|axios)', re.I)
for e in events:
agent = e['http_user_agent']
hosts = e['hosts'] if isinstance(e['hosts'], list) else [e['hosts']]
if TOOLING.search(agent):
e['category'] = 'tooling'
e['interest'] = 'high' if len(hosts) <= 2 else 'medium'
elif BROWSER.search(agent):
e['category'] = 'browser'
e['interest'] = 'low'
else:
e['category'] = 'unrecognised'
e['interest'] = 'high'
e['host_count'] = len(hosts)
events = [e for e in events if e['interest'] != 'low']"
| sort host_count
A scripted user agent on two hosts is far more interesting than the same agent on two hundred, and that judgement is one line.
Enriching Mid-hunt
When a hunt turns up something, pivot without leaving the search bar.
index=babysoarus_demo sourcetype=babysoarus:proxy dest_host="*exfil-node.top"
| stats count values(user) as users values(host) as hosts by dest_host
| exec inline="
for e in events:
domain = e['dest_host']
parts = domain.split('.')
registrable = '.'.join(parts[-2:]) if len(parts) >= 2 else domain
r = http_request('GET', f'https://rdap.org/domain/{registrable}', timeout=10)
if r['status'] == 200:
data = json.loads(r['body'])
for event in data.get('events', []):
if event.get('eventAction') == 'registration':
e['registered'] = event.get('eventDate', '')
e['registrar'] = data.get('entities', [{}])[0].get('handle', 'unknown')
else:
e['registered'] = f'lookup failed ({r[\"status\"]})'
e['registrable_domain'] = registrable"
Domain age, in the search bar, with no add-on to install.
Keeping What You Find
A hunt that finds something should leave a detection behind. Because the snippet is the artefact, that is copy and paste:
- Narrow the snippet to the finding.
- Save the search.
- Schedule it.
- Add the alert action if it should do something.
The code that found it once is the code that finds it again. No rewrite, no translation, no drift.
Practical Notes
Reduce first. stats before exec. Score 300 rows, not 4 million events.
Watch the batch boundary. State accumulates across batches within a
search, so for e in events sees 32 events at a time. Aggregate in SPL when
you need a global view.
Use head while iterating. | head 100 | exec inline="..." makes the
edit-run loop instant while you are still getting the logic right.
table at the end. The extra fields your snippet leaves behind are
useful while hunting and noise when presenting.