Working-Hour and Session-Length Anomalies for Recent Hires
Identity / cloud
Behavioral Hunt medium DPRK IT Worker FraudLaptop Farm
Sustained sign-ins at times that make no sense for where the employee says they live, which is how an operator working several jobs round the clock shows in the identity logs. Excessive working hours is among the highest-rated indicators in DTEX's own casework.
What this hunt looks for
Of the fourteen indicators DTEX rates, excessive working hours and new-joiner status are the only two scored high on both recall and precision. The behaviour follows from the business model: an operator holding several jobs works across all of them continuously, and DTEX records intervals between logon and logoff of one to twenty-three days against a normal daily pattern.
Huntress makes the timezone point and adds a caution: baseline each employee's local timezone, and treat sustained access at inconsistent hours as stronger beside VPN or proxy use than alone. This hunt measures the share of a recent hire's sign-ins that fall in the night window of the claimed location, scoped through a NewHires list.
- Technique
- Valid-account misuse (MITRE ATT&CK T1078) surfaced by session and activity patterns inconsistent with a single person in the stated location.
- Data sources
- Identity provider sign-in logs, HR onboarding records
- Last updated
- 2026-10-04
Logic
Every query below implements these steps, so the same hunt returns the same result in each product.
- Collect: successful interactive sign-ins from Entra ID or Okta, last 60 days
- Match: the user is on the NewHires list (hired in the last 180 days, exported from the HR system)
- Match: the sign-in falls in the night window, 22:00 to 05:59, of the claimed local time
- Report: users with 10 or more active days and more than 40% of sign-ins in the night window
Queries
Defender / Sentinel KQL
Written for this siteRecent hires whose sign-ins fall mostly in the night window of the claimed location on ten or more days; set OffsetHours before running it.
// Requires a watchlist named NewHires with a UserPrincipalName column holding hires of the last 180 days, exported from the HR system.
let RecentHires = _GetWatchlist('NewHires') | project UserPrincipalName = tostring(UserPrincipalName);
// TimeGenerated is UTC: set OffsetHours to the UTC offset of the claimed location.
let OffsetHours = -5;
SigninLogs
| where TimeGenerated > ago(60d)
| where ResultType == "0"
| join kind=inner RecentHires on UserPrincipalName
| extend LocalHour = datetime_part("hour", TimeGenerated + OffsetHours * 1h)
| summarize Total = count(),
NightSignIns = countif(LocalHour >= 22 or LocalHour <= 5),
ActiveDays = dcount(bin(TimeGenerated, 1d)),
Countries = make_set(tostring(LocationDetails.countryOrRegion))
by UserPrincipalName
| extend NightRatio = round(todouble(NightSignIns) / todouble(Total), 2)
| where ActiveDays >= 10 and NightRatio > 0.4
| sort by NightRatio desc
Lower-fidelity parts (1)
- the single claimed UTC offset (-5 hours, or America/New_York in LogScale) — one offset for every user ignores daylight saving and employees who relocated, which moves sign-ins in and out of the night window. Remove: Add a UtcOffset column to the NewHires watchlist and replace OffsetHours with toint(UtcOffset) projected through the join.
Splunk SPL
Written for this siteRecent hires whose sign-ins fall mostly in the night window of the claimed location on ten or more days; set claimed_offset before running it.
```sourcetype placeholders: ms:aad:signin (Entra ID) and okta; new_hires.csv has user and start_date columns holding hires of the last 180 days```
```claimed_offset is the UTC offset in hours of the claimed location```
index=identity (sourcetype="ms:aad:signin" OR sourcetype=okta) action=success earliest=-60d
| lookup new_hires.csv user OUTPUT start_date
| where isnotnull(start_date)
| iplocation src_ip
| eval claimed_offset=-5
| eval local_hour=floor(((_time + (claimed_offset * 3600)) % 86400) / 3600)
| eval night=if(local_hour>=22 OR local_hour<=5, 1, 0)
| eval day=floor((_time + (claimed_offset * 3600)) / 86400)
| stats count AS total sum(night) AS night_sign_ins dc(day) AS active_days values(Country) AS countries BY user
| eval night_ratio=round(night_sign_ins/total, 2)
| where active_days>=10 AND night_ratio>0.4
| sort 0 - night_ratio
Lower-fidelity parts (1)
- the single claimed UTC offset (-5 hours, or America/New_York in LogScale) — one offset for every user ignores daylight saving and employees who relocated, which moves sign-ins in and out of the night window. Remove: Add a utc_offset column to new_hires.csv, output it in the lookup and replace the eval claimed_offset=-5 line with it.
Elastic
Written for this siteRecent hires, joined from the new_hires lookup index with upn and start_date fields, whose sign-ins fall mostly in the night window on ten or more days.
FROM logs-azure.signinlogs-*
| WHERE @timestamp > NOW() - 60 days AND event.outcome == "success"
| EVAL upn = TO_LOWER(azure.signinlogs.properties.user_principal_name)
| LOOKUP JOIN new_hires ON upn
| WHERE start_date IS NOT NULL
| EVAL local_hour = DATE_EXTRACT("hour_of_day", @timestamp - 5 hours)
| EVAL night = CASE(local_hour >= 22 OR local_hour <= 5, 1, 0), day = DATE_TRUNC(1 day, @timestamp - 5 hours)
| STATS total = COUNT(*), night_sign_ins = SUM(night), active_days = COUNT_DISTINCT(day), countries = VALUES(source.geo.country_iso_code) BY upn
| EVAL night_ratio = ROUND(TO_DOUBLE(night_sign_ins) / total, 2)
| WHERE active_days >= 10 AND night_ratio > 0.4
| SORT night_ratio DESC
| LIMIT 200
Lower-fidelity parts (1)
- the single claimed UTC offset (-5 hours, or America/New_York in LogScale) — one offset for every user ignores daylight saving and employees who relocated, which moves sign-ins in and out of the night window. Remove: Add a utc_offset field to the new_hires lookup index and replace the two 5 hours expressions with it.
CrowdStrike LogScale
Written for this siteRecent hires whose sign-ins fall mostly in the night window of the claimed timezone on ten or more days.
// Entra ID sign-ins parsed into ECS-style fields; new_hires.csv has a user column holding hires of the last 180 days; replace the timezone with the claimed one.
event.outcome=success
| match(file="new_hires.csv", field=user.name, column=user)
| formatTime("%H", field=@timestamp, as=local_hour, timezone="America/New_York")
| formatTime("%Y-%m-%d", field=@timestamp, as=day, timezone="America/New_York")
| case { local_hour=/^(22|23|00|01|02|03|04|05)$/ | night := 1; * | night := 0 }
| groupBy([user.name], function=[count(as=total), sum(night, as=night_sign_ins), count(day, distinct=true, as=active_days)])
| night_ratio := night_sign_ins / total
| active_days >= 10 and night_ratio > 0.4
| sort(night_ratio, order=desc)
Lower-fidelity parts (1)
- the single claimed UTC offset (-5 hours, or America/New_York in LogScale) — one offset for every user ignores daylight saving and employees who relocated, which moves sign-ins in and out of the night window. Remove: Replace the timezone argument with a per-user value by adding a timezone column to new_hires.csv and including it in match().
False positives and tuning
On-call rotations, follow-the-sun teams, nocturnal employees and anyone who relocated without telling HR will appear here. The single claimed-offset constant is a simplification; use an HR feed with one offset per employee.
Sign-in logs record authentications, not presence: a session that never ends produces few sign-ins, so a quiet account can still be a round-the-clock one.
Give particular weight to accounts where the pattern started at hire rather than drifted into place.
Sources
- i3 Threat Advisory: Identifying North Korean IT Workers: Key Threat Indicators — DTEX Systems, 2025-05-14
- Insights into Suspected DPRK Workers: Red Flags to Look Out For — Huntress, 2026-08-26
- Staying a Step Ahead: Mitigating the DPRK IT Worker Threat — Google Cloud (Mandiant), 2024-09-23