Skip to content

report: npm/app-soda-layer 2.1.6 (credential + wallet stealer, SSH backdoor) - #1405

Open
rbnfrhnr wants to merge 9 commits into
ossf:mainfrom
rbnfrhnr:report/npm-app-soda-layer
Open

report: npm/app-soda-layer 2.1.6 (credential + wallet stealer, SSH backdoor)#1405
rbnfrhnr wants to merge 9 commits into
ossf:mainfrom
rbnfrhnr:report/npm-app-soda-layer

Conversation

@rbnfrhnr

Copy link
Copy Markdown

Adds a report for app-soda-layer@2.1.6 (npm).

The package was published 2026-07-27T18:47:46Z and unpublished by its author 2026-07-27T21:13:07Z — an availability window of 2h25m. It is no longer retrievable from the registry, so the source in this report was reconstructed from a system-call trace of an installation performed in an isolated sandbox during that window.

Behaviour

  • Runs on npm install via postinstall.
  • Reads Solana CLI keypairs (id.json), config.toml and .env files and uploads their contents, with the HTTP client's size limits explicitly removed.
  • Requests an SSH public key from its control server and appends it to ~/.ssh/authorized_keys, corrects ~/.ssh ownership so sshd will honour it under StrictModes, then attempts ufw allow 22/tcp. Implant success is reported back to the operator.
  • The file patterns it collects are not in the package — they are fetched from the control server at install time, so the published artifact can be retargeted without republishing.

Control server still active

The three control endpoints on 95.216.118.146:3001 were requested directly and returned HTTP 200 on both 2026-07-28T06:45:57Z and 2026-07-28T11:57:24Z — roughly 9.5 and 15 hours after the package was withdrawn. Responses were byte-identical between the two fetches (sha256 recorded in the report). The infrastructure is operated, not abandoned.

Those requests were GET-only from a disposable host; no request body was sent, and the upload endpoints (/api/v1) were deliberately not contacted.

This means the report includes the actual SSH public key the malware implants, so anyone affected can test their own authorized_keys for it directly, and the actual target list (seed, phrase, wallet, privatekey, .xls/.xlsx, .env).

Note on the key's comment field

The retrieved key carries a comment naming a third-party company's support address. An SSH key comment is arbitrary, unvalidated text chosen by whoever generated the key; it is camouflage and is not evidence of involvement by that organisation. The report states this inline, immediately below the key, because the entry will syndicate and the disclaimer needs to travel with it.

Conventions

  • id omitted (assigned automatically); summary omitted (overridden).
  • affected[].versions pinned to 2.1.6 rather than an open range — the registry record shows the package name and its only version were created 284ms apart, so no other version ever existed.
  • IOCs split into ips (host) and urls (full endpoints) per docs/schema_additions.md.

Full technical write-up, including the recovered source and the syscall evidence: https://protet.io/disclosures/app-soda-layer-2.1.6

…ckdoor)

Signed-off-by: Robin Frehner <frehner.robin@gmail.com>
@rbnfrhnr
rbnfrhnr force-pushed the report/npm-app-soda-layer branch from 5a2c7d8 to d151ad3 Compare July 28, 2026 12:02
Signed-off-by: Robin Frehner <frehner.robin@gmail.com>
@rbnfrhnr

Copy link
Copy Markdown
Author

Updated to redact the SSH key's comment field.

The retrieved key carried a comment impersonating the support address of a real, unrelated company. On reflection I don't think it belongs in a public malware record: naming the impersonated company attaches it to this entry for no detection benefit, since the base64 body is the correct thing to match on regardless — the comment can be changed without changing the key.

The key body itself is unchanged and complete. The report notes that the comment is redacted deliberately rather than unknown, and that the unredacted line stays retrievable from /api/ssh-key on the reported host for anyone who wants to verify it independently.

The corresponding write-up has been updated the same way.

@elitsa-gosst

Copy link
Copy Markdown
Collaborator

Hi, thanks for your contribution to the Malicious Packages repo!

We're always excited to have more contributors! :)

}
}
],
"details": "`app-soda-layer@2.1.6` executed a credential and cryptocurrency-wallet collector, and installed a remote-access backdoor, on `npm install`. It was published 2026-07-27T18:47:46Z and unpublished by its author 2026-07-27T21:13:07Z, an availability window of 2 hours 25 minutes. It is no longer retrievable from the registry; the source below was reconstructed from a system-call trace of an installation performed in an isolated sandbox at 2026-07-27T20:58:33Z.\n\nThe registry metadata record that survives the unpublish shows the package name created at `18:47:46.475Z` and its only version published at `18:47:46.759Z` — 284 milliseconds apart, with no prior release. This was not a compromise of an established package; it was purpose-built, and the version number `2.1.6` was chosen to suggest a release history that never existed.\n\n## Trigger\n\n`package.json` declared `\"postinstall\": \"node test.js\"`. No import or invocation by the installing project was required. `test.js` called two routines in `index.js`.\n\n## Routine 1 — fixed-target collection\n\n`from_str_1()` recursively scanned `process.cwd()` for the fixed patterns `id.json`, `config.toml`, `Config.toml`, `env` and `.env`, and POSTed the **contents** of each match to `http://95.216.118.146:3000/api/v1`, prefixed with the value of `$USER`. `maxContentLength` and `maxBodyLength` were both set to `Infinity`, removing the HTTP client's default request-size limits. `id.json` is the default keypair filename for the Solana command-line wallet and contains private key material; `config.toml` is that tool's configuration file.\n\n## Routine 2 — C2-directed collection and SSH backdoor\n\n`from_str_2()` first requested three control endpoints on port 3001 of the same host:\n\n- `http://95.216.118.146:3001/api/ssh-key`\n- `http://95.216.118.146:3001/api/scan-patterns`\n- `http://95.216.118.146:3001/api/block-patterns`\n\nThe file patterns it collects are therefore **not present in the package** — they are supplied by the operator at install time, so the same published artifact can be retargeted without republishing.\n\nThe value returned by `/api/ssh-key` was appended to the user's `~/.ssh/authorized_keys` by `addSshKeyToUser()`, which then ran `sudo chown -R <user>:<user> ~/.ssh` (required, because `sshd` ignores an `authorized_keys` file with incorrect ownership under the default `StrictModes` setting), followed by `sudo ufw enable` and `sudo ufw allow 22/tcp` to permit inbound SSH. Whether the key was installed successfully was reported back to the operator in the metadata accompanying the upload. The function contains an idempotency guard so repeated installs do not append duplicate entries.\n\nMatched files were then swept from the user's home directory (or, on Windows, every drive letter enumerated via `wmic logicaldisk get name` with a PowerShell fallback) and uploaded to `http://95.216.118.146:3001/api/v1` as multipart form data in batches of up to 100 files per request.\n\n## Control-server contents\n\nThe control endpoints were requested directly on 2026-07-28T06:45:57Z, roughly nine and a half hours after the package was withdrawn. All three returned HTTP 200, establishing that the infrastructure was operated rather than abandoned. Requests were GET-only from a disposable host; no data was sent, and the upload endpoints were deliberately not contacted.\n\n`/api/ssh-key` returned the following key, which is the backdoor an affected operator can test for directly:\n\n```\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYMx8MqdYTD/aZjqxmXo+9460+9EvsSjfiy9YAU+xwY <comment redacted>\n```\n\nThe key's comment field has been redacted here because it impersonates the support address of a real, unrelated company. An SSH key comment is arbitrary text chosen by whoever generated the key, is never validated, and carries no relationship to the address it names; it is best read as camouflage. It is omitted deliberately rather than unknown, so that this entry does not attach an uninvolved company to a malware record. **Match on the base64 body, which is the correct indicator regardless** — the comment can be changed without changing the key. The unredacted line remains retrievable from `/api/ssh-key` on the host below by anyone wishing to verify it.\n\n`/api/scan-patterns` returned the target list, matched as a case-insensitive **substring** of the filename:\n\n```\n[\".env\", \".xls\", \".xlsx\", \"privatekey\", \"private key\", \"seed\", \"wallet\", \"key\", \"phrase\", \"private\"]\n```\n\n`seed` and `phrase` target cryptocurrency wallet mnemonic recovery phrases; the spreadsheet extensions target the common practice of recording such phrases in spreadsheets. `/api/block-patterns` returned a 60-entry exclusion list (dependency directories, caches, source-code extensions, images, binaries) that suppresses the bulk of a developer machine's file count while retaining documents, spreadsheets and anything named for a key or a phrase.\n\nThese lists reflect what the host served on 2026-07-28 and are operator-controlled; they are not necessarily what was served during the package's availability window.\n\n## Concealment\n\nThe `readme.md` was copied verbatim from an unrelated, permissively licensed open-source project and bore no relation to the shipped code. The package declared no description, author, repository or homepage. `child_process` and `os` were listed as dependencies despite being Node.js built-ins. Windows enumeration suppressed subprocess output via `stdio: [\"ignore\", \"pipe\", \"ignore\"]`, `-NoProfile` and `windowsHide: true`. Every stage was wrapped in exception handlers that discard errors, so a failed collection would not cause a visibly failed install.\n\n## Remediation\n\nAny environment where this package appears in a lockfile, CI log or `node_modules` tree dated 2026-07-27 should be checked for the SSH key above in `~/.ssh/authorized_keys`, on every account — a match indicates host access rather than only credential exposure. Firewall state should be checked for port 22 having been newly permitted. Solana keypairs, SSH private keys and any secrets in `.env` files reachable from the install directory should be rotated. Because routine 2's targets were supplied by the control server, the file list above should not be treated as exhaustive.",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: can you please make the description more concise?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Signed-off-by: Robin Frehner <frehner.robin@gmail.com>
@rbnfrhnr

rbnfrhnr commented Jul 29, 2026

Copy link
Copy Markdown
Author

Thanks for the review, and for the welcome!

The description indeed was far longer than anything else in the repo. I've cut it from ~6,200 to ~1,800 characters, keeping only what a consumer of the entry needs: what it does on install, the operator-supplied targeting (the file patterns aren't in the package, they're fetched from the C2 at install time), and the implanted SSH key as a checkable indicator.

Happy to trim further if it's still too long.

@rbnfrhnr

Copy link
Copy Markdown
Author

One more update, and this one changes the framing.

This payload is not new. It matches the levex-refa / lint-builder toolkit from the February 2026 dev-protocol GitHub organisation compromise, and the same toolkit was reported here against different infrastructure in #1366 (polymarket-mcp-v2@2.1.6, C2 170.205.31.203). The overlap is in the distinctive parts: the exported from_str_1 / from_str_2 names, the exact authorized_keyschown -Rufw enableufw allow 22/tcp sequence, the /api/ssh-key /api/scan-patterns /api/block-patterns /api/v1 endpoint names on port 3001, routine 1's id.json / config.toml / .env list, and both July packages published as version 2.1.6.

So the package and the C2 are new, the payload is a redeployment. I've added a short paragraph to details saying so, plus the StepSecurity article as a reference. Happy to trim elsewhere if that pushes it past what you'd like — it's now ~2.6k characters.

One difference worth recording: the sibling obfuscates identifiers with reversed string literals ("nosj.di".split("").reverse().join("")), whereas the source recovered here has none. Different builds of the same tool.

Also, the SSH public key in this report was not published in either prior write-up, so if it's useful to cross-reference against #1366 that comparison would establish a common operator directly rather than by code similarity.

@elitsa-gosst

Copy link
Copy Markdown
Collaborator

Can you please trim it a bit more and make it more human-readable? Here's a recent example of a report, which is of a good quality: #1395

We haven't yet published a guideline for contributing to the repo, especially in regards to using AI, but the reports should still be written with a human user in mind.

…rence

Signed-off-by: Robin Frehner <frehner.robin@gmail.com>
@rbnfrhnr

rbnfrhnr commented Aug 3, 2026

Copy link
Copy Markdown
Author

Trimmed the details to two prose paragraphs in the style of #1395 and dropped the inline link, since the writeup is already in references. This report complements the Amazon Inspector record already on the package rather than repeating it: it adds attribution to the levex-refa/lint-builder toolkit from the February 2026 dev-protocol org compromise (with the cross-reference to #1366, polymarket-mcp-v2), the injected SSH public key that the Amazon finding notes is fetched but does not publish, and the file-target list retrieved from the live C2. Happy to trim further if you would like.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants