This course is targeted at using Python to monitor system services, including how they respond and how they communicate. It covers acquiring information from a server both locally and remotely, interacting with a MySQL server, interacting with a DNS server, tracking the registered physical locations of IP addresses, monitoring for abnormal process behavior, and monitoring for abnormal process communication (network) behavior. By the end of this material you should have a firm base of understanding of how Python can be used to build security tooling. The running scenario used throughout the course places you as a network security engineer for a fictional company, Globomantics. In this role you are expected to bring new tools and ideas into the security group, including Python-based utilities that can be added to your day-to-day toolkit. Each module below builds on the previous one, moving from simple local information gathering all the way up to live process and network behavior monitoring.
python -m pip install <module-name>
# or, on some systems:
pip3 install <module-name>
Table of Contents
- Module 1: Acquiring Server Information with Python
- Module 2: Using Python to Interact with MySQL
- Module 3: Interacting with DNS Servers Using dnspython
- Module 4: Tracking IP Addresses Using ip-api and Python
- Module 5: Monitoring Abnormal Process Behavior with Python
- Module 6: Monitoring Process Download Behavior with Python
- Summary
Module 1: Acquiring Server Information with Python
Scenario and Learning Environment
Imagine that you have just been brought into the network security group at Globomantics to bring new ideas and direction to the team, including tools that are not already in use. Throughout this course, these “new tools” are a set of Python modules that extend your toolkit as a security professional. The course as a whole looks at the most common use cases for these technologies, including live demonstrations of how they can be used in a real environment. This includes:
- Collecting system information, locally and remotely.
- Interacting with MySQL and DNS servers.
- Tracking the geographic locations registered to IP addresses.
- Monitoring processes for abnormal behavior, including processes that spawn unexpected child processes and processes that download remote files unexpectedly.
This first module focuses on collecting system information from both a local and a remote system, using the platform, psutil, and wmi Python modules. The information collected includes hardware and software versions, running processes, resource utilization, and network configuration. Once collected, this information can be used from two very different perspectives:
- Defensive — if you are responsible for securing the system, this information tells you what patch level is running, what processes exist, who spawned them, and whether resources are being used in an unexpected way.
- Offensive — if you are attempting to gain or extend access to a system, the same information (patch levels, Python versions, running processes) can be used to identify and exploit weaknesses.
As a Globomantics security engineer you may need to use this information both ways: to maintain the security of your own systems, and to perform authorized penetration tests against them.
flowchart TD
A[Target System] --> B[platform module]
A --> C[psutil module]
A --> D[WMI module]
A --> E[WinApps module]
B --> F[OS / architecture / Python version info]
C --> G[Processes, CPU, memory utilization]
D --> H[Local or remote system data via WMI classes]
E --> I[Installed applications and versions]
F --> J{How is this used?}
G --> J
H --> J
I --> J
J -->|Defensive| K[Detect unexpected/vulnerable software, unauthorized processes]
J -->|Offensive| L[Identify exploitable patch levels and running services]
The demonstration environment is a Windows 10 Professional virtual machine running Python 3.10.8 with Visual Studio Code as the IDE. Windows was chosen because the WMI module and several of the other examples in the course are Windows-centric, although most of the concepts (particularly psutil) apply cross-platform with small changes.
Python Modules for Local Server Information Collection
Four Python modules are used to gather information from a local device in this module:
| Module | Purpose |
|---|---|
platform | Access a target platform’s identifying data: architecture, platform string, platform version/edition, Python version, and Python implementation. |
psutil | Collect and monitor information about a system’s processes and applications, including overall and per-process resource utilization. |
wmi | A wrapper around Microsoft’s Windows Management Instrumentation (WMI) libraries, allowing management/query of a device both locally and remotely. |
winapps | Focused on managing Windows applications; used here only to collect a list of installed applications and their versions. |
All four are installed with the standard python -m pip install <module> / pip3 install <module> pattern.
Demo: Collecting Local Server Information
The platform module
The first script simply prints a variety of information obtained through the platform module.
import platform
# https://docs.python.org/3/library/platform.html
my_system = platform.uname() # Only contains the six below that can also be called individually:
print(f"Node Name: {my_system.node}") # Cross-platform
print(f"System: {my_system.system}") # Cross-platform
print(f"Release: {my_system.release}") # Cross-platform
print(f"Version: {my_system.version}") # Cross-platform
print(f"Machine: {my_system.machine}") # Cross-platform
print(f"Processor: {my_system.processor}") # Cross-platform
# Separate from uname
print(f"Platform: {platform.platform()}") # Cross-platform
print(f"Architecture: {platform.architecture()}") # Cross-platform - bit architecture and linkage format
print(f"Python Version: {platform.python_version_tuple()}") # Cross-platform
print(f"Python Implementation: {platform.python_implementation()}") # Cross-platform
# Windows specific
print(f"Windows (WIN32) Version: {platform.win32_ver()}")
print(f"Windows (WIN32) Edition: {platform.win32_edition()}")
print(f"Windows (WIN32) IoT: {platform.win32_is_iot()}") # Formerly Windows Embedded
The platform.uname() call returns six of the most commonly queried variables in one shot: node name, system, release, version, machine, and processor. These are pulled into the my_system variable and then individually referenced (my_system.node, my_system.system, and so on).
A second group of calls returns values that are not part of uname(): the full platform string, the architecture, the Python version, and the Python implementation. Because the demo machine is Windows, three additional Windows-specific calls are also shown: the Windows version, edition, and whether it is the IoT (formerly “Windows Embedded”) edition.
Running the script against the lab machine produces output that shows the system is running Windows, release 10, patch level 10.0.19044, on a 64-bit Intel/AMD processor (Intel Family 6). The platform() string reports the patch level, architecture() reports 64-bit, the Python version is 3.10.8 (CPython — the most common Python implementation), and the Windows-specific fields report version 10, build 10.0.19044 SP0, edition Professional, not IoT.
The psutil module — process listing
import psutil, pprint, tabulate
# Compile processes into a dictionary
# process_dict = {}
for proc in psutil.process_iter(['pid', 'ppid', 'name', 'username']):
print(proc.info)
psutil.process_iter() is used to walk every running process, requesting only the process ID, parent process ID, name, and the username that launched it. Running this prints a long list of every process on the machine. Each entry shows the executable name, the username that ran it, and the parent process ID — for example, the Visual Studio Code executable (Code.exe) shows a parent process ID that, when searched for in the same list, turns out to be explorer.exe. From just this simple output you can determine what is running, who ran it, and what spawned it. A more structured, tree-shaped view of “who spawned whom” is built later in Module 5.
The psutil module — utilization
Several additional psutil capabilities are shown for resource utilization:
import psutil
# Overall CPU utilization (per logical core)
print(psutil.cpu_percent(interval=1, percpu=True))
# Per-process CPU utilization (rolling, requires two samples over time)
p = psutil.Process(588)
print(f"{p.cpu_percent(interval=1)}%")
# System-wide memory usage
vm = psutil.virtual_memory()
print(f"Total: {vm.total}")
print(f"Used: {vm.used}")
print(f"Percent: {vm.percent}")
print(f"Available: {vm.available}")
# Per-process memory usage (non-swapped physical / resident memory)
p = psutil.Process(588)
print(f"{p.memory_info().rss} bytes")
psutil.cpu_percent(interval=..., percpu=True)reports overall CPU utilization; because the lab VM has four virtual cores, four numbers are returned, one per core, each representing utilization since the method was last called.- Per-process CPU usage uses
psutil.Process(<pid>).cpu_percent(), which — like the system-wide call — is a rolling measurement based on the interval between two calls to the method. - System memory usage comes from
psutil.virtual_memory(), which can report total, used, available, and percentage figures (there are other fields available as well, seen commented out in the source). On the lab VM (8 GB total), roughly 2.8 GB (32.4%) is in use, leaving about 5.8 GB available. - Per-process memory uses
psutil.Process(<pid>).memory_info().rss— the non-swapped physical (resident) memory used by that process, reported in bytes (roughly 95 MB for theCode.exeprocess in the demo).
The WMI module — local queries
Windows Management Instrumentation (WMI) is built into most modern Windows versions (although it may not be enabled on every target). The demo queries several WMI classes locally, before extending the same technique to a remote target later in the module.
import wmi
c = wmi.WMI()
for os_info in c.Win32_OperatingSystem():
print(f"OS Edition: {os_info.Caption}")
print(f"Free Physical Memory: {os_info.FreePhysicalMemory}")
print(f"Total Visible Memory: {os_info.TotalVisibleMemorySize}")
print(f"Build Number: {os_info.BuildNumber}")
print(f"Build Type: {os_info.BuildType}")
for cs_info in c.Win32_ComputerSystem():
print(f"System Name: {cs_info.Name}")
print(f"Logical Processors: {cs_info.NumberOfLogicalProcessors}")
print(f"Physical Processors: {cs_info.NumberOfProcessors}")
print(f"System Type: {cs_info.SystemType}")
for interface in c.Win32_NetworkAdapterConfiguration(IPEnabled=True):
print(f"Description: {interface.Description}")
print(f"MAC Address: {interface.MACAddress}")
print(f"IP Address(es): {interface.IPAddress}")
Each WMI class exposes a large number of attributes; the reference documentation for each class (for example Win32_OperatingSystem) lists them all. The script deliberately picks a handful of simple, commonly useful attributes:
Win32_OperatingSystem: caption/name,FreePhysicalMemory,TotalVisibleMemorySize,BuildNumber,BuildType— output largely mirrors whatplatformalready reported.Win32_ComputerSystem: system name, logical/physical processor counts, system type.Win32_NetworkAdapterConfiguration: adapter name/description, assigned MAC address, and IP address(es) — looped so each adapter is printed cleanly.
On the lab VM, this reports Windows 10 Pro, free/total memory matching the platform output, a build number consistent with the earlier platform.win32_ver() result, 4 logical/2 physical cores, and two physical network adapters — the primary Gigabit Ethernet adapter is assigned 10.10.10.101 with a 255.255.255.0 mask.
The winapps module — installed applications
import winapps
import pprint
installed = winapps.list_installed()
pprint.pprint(installed)
winapps.list_installed() returns a dictionary describing every installed application and its version. From a security standpoint this is very useful: a defensive script could compare this list against an allow-list of approved applications and flag anything unexpected, or flag applications whose installed version is below a required patch level. From an offensive standpoint, the same information tells an attacker which locally installed applications are running known-vulnerable versions.
Collecting Remote Server Information
The same WMI-based technique used locally can be extended to a remote target, provided that the target machine is configured to accept WMI communications. This is a significant caveat: any properly secured system should be firewalled and otherwise isolated from unauthenticated information-collection activity. As a network security engineer, your goal is simply to collect whatever information is reachable — remotely, in this case. In some environments WMI is intentionally left enabled to support remote infrastructure management; in other cases, gaining enough access to enable WMI on a target is itself part of an attacker’s exploitation chain.
sequenceDiagram
participant Engineer as Security Engineer (Python)
participant Target as Remote Windows Host (WMI enabled)
Engineer->>Target: wmi.WMI(computer=IP, user=USER, password=PASS)
Target-->>Engineer: Authenticated WMI connection
Engineer->>Target: Query Win32_OperatingSystem
Target-->>Engineer: Caption, FreeMemory, TotalMemory, BuildNumber...
Engineer->>Target: Query Win32_ComputerSystem
Target-->>Engineer: Name, logical/physical processor counts
Engineer->>Target: Query Win32_NetworkAdapterConfiguration
Target-->>Engineer: MAC address(es), IP address(es)
Demo: Collecting Remote Server Information
The remote demo reuses the same WMI query logic shown above, with one difference: the connection call now supplies the target IP address, username, and password.
import wmi
import socket
# Reference: see the resources published for this course for a link describing
# how to enable WMI monitoring on a target Windows machine.
target_ip = "10.10.10.101"
username = "administrator"
password = "password"
try:
connection = wmi.WMI(target_ip, user=username, password=password)
print(f"Connection established with {target_ip}")
for os_info in connection.Win32_OperatingSystem():
print(f"OS Edition: {os_info.Caption}")
print(f"Free Memory: {os_info.FreePhysicalMemory}")
print(f"Total Memory: {os_info.TotalVisibleMemorySize}")
print(f"Build Number: {os_info.BuildNumber}")
for cs_info in connection.Win32_ComputerSystem():
print(f"System Name: {cs_info.Name}")
print(f"Logical Processors: {cs_info.NumberOfLogicalProcessors}")
print(f"Physical Processors: {cs_info.NumberOfProcessors}")
for interface in connection.Win32_NetworkAdapterConfiguration(IPEnabled=True):
print(f"MAC Address: {interface.MACAddress}")
print(f"IP Address(es): {interface.IPAddress}")
except Exception as e:
print(e)
Everything after the connection is established mirrors the local demo exactly, but is now being executed against a second lab machine (LAB-2) at 10.10.10.101. The output confirms a connection was established, then reports the remote machine as also running Windows 10 Professional, with roughly 5 GB free out of 8 GB total, the same build number, and 4 logical/2 physical processors. The networking section shows a couple of additional logical interfaces on the remote host, but the primary Gigabit adapter reports the same style of information: MAC address, an assigned IPv4 address/mask (10.10.10.101 / 255.255.255.0), and an assigned link-local IPv6 address. This confirms that the same information available locally can be collected remotely, as long as WMI access is permitted.
Module 1 recap: local information gathering was handled with platform, psutil, wmi, and winapps; remote information gathering reused wmi, provided the target allows WMI connections.
Module 2: Using Python to Interact with MySQL
Collecting Information from MySQL Servers
When communicating with a MySQL server, SQL statements are used to request specific targeted information — anything from a simple list of existing databases and tables, to something much more specific, such as fields matching a set of criteria. This module does not cover SQL syntax itself (that is a large topic covered by other dedicated courses); the focus here is purely on the basics of information collection, as it would be used by a network security engineer.
For Python to communicate with MySQL, the mysql-connector-python module must be installed:
python -m pip install mysql-connector-python
Once installed, the two central methods used throughout this module are connect() and cursor(), combined with hand-crafted SQL statements to retrieve the intended information.
sequenceDiagram
participant Engineer as Security Engineer (Python)
participant MySQL as MySQL Server
Engineer->>MySQL: mysql.connector.connect(host, user, password)
MySQL-->>Engineer: Connection object
Engineer->>MySQL: mydb.cursor(dictionary=True)
MySQL-->>Engineer: Cursor object
Engineer->>MySQL: mycursor.execute("SELECT User, Host FROM mysql.user;")
MySQL-->>Engineer: List of MySQL users/hosts
Engineer->>MySQL: mycursor.execute("SHOW DATABASES;")
MySQL-->>Engineer: List of visible databases
Engineer->>MySQL: mycursor.execute("SHOW TABLES FROM <database>;")
MySQL-->>Engineer: List of tables
Engineer->>MySQL: mycursor.execute("SELECT * FROM <table>;")
MySQL-->>Engineer: Row data
Demo: Probing a MySQL Server
import mysql.connector
import sys
import pprint # Used to print Dict neatly
mydb = mysql.connector.connect(
host="10.10.10.101",
user="main",
password="password"
# A specific database can also be specified here
)
mycursor = mydb.cursor(dictionary=True)
# ACCESS TO THE MYSQL SERVER-LEVEL "mysql" DATABASE REQUIRES:
# GRANT ALL PRIVILEGES ON `mysql`.* TO `main`@`%`;
# List MySQL users and their allowed hosts
mycursor.execute("SELECT User, Host FROM mysql.user;")
for entry in mycursor:
print(entry)
# List every database visible to the current user
mycursor.execute("SHOW DATABASES;")
myservercontents = {}
mydatabaselist = []
for entry in mycursor:
mydatabaselist.append(entry["Database"])
# List the tables inside every visible database
for database in mydatabaselist:
try:
mycursor.execute("SHOW TABLES FROM {}".format(database))
except Exception as e:
continue # Permission errors on a given database simply skip it
mytablelist = []
for entry in mycursor:
mytablelist.append(entry)
myservercontents[database] = mytablelist
pprint.pprint(myservercontents)
# Example: list the fields/columns of a specific table
# mycursor.execute("use test_database_1;")
# mycursor.execute("SELECT * FROM test_table_1;")
# for entry in mycursor:
# print(entry)
Walking through the demo step by step:
- Connect —
mysql.connector.connect(host=..., user=..., password=...)opens a connection to the MySQL server at10.10.10.101using themainaccount. A specific database can optionally be supplied here, which is useful if your account’s permissions are restricted to a single database or table. - Create a cursor —
mydb.cursor(dictionary=True)returns a cursor object (mycursor) that is used to execute SQL statements and retrieve their results as dictionaries. - List users —
SELECT User, Host FROM mysql.user;returns every username defined on the server (main, plus the built-ininformation_schema,session,sys, androotaccounts) along with the host/IP each username is permitted to connect from. In the demo, themainuser has three separate host entries: one for any host, one restricted to a specific IP address, and one forlocalhostonly. - List databases —
SHOW DATABASES;only returns the databases that the connected user is permitted to see. In the demo this returns five databases: the four standard MySQL system databases (information_schema,performance_schema,mysql,sys) plus one lab database,test_database_1, seeded with dummy data. - List tables in a database —
SHOW TABLES FROM test_database_1returns the tables that exist in that specific database (a single table,test_table_1, in the demo). - List rows / fields — switching context with
use test_database_1;and thenSELECT * FROM test_table_1;returns the actual field values, which in the demo reveals three columns,Test_Field_1,Field_2, andField_3.
Why this matters for security. Many products use a MySQL back end to store the data they collect. If that data is low sensitivity, this doesn’t matter much — but most systems store some information in MySQL that can be used to further an attack: real usernames, addresses, phone numbers, email addresses, chat handles, potentially passwords (ideally salted and hashed), traffic/problem records, and sometimes even equipment names and management IP addresses. As a defender, you should treat the ability to run a script like this against your own MySQL servers — using an unauthorized or over-privileged account — as a signal that additional hardening is required. As an authorized attacker (during a penetration test), the same script can be used the way nmap probes ports: start shallow (can I connect at all?), then go deeper (can I see databases? tables? rows?), documenting exactly how far unauthorized access reaches.
| Goal | SQL used |
|---|---|
| List MySQL users and allowed hosts | SELECT User, Host FROM mysql.user; |
| List visible databases | SHOW DATABASES; |
| List tables in a database | SHOW TABLES FROM <database>; |
| Select all rows from a table | SELECT * FROM <table>; |
Module 3: Interacting with DNS Servers Using dnspython
Collecting Information from DNS Servers
This module focuses on accessing the records stored within the Domain Name System (DNS) using the dnspython module. dnspython is very extensive in its DNS support, but this course only uses a small fraction of its capability, focused on collecting information about targeted DNS names and domains.
python -m pip install dnspython
The primary interface used is the dns.resolver.Resolver class and its methods, used to perform standard DNS queries and retrieve different DNS record types.
| Record type | Purpose |
|---|---|
A | IPv4 address record |
AAAA | IPv6 address record |
CNAME | Canonical name (alias linked to an A record) |
MX | Mail exchange record, used for email routing |
NS | Name server record |
PTR | Pointer record — reverse lookup, mapping an IP address back to a name |
SOA | Start of Authority record for a domain |
Demo: Interacting with DNS
import dns.resolver
# Simple A record lookup
answers = dns.resolver.resolve('www.google.com', 'A')
for rdata in answers:
print(rdata)
# Different ways of inspecting the same result
print(answers.qname) # the query name that was looked up
print(answers.response) # the raw DNS response (flags, question, answers)
print(answers.rrset) # the answer set formatted like a zone-file record
print(answers.rrset[0]) # a single indexed record from that set
print(answers.rrset[0:3]) # a slice of records from that set
print(answers.canonical_name)
# MX record lookup (email routing)
mx_answers = dns.resolver.resolve('google.com', 'MX')
for rdata in mx_answers:
print(rdata)
# PTR (reverse) lookup — used in the real demo file included with this course
answers2 = dns.resolver.resolve_address('192.124.249.7') # For PTR records
for rdata in answers2:
print(rdata)
The result object (answers) can be inspected in a number of different ways:
answers.qname— echoes back the name that was queried.answers.response— the full raw response data, including DNS flags, the original question, and every answer returned.answers.rrset— the same answer data formatted more like lines in a DNS zone file; individual records can be indexed (answers.rrset[4]) or sliced (answers.rrset[0:3]).- Querying an
MXrecord for a bare domain (google.com) rather than thewwwsubdomain returns the mail-exchange target — in the demo this resolves tosmtp.google.com, which itself can be further resolved to its own set ofArecords.
sequenceDiagram
participant Engineer as Security Engineer (Python)
participant DNS as DNS Resolver
Engineer->>DNS: resolve('www.google.com', 'A')
DNS-->>Engineer: List of A records
Engineer->>DNS: resolve('google.com', 'MX')
DNS-->>Engineer: Mail exchange target (e.g. smtp.google.com)
Engineer->>DNS: resolve_address('192.124.249.7')
DNS-->>Engineer: PTR record (reverse hostname)
Why this matters for security. Using a module like dnspython extends the range of information you can collect about a target beyond a bare IP address. It fills in gaps that would otherwise be missing if all you had was an IP address or a hostname alone — for example, discovering related mail infrastructure through MX records, or confirming the identity behind an address through reverse (PTR) lookups.
Module 4: Tracking IP Addresses Using ip-api and Python
Collecting Address Owner Information
This module covers tracking the physical location registered to an IP address or hostname, using the free-to-use ipapi module and lookup service. On top of the location lookup, the module also shows how to use the retrieved information to look up the corresponding Autonomous System Number (ASN) and its owner organization, via the free ASN lookup service offered by Shadowserver.
Several modules are combined for this demonstration:
| Module | Purpose |
|---|---|
ipapi | Perform IP/location lookups against the ip-api service. |
socket | Resolve a hostname to an IP address, if a hostname (rather than a raw IP) is supplied. |
ipaddress | Validate whether a supplied string is already a valid IP address. |
requests | Perform the HTTP request needed for the Autonomous System Network lookup. |
webbrowser | Launch a browser pointed at Google Maps, centered on the returned coordinates. |
pprint, sys, time | Supporting utilities for formatting output, exiting on error, and pausing before opening the browser. |
flowchart TD
A[Input: hostname or IP] --> B{Valid IP address?}
B -->|Yes| D[Use as-is]
B -->|No - hostname| C[socket.gethostbyname]
C --> D
D --> E[ipapi.location]
E --> F[Location dict: country, region, city, lat/long, ASN, org...]
F --> G[requests.get Shadowserver ASN API]
G --> H[Authoritative org for that IP range]
F --> I[webbrowser.open Google Maps at lat/long]
Demo: Collecting Owner Information
import ipapi # Location lookup
import webbrowser # For Google Maps
from pprint import pprint
import socket # For hostname lookup
from ipaddress import ip_address # Validating IP address
import sys, time
from requests import get # ASN lookup
lookup = "www.yahoo.com"
# Perform a name lookup if required
try:
ip = str(ip_address(lookup))
except ValueError:
print("Looking up Name...")
ip = socket.gethostbyname(lookup)
except Exception as e:
print(e)
sys.exit()
# Perform a location lookup
try:
print("Looking up location data...")
location_result = ipapi.location(ip) # , output='latitude')
"""
{'asn': 'AS30148',
'continent_code': 'NA',
'country': 'US',
'country_area': 9629091.0,
'country_calling_code': '+1',
'country_code': 'US',
'country_name': 'United States',
'in_eu': False,
'ip': '192.124.249.7',
'languages': 'en-US,es-US,haw,fr',
'latitude': 33.6647,
'longitude': -117.1743,
'network': '192.124.249.0/29',
'org': 'SUCURI-SEC',
'postal': '92584',
'region': 'California',
'region_code': 'CA',
'timezone': 'America/Los_Angeles',
'utc_offset': '-0700',
'version': 'IPv4'}
"""
except Exception as e:
print(e)
else:
# print(location_result)
# pprint(location_result)
# --- ASN Lookup ---
# print("Looking up ASN data...")
# loc = get('https://api.shadowserver.org/net/asn?origin={}'.format(ip))
# pprint((loc.json()))
# ---
print("Launching browser with location coordinates...")
latitude = location_result["latitude"]
longitude = location_result["longitude"]
# Opens a browser with Google Maps centered at the returned coordinates
location_query = (
"https://www.google.com/maps/search/?api=1&query="
+ str(latitude) + "%2C" + str(longitude)
)
time.sleep(2) # Give 2 seconds to read output
webbrowser.open(location_query, new=2)
Walking through the flow:
- Normalize the input. The script accepts either an IP address or a hostname in
lookup. It first triesip_address(lookup); if that raisesValueError(because the input is a hostname, not an IP), it falls back tosocket.gethostbyname(lookup)to resolve it. - Location lookup.
ipapi.location(ip)returns a dictionary of fields describing the IP’s registered location: country, region, city, postal code, timezone, latitude/longitude, the owning ASN, and the organization name. Printing this raw dictionary is legible but “ugly”; usingpprint.pprint()instead formats it far more readably. - ASN / owner lookup. Using the resolved
ipvalue, a plain HTTPGETagainst Shadowserver’s ASN API (https://api.shadowserver.org/net/asn?origin=<ip>) returns the organization responsible for that IP block — in the demo, the block74.6.231.0/24is registered to Oath Holdings. - Map the location. Because most people don’t recognize raw latitude/longitude values, the script builds a Google Maps search URL from those coordinates and opens it in the default browser using
webbrowser.open(). In the demo, the location resolves to Omaha.
Important caveat. The location registered to an IP address is not necessarily where the device physically sits — it is where the address block is registered. If you run a server using an address allocated by your ISP, a location lookup on that address is likely to report your ISP’s registered address, not your own.
Why this matters for security. Extending a raw IP address into a registered location, an ASN, and an owning organization gives you meaningfully more context about a remote endpoint than the address alone — useful for building an incident timeline, corroborating threat intelligence, or simply understanding who is on the other end of a connection.
Module 5: Monitoring Abnormal Process Behavior with Python
Reviewing Processes for Abnormal Behavior
This module has two goals:
- Use
psutilto read the full list of running processes and format them into a nested dictionary, arranged by parent/child relationship, for future reference and display. - Use a process list from
psutilto build filtered views of the processes consuming the most CPU and the most memory.
The modules used are psutil (process/resource information — already introduced in Module 1), time and sys (script control), pprint (readable dictionary output), and os (running CLI commands such as clearing the screen).
flowchart TD
A[Create_Process_List] --> B[psutil.process_iter pid, name]
B --> C[generate_process_dict]
C --> D{Does process have a parent?}
D -->|No parent - root process| E{Has children?}
E -->|No| F[Leaf entry: pid, len 0]
E -->|Yes| G[Nested dict placeholder]
G --> H[process_iterator - recursive walk of children]
H --> H
F --> I[Final nested process dictionary]
G --> I
Demo: Building a Nested Process Dictionary
The real demonstration script builds a dictionary that nests every process underneath the parent that spawned it — if process 1 spawned process 2, process 2 appears one tier down; if process 2 then spawned process 3, process 3 sits a further tier down, and so on.
# Import the required libraries
import psutil
import sys, pprint
# Create main process list
def Create_Process_List():
proc = []
for p in psutil.process_iter(['pid', 'name']):
try:
p.cpu_percent()
proc.append(p)
except Exception as e:
pass
return proc
# Iterates through all of the processes and places them in a large nested dict variable
def process_iterator(kids_iterator, key_iterator):
for kids in kids_iterator.children():
if len(kids.children()) == 0: # Skip further walks - no additional children
key_iterator[kids.pid] = [kids, len(kids.children())]
else:
key_iterator[kids.pid] = [kids, len(kids.children()), {}]
process_iterator(key_iterator[kids.pid][0], key_iterator[kids.pid][2])
def generate_process_dict(proc):
processes = {}
for p in proc:
if p.pid != 0: # Skips the initial PID 0 (root process on Windows)
if psutil.pid_exists(p.pid): # Prevents errors if a process closes mid-walk
if len(p.parents()) == 0: # Only root processes (no parents) start a tree
if len(p.children()) == 0:
processes[p.pid] = [p, len(p.children())]
else:
processes[p.pid] = [p, len(p.children()), {}]
kids_iterator = p
key_iterator = processes[p.pid][2]
process_iterator(kids_iterator, key_iterator)
return processes
# Searches the process dict for a specified PID, returns a bool
def search_processes(pid, proc_dict):
global found
for pid_keys in proc_dict.keys():
if pid == pid_keys:
found = True
if proc_dict[pid_keys][1] != 0:
search_processes(pid, proc_dict[pid_keys][2])
return found
def parent_process_count(proc_dict, parent_pid):
global found, process_counter
found = False
search_processes(parent_pid, proc_dict)
if found:
parent_process_count_iterator(proc_dict, parent_pid)
else:
process_counter = -1
def parent_process_count_iterator(proc_dict, parent_pid):
for pid_keys in proc_dict.keys():
if parent_pid == pid_keys:
if proc_dict[pid_keys][1] != 0:
process_count(proc_dict[pid_keys][2])
elif proc_dict[pid_keys][1] != 0:
parent_process_count_iterator(proc_dict[pid_keys][2], parent_pid)
def process_count(proc_dict):
global process_counter
process_counter += len(proc_dict.keys())
for pid_keys in proc_dict.keys():
if proc_dict[pid_keys][1] != 0:
process_count(proc_dict[pid_keys][2])
def total_processes(dict):
global process_counter
process_counter = 0
process_count(dict)
print("There are a total of", process_counter, "processes in this processes dictionary")
def parent_processes(dict, Parent_PID):
global process_counter
process_counter = 0
parent_process_count(dict, Parent_PID)
print("There are", process_counter, "processes under the parent PID", Parent_PID) # -1 = not found
def main():
# CREATES A NESTED DICT OF ALL THE CURRENT PROCESSES
try:
thisdict = generate_process_dict(Create_Process_List())
except Exception as e:
print(e)
sys.exit()
pprint.pprint(thisdict)
# SEARCH FUNCTIONALITY - requires the nested dict created above
global found
found = False
print(search_processes(5692, thisdict))
# Get total process count in the referenced dict:
# total_processes(thisdict)
# Get total parent-process count under a given PID:
# parent_processes(thisdict, 5692)
if __name__ == "__main__":
main()
Walking through the logic:
Create_Process_List()returns a flat list of process objects usingpsutil.process_iter(['pid', 'name']), the same technique shown in Module 1.generate_process_dict()loops over that flat list and, for every root process (a process with no parents, excluding PID0), begins building a dictionary entry keyed by PID. Each entry is a list:[process_object, child_count], or[process_object, child_count, {}]if it has children — the empty dict acts as a placeholder that is filled in recursively.process_iterator()is the recursive function that walks each parent’schildren(), filling out the nested placeholder dictionaries however many levels deep the tree goes. A process with zero children is stored as a simple leaf entry; a process with children stores a further nested dict and recurses into its own children.- A
psutil.pid_exists()check guards against a race condition: because the system is live, a process could exit between building the flat list and walking its children — silently skipping stale PIDs avoids raising an exception mid-walk.
Running main() prints the full nested dictionary. In the sample output, PID 556 (wininit) has three direct children, one of which — PID 700 (services) — has 74 further children of its own, one of which (844, svchost) itself has 16 children (including things like Photos and RuntimeBroker). Visually walking this structure makes it obvious exactly which process spawned which — similar to what Microsoft’s own Process Monitor tool shows.
Why this matters for security. If you need to determine whether any service on a target machine is running an unexpected process, or spawning processes it shouldn’t be, a live nested view like this lets you see, at a glance, where every currently running process sits in the spawn tree, what its status is, and who its parent was. The search_processes() function locates a specific PID anywhere in the tree; total_processes() and parent_processes() report simple counts — the total number of processes tracked overall, and the number of descendants under a specific parent PID. In the demo, the dictionary contains 123 total processes, and PID 5692 has 5 descendants spread across multiple tiers. This kind of count is useful as a lightweight “has anything changed?” check: if you already know a specific process normally spawns 5 children, and a later check reports more, that’s a signal worth investigating — especially if the parent process is one you consider higher risk.
Demo: Monitoring Top CPU and Memory Consumers
A second, related script builds on the same Create_Process_List() helper but instead continuously displays the processes currently consuming the most CPU and the most memory.
# Import the required libraries
import psutil
import time
from subprocess import call
from prettytable import PrettyTable
import sys, pprint
def Create_Process_List():
proc = []
for p in psutil.process_iter(['pid', 'name']):
try:
p.cpu_percent()
proc.append(p)
except Exception as e:
pass
return proc
def main():
try:
while True:
process_cpu_table = PrettyTable(['PID', 'PNAME', 'STATUS', 'CPU', 'NUM THREADS', 'MEMORY(MB)'])
topcpu = {}
topmem = {}
time.sleep(0.5)
for p in Create_Process_List():
if p.pid != 0:
topcpu[p] = p.cpu_percent() / psutil.cpu_count()
topmem[p] = p.memory_info().rss
top_cpu_list = sorted(topcpu.items(), key=lambda x: x[1], reverse=True)
top_mem_list = sorted(topmem.items(), key=lambda x: x[1], reverse=True)
top10cpu = top_cpu_list[:10]
top10mem = top_mem_list[:10]
for p, cpu_percent in top10cpu:
try:
with p.oneshot(): # Improves the efficiency of retrieving multiple attributes
process_cpu_table.add_row([
str(p.pid),
p.name(),
p.status(),
f'{cpu_percent:.2f}' + "%",
p.num_threads(),
f'{p.memory_info().rss / 1e6:.3f}'
])
except Exception as e:
pass
process_mem_table = PrettyTable(['PID', 'PNAME', 'STATUS', 'CPU', 'NUM THREADS', 'MEMORY(MB)'])
for p, mem in top10mem:
try:
with p.oneshot():
process_mem_table.add_row([
str(p.pid),
p.name(),
p.status(),
f'{(p.cpu_percent() / psutil.cpu_count()):.2f}' + "%",
p.num_threads(),
f'{mem / 1e6:.3f}'
])
except Exception as e:
pass
os.system('cls') # Use 'clear' on Linux
print(process_cpu_table)
print(process_mem_table)
time.sleep(1)
except KeyboardInterrupt:
print("Stopping")
sys.exit(0)
except Exception as e:
print(e)
if __name__ == "__main__":
main()
Expected output (refreshing every second):
+------+----------+---------+-------+-------------+------------+
| PID | PNAME | STATUS | CPU | NUM THREADS | MEMORY(MB) |
+------+----------+---------+-------+-------------+------------+
| 1234 | python.exe | running | 24.60% | 4 | 18.421 |
| 3096 | Code.exe | running | 0.00% | 22 | 312.884 |
| ... | ... | ... | ... | ... | ... |
+------+----------+---------+-------+-------------+------------+
Key implementation details:
- The
PrettyTablemodule (new in this module) formats console output into readable tables; only a fraction of its formatting capability is used here. - Two tables are built every iteration:
process_cpu_table(sorted by CPU usage) andprocess_mem_table(sorted by memory usage). Each is populated from a Python dictionary (topcpu/topmem) mapping process objects to their utilization values, sorted in descending order, then sliced down to the top 10 ([:10]). with p.oneshot():is used as a performance optimization — it caches multiplepsutilattribute lookups for a process into a single internal call.os.system('cls')clears the console before each redraw (substituteclearon Linux/macOS).- The whole block runs inside a
while True:loop with a 1-second delay (time.sleep(1)), wrapped in atry/except KeyboardInterruptso thatCtrl+Cexits cleanly rather than dumping a traceback.
In the lab run, the top CPU consumer was the Python process running the script itself (around 24–25%), with everything else near 0%. The top memory consumers were, unsurprisingly, several Code.exe (Visual Studio Code) processes, with the single largest instance identified by its specific PID.
Module 6: Monitoring Process Download Behavior with Python
Watching LOLBin Processes
This module extends the previous one, focusing specifically on Living Off the Land Binaries (LOLBins) — legitimate operating system executables — that spawn unexpected child processes or exhibit abnormal upload/download behavior. Nothing here is actually LOLBin-specific at the code level; the same techniques apply equally well to any process you want to track, not only known LOLBins.
Two capabilities are built in this module:
- Reuse the nested process dictionary technique from Module 5, but scoped to a specific process tree rather than the whole system.
- Monitor network traffic per process: total bytes sent/received, and current upload/download speed.
The network-monitoring script relies on the scapy module for packet capture. As noted in the companion Network Activity and Packet Analysis with Python material, scapy is not a high-performance packet capture engine, and may miss packets on very high-speed networks. Two additional modules are introduced for this module: collections (specifically defaultdict) and threading (to run traffic collection and traffic display concurrently).
flowchart TD
A[generate_process_dict proc, parent_pid] --> B{PID == parent_pid?}
B -->|Yes| C[Build nested dict rooted at parent_pid]
C --> D[process_iterator recursively fills children]
D --> E[Nested tree for one LOLBin process only]
Demo: Monitoring a Specific Process Tree
The script is a close derivative of the Module 5 nested-dictionary builder, modified to accept a target parent_pid and only build the tree rooted at that specific process, rather than every root process on the system.
# Import the required libraries
import psutil
import sys, pprint
# Create main process list
def Create_Process_List():
proc = []
for p in psutil.process_iter(['pid', 'name']):
try:
p.cpu_percent()
proc.append(p)
except Exception as e:
pass
return proc
# Iterates through all of the processes and places them in a large nested dict variable
def process_iterator(kids_iterator, key_iterator):
for kids in kids_iterator.children():
if len(kids.children()) == 0:
key_iterator[kids.pid] = [kids, len(kids.children())]
else:
key_iterator[kids.pid] = [kids, len(kids.children()), {}]
process_iterator(key_iterator[kids.pid][0], key_iterator[kids.pid][2])
def generate_process_dict(proc, parent_pid):
processes = {}
for p in proc:
if p.pid == parent_pid: # Only build the tree for the requested parent PID
if psutil.pid_exists(parent_pid): # Prevents errors if the process exits mid-walk
if len(p.children()) == 0:
processes[p.pid] = [p, len(p.children())]
else:
processes[p.pid] = [p, len(p.children()), {}]
kids_iterator = p # Parent process to investigate
key_iterator = processes[p.pid][2] # Reference to nested dict inside processes dict
process_iterator(kids_iterator, key_iterator)
return processes
def main():
# CREATES A NESTED DICT OF ALL THE CURRENT PROCESSES UNDER A SPECIFIC PARENT PID
try:
thisdict = generate_process_dict(Create_Process_List(), 6116) # 6116 = explorer.exe, in the demo
except Exception as e:
print(e)
sys.exit()
pprint.pprint(thisdict)
if __name__ == "__main__":
main()
The only meaningful change from Module 5’s script is the extra parent_pid parameter passed into generate_process_dict(). Instead of walking every root process, the function skips every entry except the one matching parent_pid, then recursively builds out that single tree.
In the lab demonstration, PID 6116 (explorer.exe) is used as the LOLBin of interest. Running the script shows that explorer.exe currently has four direct children, and those children have their own children in turn — five descendants in total for that particular root. If explorer.exe (or any similar LOLBin) were being abused by an unexpected tool, a display like this would immediately reveal where in the spawn tree the unexpected activity is occurring, along with its process ID, so it can be investigated further.
Demo: Monitoring Process Network Communications
The second script in this module shifts focus from spawning behavior to network behavior — watching every active connection on the machine and reporting how much traffic each owning process is sending and receiving, live.
# Reference: https://www.thepythoncode.com/article/make-a-network-usage-monitor-in-python
from scapy.all import *
import psutil
from collections import defaultdict
from threading import Thread
from prettytable import PrettyTable
import os
import time, sys
# Get all local network adapter MAC addresses
local_macs = {iface.mac for iface in ifaces.values()}
# Maps a connection (local port, remote port) to its owning process ID (PID)
connection2pid = {}
# Maps a PID to total [upload, download] traffic in bytes
pid2traffic = defaultdict(lambda: [0, 0])
global Running
Running = True
# Tracks the last-seen traffic totals, used to compute speed
network_stats = {}
def get_size(bytes):
"""Returns a byte count formatted with a human-readable unit suffix."""
for unit in ['', 'K', 'M', 'G', 'T', 'P']:
if bytes < 1024:
return f"{bytes:.2f}{unit}B"
bytes /= 1024
def get_connections():
global connection2pid
global Running
while 1:
# Using psutil, grab each connection's source/destination ports and owning PID
for c in psutil.net_connections():
if c.laddr and c.raddr and c.pid:
connection2pid[(c.laddr.port, c.raddr.port)] = c.pid
connection2pid[(c.raddr.port, c.laddr.port)] = c.pid
if not Running:
break
time.sleep(1)
def process_packet(packet):
global pid2traffic
try:
packet_connection = (packet.sport, packet.dport)
except (AttributeError, IndexError):
# Not a TCP/UDP packet; nothing further to do with it
pass
else:
packet_pid = connection2pid.get(packet_connection)
if packet_pid:
if packet.src in local_macs:
# Source MAC is ours -> outgoing traffic -> upload
pid2traffic[packet_pid][0] += len(packet)
else:
# Otherwise -> incoming traffic -> download
pid2traffic[packet_pid][1] += len(packet)
def print_pid2traffic():
global network_stats
traffic_table = PrettyTable()
traffic_table.field_names = ["PID", "Process Name", "Upload", "Download", "Upload Speed", "Download Speed"]
for pid, traffic in pid2traffic.items():
try:
p = psutil.Process(pid)
except psutil.NoSuchProcess:
continue
name = p.name()
if pid in network_stats.keys():
traffic_table.add_row([
pid, name,
get_size(traffic[0]), get_size(traffic[1]),
"{}/s".format(get_size((traffic[0] - network_stats[pid][0]))),
"{}/s".format(get_size(traffic[1] - network_stats[pid][1]))
])
else:
traffic_table.add_row([
pid, name,
get_size(traffic[0]), get_size(traffic[1]),
"{}/s".format(get_size(0)), "{}/s".format(get_size(0))
])
network_stats[pid] = [traffic[0], traffic[1]]
os.system("cls") # Use 'clear' on Linux
traffic_table.sortby = "Process Name"
print("Process Table:")
print(traffic_table)
def print_stats():
global Running
while 1:
time.sleep(1)
print_pid2traffic()
if not Running:
break
def main():
global Running
printing_thread = Thread(target=print_stats)
printing_thread.start()
connections_thread = Thread(target=get_connections)
connections_thread.start()
mac_filter_list = ""
print("Started sniffing")
for x in local_macs:
if x != "00:00:00:00:00:00" and x != "":
if len(mac_filter_list) == 0:
mac_filter_list = "ether host " + x
else:
mac_filter_list += " || ether host " + x
# Start sniffing, running process_packet() on every captured packet
sniff(prn=process_packet, filter=mac_filter_list, store=False)
print("Exiting")
Running = False
printing_thread.join()
connections_thread.join()
sys.exit()
if __name__ == "__main__":
main()
Breaking down how the pieces fit together:
sequenceDiagram
participant Main as main()
participant Conn as get_connections() thread
participant Print as print_stats() thread
participant Scapy as scapy.sniff()
Main->>Print: Thread(target=print_stats).start()
Main->>Conn: Thread(target=get_connections).start()
Main->>Scapy: sniff(prn=process_packet, filter=mac_filter_list)
loop Every second
Conn->>Conn: psutil.net_connections() -> connection2pid dict
end
Scapy->>Scapy: process_packet(packet) for every captured packet
Scapy->>Scapy: Look up owning PID via connection2pid
Scapy->>Scapy: pid2traffic[pid][0 or 1] += len(packet)
loop Every second
Print->>Print: print_pid2traffic() renders PrettyTable
end
- Startup.
main()spawns two background threads —print_stats(renders the table once a second) andget_connections(refreshes the connection-to-PID map once a second) — then builds a BPF filter string matching every local MAC address, and startsscapy.sniff()withprocess_packetas the per-packet callback. get_connections()usespsutil.net_connections()to enumerate every active socket connection and its owning PID, storing the mapping both ways ((local_port, remote_port)and(remote_port, local_port)) in theconnection2piddictionary. This mapping is not somethingpsutil(orscapy) provides on its own — it has to be built by hand, which is the reason for this helper function.process_packet()runs once per captured packet. Non-TCP/UDP packets (which have no source/destination port) are silently skipped via a caughtAttributeError/IndexError. For TCP/UDP packets, the function looks up the owning PID fromconnection2pid; if found, it checks whether the packet’s source MAC address is one of the local machine’s own adapters. If so, the packet is outbound (upload); otherwise it’s inbound (download). Either way, the packet length is accumulated intopid2traffic[pid][0](upload) orpid2traffic[pid][1](download).print_pid2traffic()builds and prints aPrettyTablefrompid2traffic. For every tracked PID it resolves the process name viapsutil.Process(pid)(skipping PIDs that have since exited), then computes upload/download speed by comparing the current cumulative total against the value recorded the previous time this function ran (stored innetwork_stats). The very first time a PID is seen, speed is reported as0/sbecause there is no previous sample to compare against.
Expected (simplified) output, refreshing once a second:
Process Table:
+------+--------------+---------+----------+--------------+----------------+
| PID | Process Name | Upload | Download | Upload Speed | Download Speed |
+------+--------------+---------+----------+--------------+----------------+
| 3096 | Code.exe | 12.40KB | 128.55KB | 0.00B/s | 1.20KB/s |
| 4210 | Code.exe | 2.10KB | 40.02KB | 0.00B/s | 0.00B/s |
+------+--------------+---------+----------+--------------+----------------+
Why this matters for security. This module’s stated focus was monitoring unexpected download behavior, but the same script covers upload just as well. Any process currently sending or receiving TCP/UDP traffic shows up here automatically — you don’t need to already suspect a specific executable. Beyond a simple console display, this can be extended to fire an alert whenever a specific process (or a maintained list of known LOLBins) is seen transmitting or receiving traffic — particularly useful for processes that are not normally expected to communicate over the network at all.
Summary
This course walked through building a Python-based toolkit for monitoring system services and process activity, structured around six practical capabilities:
- Local and remote system information collection, using
platform,psutil,wmi, andwinappsto gather OS/version data, running processes, resource utilization, and installed applications — the same information used defensively (detecting unpatched or unauthorized software) or offensively (identifying exploitable patch levels). - MySQL server interrogation, using
mysql-connector-python’sconnect()/cursor()pattern to enumerate users, databases, tables, and rows, treating unauthorized MySQL access the way a port scanner treats open ports — start shallow, then probe deeper. - DNS record collection, using
dnspython’s resolver to look upA,MX, andPTRrecords, filling in context that a bare IP address or hostname alone doesn’t provide. - IP address geolocation and ownership, using
ipapifor location data, a simple HTTP call to Shadowserver for ASN/organization ownership, andwebbrowserto visualize the result on a map — while remembering that registered location is not the same as physical location. - Nested process-tree monitoring, using
psutil.process_iter()combined with a recursive dictionary builder to visualize exactly which process spawned which, at any depth, with helper functions to search for a specific PID or count descendants under a parent. - Process network-behavior monitoring, combining
psutil.net_connections()withscapypacket capture (run on separate threads) to attribute live upload/download traffic — and traffic speed — back to the specific process responsible for it.
Quick-reference: modules and their purpose
| Module | Used for | Introduced in |
|---|---|---|
platform | Local OS/hardware/Python version identification | Module 1 |
psutil | Process listing, CPU/memory utilization, active network connections | Modules 1, 5, 6 |
wmi | Local and remote Windows system queries (OS, computer system, network adapters) | Module 1 |
winapps | Enumerating installed applications and their versions | Module 1 |
mysql-connector-python | Connecting to and querying a MySQL server | Module 2 |
dnspython | DNS record resolution (A, MX, PTR, etc.) | Module 3 |
ipapi | IP/hostname geolocation lookups | Module 4 |
requests | HTTP calls for ASN/ownership lookup (Shadowserver) | Module 4 |
webbrowser | Launching a browser to visualize a location on a map | Module 4 |
ipaddress, socket | Validating and resolving IP addresses/hostnames | Module 4 |
pprint | Readable dictionary/console output | Modules 1, 2, 4, 5, 6 |
prettytable | Formatted console tables | Modules 5, 6 |
scapy | Packet capture for network traffic attribution | Module 6 |
threading, collections (defaultdict) | Concurrent collection/printing and default-valued traffic tracking | Module 6 |
Study checklist
- Can explain the difference between defensive and offensive uses of the same collected system information.
- Can use
platform.uname()and related calls to fingerprint a local system. - Can use
psutil.process_iter()to list processes with selected attributes (pid,ppid,name,username). - Can read and interpret per-process and system-wide CPU/memory statistics from
psutil. - Understands the WMI classes
Win32_OperatingSystem,Win32_ComputerSystem, andWin32_NetworkAdapterConfiguration, and how to query them both locally and remotely. - Can connect to a MySQL server with
mysql-connector-pythonand enumerate users, databases, tables, and rows. - Can perform
A,MX, andPTRDNS lookups withdnspython. - Can resolve an IP’s registered location and owning organization with
ipapiand a Shadowserver ASN lookup, understanding that this reflects registration, not physical location. - Can build a recursive nested dictionary representing a full process spawn tree, and search/count within it.
- Can build a live top-N CPU/memory monitor using
PrettyTableandpsutil. - Can explain what a LOLBin is and why monitoring its spawned children matters.
- Can explain how
scapypacket capture is combined withpsutil.net_connections()to attribute network traffic to a specific process, including the distinction between upload and download.
Search Terms
python · system · services · activity · monitoring · linux · systems · administration · networking · security · information · collecting · process · server · behavior · dns · local · mysql · servers · abnormal · interacting · owner · processes · psutil