maliciousCovers 1.2.0Reviewed by BitbisonUpdated 2026-10-28
setup.py drops ~/.cache/.pysvc/.agent.py and starts it detached during the build. The agent polls a GitHub gist every 5 seconds, runs the commands it finds, and uploads any file it is told to through GitHub's API. OSV MAL-2026-18343.
On October 27, 2026, numprint 1.2.0 was published to PyPI as "tiny dependency-free helpers for multiplication tables and parity checks". It was the project's only release. Its setup.py installs a remote access trojan that takes its orders through GitHub gists. The registry built it two minutes after it went up, and the trojan started inside the build. OSV lists it as MAL-2026-18343. PyPI has since removed the project.
What the analysis saw
The registry's detonate worker built numprint 1.2.0 from its source distribution (sha256 55a2c1e71513b89a1b6046108f1652e964e8a8525091057265ca3bcad718d3e6) on October 27 at 16:17 UTC. The capture shows this:
- pip ran the package's PEP 517 hook (
get_requires_for_build_wheel), which executes setup.py. In the same millisecond that the hook read setup.py, it created ~/.cache/.pysvc/, wrote .agent.py.tmp there and renamed it to .agent.py.
- A detached process then started
/usr/bin/python3 ~/.cache/.pysvc/.agent.py (pid 2377), with its output going to ~/.cache/.pysvc/.log. It took a lock file, .lock, resolved api.github.com and opened five HTTPS connections to it between 16:17:43 and 16:17:49.
setup.py runs again for each of pip's hooks and for the editable install, so the agent was started eight times. The seven later copies found the lock and exited within about 70 ms.
- The capture ended at 16:17:51 with the first agent still running. In those eight seconds the agent read none of the credentials planted in the build environment and started no command.
The first analysis (tarp 0.1.0+20261027055608-g559e05360a6b) rated the build Suspicious, with one Medium finding: "Build script reached api.github.com". It did not tie the connection to a program the build script had written. The registry then taught tarp that pattern using this capture: a build script that runs a program it wrote outside every tree the build owns is High. On October 28 the stored capture was analyzed again with tarp 0.1.0+20261028052216-g1a0ac221a3f8. The build is now Flagged, with "Build script ran code it wrote outside the build" (High) beside the egress finding.
What the code does
All of this is in setup.py (sha256 160f4a5068f56e38a9077a5c132c3c5b9df3d6e1ab401f29e40b4f2ee243edf8, 919 lines, taken from the build's own record of its inputs). Nothing in pyproject.toml needs it. The file's docstring and its setup() call name a different package, "py-sysinfo 1.0.1", which suggests a reused template.
- Line 909 calls
_run_maintenance_task() at import, so the dropper runs every time pip, setuptools or an editable install loads setup.py. Errors are swallowed so the install never fails (line 906).
- Lines 866–875 write the embedded agent to
~/.cache/.pysvc/.agent.py through a .tmp file and an atomic rename.
- Lines 877–899 fork, call
setsid and fork again, so the agent is detached from pip. It points its output at ~/.cache/.pysvc/.log, changes to / and execs it. Lines 851–863 pick /usr/bin/python3 over pip's temporary environment "so the daemon survives pip's build-environment teardown".
- Lines 9–848 are the agent, which calls itself the "ghc2 agent - GitHub gist transport":
- It authenticates to
api.github.com with a GitHub personal access token embedded in the file. The token is split into a hex-escaped prefix and four pieces "so the credential shape never appears in this file's plain text" (lines 31–35).
- It enrolls in an operator gist,
3775fd9235aa6ef29e96eb96274a0e34 (line 36). It adds itself under an ID built from the machine's boot ID and hostname (lines 148–161), waits to be assigned a gist of its own (lines 182–221) and posts its hostname there (lines 223–226).
- Every 5 seconds (line 48) it reads
cmds.txt from its gist. It runs each command with sh -c in its own process group, for up to 30 minutes, and writes the output back to out.txt (lines 252–321, 746–803). Messages are signed with an HMAC key shared with the operator (lines 47, 72–96).
- A
pull command archives any path on the machine, up to 64 MB compressed. It uploads the archive to a repository the operator names, on the branch runs/<client id>, through GitHub's Git Data API (lines 554–743).
- A lock keeps one copy running (lines 165–176, 805–809). Its requests send the user agent
svc-notifier/1.0 (line 114).
In the registry's sandbox the agent got as far as enrolling. Anywhere else, whoever held the operator's gist could run commands as the installing user and copy any file that user can read. That covers a developer's machine and a CI runner, with whatever secrets either holds.
Timeline (UTC)
| When | What |
| 10/27/26 16:15:18 | numprint 1.2.0 is published to PyPI, the project's only release |
| 10/27/26 16:17:38 | The registry's detonate worker starts building it |
| 10/27/26 16:17:43 | setup.py writes ~/.cache/.pysvc/.agent.py and starts it detached; its first request to api.github.com follows 77 ms later |
| 10/27/26 16:17:49 | Fifth and last request to api.github.com in the capture |
| 10/27/26 16:17:53 | First analysis: Suspicious (Medium: "Build script reached api.github.com") |
| 10/27/26 16:48:48 | OSV publishes MAL-2026-18343, reported by Kamil Mańkowski |
| 10/27/26 23:47 | tarp gains the rule this capture showed was missing |
| 10/28/26 05:22 | tarp 0.1.0+20261028052216 is built for the registry's workers |
| 10/28/26 07:18 | The stored capture is analyzed again: Flagged (High) |
| 10/28/26 | numprint returns 404 on PyPI |
If you installed it
Treat the machine as compromised. Stop any python3 ~/.cache/.pysvc/.agent.py process and remove ~/.cache/.pysvc/. Then rotate every credential the user or the CI job could read, and check GitHub for repositories or branches named runs/… that you did not create.
Indicators
- hosts
api.github.com- requests
GET https://api.github.com/gists/3775fd9235aa6ef29e96eb96274a0e34User-Agent: svc-notifier/1.0- branches
runs/<client id>- files
~/.cache/.pysvc/.agent.py~/.cache/.pysvc/.agent.py.tmp~/.cache/.pysvc/.log~/.cache/.pysvc/.lock- processes
/usr/bin/python3 ~/.cache/.pysvc/.agent.py
Examined
- 1.2.0
sha256:55a2c1e71513b89a1b6046108f1652e964e8a8525091057265ca3bcad718d3e6
Links