3CX and the trust you never audited: controlling what enters your software supply chain
3CX shipped malware inside a correctly signed release because a vendor it trusted was compromised first. Signatures answer "who built this?"; ingress control answers "is this allowed in?" How to build the second one for npm, PyPI, Docker, and friends.
TL;DR
- The 3CX compromise (March 2023) did not start with a vulnerability in 3CX. It started with a trojanized installer from a different vendor, on an employee's personal machine. From there the attacker reached the build environment and shipped malware inside a correctly signed release.
- A signature, a green pipeline, and a hardened network all answer the question "is this what the vendor built?" None of them answer "should this be allowed in?" Those are different controls, and most teams only have the first.
- The same trust chain exists for every npm, PyPI, Composer, Maven, Cargo, and Docker dependency you pull. The actionable takeaway: put one governed ingress point in front of your dependencies, keep an inventory of what crossed it, and make that point able to say no.
- Who this is for: platform engineers, tech leads, and engineering managers who own the build pipeline and need a concrete model of where "trust" enters it.
What actually happened at 3CX
Source: the incident details below are drawn from Mandiant's investigation of the 3CX compromise as reported by BleepingComputer: 3CX hack caused by trading software supply chain attack.
The short version most people remember is "3CX's desktop app was backdoored." The full chain, as documented by Mandiant and reported by BleepingComputer, is more instructive because it is a cascading supply chain attack: one compromised vendor was used to compromise another.

- Trading Technologies distributed an installer for X_TRADER, a trading application it had already discontinued. The installer had been trojanized and carried a multi-stage backdoor Mandiant named VEILEDSIGNAL. The installer was signed with a valid Trading Technologies certificate.
- A 3CX employee installed X_TRADER on a personal computer. The backdoor harvested credentials, including 3CX corporate credentials.
- Using those credentials, the threat actor (tracked as UNC4736, assessed to be North Korea-linked and overlapping with the AppleJeus cluster) moved into 3CX's network and then into its build environments. On the Windows build host they used DLL side-loading through the IKEEXT service (TAXHAUL launcher, COLDCAT downloader); on macOS they used a LaunchDaemon-persisted backdoor (POOLRAT).
- The build pipeline then produced trojanized 3CXDesktopApp releases for Windows and macOS. On Windows, the legitimate signed executable loaded a malicious
ffmpeg.dllthat read an encrypted payload fromd3dcompiler_47.dll. The installer package itself was signed with 3CX's code-signing certificate and published through 3CX's normal update channel. - Customers updated. Endpoint detection products started flagging a signed binary from a known vendor, and the first reaction on the 3CX community forum was that it looked like a false positive. The compromise was acknowledged on March 29, 2023.
3CX reported around 600,000 customer businesses and 12 million daily users at the time. Every one of those installs did exactly what it was supposed to do: verify the signature, trust the vendor, run the code.
The two questions a signature cannot separate
A code signature (Authenticode, notarization, Sigstore, GPG on a tarball, a Docker image signed with cosign) proves one thing: the holder of this key produced these bytes. That is a statement about origin. It says nothing about whether the build that produced those bytes was clean.
When the build host itself is compromised, the signature becomes the attacker's best asset. Everything downstream, from update clients to EDR allow-lists to your own "only install signed packages" policy, is now working for the payload.
This is why the framing in the original post matters. The two questions are:
| Question | What answers it | What it protects against |
|---|---|---|
| "Is this package vulnerable?" | Advisory databases, CVE feeds, scanners | Known-bad versions of otherwise legitimate packages |
| "Do I control what enters my supply chain?" | An ingress boundary with inventory and policy | Unknown-bad versions, compromised builds, name confusion, anything the scanner has not caught yet |
Vulnerability scanning is necessary. It is also reactive by construction: an advisory exists only after someone found and reported the problem. In the 3CX case, the window between the first EDR alerts and the vendor acknowledgment was about a week. The trojanized DLL also waited roughly a week after install before contacting its command-and-control infrastructure. Neither delay is unusual. If your only control is "block what OSV says is bad," you are choosing to be exposed for the entire detection lag, on every ecosystem.
The same trust chain, in your dependency graph
Replace "3CX desktop app" with any dependency your build resolves, and the chain is identical:
- A maintainer's laptop or CI token is compromised (the X_TRADER step).
- The attacker publishes a new version of a legitimate package, from the legitimate account, with legitimate provenance (the build environment step).
- Your resolver picks it up because a semver range allows it, or a transitive dependency bumped, or an agent ran
npm installon a fresh branch (the customer-update step). - It runs in a
postinstallhook, in a build step, or at runtime inside a container you shipped.
The ecosystems differ in mechanics but not in shape:
| Ecosystem | Where trust is granted implicitly |
|---|---|
| npm / pnpm / yarn | Semver ranges, lifecycle scripts, transitive depth |
| PyPI | pip install resolving the newest compatible release, setup.py executing at install |
| Composer | Packagist metadata, post-install-cmd scripts |
| Maven / Gradle | Version ranges, plugin resolution, snapshot repositories |
| Cargo | Build scripts (build.rs) executing during cargo build |
| Go | Module proxy fetches, go generate conventions |
| Docker / OCI | Mutable tags (latest, 3.19), base image rebuilds |
In every row, the answer to "why did this code run?" is "because the resolver trusted the source." That trust was configured once, usually by whoever set up the repo, and has not been revisited since.
What "controlling ingress" means concretely
Control is not a scanner and not a policy document. It is a set of properties of the path between the public internet and your builders. Here is the minimum set, ordered by leverage.
1. One path in, per ecosystem
Every builder, developer machine, and agent must resolve dependencies through a registry you operate, which itself proxies the public upstream. Not "we recommend it." Enforced.
For npm, that means the registry is pinned in a committed .npmrc and CI asserts it:
# .npmrc (committed)
registry=https://registry.example.com/acme/npm-proxy/
always-auth=true
# CI step: fail if anything resolves outside the governed host
- name: Assert registry host
run: |
test "$(npm config get registry)" = "https://registry.example.com/acme/npm-proxy/"
! grep -E 'resolved": "https://registry\.npmjs\.org' package-lock.json
Equivalent asserts exist for pip.conf (index-url), composer.json (repositories with packagist.org: false), settings.xml mirrors, .cargo/config.toml source replacement, and GOPROXY. If the assert is missing, the rest of this list applies to a fraction of your traffic and you will not know which fraction.
The reason this comes first: without a single path, you have no inventory, and without inventory, every other control is guesswork.
2. Inventory: know what crossed the boundary, and who pulled it
A proxy registry that caches upstream artifacts is, as a side effect, a record of every version that ever entered your organization. That record is what lets you answer, in minutes rather than days:
- Did we ever pull
[email protected]? - Which tokens, bot users, or pipelines fetched it, and when?
- Is it still being fetched, or is it only in old lockfiles?
This requires that the identity presented to the registry is meaningful. A single shared "CI token" used by forty pipelines makes the second question unanswerable. Per-consumer tokens with install-only scope are the prerequisite for attribution, and they are cheap to issue.
3. Fail-closed policy at serve time
When a version is known or suspected to be hostile, the registry must be able to refuse to serve it, and the refusal must apply to resolve as well as download. Removing a version from the metadata response is what stops the resolver from choosing it; returning 403 on the tarball is what stops a pinned lockfile from fetching it.
The important design property is fail-closed with an explicit override, not "warn and continue." A warning in CI output is read by nobody at 2 a.m. during a deploy.
4. Version freshness as a policy input
The 3CX payload was live for about a week before it was detected. Most malicious package publishes are pulled from public registries within days. A minimum-age rule ("do not resolve versions published less than N days ago unless explicitly allowed") converts detection lag into a window you deliberately do not consume.
Client-side options exist for some ecosystems. pnpm exposes minimumReleaseAge, and Renovate has the same setting for automated bumps:
# .npmrc for pnpm ≥ 10.16
minimum-release-age=4320 # minutes; 3 days
Client-side settings are useful but bypassable per repository. A registry-side rule applies to every client that goes through the governed path, including the ones you forgot about.
5. Immutable references for things that can change under you
Mutable Docker tags and latest references are the container equivalent of an open semver range. Pin base images by digest, and have the proxy serve exactly the digest that was resolved when the build was reviewed:
FROM registry.example.com/v2/acme/docker-proxy/library/node@sha256:4f0a...9c1e
Rebuilds then reproduce what was reviewed instead of whatever upstream published this morning.
6. Provenance where it exists, without treating it as sufficient
npm provenance attestations, Sigstore-signed images, and SLSA build levels tell you which build system produced an artifact. They are worth requiring where the ecosystem supports them. They do not help when the build system itself is the compromised component, which is precisely what happened at 3CX. Provenance narrows the set of things you trust; ingress control decides what happens when one of them turns out to be wrong.
Constraints you will hit
- Air-gapped or regulated networks. Public advisories require outbound reach to advisory databases. If the registry cannot reach them, scans are silent, not wrong. Budget egress for the registry host specifically, or plan a mirrored feed.
- Polyglot sprawl. Six ecosystems with six vendor UIs means six places to configure policy and six places for it to drift. Consolidation matters more for the policy plane than for the storage.
- Developer friction. Fail-closed on new versions will block someone's Friday upgrade. Decide who can grant an exception and how it is recorded before the first block, not during it.
- Transitive depth. A blocked version three levels deep produces an error message that names a package nobody recognizes. Make the registry's error response explain why (advisory ID, policy name), or the on-call engineer will assume the registry is broken.
- Runtime artifacts. A registry block stops new resolves. It does nothing for the container image built last Tuesday. Track "what is deployed" separately from "what is installable."
Failure modes
| Failure mode | What it looks like | Detection |
|---|---|---|
| Partial path enforcement | Some builders resolve from the public registry; block "works" but installs still succeed | CI host assert; registry access logs show fewer consumers than pipelines |
| Shared god-token | "Who pulled this?" has no answer | Token inventory shows one token, many jobs |
| Scanner-only control | Bad version served during detection lag | Compare advisory publish time against your first fetch time |
| Warn-only policy | Warnings scroll past; nothing fails | Grep CI logs for policy warnings that never became failures |
| Exception without expiry | A "temporary" allow rule from a past incident still active | Periodic review of allow-lists with owner and date |
| Mutable image tags | Rebuild pulls a different base than reviewed | Digest pinning lint in Dockerfiles |
What good looks like
- Every ecosystem in use has exactly one resolvable registry host, asserted in CI.
- You can produce a list of consumers for any package version from registry evidence, not from grepping pipeline logs.
- A hostile version can be made unresolvable and unfetchable in minutes by someone on call, and the change is recorded.
- New versions have a deliberate quarantine window before automated bumps consume them.
- Container base images are referenced by digest and served from your cache.
- Exceptions have an owner, a reason, and an expiry.
None of these require a large team. They require that the boundary exists and that someone owns it.
How this looks with Omni Line
The controls above are properties of the ingress point, so the practical question is whether you have one. Omni Line is a self-hosted registry that gives you that point for npm, Composer, Docker, PyPI, Go, Cargo, Maven, RubyGems, and generic artifacts, behind one URL scheme and one management API.
- Proxy mode puts a read-through cache in front of each public upstream, so every version that enters your organization is stored on your infrastructure and visible as inventory. Virtual mode aggregates hosted and proxy registries by priority so clients see one URL per ecosystem, which is what makes the CI host assert simple to write.
- Personal access tokens are scoped per consumer and intersected with organization RBAC on every request, so "which token pulled this?" is a query, not an archaeology project. See Core concepts for the role and scope model.
- Vulnerability scanning checks the versions already in a registry against OSV on a schedule or on publish, and install protection can deny resolve and download for open malicious advisories, so the registry, not a dashboard, is what fails closed. Details in Vulnerability scanning.
- Because it runs on your infrastructure, the control plane does not add another vendor to the trust chain you are trying to shorten.
Docker image layer scanning is not part of the OSV path today, so treat digest pinning and a separate image scanner as the container-side equivalent rather than assuming one control covers both.
Takeaways
- 3CX was breached through a vendor it trusted, and shipped the breach to customers who trusted it. Trust composed transitively, and nobody had a control at the boundary.
- Signatures and provenance answer "who built this?" Ingress control answers "is this allowed in?" You need both, and most teams only have the first.
- The controls that matter are structural: one path in, inventory of what crossed, fail-closed policy, a freshness window, immutable references, and owned exceptions.
- Scanners find what is already known. The boundary is what protects you during the week before it is known.
If you want to try this on your own infrastructure, start with the quickstart: one proxy registry for the ecosystem with the most transitive depth, a committed client config, and a CI assert. Inventory starts accumulating from the first install.
Sources
- BleepingComputer, 3CX hack caused by trading software supply chain attack: https://www.bleepingcomputer.com/news/security/3cx-hack-caused-by-trading-software-supply-chain-attack/
- Mandiant, 3CX software supply chain compromise initiated by a prior software supply chain compromise (April 20, 2023), the primary investigation the article reports on.