#The situation
Both are RoamSwitch alerts: "Resource Exhaustion Guard: sustained high CPU". Then a colleague writes: "The server is so slow that pages barely load."
CPU or memory runs out for many reasons. A nightly report may simply be running long. A service may hit its memory limit and keep crashing and coming back. Or an intruder may have planted a cryptocurrency miner.
The operator has three things to decide. Which process is the cause. Whether it is legitimate work or something planted. Whether to stop it or limit it.
This playbook walks through those decisions and steps, triggered for real inside a disposable VM.
| Step | What you do | What you use |
|---|---|---|
| 1. Trigger | Run three harmless loads | systemd-run, Python |
| 2. Look | Read the alerts and the records | Resource exhaustion and crash detection, Notification history, Incident timeline |
| 3. Investigate | Decide which process, which parent, and since when | ps, ss, Process execution recording and attack-technique detection |
| 4. Respond | Stop, limit, and prevent a repeat | systemctl, kill, unit settings |
| 5. Undo | Clean up the practice setup |
How the detection works, and how to trigger memory growth and a container crash loop, are covered in Containers and servers. This module focuses on the decisions and steps after an alert arrives.
This module deliberately runs a process that uses up CPU and a service that uses up memory. The memory service gets a MemoryMax=100M limit. It stops inside that limit, so the VM as a whole does not run out of memory. Every load runs inside the VM only, and nothing is sent outside it.
#Prepare the practice setup
Resource exhaustion and crash detection only notifies. It never stops a process. The CPU check defaults are as follows (from /etc/roamswitch/server.conf.example and the defaults in the source).
| Key | Default | Meaning |
|---|---|---|
resource_guard_cpu_enabled | true | Watch for processes that keep using CPU |
resource_guard_cpu_sample_interval_secs | 60 | How often CPU is measured (seconds) |
resource_guard_cpu_window_secs | 600 | The window that is judged (seconds) |
resource_guard_cpu_min_samples | 8 | Measurements needed before judging |
resource_guard_cpu_pct_threshold | 80 | Per core, this % or more counts as "high" |
An alert fires when at least 90% of the measurements in the window are at 80% or more. Unlike the memory check, processes that do not listen on a port are included. A miner only connects out and never listens.
By default a verdict takes about 8 minutes. For the exercise, the interval goes down to 10 seconds. The log audit, which is what reads crash loops, goes down to its minimum of 300 seconds (this changes the machine; the last lab puts it back). Each change needs a restart to apply.
sudo roamswitch server config set resource_guard_rss_sample_interval_secs 10
sudo roamswitch server config set resource_guard_cpu_sample_interval_secs 10
sudo roamswitch server config set log_audit_interval_secs 300
sudo roamswitch server restart ✓ Server configuration updated and saved. (resource_guard_rss_sample_interval_secs = 10)
To apply: sudo systemctl restart roamswitch-server or sudo roamswitch server restart
✓ Server configuration updated and saved. (resource_guard_cpu_sample_interval_secs = 10)
✓ Server configuration updated and saved. (log_audit_interval_secs = 300)
🔄 Restarting roamswitch-server service...
✓ roamswitch-server service successfully restarted.Shorten both the memory and the CPU interval. Right after a restart, the execution record may not have started yet. Wait about 30 seconds before the next lab.
A short interval makes the daemon read /proc more often. Short spikes, such as a build, also become more likely to alert. Put the defaults back after the exercise.
#Find the process that keeps using CPU
Two harmless loads run, in two shapes.
- A nightly report: runs as
lab-report.service, asnobody. It only spins the CPU - A planted miner:
python3copied to/var/tmp/.cache/kswapd0. Its name imitates a kernel thread, and it stays connected to127.0.0.1:3333. If it is stopped, its parent shell starts it again 5 seconds later
Port 3333 is one that mining pools commonly use. A lab-pool.service inside the VM only accepts connections and stands in for an outside pool.
This starts processes (it changes the machine). How to stop them is in the labs of the following chapters.
sudo mkdir -p /opt/lab
sudo tee /opt/lab/busy.py >/dev/null <<'PY'
import socket, sys
if len(sys.argv) > 1:
conn = socket.create_connection(("127.0.0.1", int(sys.argv[1])))
while True:
pass
PY
sudo tee /opt/lab/pool.py >/dev/null <<'PY'
import socket
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 3333)); s.listen()
held = []
while True:
c, _ = s.accept(); held.append(c)
PY
sudo systemd-run --unit=lab-report -p User=nobody python3 /opt/lab/busy.py
sudo systemd-run --unit=lab-pool python3 /opt/lab/pool.py
mkdir -p /var/tmp/.cache
cp /usr/bin/python3 /var/tmp/.cache/kswapd0
setsid sh -c 'while true; do /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333; sleep 5; done' </dev/null >/dev/null 2>&1 &Running as unit: lab-report.service; invocation ID: 6deadc33aad94898b3bd398bb584c29d
Running as unit: lab-pool.service; invocation ID: 85a1340921cd45d5bb62c005e3bab6b1In this VM, two alerts appeared after about a minute and a half.
sudo roamswitch notifications | grep -A11 "sustained high CPU" [CORRELATED] Resource Exhaustion Guard: sustained high CPU — kswapd0
🚨 Process has stayed near-saturated CPU for a sustained period (possible cryptominer)
Host: ubuntu (IP: 192.168.64.33)
Process: kswapd0 (PID: 1562)
Container: not a container process
Average CPU in window: 100%
Correlated signals: mining_port_signature
Timestamp: 2026-11-12 02:12:24
Process: admin / /var/tmp/.cache/kswapd0 / command: /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333 (pid 1562)
Parent processes: dash ← dash ← tart-guest-agent ← systemd / Service: tart-guest-agent.service
2026-11-12T02:12:24.868630820+00:00
[POSSIBLE] Resource Exhaustion Guard: sustained high CPU — python3
⚠️ Process has used sustained high CPU for an extended period (common for compiles, scans, or batch jobs — review if unexpected)
Host: ubuntu (IP: 192.168.64.33)
Process: python3 (PID: 1550)
Container: not a container process
Average CPU in window: 100%
Correlated signals: none
Timestamp: 2026-11-12 02:12:24
Process: nobody / /usr/bin/python3.12 / command: /usr/bin/python3 /opt/lab/busy.py (pid 1550)
Parent processes: systemd / Service: lab-report.serviceThe CPU use is the same, but the two headlines differ.
- [POSSIBLE] and "common for compiles, scans, or batch jobs": nothing backs it up apart from the CPU use
- [CORRELATED] and "possible cryptominer":
mining_port_signaturewas added. The process held a connection to a port that pools commonly use - Timestamp: it is in UTC (11:12 in Japan time)
Besides the alerts, the unified timeline lists the same detections.
sudo roamswitch timeline | head -22 2026-11-12T02:12:24.913354996+00:00 [Resource Guard] recorded only
cpu_saturation: avg CPU 100% sustained over window
process: kswapd0 (pid 1562)
process ancestry: dash ← dash ← tart-guest-agent ← systemd
action taken: Logged & Monitored (notify-only)
2026-11-12T02:12:24.869087652+00:00 [Resource Guard] recorded only
cpu_saturation: avg CPU 100% sustained over window
process: python3 (pid 1550)
process ancestry: systemd
action taken: Logged & Monitored (notify-only)
2026-11-12T02:10:55.916215869+00:00 [Exec Recorder] recorded only
EXR-002: execution from a world-writable directory, category: tmp, path: /var/tmp/.cache/kswapd0
process: /var/tmp/.cache/kswapd0 (pid 1562)
process ancestry: dash ← dash ← tart-guest-agent ← systemd
MITRE ATT&CK: T1059 (Command and Scripting Interpreter)
action taken: notifykswapd0 has one more detection, about 90 seconds earlier at 02:10:55, right after it started. It is from the Process execution recording and attack-technique detection: the program ran from /var/tmp, which anyone can write to. As the "action taken" lines show, neither guard stopped the process.
#Find out which process it is and where it came from
Start from the PIDs in the alerts and check four things: where the executable is, the parent, the connections, and when it started. All of these only read; nothing changes.
Look at the two PIDs side by side.
uptime
ps -o pid,ppid,user,lstart,pcpu,args -p 1550,1562
sudo ls -l /proc/1550/exe /proc/1562/exe
sudo ss -tnpH state established '( dport = :3333 )'
cat /proc/1550/cgroup /proc/1562/cgroup 02:12:27 up 2 min, 0 user, load average: 1.83, 0.62, 0.22
PID PPID USER STARTED %CPU COMMAND
1550 1 nobody Thu Nov 12 02:10:55 2026 99.9 /usr/bin/python3 /opt/lab/busy.py
1562 1557 admin Thu Nov 12 02:10:55 2026 99.9 /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333
lrwxrwxrwx 1 nobody nogroup 0 Nov 12 02:12 /proc/1550/exe -> /usr/bin/python3.12
lrwxrwxrwx 1 admin admin 0 Nov 12 02:12 /proc/1562/exe -> /var/tmp/.cache/kswapd0
0 0 127.0.0.1:45344 127.0.0.1:3333 users:(("kswapd0",pid=1562,fd=3))
0::/system.slice/lab-report.service
0::/system.slice/tart-guest-agent.serviceHow to read it:
- load average: 1.83: in this 4-core VM, two processes each use up one core. They started about 90 seconds ago, so the 1-minute average has not reached 2 yet
- 1550: the executable is
/usr/bin/python3.12, and it belongs tolab-report.service. It is a named service that systemd runs - 1562: the executable is
/var/tmp/.cache/kswapd0. It is connected to port 3333, and its parent is PID 1557.tart-guest-agent.serviceis the service in this VM that receivestart exec. On a real host, it would show up under an SSH session or cron
The real kswapd0 is a kernel thread. Searching by name shows the difference.
ps -C kswapd0 -o pid,ppid,user,args PID PPID USER COMMAND
65 2 root [kswapd0]
1562 1557 admin /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333A kernel thread has PID 2 as its parent, and its name is shown in [ ]. A kswapd0 with an executable is an imitation.
The execution record shows how the parent, PID 1557, started.
sudo roamswitch events tree 1562
sudo roamswitch events search --since 1h --exe kswapd0
sudo roamswitch events search --since 1h --pid 15571 /usr/lib/systemd/systemd
697 /usr/bin/tart-guest-agent
878 /usr/bin/dash
1557 /usr/bin/dash
1562 /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333
2026-11-12T02:10:55.846Z pid=1562 ppid=1557 admin /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333
2026-11-12T02:10:55.846Z pid=1557 ppid=878 admin /usr/bin/dash -c while true; do /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333; sleep 5; done
2026-11-12T02:10:55.845Z pid=1557 ppid=878 admin /usr/bin/setsid sh -c while true; do /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333; sleep 5; doneSearching with --pid 1557 shows the parent's arguments. It was a shell repeating while true; do /var/tmp/.cache/kswapd0 …; sleep 5; done. Stopping the child is not enough: it starts again 5 seconds later.
Decide from the alerts and what you found.
- It belongs to a systemd unit or cron, its executable is where packages install, and you know who owns it: it is most likely legitimate. Choose "limit" in the next chapter
- Its executable is in
/tmp,/var/tmpor/dev/shm, its name imitates a kernel thread or a system process, ormining_port_signaturewas added: treat it as planted. Keep evidence, then stop it - You cannot tell: limit it and wait until you can ask the owner. Stopping it may lose the results of legitimate work
mining_port_signature is added for a TCP connection to port 3333, 4444, 5555, 7777, 9999, 14444, 17777, 19999 or 45560 (the list in the source). Port 8080 is left out, because application servers and upstreams commonly use it. The listed ports can have other uses too. Do not decide on this mark alone; look at the executable's location and the parent as well.
#Stop it or limit it
Legitimate work gets a CPU limit. Something planted is stopped from the parent down, after keeping evidence.
This limits and stops processes (it changes the machine). A --runtime limit disappears on reboot.
First, limit the report's lab-report.service to 25% of one core. It is not stopped, so the report keeps running.
sudo systemctl set-property --runtime lab-report.service CPUQuota=25%
systemctl show lab-report.service -p CPUQuotaPerSecUSec
top -b -n 2 -d 3 -p 1550 | tail -2CPUQuotaPerSecUSec=250ms
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1550 nobody 20 0 17836 10368 6116 R 25.0 0.3 1:33.48 python3It dropped to 25.0% right away.
Next, the miner. Before stopping it, keep its executable and arguments. Even if the executable is deleted, it can still be copied from /proc/<PID>/exe.
sudo mkdir -p /root/evidence
sudo cp /proc/1562/exe /root/evidence/kswapd0.bin
sudo sha256sum /root/evidence/kswapd0.bin /usr/bin/python3.12
tr '\0' ' ' < /proc/1562/cmdline; echoc86f21d0da9290ece2d7d49a3f7ed52aed59cb029abdf0ea25c79634f6cbe960 /root/evidence/kswapd0.bin
c86f21d0da9290ece2d7d49a3f7ed52aed59cb029abdf0ea25c79634f6cbe960 /usr/bin/python3.12
/var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333 The hash matches python3.12, because this exercise uses a copy of python3. On a real host, record this hash. Now stop only the child.
kill 1562
sleep 6
ps -C kswapd0 -o pid,ppid,user,args PID PPID USER COMMAND
65 2 root [kswapd0]
2351 1557 admin /var/tmp/.cache/kswapd0 /opt/lab/busy.py 3333It came back as PID 2351 under the same parent. Stop the parent first, then the child.
kill 1557
pkill -x kswapd0 -u admin
sleep 6
ps -C kswapd0 -o pid,ppid,user,args
crontab -l
sudo find /etc/systemd/system /etc/cron.d /home/admin/.config/systemd -type f -mmin -30 2>/dev/null PID PPID USER COMMAND
65 2 root [kswapd0]
no crontab for adminThis time it does not come back. No cron or systemd file changed in the last 30 minutes either. On a real host, look for anything that brings it back after a reboot with the steps in Persistence and tampering.
As shown above, with the parent still alive it came back in 5 seconds. Also avoid reaching for kill -9 first. If the process disappears before you keep its executable and arguments, you can no longer copy it from /proc/<PID>/exe. The order is: record, stop the parent, stop the child, confirm it does not return.
- Legitimate and not urgent: set a
CPUQuota=limit and let it keep running. To make it permanent, runset-propertywithout--runtime, or write it into the unit settings - Legitimate, but it is starving other services: stop it for now, and start it again after you decide on a time slot or a limit
- Planted: keep evidence and stop it. Until you have checked the way in (passwords, the web application, keys), treat that user's sessions and keys as suspect
#A service that runs out of memory and keeps crashing
Next, memory. A service with MemoryMax=100M allocates 5 MB every 0.1 seconds. When it reaches the limit, the kernel stops only that service's process. With Restart=always, systemd starts it again 10 seconds later, and it stops again.
Resource exhaustion and crash detection counts this systemd loop from the "Scheduled restart job" lines in the journal. By default it alerts on 3 restarts within 300 seconds (resource_guard_crash_loop_window_secs, resource_guard_crash_loop_min_restarts). The journal is read at each log audit interval (log_audit_interval_secs, default 1800 seconds). With log_audit_enabled=false, this count does not run.
This starts and stops a service (it changes the machine).
sudo tee /opt/lab/hog.py >/dev/null <<'PY'
import time
hold = []
while True:
hold.append(bytearray(5 * 1024 * 1024))
time.sleep(0.1)
PY
sudo systemd-run --unit=lab-cache -p MemoryMax=100M -p MemorySwapMax=0 -p Restart=always -p RestartSec=10 python3 /opt/lab/hog.py
sleep 30
sudo journalctl -u lab-cache --no-pager | tail -4Nov 12 02:24:02 ubuntu systemd[1]: lab-cache.service: Scheduled restart job, restart counter is at 2.
Nov 12 02:24:02 ubuntu systemd[1]: Started lab-cache.service - /usr/bin/python3 /opt/lab/hog.py.
Nov 12 02:24:04 ubuntu systemd[1]: lab-cache.service: Main process exited, code=killed, status=9/KILL
Nov 12 02:24:04 ubuntu systemd[1]: lab-cache.service: Failed with result 'signal'.It stops after 2 seconds and comes back 10 seconds later, over and over. The systemd lines only say it was killed by signal 9 (status=9/KILL). The kernel log, checked below, tells you who killed it. The alert appeared after the next log audit, about 4 and a half minutes after the service started in this VM.
sudo roamswitch notifications | grep -A8 "crash loop" [POSSIBLE] Resource Exhaustion Guard: crash loop — lab-cache.service
⚠️ The OOM killer keeps stopping the unit's process and it keeps restarting (the unit's memory limit such as MemoryMax, or the host running out of memory; not the shape of an attack. Check the limit and the usage)
Host: ubuntu (IP: 192.168.64.35)
Unit/container: lab-cache.service
Process: unknown (PID: none)
Restarts in window: 3 (window: 300s)
Correlated signals: unit_oom_killed
Timestamp: 2026-11-12 02:28:08The alert names the unit and has no PID, because the process keeps being replaced. The correlated signal unit_oom_killed means the OOM killer was stopping that unit's process, and the headline itself says it is not the shape of an attack. Confirm it from the unit and the kernel log.
systemctl show lab-cache.service -p NRestarts -p Result
sudo journalctl -k --no-pager | grep "Memory cgroup out of memory" | tail -1Result=signal
NRestarts=22
Nov 12 02:28:06 ubuntu kernel: Memory cgroup out of memory: Killed process 3056 (python3) total-vm:120116kB, anon-rss:102100kB, file-rss:5936kB, shmem-rss:0kB, UID:0 pgtables:276kB oom_score_adj:0- NRestarts=22: it came back 22 times in about 4 and a half minutes. The "3" in the alert is the count at the moment the threshold was reached
- Memory cgroup out of memory: the kernel stopped it at the limit set on the service (its memory cgroup). The VM as a whole did not run out of memory
Stop it.
sudo systemctl stop lab-cache.service
systemctl is-active lab-cache.serviceinactiveThe alert's correlated signal and headline are the first clue. With unit_oom_killed, it is a memory limit or a shortage. Without it too, do not decide on the headline alone; use the kernel log to tell the cases apart.
Memory cgroup out of memoryappears for that unit: it is hitting its limit. Suspect how it uses memory (a leak, a sudden load) or a limit that is too small- No
Memory cgroupline for that unit, and other processes are being stopped too: suspect competition for the host's memory. Find the large processes withps -eo pid,rss,args --sort=-rss - No memory record, and it exits with
code=dumpedorstatus=11/SEGV: it may be crashing on abnormal input. Look at the access logs for that service together with other detections at the same time
After stopping it, prevent a repeat. Keep the limit, and stop the service from coming back endlessly.
This places a unit file (it changes the machine). The end of this lab puts everything back.
StartLimitIntervalSec=300 and StartLimitBurst=3 allow at most 3 starts within 300 seconds.
sudo tee /etc/systemd/system/lab-cache.service >/dev/null <<'UNIT'
[Unit]
Description=lab cache (practice)
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
ExecStart=/usr/bin/python3 /opt/lab/hog.py
MemoryMax=100M
MemorySwapMax=0
Restart=on-failure
RestartSec=10
UNIT
sudo systemctl daemon-reload
sudo systemctl start lab-cache.service
sleep 50
systemctl show lab-cache.service -p NRestarts -p Result -p ActiveState
sudo journalctl -u lab-cache.service --no-pager --since "-1min" | tail -3Result=signal
NRestarts=3
ActiveState=failed
Nov 12 02:20:54 ubuntu systemd[1]: lab-cache.service: Start request repeated too quickly.
Nov 12 02:20:54 ubuntu systemd[1]: lab-cache.service: Failed with result 'signal'.
Nov 12 02:20:54 ubuntu systemd[1]: Failed to start lab-cache.service - lab cache (practice).After 3 restarts, systemd gave up and left it failed. It no longer keeps crashing and using CPU and log space. On a real host, add systemctl --failed to your routine checks so a failed unit is not missed.
Finally, clean up the practice setup and put the settings back to the defaults.
sudo systemctl stop lab-report.service lab-pool.service
sudo rm /etc/systemd/system/lab-cache.service
sudo systemctl daemon-reload
sudo systemctl reset-failed
rm -rf /var/tmp/.cache
sudo rm -rf /opt/lab /root/evidence
sudo roamswitch server config set resource_guard_rss_sample_interval_secs 60
sudo roamswitch server config set resource_guard_cpu_sample_interval_secs 60
sudo roamswitch server config set log_audit_interval_secs 1800
sudo roamswitch server restart
systemctl list-units --all 'lab-*' --no-legend
ps -eo pid,pcpu,args --sort=-pcpu | head -3 ✓ Server configuration updated and saved. (resource_guard_rss_sample_interval_secs = 60)
✓ Server configuration updated and saved. (resource_guard_cpu_sample_interval_secs = 60)
✓ Server configuration updated and saved. (log_audit_interval_secs = 1800)
🔄 Restarting roamswitch-server service...
✓ roamswitch-server service successfully restarted.
PID %CPU COMMAND
5332 55.9 /usr/lib/roamswitch/roamswitch-server-daemon
1502 0.1 /usr/libexec/fwupd/fwupdNo lab- units remain. The top CPU user is the daemon that has just started, followed by fwupd, which checks for firmware updates. The %CPU of ps is an average since the process started, so it reads high right after a start. On a real host, keep the evidence (/root/evidence); do not delete it.
#What this does not show
Checked on a disposable Ubuntu 24.04 VM (4 cores, 4 GB of memory):
- Resource exhaustion and crash detection only notifies and records. Stopping a process or limiting it is done by the operator with
systemctlorkill - The CPU check is per process. A load split across many small processes, none of which reaches 80% of a core, is outside this check. This module did not trigger that shape
mining_port_signaturecomes from a list of port numbers. It is not added for a pool on a port outside the list, or for mining over web port 443- Crash loop alerts arrive once per log audit interval. With the default 1800 seconds, an alert can take up to about 30 minutes
- The trend of the load average and of the zombie count (
resource_guard_load_enabled,resource_guard_zombie_min_count) is also watched, per the notes in the settings file. This module did not trigger them - When a server really stops responding, SSH becomes slow too. This VM was driven with
tart exec. On a real host, check a way in that is not SSH, such as a console, in advance
A [CORRELATED] CPU alert fires. The executable is /dev/shm/.x/sshd, and after kill it comes back a few seconds later under a different PID. What do you do first?
Show the explanation
It comes back because a parent restarts it. Stopping only the child does not end it. Before stopping, copy the executable from
/proc/<PID>/exeand record its hash. Raising the threshold also hides the next miner.
A crash loop alert fires. journalctl -k shows repeated Memory cgroup out of memory lines for that unit. How do you read it?
Show the explanation
A
Memory cgroupline shows that it stopped at the limit set on the service. The alert's correlated signal isunit_oom_killedtoo, and its headline says it is not the shape of an attack. Neither a reboot nor isolation is needed. Suspect an attack when there is no memory record and it crashes abnormally (withSEGVand the like).
#Next steps
- How the detection works, memory growth, and container crash loops are covered in Containers and servers
- How to trace back through the execution record is in Suspicious execution and malware
- Whether something planted left a way to come back after a reboot is checked in Persistence and tampering
- The steps after an isolation fires are in A containment fired: the first hour
- Resource exhaustion and crash detection
- Notification history
- Incident timeline
- Process execution recording and attack-technique detection
Related techniques (ATT&CK): T1059.004 T1027 T1105 T1553.001
Verified on 2026-11-12. Target: Linux 1.17.39. On disposable Ubuntu 24.04 VMs with the published 1.17.39, with shorter sampling intervals, a harmless CPU load, a miner-named copy of python3 and a service crashing at MemoryMax were run in order, and the alerts, timeline, exec record, systemd and kernel output were taken in Japanese and English on separate VMs.
Command output varies with the product version and the environment. If yours differs, check the version first.