CoursesDetection engineeringBehavioral analytics (UEBA)

Behavioral analytics (UEBA)

Baseline normal, flag meaningful deviation.

Expert30 min · lesson 9 of 15

Your bank never wrote a rule that says *block anyone buying a jet-ski in Lima at 3am*. It learned your normal instead: where you shop, how much you spend, what hours you are awake. Then it flagged the charge that did not fit the shape of you. UEBA (User and Entity Behavior Analytics) points that same trick at your identities and your workloads. Rule and signature detections catch the patterns somebody already knew to look for. UEBA catches the new, the quiet and the insider, by learning what normal looks like and measuring how far each fresh observation sits from it.

The four words the whole idea rests on

Four words carry this entire discipline, so pin them down before you touch a query. An entity is anything with repeat behavior worth profiling: a person, a service account, a host, a container workload, a source IP address. A feature is one measurable dimension of that behavior, like login hour, source ASN (autonomous system number, the ID of the network block an address belongs to), bytes sent out per hour, how many distinct resources got touched, or how many API calls (programmatic requests to a service, with no human clicking anything) it makes per minute. A baseline is the statistical profile of one feature for one entity over a training window, say 21 days. An anomaly score is how far a fresh observation sits from that baseline, expressed in standard units. That is the whole machine: build baselines per entity per feature, then score every new event against them.

The payoff is that none of this needs advance knowledge of the attack. A rule has to name the bad thing before it happens (*a secret read by a principal that is not on the allowlist*). A baseline only needs the bad behavior to look different from the settled norm, which compromise, stolen credentials and insider abuse nearly always do. Take a service account that has read reports-db-creds from one ASN every working day for a month. Then it reads root-signing-key from a hosting-provider ASN at 03:14 UTC. That scores high, and nobody ever had to write a rule for that exact case.

The math, and where plain statistics lets you down

The obvious scorer is the z-score: (observed − mean) / stdev. It has one flaw, and in security that flaw is fatal. The outlier spikes you are hunting are the same numbers that drag the mean and the standard deviation upward, so a single spike sitting in your training data raises the bar and hides the next one. Production UEBA uses the modified z-score instead. That version is built on the *median* and on MAD (median absolute deviation, the median of |x − median|). Both the median and MAD shrug off outliers, so one strange hour in history does not poison the baseline. The 0.6745 constant rescales the answer so it reads on the same scale as an ordinary z-score, because for normally distributed data MAD ≈ 0.6745 σ. The conventional flag threshold of 3.5 comes from Iglewicz and Hoaglin's outlier-labeling work.

Two more pieces finish the engine. EWMA (exponentially weighted moving average, a rolling average that counts recent hours more heavily than old ones) lets a baseline follow *legitimate* drift, so a team that genuinely doubled its traffic last month does not alert forever. Handling seasonality stops you paging on a 3am batch job that has run at 3am since the day it shipped, while keeping a 3am human login every bit as suspicious as it should be.

ueba_score.py
# Robust anomaly score for one entity/feature (the core UEBA computation)
import numpy as np
# 21 days of hourly "bytes egressed" for service account svc-reporting (504 points)
history = np.load("svc-reporting.bytes_out.npy")
observed = 8.4e9 # this hour
# EWMA baseline: recent hours weighted more (alpha=0.1 ~= 10h memory)
alpha, ewma = 0.1, history[0]
for x in history:
ewma = alpha * x + (1 - alpha) * ewma
# Modified z-score: median + MAD resist the past spikes that break plain stdev
med = np.median(history)
mad = np.median(np.abs(history - med)) or 1e-9 # guard divide-by-zero
robust_z = 0.6745 * (observed - med) / mad
print(f"ewma_baseline={ewma:.2e} median={med:.2e} observed={observed:.2e}")
print(f"robust_z={robust_z:.1f} {'ANOMALY' if abs(robust_z) > 3.5 else 'normal'}")
# ---- output ----
# ewma_baseline=1.31e+08 median=1.20e+08 observed=8.40e+09
# robust_z=182.9 ANOMALY

Letting the platform do the math

You rarely hand-roll any of this in production, because your SIEM (security information and event management platform, the system that swallows your logs and runs detections over them) already ships it. In Microsoft Sentinel and Azure Data Explorer you write KQL (Kusto Query Language). make-series chops events into a per-entity time series, and series_decompose_anomalies() splits that series into its seasonal part, its trend and the leftover residual, then hands back an anomaly flag (+1/-1/0), a score and the fitted baseline in one call.

The signature is series_decompose_anomalies(Series, Threshold, Seasonality, Trend, ...), and every argument in the query below is doing real work. 3.0 is the anomaly threshold, a *k* multiplier on a Tukey's-fence test over the residuals: the default 1.5 flags mild wobbles, 3.0 keeps only the loud ones. The -1 tells it to work out seasonality on its own, picking up daily and weekly cycles. 'linefit' extracts the trend with linear regression, where the default 'avg' assumes there is no trend at all. Read the result rows carefully. The baseline for svc-reporting is 2.1 logins an hour, so 47 of them at 03:00 scores 6.2.

sentinel-login-anomaly.kql
// Per-user hourly sign-in volume: auto-baseline + anomaly flag in one query
let lookback = 21d;
SigninLogs
| where TimeGenerated > ago(lookback)
| where ResultType == "0" // successful sign-ins only
| make-series events = count() default=0
on TimeGenerated step 1h by UserPrincipalName
| extend (flag, score, baseline) =
series_decompose_anomalies(events, 3.0, -1, 'linefit')
| mv-expand TimeGenerated to typeof(datetime), events to typeof(long),
flag to typeof(int), score to typeof(real), baseline to typeof(real)
| where flag == 1 and TimeGenerated > ago(1d) // positive spikes in last day
| project UserPrincipalName, TimeGenerated, events,
score = round(score,1), baseline = round(baseline,1)
| sort by score desc
// UserPrincipalName TimeGenerated events score baseline
// [email protected] 2026-07-13 03:00:00 47 6.2 2.1
// [email protected] 2026-07-13 09:00:00 31 3.4 12.0 <- borderline

Impossible travel, and judging people against their peers

Not every behavioral signal is a volume spike. Impossible travel is a baseline made of physics rather than statistics: two authenticated sessions for one identity, from places too far apart to travel between in the time that actually passed. In Splunk's SPL (Search Processing Language), streamstats walks each user's events in time order and carries the previous country and timestamp forward, so you can work out the gap between one login and the next. A hop between two countries inside a fraction of an hour is not a rare baseline at all. Physics says it cannot happen.

Peer grouping covers the other side of the problem. Score an entity against its cohort, meaning people in the same role or on the same team, so a new hire inherits a sensible baseline before they have built up any history of their own. It also stops one noisy account excusing itself on the grounds that being noisy is *its* normal.

splunk-impossible-travel.spl
index=auth action=success earliest=-24h
| sort 0 user, _time
| streamstats current=f last(src_country) as prev_country
last(_time) as prev_time by user
| eval gap_hours = round((_time - prev_time)/3600, 2)
| where isnotnull(prev_country) AND src_country != prev_country AND gap_hours < 2
| table _time, user, prev_country, src_country, gap_hours
* _time user prev_country src_country gap_hours
* 2026-07-13 03:14 svc-reporting US RU 0.20
* -- 0.2h between a US and RU session for the same identity: physically impossible

Prove the model works before you trust it

A behavioral model you have never exercised is a hope, not a detection. Cause the deviation on purpose, then check that the baseline actually moves. This is where Atomic Red Team earns its keep. Invoke-AtomicTest runs small, self-contained attacker behaviors mapped to ATT&CK (MITRE's public catalogue of the techniques real attackers use). Technique T1098.001 (*Account Manipulation: Additional Cloud Credentials*) ships an atomic that creates an AWS access key on a test principal, which is exactly the odd credential event your CloudTrail analytics (the AWS log of every API call made in an account) ought to score.

For the sign-in query above there is no atomic to run, so seed the deviation yourself: script an off-hours logon with a dedicated test account. Either way the drill is the same. Wait for the logs to land, re-run the analytic, and check that the seeded entity comes back with baseline = 0, meaning first-ever activity in that slot, and a positive flag. If it never shows up, your window, your step size or your threshold is wrong. Fix that now, in daylight, rather than at 2am in the middle of a real incident.

validate-baseline.ps1
# List the atomics for Account Manipulation: Additional Cloud Credentials
PS> Invoke-AtomicTest T1098.001 -ShowDetailsBrief
PathToAtomicsFolder = C:\AtomicRedTeam\atomics
T1098.001-1 Azure AD Application Hijacking - Service Principal
T1098.001-2 Azure AD Application Hijacking - App Registration
T1098.001-3 AWS - Create Access Key and Secret Key
# Run test 3 against a dedicated test principal (uses your configured AWS creds)
PS> Invoke-AtomicTest T1098.001 -TestNumbers 3
PathToAtomicsFolder = C:\AtomicRedTeam\atomics
Executing test: T1098.001-3 AWS - Create Access Key and Secret Key
Done executing test: T1098.001-3 AWS - Create Access Key and Secret Key
# The CreateAccessKey event lands in CloudTrail for the test principal. For the
# Sentinel sign-in analytic, seed with a scripted 03:00 logon instead; after
# ingestion the seeded entity should surface in the anomaly query:
# [email protected] 2026-07-13 03:22:00 1 4.8 0.0
# baseline=0.0 -> first-ever 03:00 logon for this principal = high score. Model works.
A poisoned baseline never fires
UEBA learns *normal* from a training window. If an attacker or a malicious insider is already busy during that window, their activity gets baked into the baseline, and everything they keep doing afterwards scores as perfectly ordinary. Forever. The detection goes quietly blind to the exact thing you bought it for. Build baselines only from periods you have some reason to trust, cut known-incident timeframes out of the training data, sanity-check an individual's baseline against their peer group's, and re-baseline on a rolling window so one quiet compromise cannot define *normal* for good.

What it is good at, and where it falls over

Every strength here casts its own shadow. UEBA is genuinely good at the unknown and at the insider, but an anomaly is not an intent. A product launch, a datacentre migration or a brand new project is a real deviation with a boring explanation, so an untuned analytic becomes a noise machine that teaches responders to ignore it. It has a cold-start problem: no clean history means no baseline you can trust, which is the whole reason peer grouping matters for new entities. It suffers drift, because normal really does change. It can be walked past by patient evasion, where an attacker ramps activity up so gradually that the baseline moves along with them and every individual step stays under the threshold. And it costs money. Per-entity time series across millions of identities is real storage and real compute, so pick the features that actually separate benign from malicious instead of baselining everything you happen to collect.

A grown-up detection program refuses the either/or. Precise rule and TTP (tactics, techniques and procedures) detections cover the techniques you already know about, and they buy you high precision with low recall: few false alarms, plenty of misses. Behavioral analytics cover deviation and give you the mirror image, high recall with low precision. Correlation is the step that turns weak signals into confident ones. A lone anomaly is a hunting lead, and a lead is not an incident. An anomaly plus a suspicious API call plus a burst of outbound data is something you can call an incident. That risk-aggregation step is what makes it safe to page a human on a behavioral signal rather than only hunting with it. A high-confidence one, say a service account pushing 70 times its baseline of data out at 3am from an ASN nobody has ever seen before, is precisely the thing that opens an investigation. The next lesson, Cloud DFIR process (digital forensics and incident response), starts right at that handover: how to scope, preserve and collect evidence once a behavioral anomaly has graduated into a confirmed incident.

From raw events to a confident incident
1Collect
per-entity events & features
2Baseline
median/MAD, EWMA, seasonality
3Score
robust z / anomaly score
4Correlate
aggregate weak signals into risk
5Alert or hunt
high-confidence, or lead
Rules catch the known; analytics catch the deviation. Correlation turns weak anomalies into confident incidents.

Try this

Pull one entity's feature summary out of your UEBA job or your SIEM's anomaly table: login hour, how many different networks it connected from, admin API call count. Then explain the high score out loud using those features and nothing else. No gut feeling allowed.

terminal
// Illustrative KQL — replace with your UEBA table
BehaviorAnalytics
| where TimeGenerated > ago(7d)
| where UserName == "j.alvarez"
| project TimeGenerated, ActivityInsights, InvestigationPriority, DevicesInsights
| order by InvestigationPriority desc
| take 5
# InvestigationPriority 25+
# ActivityInsights: {"unusual_hour":true,"new_country":true,"admin_ops":3}
# Write the human sentence: "First admin API burst from a new country at 03:10 local."

Takeaway

UEBA baselines normal and scores deviation. It sits alongside your signature and rule detections; it does not replace them. Segment your entities properly, put the contributing features on the face of every alert, and tune it for precision the way you would tune any other detection.

Next step: baseline one privileged cohort for two weeks with paging switched off, then turn paging on only for scores whose top features you can explain to an analyst in a single sentence.

Quick check
01You're building a UEBA scorer for "bytes egressed per hour" on a service account. Why does production UEBA use the modified z-score (median + MAD) rather than the ordinary z-score (mean + standard deviation)?
Correct — The lesson calls this the z-score's fatal flaw for security: outliers poison the baseline, while the median and MAD are outlier-resistant, so one strange hour in history does not mask the next one.
Incorrect — The lesson's reason is resistance to outliers, not compute cost. Cost is handled separately, by scoping which features you baseline at all, not by the choice of statistic.
Incorrect — The modified z-score still needs a threshold. The lesson uses the conventional 3.5 cutoff from Iglewicz and Hoaglin's outlier-labeling work.
Incorrect — The documented reason is outlier contamination. The lesson even notes that MAD is calibrated against the normal case, since MAD ≈ 0.6745 σ for normally distributed data.
02Your UEBA analytic trained its baselines on the last 21 days of activity for every service account. Months later you learn an attacker held valid credentials for one of those accounts for that entire training window. What does that mean for the analytic?
Correct — This is the poisoned baseline problem. UEBA learns normal from its training window, so activity present throughout that window becomes normal, and the detection goes quietly blind to exactly what it was bought to catch.
Incorrect — The median and MAD resist a single stray spike in history. They cannot help when the malicious behavior was present all through the window, because it is then the majority of the data, not an outlier.
Incorrect — EWMA helps a baseline follow legitimate drift, but if the attacker is still active the recent data is contaminated too, so weighting it more heavily does not clean anything up.
Incorrect — It scores lower, not higher. Whatever sits inside the training window defines the baseline, so that behavior becomes the reference point rather than a deviation from one.
03You seeded a scripted 03:00 logon with a dedicated test account, waited for ingestion, and re-ran the Sentinel sign-in anomaly query. The test account does not appear in the results at all. What should you conclude, and what do you do?
Correct — A validation run that produces nothing is a failed validation. The lesson's expected result is the seeded entity appearing with baseline = 0 and a positive flag, so a silent query means the window, step size or threshold is wrong.
Incorrect — Volume alone is not the point here. Against a baseline of 0 for that hourly slot, a single logon is a large deviation, which is why the lesson's worked example shows a score of 4.8 for exactly one event.
Incorrect — The threshold is one of three things to check, not a foregone conclusion, and dropping to 1.5 flags mild anomalies as well, which is how you get a noise generator that trains responders to ignore the alert.
Incorrect — A poisoned baseline comes from an attacker being active during the training window. A brand new dedicated test account has no such history, which is precisely why its baseline should read 0.

Related