A Poisoned Release Pipeline Turned an AI Memory Plugin Into a Self-Spreading Credential Stealer

On September 23 an attacker published malicious releases of two MemTensor packages that give AI agents long-term memory: the npm plugin @memtensor/memos-cloud-openclaw-plugin and the PyPI library MemoryOS. Both carried the same cross-platform Go implant, tracked as sckit, which harvests credentials from the home directory and from CI job environments and ships them to hosts under skyleen[.]fr.

The payload is unremarkable as malware. The delivery is not. According to SafeDep’s analysis, the attacker did not need to steal a maintainer’s laptop token. They pushed commits that made the project’s own GitHub Actions release jobs hand over the npm and PyPI publish tokens, in a step that ran before the publish step — turning a legitimate, project-owned release pipeline into the credential-exfiltration stage of its own compromise.

What shipped, and what is still reachable

The malicious npm versions are 0.1.21, 0.1.23 and 0.1.25; 0.1.22 and 0.1.24, published minutes apart between them, contain clean 0.1.20 content. On PyPI, MemoryOS 2.0.34 is the single malicious release.

We checked both registries directly while writing this. The npm package now lists latest as 0.1.24, and 0.1.21, 0.1.23 and 0.1.25 no longer appear in its version list; the npm metadata still shows the original publish timestamps, from 02:23:04 UTC (0.1.21) to 04:36:58 UTC (0.1.25) on September 23. On PyPI the newest available MemoryOS release is 2.0.33, published September 3, with no 2.0.34 artifacts served. The Hacker News reports that MemoryOS was quarantined on PyPI and that the malicious versions were subsequently removed from both registries.

Removal from a registry does not clean a lockfile, a warm CI cache, a container layer, or a vendored directory. Anyone who installed during the roughly two-hour window should treat the artifacts as still present in their environment until they have verified otherwise.

The publish token was stolen by a script that ran one step early

The npm release workflow reads secrets.NPM_TOKEN into the publish step and accepts a workflow_dispatch input naming an exact 40-character commit to build. SafeDep documents the attacker creating, pushing and deleting a short-lived sc/release-* branch five times, then having the workflow build one of those commits.

The added code is three lines in a release-validation script that runs earlier in the same job. It appends a BASH_ENV entry to $GITHUB_ENV, pointing at an attacker-supplied shell file. Every later step in the job inherits that variable, and Bash sources the named file before running any non-interactive script — so the attacker’s bridge script executed inside the publish step, checked that it was really the publish step by testing the package name and a distinctive 150-second timeout value, passed the token to the implant, deleted itself, and exited non-zero.

The PyPI side is the same trick wearing a different costume. Commit b52958f changed pyproject.toml to load a repository-local build backend, sckit_poetry_build, which wrote the same BASH_ENV pointer on import. The resulting bridge fired only inside pypa/gh-action-pypi-publish’s container — it tests that $0 is /app/twine-upload.sh — captured INPUT_PASSWORD, then unset the token variables and exited 0 so nothing was actually uploaded. About two hours later a follow-up commit removed the single registration call with the message “allow native PyPI upload,” the v2.0.34 tag was deleted and recreated on it, and MemTensor’s own workflow built and uploaded the malicious package with the project’s real token 62 seconds later. A deleted tag named v2.0.34-capture-1 is consistent with SafeDep’s reading that the first run existed only to capture the token.

Two structural lessons sit in that sequence. First, any script a release job runs before the publish step can rewrite the environment of the publish step; $GITHUB_ENV plus BASH_ENV is a supported mechanism, not a bug. Second, both repositories built commits that were never merged to main, which means tag protection and a required reviewer on the release environment would have stopped the run before any token was in scope. SafeDep notes the Actions API returns no workflow runs for either repository after September 7, so the run logs appear to have been deleted, and says it could not confirm how the attacker obtained push access under the Memtensor-AI account.

The trigger is the agent’s hot path, not an install hook

This is where the choice of target matters. The npm package is a memory plugin for an agent gateway. The implant is launched at gateway startup and again on every memory-recall event, spawned detached with the full process environment — and, on recall, with the user’s prompt text passed in as SCKIT_EVENT_TEXT. The Python library starts the binary the first time logging is configured, which nearly every import path does.

No install hook is involved, so --ignore-scripts offers no protection. The independent reporter who opened issue #173 at 04:17 UTC — noting that the published versions matched no commit or tag in the repository — also observed a second function reading NPM_TOKEN and NODE_AUTH_TOKEN explicitly, and states the analysis was static only.

A component that sits between an agent runtime and its memory store runs inside a process that already handles user input and already holds credentials. The unpacked npm artifact grew from roughly 272 KB to about 43 MB, and the Python wheel from 951 KB to 19 MB, because six platform-specific binaries were bundled. Size alone was a detectable signal here; most teams were not watching for it.

Collection, worming, and operator targeting

The implant’s Go build metadata names the module supplychain.local/campaign, and its function and string tables describe the behavior plainly:

  • Credential collection across .npmrc, .pypirc, .git-credentials, .netrc, SSH private keys, .vault-token, MSAL token caches and access_tokens.json, plus regular expressions matching JWTs, AWS AKIA/ASIA keys, GitHub and GitLab tokens, npm and PyPI tokens, Hugging Face and HashiCorp Vault tokens, and Slack, Stripe and SendGrid keys. The campaign configuration sets inventory_roots to the whole home directory.
  • Self-propagation through functions such as findRepositories, PrepareRemoteRepository and recursivePublish, with embedded loader templates for Python packages, Node packages, and a GitHub Actions workflow saved as runtime-update.yml. SafeDep found no packages beyond MemTensor’s, but the capability is there and that list can grow.
  • Operator-grade C2 using CBOR, X25519 key exchange and XChaCha20-Poly1305 with signed leases and run-time-delivered modules, plus host-profiling fields named geo_iso, deny_iso and ja4_deny that imply victim filtering by country and TLS fingerprint, and scheduleSelfDelete for cleanup. The configuration carries a not_after value decoding to late October 2026.

Version 0.1.25 also bundled a CA root file and pointed SSL_CERT_FILE at it — a detail that only makes sense if the operator expected victims in slim containers with no system trust store. That is an assumption about CI runners, not developer laptops.

What defenders should do now

  • Pin to a known-clean baseline and verify what is installed. Use 0.1.24 or 0.1.20 for the npm plugin and 2.0.33 for MemoryOS. Check lockfiles, container images and CI caches rather than only the registry’s current state.
  • Treat every secret reachable from $HOME and from affected jobs as exposed. Rotate npm and PyPI tokens, GitHub and GitLab tokens, SSH keys, cloud CLI credentials, Vault tokens and .env secrets. On the npm path the implant also received prompt text, so review what your agents were handling during the window.
  • Hunt for the specific artifacts. Kill any sckit process; remove $HOME/.openclaw/.cache/runtime and $HOME/.memos/.cache/runtime; block skyleen[.]fr and its subdomains at egress; and search repositories you can push to for an unexpected runtime-update.yml workflow or a .sckit/ directory.
  • Audit release jobs for pre-publish execution. Assume any step that runs before publishing can control the publish step through $GITHUB_ENV. Move validation into a separate job that never sees the token, or run it on a pinned, reviewed revision.
  • Adopt trusted publishing and provenance. Short-lived OIDC credentials remove the long-lived token from the job entirely, and attestations would have made artifacts built from an unmerged commit visibly anomalous. Neither MemTensor package had provenance attestations.
  • Protect tags and release environments. Require review for tag creation and for deploys to the publishing environment. A deleted-and-recreated tag pointing at an unmerged commit should never be able to reach a registry.
  • Watch artifact size and file-count deltas. A 150× jump in unpacked size between patch releases is a cheap, high-signal alert that does not depend on recognizing the payload.

The AI ecosystem keeps supplying attackers with components that combine high install counts, deep process placement, and maintainers who are shipping fast. We have covered that pattern through the Shai-Hulud worm’s runs at npm and PyPI and through workflow-permission abuse in AI coding integrations. What is sharpest here is the inversion: the release pipeline, the thing meant to prove an artifact’s origin, was the instrument that surrendered the credential. Until publishing identity is short-lived and artifacts carry verifiable provenance, “it came from the project’s own CI” means only that the attacker got there first.

Sources: