May 10, 2022·6 min read·engineeringrustsecuritydependenciessupply-chain

Rustdecimal used a familiar API to hide an unfamiliar package

The malicious rustdecimal package copied a legitimate decimal library but changed a constructor to launch a payload in GitLab CI. The disclosure distinguishes package identity, activation and the limits of the available investigation.

An arithmetic library can return the expected results while performing work unrelated to arithmetic. The malicious rustdecimal package copied a legitimate library's functionality and changed a constructor to launch another program under selected conditions. That made ordinary API familiarity a poor way to establish which software a project had actually selected.

The 10 May advisory identifies rustdecimal as a malicious package whose name resembled rust_decimal. The legitimate rust_decimal package was not compromised. Keeping those spellings distinct is necessary to understand the incident, because the attack depended on confusing two different registry identities.

I am impressed by Rust as a systems language, but its semantics cannot establish the intent of code a developer chooses to run. A dependency can use ordinary program facilities to act maliciously. The trust decision concerns the package and its behavior, even when its public API looks familiar and its code is accepted by the compiler.

The report concerned a separate package

The initial issue, opened on 2 May, reported a suspicious clone to the legitimate project's repository. That is a useful starting point for the chronology. The concern was not that the established project had published a compromised version under its own identity. It was that another package appeared to impersonate it.

The advisory says crates.io removed the malicious package permanently when notified. Its analysis found no other registry crate with similar code patterns. It also reported fewer than 500 downloads and no crates on the registry depending on rustdecimal at the time of the investigation.

Those observations describe the available registry evidence. They do not give a count of executed payloads or compromised environments. A download is not the same event as calling the modified constructor, and a registry dependency count does not inventory private application manifests. The distinctions matter when interpreting the apparent size of the incident.

The reporting path is also instructive. A suspicious similarity between projects became a concrete issue for the legitimate maintainer and then a registry response. The public advisory connects the package name to specific code behavior, which is stronger evidence than resemblance alone.

The constructor checked its environment

According to the advisory, Decimal::new checked for the GITLAB_CI environment variable. When that condition held, it downloaded a binary to /tmp/git-updater.bin and executed it. The payload supported Linux and macOS, but not Windows. The package's first release was on 25 March.

This is an activation condition inside an ordinary library operation. The public account identifies the constructor call, so it should not be rewritten as a claim that downloading the crate alone executed the payload. The relevant investigation follows whether the package's code entered the execution path described in the advisory.

The environment check gives the same package different behavior depending on where the constructor runs. A local program using the arithmetic API may not encounter the selected branch. That does not make local success evidence that the package will behave the same way in CI.

There is a broader testing limitation here. A test concerned only with returned decimal values can pass while an additional side effect occurs. Functional equivalence for an API's ordinary result does not establish equivalence for network access or child-process creation. The malicious change exploited the difference between what callers expected to observe and what the implementation could also do.

My preference is to examine authority as well as results when assessing a dependency. A package selected for arithmetic does not acquire a business requirement to fetch and execute a binary simply because it can still perform arithmetic. The purpose of the dependency helps identify behavior that needs a separate explanation.

The payload's later actions were unknown

The advisory states that investigators could not analyze the binary because the download URL no longer worked. That limits the conclusions available on 10 May. The downloader and execution behavior are established, while the payload's subsequent actions are not described by the available analysis.

It would therefore be inaccurate to invent a list of stolen secrets or changes made to a machine. The known behavior is already sufficient to make the package malicious. Uncertainty about the unavailable binary does not turn downloading and executing an unrequested payload into an acceptable library operation.

This distinction helps separate two questions in an investigation. One asks whether a dependency crossed an intended authority boundary. Another asks what happened after it did. The source can establish the first even when the missing second-stage program prevents a complete answer to the second.

For affected GitLab CI users, the advisory recommends considering the environment compromised. That recommendation follows the execution opportunity described in the analysis. It should not be confused with a claim that investigators recovered and characterized the unavailable binary on every affected machine.

Cargo names and package identities can differ

The Cargo dependency documentation available with Rust 1.60 describes dependency renaming. A manifest key normally matches the crate name used by source code, but the package field can identify a differently named package. The same package can therefore be imported locally through another name.

That is a legitimate feature for avoiding naming conflicts or depending on multiple versions. It also means that reviewing source imports alone does not establish the registry package selected by the manifest. The dependency declaration and its package field supply part of that identity.

For this incident, the distinction reinforces why spelling belongs in the evidence. A familiar identifier in source is not a complete dependency record. The manifest may rename the dependency, and a similarly spelled registry name can refer to a different publisher's software.

This is not an argument against renaming. It is a reason to follow the mapping that Cargo actually uses. An alias can make local code clearer while the package field preserves the distribution identity. A review needs both when it evaluates which code enters a build.

The documentation also permits selecting packages from Git repositories and alternate registries. A package name therefore needs its source context when the question is which implementation a project selected. An import name is useful inside a Rust program, while a dependency record explains where the corresponding package came from. Confusing those roles makes it easier to approve a familiar-looking identifier without examining the distribution it selects or the authority its code receives.

That separation is relevant even when no alias is present. Similar functionality can be published under a different registry name, as this disclosure demonstrates. Familiar method names help a developer use a library, but they do not identify its publisher. The source of a dependency and the spelling of its registry package deserve explicit attention when an application begins trusting another implementation with its process permissions.

Requirements differ from resolved dependencies

Cargo's manifest and lockfile guide distinguishes a broad dependency declaration from the exact information retained in Cargo.lock. The lockfile records the selected package versions and sources so subsequent builds can use the same resolution.

That makes the lockfile useful evidence of what a build selected. A declaration expressing a version range does not, by itself, identify the exact implementation used by an existing artifact. Conversely, a resolved package identity does not establish whether its relevant code was executed in CI.

Those are complementary facts rather than interchangeable answers. Package resolution identifies the code available to the build. The constructor's call path and environment identify the activation described in this disclosure. An investigation that keeps those facts separate can state what is known without turning every dependency reference into a claim of payload execution.

The same guide explains that reproducible resolution preserves a selected dependency. That property is useful even when investigating a bad selection, because the record can explain which package was chosen. Reproducibility is not an assessment of a package's intent. It can reproduce a malicious dependency as consistently as a legitimate one.

I regard the package distinction as the most important fact to preserve in this account. The legitimate decimal library remained separate from the impersonating package. The disclosed constructor then supplied a specific activation path for the malicious behavior. Together those facts let maintainers investigate an exact dependency identity and execution condition without attributing the attack to the project it copied.