v vanemmerik.ai / SUPPLY-CHAIN
Supply Chain · Watch Friday · 31 July 2026 End-of-day synthesis 4 watches · 20 items

From the watchtower — what crossed the wire today.

A four-times-a-day standing watch on the open-source supply chain. Each pass pulls newly disclosed CVEs, freshly catalogued KEV adds, and active attacks reported in the wild — then ranks them by severity for the day.

The story of the day — A live ad-script supply chain attack is siphoning cryptocurrency from every site running Adform's tags, landing the same day as five critical disclosures — a CMS, a low-code platform, a Kubernetes admission webhook, a game-hosting daemon, and AWS Amplify — plus a late-arriving second and third Apostrophe advisory showing the CMS's trust-boundary problem wasn't a one-off.

Today's headline isn't a CVE, it's an active incident: Adform's ad-serving script has been swapping cryptocurrency wallet addresses in visitors' clipboards for at least a week, with no public disclosure from the vendor. Every site embedding Adform's tags has been quietly redistributing that payload to its own visitors the whole time — the textbook shape of a third-party-script supply chain attack, invisible from server-side logs.

Five unrelated critical disclosures landed independently on top of that, and the pattern rhymes even though the campaigns don't: Apostrophe CMS's prototype pollution disables authorization process-wide from one authenticated write, NocoBase's SQL injection chains an anonymous self-signup into Postgres superuser RCE, Bank-Vaults' admission webhook trusts an attacker-supplied Vault address and hands out cluster-wide ServiceAccount tokens, Pterodactyl's Wings daemon leaks its own auth token through a template placeholder a tenant can smuggle in, and AWS Amplify Studio's codegen package turns unsanitized component schemas into arbitrary JS at build time. None of these share an origin — they share a shape: something downstream trusted an input it shouldn't have. The bright spot is process, not code: NLTK and Thumbor each shipped batched disclosures — four and six advisories respectively — reading like a single coordinated security sweep of each project's trust boundary, published all at once instead of trickled out.

Late escalation at 21:00 ET: Apostrophe's trust-boundary problem turned out not to be a single bug. Two more advisories from the same release window surfaced after First Watch — a stored XSS in @apostrophecms/seo where an admin-set Google Analytics/GTM ID renders as unsanitized script, and an unauthenticated SSRF in @apostrophecms/file's pretty-URL handling via the Host header. Same vendor, same day, three packages.

→ Operational priority for the night if you run Adform ad tags anywhere, pull them now and check outbound clipboard/network behavior in any cached browser sessions from the past week; if you run Apostrophe CMS, patch core, @apostrophecms/seo, and @apostrophecms/file together — don't stop at the prototype-pollution fix you caught at 18:00.

21:00 ET · Last Watch

Apostrophe's SEO plugin stores an unsanitized GTM/Analytics ID as executable script; its file plugin has an unauthenticated Host-header SSRF

@apostrophecms/seo takes an admin-supplied Google Analytics or GTM container ID and renders it into page markup with no sanitization, so any editor role that can set a tracking ID gets stored XSS against every visitor (GHSA-wf43, CVSS 8.7). In the same release, @apostrophecms/file's pretty-URL handling trusts the Host header when building outbound URLs, giving an unauthenticated caller an SSRF primitive with no role check ahead of it (GHSA-34pj). Same vendor, same day as the apos.util.set() prototype pollution flagged at 18:00 — patch core, @apostrophecms/seo, and @apostrophecms/file together, not just the CVE you already caught.

sanitize-html's allowedSchemes check misses case and whitespace variants, letting javascript: URIs through its own XSS filter

sanitize-html's scheme allowlist compares raw strings without normalizing case or stripping embedded control characters, so mixed-case or whitespace-split javascript: payloads slip past a filter whose entire job is blocking exactly that. It's one of the most widely vendored HTML sanitizers in the npm ecosystem, so the blast radius is anything rendering user-supplied HTML through its default config. Don't treat sanitize-html as a complete XSS backstop until you're on a patched version.

sigstore-go verifies a signature's cryptographic validity but not whether the signing key was valid at signing time

sigstore-go confirms a signature matches its claimed key but never checks the signature timestamp against that key's validity window, so a signature made with an expired or revoked key still verifies as trusted. Low severity — exploitation needs an already-compromised or mismanaged key — but sigstore-go exists specifically to give projects integrity guarantees, so a gap in the guarantee itself is worth a line even at CVSS 3.1. Patch-cycle item, not a fire drill.

18:00 ET · First Watch

Adform's ad-serving script hijacked to swap cryptocurrency wallet addresses on every site it touches

trackpoint-async.js, a script Adform serves to every publisher embedding its ad tags, was modified to run clipper malware — it regexes the clipboard for BTC/ETH/TRX address patterns and silently swaps in attacker wallets, while also logging visitor IP, referrer, and URL path. The malicious code has reportedly been live for at least a week with no public disclosure from Adform, meaning every site running their tags has been redistributing the payload to its own visitors the whole time. This is the textbook third-party-script supply chain shape — the compromised dependency isn't in your repo, it's in your ad tag, and it's invisible from server logs. Check outbound network calls and any clipboard-write behavior in browser sessions that loaded Adform-served JS this week; pull the tag if you can't confirm it's clean.

Apostrophe CMS's apos.util.set() lets an authenticated editor pollute Object.prototype and disable auth for every request

apos.util.set() traverses dot-notation paths without rejecting __proto__, and the $pullAll patch operator hands it attacker-controlled keys straight from an authenticated editor session. The confirmed gadget lands in publicApiCheck(), so one polluted write disables authorization on every piece-type REST endpoint for the remaining lifetime of the Node process — no repeat request needed. Any multi-editor Apostrophe deployment should treat this as a full compromise until patched, and restart processes after upgrading since the pollution persists in memory.

NocoBase's channel-list filter lets an anonymous self-signup account escalate to shell commands inside the Postgres container

GET /api/myInAppChannels:list splices the filter[latestMsgReceiveTimestamp][$lt] value straight into a Sequelize.literal() template with no escaping, and the pg driver accepts stacked statements — so it runs from boolean/timing oracle to arbitrary SQL. The shipped docker-compose.yml creates the nocobase DB role on stock postgres:16 with rolsuper still set, so COPY ... TO PROGRAM runs shell commands as the postgres container user, and the default auth-basic authenticator ships allowSignUp: true — so signup, sign-in, and RCE are reachable by anyone with network access and zero prior credentials. Revoke superuser from the nocobase role regardless of patch status; that misconfiguration is what turns injection into RCE.

Bank-Vaults' vault-secrets-webhook trusts an unvalidated vault-addr annotation on any admitted ConfigMap or Secret

parseVaultConfig() reads the vault.security.banzaicloud.io/vault-addr annotation with no scheme validation, hostname allowlist, or RFC-1918/link-local filter, then the admission handler synchronously calls that attacker-supplied address from inside the webhook process during admission review. Pair it with the vault-serviceaccount annotation — which the webhook also trusts to pick which ServiceAccount's JWT gets fetched via a cluster-wide serviceaccounts/token:create grant — and anyone who can create a ConfigMap or Secret in a watched namespace can exfiltrate any ServiceAccount token in the cluster. Audit RBAC on who can create ConfigMaps/Secrets in namespaces this webhook watches; that's the real blast radius, not just Vault access.

Pterodactyl's Wings daemon resolves {{config.*}} placeholders against its entire config, letting a game-server tenant read the node's own auth token

Wings renders egg configuration-file templates against the full marshalled daemon config with no path restriction, and the Panel substitutes user-controlled egg variable values into those templates before Wings ever sees them — so a low-privileged tenant smuggles a {{config.token}} placeholder through a variable field and reads it back via the file manager or SFTP. config.token is both the Panel↔Wings API bearer and the HMAC key signing every JWT the node issues, and config.docker.registries leaks container-registry credentials too. Anyone running shared Pterodactyl hosting should treat this as node-level compromise for any tenant who's touched an egg variable; rotate daemon tokens after patching.

AWS Amplify Studio's codegen package doesn't validate component schema properties before turning them into expressions

amplify-codegen-ui-react's property-binding step converts UI Builder component schema fields into JS expressions without validating them first, so an authenticated user who can create or import a component — including via aws amplifyuibuilder create-component — can get arbitrary JavaScript executed during component rendering and the build process. That's a CI/build-pipeline code-execution primitive, not just a runtime bug: anywhere Amplify Studio component schemas come from a source you don't fully trust (shared team accounts, imported templates) is a path to build-time compromise. Pin to a patched amplify-codegen-ui-react and audit any imported component schemas for injected expressions.

NLTK's nltk.pathsec SSRF/path-traversal sandbox — the control the project tells you to enable — has four independent bypasses disclosed together

DNS rebinding defeats the SSRF filter in nltk.download/nltk.data.load because validate_network_url() checks one resolved IP but the actual HTTP connection re-resolves the hostname independently at connect time (GHSA-qvv7). Two corpus readers, NKJPCorpusReader and FramenetCorpusReader, build file paths with no containment check and call the builtin open() directly, bypassing the sandbox entirely for arbitrary file read even under strict ENFORCE=True (GHSA-6hm5, GHSA-xh95). A fourth advisory is a plain ReDoS in ReviewsCorpusReader's FEATURES regex (GHSA-fg7f). If you run NLTK against untrusted corpus names or URLs in a multi-tenant or web-facing service, pathsec's ENFORCE mode is not the backstop its docs claim — patch nltk and don't rely on the sandbox alone until you do.

Thumbor's HMAC URL-signature check and ALLOWED_SOURCES filter both have bypasses, plus four separate DoS paths

The signature-verification path strips the URL signature with multiple chained .replace() calls, which an attacker can abuse to make an unsigned request validate as signed (GHSA-mw3h) — that defeats the entire point of signed image-proxy URLs. Separately, ALLOWED_SOURCES entries are matched as unescaped regex, so a wildcard dot in an allowed hostname pattern lets an attacker's lookalike domain slip through the source allowlist (GHSA-6x26); chain the two and you get server-side requests to attacker-chosen origins with no valid signature required. Four more advisories in the same batch cover DoS — post-validation URL-decoding path traversal in file_loader (GHSA-cj54), unbounded post-transform resizing (GHSA-phj3), ReDoS in the convolution filter (GHSA-5vjc), and a divide-by-zero in the convolution filter's C extension (GHSA-cqjp). If your Thumbor deployment relies on signed URLs or an allowlist to restrict what it will fetch, treat both controls as bypassed until you're on a patched release.

Netty's HTTP/2 decompressor leaks a direct ByteBuf on every DATA frame delivered to an already-closed stream, crashing the JVM

DelegatingDecompressorFrameListener retains a frame's buffer before calling writeInbound(), but writeInbound()'s first action is ensureOpen() — which throws once the stream's decompressor channel is already closed — so the retain() never gets released on that error path. A remote, unauthenticated peer just needs to keep a long-lived HTTP/2 connection open and trigger the race repeatedly to exhaust direct memory and OOM-crash the JVM. Check whether your Netty-based proxy or server has DelegatingDecompressorFrameListener wired in for HTTP/2 content decompression before assuming this doesn't apply.

Spring Data caches every distinct sort/projection path string forever, including ones that don't resolve, letting an attacker grow the heap unbounded

PersistentPropertyPathFactory.propertyPaths is an unbounded ConcurrentHashMap keyed by the raw path string, and computeIfAbsent caches unresolvable paths too — so a remote caller sending unique ?sort=aaaa<n> values through any endpoint that forwards user input to MappingContext.getPersistentPropertyPath() grows the cache forever with garbage entries. Spring Data REST and several other consuming modules route sort/projection/PATCH-path parameters through exactly this call with no length or cardinality limit upstream. If you expose sort or projection parameters to untrusted callers, rate-limit distinct values or upgrade — there's no eviction to fall back on.

New Go-based HollowFrame loader drops a Rust Matryoshka backdoor via law-firm spear-phishing

Blackpoint Cyber documents a multi-stage chain — phishing email, encrypted archive, LNK file, HollowFrame (a new Go loader), then Matryoshka, a Rust-based backdoor — deployed against a law firm. It's not a package-registry or dependency attack; the entry vector is email, not a compromised package. But the loader/backdoor pairing and staged, multi-language delivery mirror the same anti-detection playbook showing up across recent supply-chain campaigns, worth having on hand as reference tradecraft even though it doesn't belong in the campaign-tracking list above.

12:00 ET · Forenoon Watch

@dynatrace-oss/dynatrace-mcp-server's --http mode executes MCP tool calls with zero authentication

The --http transport spins up a StreamableHTTPServerTransport with no bearer-token, session, or Host/Origin check, so any network-reachable caller can fire a raw JSON-RPC tools/call and have it execute under the victim's own Dynatrace credentials. execute_dql is reachable unauthenticated and returns arbitrary Grail data (logs, security events, user sessions), and create_dynatrace_notebook writes into the tenant — both skip the requestHumanApproval gate that protects the one tool (send_event) that is actually guarded. It's the same missing-allowlist shape as yesterday's Ruby MCP SDK DNS-rebinding bug (GHSA-rjr6): don't expose --http mode to any network you don't fully trust until a patched release ships.

create_workflow_for_notification lets a caller inject Jinja2 expressions into a persistent Dynatrace workflow

The tool interpolates caller-supplied teamName/problemType/channel straight into a Dynatrace Workflow definition, and Dynatrace Workflows evaluate {{ }} expressions at runtime for every action except Run Javascript — so a value like teamName = "{{ event() }}" gets the workflow engine to serialize the full event object into whatever Slack channel the notification targets. The payload isn't transient: the workflow persists in the tenant after the MCP session ends, giving an attacker a standing exfil channel rather than a one-shot leak. Audit any workflow this tool has created for injected {{ }} expressions before trusting its notifications.

Dynatrace MCP server's read tools let identifier-typed parameters break out into arbitrary DQL

Several read tools interpolate parameters documented as plain identifiers — entity names, timeframe shorthand like "24h", Kubernetes UIDs — directly into DQL query strings, letting a caller break out of the string literal and append arbitrary pipeline stages. The privilege ceiling is low since the same token already has execute_dql, but the injection bypasses the tools' contract: the readOnlyHint auto-approve signal, per-tool field scoping, and the display/time-window caps that a client relies on to treat these as bounded reads. Same package as the two Dynatrace advisories above — one release, three bugs; update dynatrace-mcp-server and re-check any automation trusting these tools' declared scope.

06:00 ET · Morning Watch

MCP Ruby SDK never expires sessions by default, letting an initialize flood exhaust memory

StreamableHTTPTransport's default config never expires sessions — session_idle_timeout defaults to nil, so the reaper thread that prunes idle sessions never starts, and the only way a session gets removed is a client-issued DELETE. An unauthenticated attacker can spray initialize requests (50,000 in 26 seconds in the PoC) to pin memory until the worker OOMs. This is the third Ruby MCP SDK transport bug from yesterday's batch to get its own line — pair it with the stdio buffer and DNS-rebinding issues (GHSA-7683, GHSA-rjr6) as one bad release. Set session_idle_timeout explicitly and cap max sessions until a fixed gem ships.

MCP Ruby SDK's Streamable HTTP transport never checks Host/Origin, opening it to DNS rebinding

The transport reads Accept, Content-Type, and session headers but never inspects Host or Origin, and ships no allowlist — a malicious webpage can DNS-rebind its hostname to 127.0.0.1 and drive any locally-running MCP server cross-origin, enumerating and invoking its tools from the browser. The PoC confirms full session issuance and tool-output exfiltration with no 403 anywhere in the path. Every other MCP SDK (TypeScript, Python, Go, Rust, C#, Java) already ships this guard; Ruby is the outlier, and it's the last of yesterday's three Ruby transport advisories (see GHSA-52jp, GHSA-7683). Front any loopback/LAN Ruby MCP server with a Host/Origin check until the gem adds one.

OliveTin's StartActionAndWait endpoints return full action output regardless of the caller's logs ACL

StartActionAndWait and StartActionByGetAndWait check exec permission before running an action but never check logs permission before returning its output — the normal log-reading paths (GetLogs, GetActionLogs, EventStream) enforce the ACL correctly, these two synchronous endpoints don't. Anyone allowed to run an action but explicitly denied its output (exec:true, logs:false) can read that output anyway, which matters when action scripts print credentials, paths, or hostnames to stdout. Sibling bug to yesterday's regex-argument shell-escape bypass in the same release (GHSA-xc5w). Audit any OliveTin ACL with exec:true/logs:false and treat it as fully readable until patched.