Back to field notes

Field Note

Hunting Anomalous Python Execution

Endpoint investigation Process, network, and file telemetry

A renamed Dropbox updater, a year-old staging link, and a prayer; surfacing CobaltStrike one hypothesis at a time.

#cobaltstrike#sideloading#dfir#c2

Observed signals

  • CobaltStrike
  • Renamed PythonW
  • Sideloading python310.dll

It’s rare that a threat hunt goes according to plan.

Threat hunts generally fall into two categories: IOC-driven and hypothesis-driven. The latter has always been the more interesting approach. Rather than chasing known indicators, the goal is to ask a simple question:

If an adversary wanted to accomplish X, how would they do it, and what evidence would that leave behind?

Today’s field note started with exactly that question.

Background

Python-based malware has always been an interesting niche. Because so much legitimate software embeds the Python runtime, it presents attackers with plenty of opportunities to blend into otherwise benign environments.

The hypothesis for this hunt was straightforward:

pythonw.exe launched without arguments should be rare, predictable, and therefore worth investigating.

If that assumption proved wrong, the hunt would still validate an understanding of how legitimate Python applications behave.

pythonw.exe is simply the windowless Python interpreter. Unlike python.exe, it doesn’t create a console window, making it ideal for GUI applications like IDLE or software such as DWAgent.

Under normal circumstances, though, Python is expected to execute something:

pythonw.exe myscript.py

If no script or module is supplied, execution has to originate somewhere else— and those alternate execution paths make excellent hunting opportunities.

The two most common startup hooks are:

  • .pth files
  • sitecustomize.py

Both execute arbitrary Python code automatically during interpreter initialization. Think of them as startup hooks built directly into Python.

A Renamed PythonW

The initial query was fairly simple:

FROM logs-*
| WHERE process.name.caseless: "pythonw.exe"
   OR process.pe.original_file_name.caseless: "pythonw.exe"
| WHERE process.args_count == 0
| WHERE organization.name != "Validation Org"
| KEEP @timestamp, ..., process.command_line.caseless,
       process.name, process.pe.original_file_name,
       process.args_count
| WHERE process.name != "dwagent.exe"

The results were mostly noise—quirks in telemetry surrounding what actually constitutes an argument— but one executable immediately stood out:

C:\Program Files\Dropbox\DropboxUpdater\DropboxUpdater.exe

At first glance, everything looked legitimate. The executable had been present on multiple endpoints for months and lived alongside version metadata, updater state, and other files one would reasonably expect from a Dropbox updater.

The directory also contained two notable files:

  • a renamed pythonw.exe
  • python310.dll

More importantly, it lacked both of the expected startup mechanisms.

If the interpreter wasn’t receiving a script and wasn’t using Python’s built-in startup hooks, only a handful of execution paths remained.

One particularly interesting possibility was python310.dll itself.

Since pythonw.exe transfers execution into the DLL through Py_Main, replacing or hijacking that DLL provides a straightforward way to redirect execution while leaving the executable itself completely untouched.

Rapid Pivots

When the obvious execution mechanisms disappear, the next question becomes whether the supporting binaries are actually legitimate.

The SHA-256 for python310.dll was:

29cabe1e75454764200c28b9b40951c3e97b995fe7aefcaa293e774d6c45a535

Python runtimes are everywhere. They ship with countless applications, are routinely analyzed, and are heavily represented across public malware repositories.

A Python runtime with no presence in VirusTotal is unusual enough that it swiftly became the primary lead.

Binary Ninja Time

The sample didn’t hide its intentions for very long.

A simple strings pass immediately exposed the staging URL:

tronconse[.]org/icons/favicon.ico

Using Binary Ninja’s MCP integrations with Claude swiftly recovered the XOR key protecting the staged payload:

AA BB CC DD EE FF 11 22
33 44 55 66 77 88 99 00

Subtle.

One assumption at this point was that the infrastructure had likely gone dark. The sample had apparently existed in the environment for nearly nine months with very little observable activity, making it unlikely the staging server would still return anything useful.

Surprisingly, it did.

The response certainly wasn’t a favicon.

After applying the recovered XOR key, the payload immediately revealed itself as an x86-64 reflective loader. Rather than continue manually reversing the sample, several Cobalt Strike configuration extractors were thrown at it, eventually producing a clean result with SentinelOne’s CobaltStrikeParser:

https://github.com/Sentinel-One/CobaltStrikeParser

The beacon itself wasn’t especially novel. It reused the staging infrastructure, employed a jQuery Malleable C2 profile, and retained the default dllhost.exe spawnto configuration.

What was interesting was how it had been discovered. The hunt began with nothing more than a hypothesis about how a legitimate Python interpreter ought to behave, and concluded with the discovery of a dormant, previously undetected Cobalt Strike beacon whose staging infrastructure was still happily serving payloads.

Lessons Learned

  • Hypothesis-driven hunts often answer questions you weren’t asking. A hunt for anomalous Python startup behavior ultimately uncovered a previously undetected Cobalt Strike beacon.
  • Well-defined hypotheses naturally translate into detection logic. If suspicious behavior can be described clearly, it can usually be queried.
  • Missing artifacts can be just as valuable as present ones. In this case, the absence of Python’s expected startup hooks was the strongest signal in the entire investigation.