Aaron SawitSecurity engineer · Singapore

Detections · Sigma · MITRE ATT&CK

Rules that are tested, not just written

A rule that parses is not a rule that works. Each of these has events it must match and events it must not, checked by a small harness, validated with sigma check, and converted to Splunk SPL and Elastic Lucene. 52 fixture checks, all passing in CI. The repository is on GitHub.

System DLL loaded from a user-writable path

T1574.001level: highSysmon event 7

DLL search-order hijacking. Written from a Sysmon lab where a copied calc.exe loaded a planted WININET.dll. Where it came from

Sigma rule
title: System DLL Loaded From a User-Writable Path
id: 6f2b1d0a-7c1e-4a53-9a57-2f1d3c8e4b10
status: experimental
description: >
  Detects a well-known Windows system DLL being loaded from a user-writable directory instead of
  System32 or SysWOW64. This is the footprint of DLL search-order hijacking, for example a copy of
  calc.exe on the Desktop loading a planted WININET.dll that sits beside it.
references:
  - https://attack.mitre.org/techniques/T1574/001/
  - https://aaronsawit.com/writing/hunting-dll-hijacking-with-sysmon/
author: Aaron Sawit
date: 2026-09-20
tags:
  - attack.stealth
  - attack.execution
  - attack.t1574.001
logsource:
  product: windows
  category: image_load
detection:
  selection_dll:
    ImageLoaded|endswith:
      - '\wininet.dll'
      - '\version.dll'
      - '\dbghelp.dll'
      - '\winhttp.dll'
      - '\cryptbase.dll'
      - '\userenv.dll'
  selection_path:
    ImageLoaded|contains:
      - '\Users\'
      - '\ProgramData\'
      - '\Windows\Temp\'
      - '\Downloads\'
  filter_signed_microsoft:
    Signed: 'true'
    Signature: 'Microsoft Windows'
  condition: selection_dll and selection_path and not filter_signed_microsoft
falsepositives:
  - Portable applications that ship their own copy of a redistributable DLL. Tune by Image path.
level: high
Splunk SPL, generated
ImageLoaded IN ("*\\wininet.dll", "*\\version.dll", "*\\dbghelp.dll", "*\\winhttp.dll", "*\\cryptbase.dll", "*\\userenv.dll") ImageLoaded IN ("*\\Users\\*", "*\\ProgramData\\*", "*\\Windows\\Temp\\*", "*\\Downloads\\*") NOT (Signed="true" Signature="Microsoft Windows")
Elastic Lucene, generated
(ImageLoaded:(*\\wininet.dll OR *\\version.dll OR *\\dbghelp.dll OR *\\winhttp.dll OR *\\cryptbase.dll OR *\\userenv.dll)) AND (ImageLoaded:(*\\Users\\* OR *\\ProgramData\\* OR *\\Windows\\Temp\\* OR *\\Downloads\\*)) AND (NOT (Signed:true AND Signature:"Microsoft Windows"))

.NET runtime loaded by a process that is not a .NET host

T1059.001T1055level: highSysmon event 7

Unmanaged PowerShell: the CLR injected into a native process so PowerShell runs with no powershell.exe in the process log. Where it came from

Sigma rule
title: .NET Runtime Loaded by a Process That Is Not a .NET Host
id: 0c7a9e52-3b64-4d0f-8a21-5e9d1f6b7c33
status: experimental
description: >
  Detects clr.dll or clrjit.dll being loaded by a native Windows binary that has no reason to host
  the .NET runtime. Injecting the CLR into another process is how "unmanaged PowerShell" runs
  PowerShell code without powershell.exe appearing in process logs.
references:
  - https://attack.mitre.org/techniques/T1059/001/
  - https://attack.mitre.org/techniques/T1055/
author: Aaron Sawit
date: 2026-09-20
tags:
  - attack.execution
  - attack.stealth
  - attack.t1059.001
  - attack.t1055
logsource:
  product: windows
  category: image_load
detection:
  selection:
    ImageLoaded|endswith:
      - '\clr.dll'
      - '\clrjit.dll'
    Image|endswith:
      - '\spoolsv.exe'
      - '\notepad.exe'
      - '\calc.exe'
      - '\rundll32.exe'
      - '\svchost.exe'
      - '\lsass.exe'
  condition: selection
falsepositives:
  - rundll32.exe legitimately hosting a managed component. Baseline first, then exclude by command line.
level: high
Splunk SPL, generated
ImageLoaded IN ("*\\clr.dll", "*\\clrjit.dll") Image IN ("*\\spoolsv.exe", "*\\notepad.exe", "*\\calc.exe", "*\\rundll32.exe", "*\\svchost.exe", "*\\lsass.exe")
Elastic Lucene, generated
(ImageLoaded:(*\\clr.dll OR *\\clrjit.dll)) AND (Image:(*\\spoolsv.exe OR *\\notepad.exe OR *\\calc.exe OR *\\rundll32.exe OR *\\svchost.exe OR *\\lsass.exe))

SSH password brute force from one source

T1110.001level: mediumsshd auth log

Ten failed passwords from one address in five minutes, as a Sigma correlation rule. Ported from my log triage tool. Where it came from

Sigma rule
title: SSH Failed Password
id: 9d3e5a71-42c8-4f6b-b0d9-7a1c2e3f4a55
status: experimental
description: Base event for the brute-force correlation below. A single failed SSH password is not an alert.
author: Aaron Sawit
date: 2026-09-20
tags:
  - attack.credential-access
  - attack.t1110.001
logsource:
  product: linux
  service: sshd
detection:
  selection:
    message|contains: 'Failed password for'
  condition: selection
level: informational
---
title: SSH Password Brute Force From One Source
id: 1b8f6c24-9e07-4a3d-8c52-6d4e5f7a8b66
status: experimental
description: >
  Ten or more failed SSH passwords from one source address within five minutes. Ported from the
  ssh brute-force rule in my blue-team-ai log triage tool.
references:
  - https://attack.mitre.org/techniques/T1110/001/
  - https://github.com/aaronsawit/blue-team-ai
author: Aaron Sawit
date: 2026-09-20
tags:
  - attack.credential-access
  - attack.t1110.001
correlation:
  type: event_count
  rules:
    - 9d3e5a71-42c8-4f6b-b0d9-7a1c2e3f4a55
  group-by:
    - src_ip
  timespan: 5m
  condition:
    gte: 10
falsepositives:
  - A misconfigured backup job or monitoring probe with a stale password.
level: medium
Splunk SPL, generated
message="*Failed password for*"

| bin _time span=5m
| stats count as event_count by _time src_ip

| search event_count >= 10

Client bypassing the filtering resolver with encrypted DNS

T1071.004T1572level: lowDNS query log

A device looks up a DNS-over-HTTPS or Private Relay bootstrap host, after which the resolver stops seeing it. Written after it happened on my own network. Where it came from

Sigma rule
title: Client Bypassing the Filtering Resolver With Encrypted DNS or a Relay
id: 4e1a7b93-5d20-4c8e-9f36-8b2c0d1e9f77
status: experimental
description: >
  A client on a network with a mandatory filtering resolver looks up a public DNS-over-HTTPS
  endpoint or an iCloud Private Relay bootstrap host. Once that lookup succeeds the client's
  remaining DNS leaves the network encrypted, and the resolver's logs and blocklists stop applying
  to it. Written after one phone on my own network went silent in the resolver log.
references:
  - https://attack.mitre.org/techniques/T1071/004/
  - https://attack.mitre.org/techniques/T1572/
  - https://developer.apple.com/support/prepare-your-network-for-icloud-private-relay/
  - https://aaronsawit.com/writing/iphone-bypassing-dns-filter-private-relay/
author: Aaron Sawit
date: 2026-09-20
tags:
  - attack.command-and-control
  - attack.t1071.004
  - attack.t1572
logsource:
  category: dns
detection:
  selection:
    query|endswith:
      - 'mask.icloud.com'
      - 'mask-h2.icloud.com'
      - 'dns.google'
      - 'cloudflare-dns.com'
      - 'one.one.one.one'
      - 'dns.quad9.net'
      - 'doh.opendns.com'
      - 'dns.nextdns.io'
  filter_resolver_itself:
    src_ip:
      - '127.0.0.1'
      - '172.20.0.2'
  condition: selection and not filter_resolver_itself
falsepositives:
  - Personal devices with Private Relay enabled. On a home or guest network this is a visibility signal, not an incident.
level: low
Splunk SPL, generated
query IN ("*mask.icloud.com", "*mask-h2.icloud.com", "*dns.google", "*cloudflare-dns.com", "*one.one.one.one", "*dns.quad9.net", "*doh.opendns.com", "*dns.nextdns.io") NOT (src_ip IN ("127.0.0.1", "172.20.0.2"))
Elastic Lucene, generated
(query:(*mask.icloud.com OR *mask\-h2.icloud.com OR *dns.google OR *cloudflare\-dns.com OR *one.one.one.one OR *dns.quad9.net OR *doh.opendns.com OR *dns.nextdns.io)) AND (NOT (src_ip:(127.0.0.1 OR 172.20.0.2)))

Systemd unit written outside a package manager

T1543.002T1053.006level: mediumLinux file events

A new service or timer unit appears, written by something that is not a package manager or systemd. Units are the usual way to survive a reboot on Linux. I write them by hand on my own server, so I know the normal noise: rare, and always an editor or a shell. Where it came from

Sigma rule
title: Systemd Unit Written Outside A Package Manager
id: 5c2a9e10-7b34-4d8f-a6c1-3e9f0b2d4c77
status: experimental
description: >
  A new .service or .timer unit appears in a systemd unit directory, written by a process that is not a
  package manager or systemd itself. Service and timer units are the most common way to survive a reboot on
  Linux. I write timers by hand on my own server, so the expected noise is an administrator's editor or
  shell: few enough events to review each one.
references:
  - https://attack.mitre.org/techniques/T1543/002/
  - https://attack.mitre.org/techniques/T1053/006/
author: Aaron Sawit
date: 2026-09-21
tags:
  - attack.persistence
  - attack.privilege-escalation
  - attack.t1543.002
  - attack.t1053.006
logsource:
  product: linux
  category: file_event
detection:
  selection_system_dir:
    TargetFilename|startswith:
      - '/etc/systemd/system/'
      - '/usr/lib/systemd/system/'
      - '/lib/systemd/system/'
  selection_user_dir:
    TargetFilename|contains: '/.config/systemd/user/'
  selection_unit:
    TargetFilename|endswith:
      - '.service'
      - '.timer'
  filter_package_manager:
    Image|endswith:
      - '/dpkg'
      - '/apt'
      - '/apt-get'
      - '/rpm'
      - '/dnf'
      - '/yum'
      - '/pacman'
      - '/systemd'
      - '/systemctl'
  condition: (selection_system_dir or selection_user_dir) and selection_unit and not filter_package_manager
falsepositives:
  - An administrator writing a unit by hand with an editor, cp or tee. Expected, and worth a look each time.
  - Configuration management (Ansible, Puppet). Add its binary to the filter once it is known.
level: medium
Splunk SPL, generated
TargetFilename IN ("/etc/systemd/system/*", "/usr/lib/systemd/system/*", "/lib/systemd/system/*") OR TargetFilename="*/.config/systemd/user/*" TargetFilename IN ("*.service", "*.timer") NOT (Image IN ("*/dpkg", "*/apt", "*/apt-get", "*/rpm", "*/dnf", "*/yum", "*/pacman", "*/systemd", "*/systemctl"))
Elastic Lucene, generated
((TargetFilename:(\/etc\/systemd\/system\/* OR \/usr\/lib\/systemd\/system\/* OR \/lib\/systemd\/system\/*)) OR TargetFilename:*\/.config\/systemd\/user\/*) AND (TargetFilename:(*.service OR *.timer)) AND (NOT (Image:(*\/dpkg OR *\/apt OR *\/apt\-get OR *\/rpm OR *\/dnf OR *\/yum OR *\/pacman OR *\/systemd OR *\/systemctl)))

Container started with host-level access

T1610T1611level: mediumLinux process creation

A container asks to be privileged, to mount the Docker socket, or to share the host's process or network namespace. Any of these turns code in a container into code on the host. I run 40+ containers and know which few legitimately need it. Where it came from

Sigma rule
title: Container Started With Host-Level Access
id: 7e1d4b92-3c5a-4f80-9a27-b6c8d0e1f233
status: experimental
description: >
  A container is started privileged, with the Docker socket mounted, in the host's process or network
  namespace, or with the host's root filesystem mounted. Any of these turns "code running in a container"
  into "code running on the host". On my own server a few services legitimately need one of these, and they
  are known by name, so anything else is worth a look.
references:
  - https://attack.mitre.org/techniques/T1610/
  - https://attack.mitre.org/techniques/T1611/
author: Aaron Sawit
date: 2026-09-21
tags:
  - attack.execution
  - attack.privilege-escalation
  - attack.t1610
  - attack.t1611
logsource:
  product: linux
  category: process_creation
detection:
  selection_docker:
    Image|endswith:
      - '/docker'
      - '/podman'
      - '/nerdctl'
    CommandLine|contains:
      - ' run '
      - ' create '
  selection_risky:
    CommandLine|contains:
      - '--privileged'
      - '/var/run/docker.sock'
      - '--pid=host'
      - '--pid host'
      - '--net=host'
      - '--network=host'
      - '--network host'
      - '--cap-add=SYS_ADMIN'
      - '--cap-add SYS_ADMIN'
      - '-v /:/'
      - '--volume /:/'
  condition: selection_docker and selection_risky
falsepositives:
  - Monitoring agents, reverse proxies and container managers that are designed to need the socket or host network. List them and filter by container image.
level: medium
Splunk SPL, generated
Image IN ("*/docker", "*/podman", "*/nerdctl") CommandLine IN ("* run *", "* create *") CommandLine IN ("*--privileged*", "*/var/run/docker.sock*", "*--pid=host*", "*--pid host*", "*--net=host*", "*--network=host*", "*--network host*", "*--cap-add=SYS_ADMIN*", "*--cap-add SYS_ADMIN*", "*-v /:/*", "*--volume /:/*")
Elastic Lucene, generated
((Image:(*\/docker OR *\/podman OR *\/nerdctl)) AND (CommandLine:(*\ run\ * OR *\ create\ *))) AND (CommandLine:(*\-\-privileged* OR *\/var\/run\/docker.sock* OR *\-\-pid\=host* OR *\-\-pid\ host* OR *\-\-net\=host* OR *\-\-network\=host* OR *\-\-network\ host* OR *\-\-cap\-add\=SYS_ADMIN* OR *\-\-cap\-add\ SYS_ADMIN* OR *\-v\ \/\:\/* OR *\-\-volume\ \/\:\/*))

SSH authorized_keys modified outside SSH tooling

T1098.004level: highLinux file events

Adding a key is the quietest way to keep access to a Linux host: no password change, no new account, and it survives a reboot. Where it came from

Sigma rule
title: SSH Authorized Keys Modified By Something Other Than SSH Tooling
id: 2f9c6a18-8d47-4e3b-b150-c4a7e9d2f344
status: experimental
description: >
  An authorized_keys file is written by a process that is not part of the normal key-management path. Adding a
  key is the quietest way to keep access to a Linux host: no password changes, no new account, and it survives
  a reboot.
references:
  - https://attack.mitre.org/techniques/T1098/004/
author: Aaron Sawit
date: 2026-09-21
tags:
  - attack.persistence
  - attack.t1098.004
logsource:
  product: linux
  category: file_event
detection:
  selection:
    TargetFilename|endswith:
      - '/.ssh/authorized_keys'
      - '/.ssh/authorized_keys2'
  filter_expected:
    Image|endswith:
      - '/ssh-copy-id'
      - '/sshd'
      - '/cloud-init'
      - '/ansible'
  condition: selection and not filter_expected
falsepositives:
  - An administrator adding a key by hand with an editor or a shell redirect. Expected, rare, and worth confirming with the person.
level: high
Splunk SPL, generated
TargetFilename IN ("*/.ssh/authorized_keys", "*/.ssh/authorized_keys2") NOT (Image IN ("*/ssh-copy-id", "*/sshd", "*/cloud-init", "*/ansible"))
Elastic Lucene, generated
(TargetFilename:(*\/.ssh\/authorized_keys OR *\/.ssh\/authorized_keys2)) AND (NOT (Image:(*\/ssh\-copy\-id OR *\/sshd OR *\/cloud\-init OR *\/ansible)))

Sudoers written outside visudo or a package manager

T1548.003level: highLinux file events

One line in /etc/sudoers.d is a permanent route to root. Almost nothing legitimate writes there except visudo and package installs. Where it came from

Sigma rule
title: Sudoers Configuration Written Outside Visudo Or A Package Manager
id: 9a3e7c51-6b20-4d94-8f13-e5d1a0b4c655
status: experimental
description: >
  /etc/sudoers or a file under /etc/sudoers.d is written by a process other than visudo or a package manager.
  One line there ("user ALL=(ALL) NOPASSWD: ALL") is a permanent route to root.
references:
  - https://attack.mitre.org/techniques/T1548/003/
author: Aaron Sawit
date: 2026-09-21
tags:
  - attack.privilege-escalation
  - attack.t1548.003
logsource:
  product: linux
  category: file_event
detection:
  selection_file:
    TargetFilename: '/etc/sudoers'
  selection_dir:
    TargetFilename|startswith: '/etc/sudoers.d/'
  filter_expected:
    Image|endswith:
      - '/visudo'
      - '/dpkg'
      - '/apt'
      - '/apt-get'
      - '/rpm'
      - '/dnf'
      - '/yum'
      - '/cloud-init'
  condition: (selection_file or selection_dir) and not filter_expected
falsepositives:
  - Configuration management writing a drop-in file. Add its binary to the filter once known.
level: high
Splunk SPL, generated
TargetFilename IN ("/etc/sudoers", "/etc/sudoers.d/*") NOT (Image IN ("*/visudo", "*/dpkg", "*/apt", "*/apt-get", "*/rpm", "*/dnf", "*/yum", "*/cloud-init"))
Elastic Lucene, generated
(TargetFilename:(\/etc\/sudoers OR \/etc\/sudoers.d\/*)) AND (NOT (Image:(*\/visudo OR *\/dpkg OR *\/apt OR *\/apt\-get OR *\/rpm OR *\/dnf OR *\/yum OR *\/cloud\-init)))

Download piped straight into a shell

T1059.004T1105level: mediumLinux process creation

curl piped into sh is how many installers work and how most Linux first stages work, because the payload never touches the disk. On a server that is already built it should be rare enough to review. Where it came from

Sigma rule
title: Download Piped Straight Into A Shell
id: 4c8b2e07-1f63-4a5d-9e72-d3f6b8a1c766
status: experimental
description: >
  curl or wget output piped directly into a shell or interpreter. It is how a lot of legitimate software
  tells you to install it, and it is also the most common first stage on a compromised Linux host, because the
  payload never touches the disk. On a server that is already built, it should be rare enough to review.
references:
  - https://attack.mitre.org/techniques/T1059/004/
  - https://attack.mitre.org/techniques/T1105/
author: Aaron Sawit
date: 2026-09-21
tags:
  - attack.execution
  - attack.command-and-control
  - attack.t1059.004
  - attack.t1105
logsource:
  product: linux
  category: process_creation
detection:
  selection_fetch:
    CommandLine|contains:
      - 'curl '
      - 'wget '
  selection_pipe:
    CommandLine|contains:
      - '| sh'
      - '|sh'
      - '| bash'
      - '|bash'
      - '| sudo sh'
      - '| sudo bash'
      - '| python'
      - '|python'
      - 'bash <(curl'
      - 'sh <(curl'
      - 'bash -c "$(curl'
      - 'sh -c "$(curl'
  condition: selection_fetch and selection_pipe
falsepositives:
  - Installing software from a vendor's one-line installer. Legitimate, and still worth knowing about on a production host.
level: medium
Splunk SPL, generated
CommandLine IN ("*curl *", "*wget *") CommandLine IN ("*| sh*", "*|sh*", "*| bash*", "*|bash*", "*| sudo sh*", "*| sudo bash*", "*| python*", "*|python*", "*bash <(curl*", "*sh <(curl*", "*bash -c \"$(curl*", "*sh -c \"$(curl*")
Elastic Lucene, generated
(CommandLine:(*curl\ * OR *wget\ *)) AND (CommandLine:(*\|\ sh* OR *\|sh* OR *\|\ bash* OR *\|bash* OR *\|\ sudo\ sh* OR *\|\ sudo\ bash* OR *\|\ python* OR *\|python* OR *bash\ \<\(curl* OR *sh\ \<\(curl* OR *bash\ \-c\ \"$\(curl* OR *sh\ \-c\ \"$\(curl*))

DNS query with a very long label

T1071.004T1048.003level: mediumDNS query log

Tools that move data over DNS pack it into labels of up to 63 characters. People do not type 50-character hostnames, so long labels are what tunnelling looks like in a resolver log. Where it came from

Sigma rule
title: DNS Query With A Very Long Label
id: 6d5f1a83-2e94-4c76-a041-f7b9c3e5d877
status: experimental
description: >
  A DNS name containing a single label of 50 or more characters. Real hostnames are short because people type
  them. Tools that move data over DNS pack it into labels, which can be up to 63 characters, so long labels
  are what tunnelling and DNS exfiltration look like in a resolver log.
references:
  - https://attack.mitre.org/techniques/T1071/004/
  - https://attack.mitre.org/techniques/T1048/003/
author: Aaron Sawit
date: 2026-09-21
tags:
  - attack.command-and-control
  - attack.exfiltration
  - attack.t1071.004
  - attack.t1048.003
logsource:
  category: dns
detection:
  selection:
    query|re: '(^|\.)[A-Za-z0-9_-]{50,63}\.'
  filter_known_long:
    query|endswith:
      - '.in-addr.arpa'
      - '.ip6.arpa'
      - '._domainkey.'
      - '.cloudfront.net'
      - '.amazonaws.com'
  condition: selection and not filter_known_long
falsepositives:
  - Some CDNs, DKIM lookups and anti-virus reputation services use long generated labels. Add the domains you see to the filter.
level: medium
Splunk SPL, generated
NOT (query IN ("*.in-addr.arpa", "*.ip6.arpa", "*._domainkey.", "*.cloudfront.net", "*.amazonaws.com"))
| regex query="(^|\\.)[A-Za-z0-9_-]{50,63}\\."
Elastic Lucene, generated
query:/(^|\.)[A-Za-z0-9_-]{50,63}\./ AND (NOT (query:(*.in\-addr.arpa OR *.ip6.arpa OR *._domainkey. OR *.cloudfront.net OR *.amazonaws.com)))