Veridise enhances Soroban security: Breaking our Teeth on Stale Dependencies
This article highlights an issue with Soroban’s build-test-deploy process that was recently discovered by Veridise security analysts. It can lead to the deployment of incorrect contracts that utilize Soroban’screateimport!
macro as this macro does not require that imported contracts are listed as crate dependencies. As a result, imported resources may be outdated. Furthermore, running tests may not identify the issue and can create a false sense of security. If not addressed promptly, this could lead to severe consequences for a project, such as the deployment of broken contracts in a production environment. In this article, we discuss the issue, demonstrate how to replicate it, and ultimately provide a straightforward solution.
Introduction
Soroban is a Rust dialect for writing smart-contracts for the Stellar blockchain. It inherits the Rust language and its build system. In Soroban, contracts are compiled into .wasm binary modules which contain both the binary code of a contract and its ABI. This differs from Solidity as there is no need to have a separate file with an interface definition in order to communicate with a contract.
The Soroban SDK defines a macro called contractimport!
which should be used when:
- Contract A needs to communicate with another contract B. In this case, contract A needs to import the ABI of contract B.
- Contract A needs to import some constants/enums from contract B.
- Contract A needs to deploy an instance of contract B. In this case, contract A needs to import binary code of the contract B.
The below code snippet shows a typical instance of contractimport!
mod contract_a {
soroban_sdk::contractimport!(
file = "../../target/wasm32-unknown-unknown/release/contract_b.wasm"
);
}
As can be seen above, the developer imports a binary file which must exist by the time the calling contract is compiled. If that file does not exist, the macro will halt compilation with an error. Otherwise, several named constants will be introduced for use in the calling contract, such as a constant named WASM
containing an array of bytes with the imported contract’s bytecode. To gain a better understanding of how the contractimport!
is used, we will first show a few examples and then describe how errors can be introduced.
Example 1
mod contract_a {
soroban_sdk::contractimport!(
file = "../../target/wasm32-unknown-unknown/release/contract_a.wasm"
);
}
let contract_addr : Address = ...;
let client = contract_a::Client::new(&env, &contract_addr);
client.add(&x, &y); // the call from contract_b into contract_a
The above snippet shows how one would call Contract A from some other contract. As can be seen, a call requires that the contract is imported and a client exists for the contract.
Example 2
mod contract_a {
soroban_sdk::contractimport!(
file = "../../target/wasm32-unknown-unknown/release/contract_a.wasm"
);
}
#[contractimpl]
impl ContractB {
pub fn critical_computation(env:Env) -> u32 {
contract_a::VerySeriousEnum::Warning as u32
}
}
The above snippet shows how one would use a value that was declared in another contract. Similar to Example 1, this requires that the contract be imported but static values can be referenced without creating a client.
The Issue
As seen in the previous examples, the contractimport!
function is essential when implementing behaviors commonly found in smart contracts. It should be noted, however, that imports via the macro are performed using a developer-provided relative file path. While this essentially introduces a dependency between the current contract and the referenced contract, there is no obligation to set a dependency for the rust crate in which it resides. This fact introduces a set of problems that we describe in the following sections.
Stale Wasm Dependency
As discussed in the previous section, the contractimport!
macro references the bytecode of a contract that has already been compiled. However, it is possible for a contract’s bytecode to exist, but to also be outdated. Let’s see how this can occur.
Consider the following source tree:
project/
Cargo.toml
contracts/contract_a/{Cargo.toml, src/lib.rs}
contracts/contract_b/{Cargo.toml, src/lib.rs}
Makefile
With a Makefile structured as shown below:
all:
soroban contract build
test:
cargo test
deploy:
# contract deployment commands go here
A typical developer may execute the following commands to build, test, and deploy a project.
make
make test
make deploy # if tests passes, deploy into the network
make
will run soroban contract build
. To find out what this command actually does, we execute it with a special key:
$ soroban contract build --print-commands-only
And it will show you:
cargo rustc --manifest-path=contracts/contract_a/Cargo.toml \
--crate-type=cdylib --target=wasm32-unknown-unknown --release
cargo rustc --manifest-path=contracts/contract_b/Cargo.toml \
--crate-type=cdylib --target=wasm32-unknown-unknown --release
More generally, it builds all the contracts it can find from the contracts
directory in alphabetical order. While cargo has several advanced algorithms for resolving build dependencies and correcting the compilation order, these algorithms require that the dependencies are recorded in the project’s Cargo.toml file. After inspecting Soroban projects on GitHub and the Soroban development documentation, we determined that it is not common practice to add dependencies between contracts imported via the contractimport!
macro.
At the time that contract_b
is being built, contract_a
has been already built, so the import is safe. The problem appears in an opposite case: if contract_a
depends on contract_b
. In this case, by the time the contract_a
is compiled, the contract_b
wasm file is not yet updated to the latest version (if there were any recent changes), and so the previous version of the contract is imported. So, if contract_a
deploys contract_b
, then the very latest version of contract_a
will deploy the previous (outdated) version of contract_b
.
In the next section, we outline an example project reproducing this exact scenario.
Problem Reproduction
In this proof-of-concept, we show how wasm binaries may become out of sync with test results. For this purpose, we provide a repository with a very simple system of two contracts that are named first
and second
.
The first
contract imports a single enum
value from the second
contract, using the contractimport
directive, and uses it inside a function called consumer
. Our plan is to show that editing this enum
value inside second
contract and running a typical make ; make test
will lead to a stale first
wasm binary. This in turn leads to the deployment of an incorrect contract to the network.
💡 Note that, by default,
first
is built beforesecond
, due to the alphabetical order in which they appear tocargo
We provide a script with comments that explain what we are doing and what are we aiming for.
💡 Note that we tested the following code using version 21.0.0-preview.1 of the Soroban CLI
# clone the repo with a simple project
git clone https://github.com/Veridise/soroban_stale_dep
cd soroban_stale_dep
# To make a compilation of `first` succeed, we need to compile
# the `second` contract beforehand. For that, we created a special
# make target called `second`.
make second # build second.wasm
make # build first.wasm
make test # run tests
# Test passes. Everything looks good. Now go to second/src/contract.rs and
# change the value of `Warning` enum to, say, 10. After that, go to
# first/src/test.rs and change the test accordingly, it should expect value 10.
# You made a very simple change. We can now run test to ensure that everything
# is fine.
make
make test
# Test works, everything looks good. Now lets deploy the contract to the testnet.
make keys # prepare some dummy Soroban account and fund it with tokens
make deploy # deploy `first` smartcontract into the network (it is unique each time)
make invoke_consumer # call `consumer` function. It will return the enum value.
# As you can see, the deployed contract contains outdated enum value: 5.
# It should have contained 10, but it doesn't.
Notice that, due to the stale dependency, a stale value of 5 is shown from the on-chain contract. By the time the contract first
was compiled, the new version of second
contract has not been built yet, hence the old value from the already existing second.wasm
file was imported.
Why did make test
show a correct result? Lets break it down:
- After running the first
make
we have: stalefirst.wasm
(because during compilation it imported oldsecond.wasm
) and a freshsecond.wasm
. - After running
make test
, sincetest.rs
appears insidefirst
crate, the crate gets rebuilt. During the compilation, already updatedsecond.wasm
binary is taken for the import. This is why for the test binary, everything looks good. - Since making a test binary does not involve rebuilding
wasm
files, we still havefirst.wasm
file outdated
Dry result: test shows that everything is correct, but deployed contract is incorrect nevertheless.
How to fix the issue?
The fix is simple. For contracts that depend on other contracts, one must specify the dependency in their Cargo.toml
file. For example, for the provided POC repo, a dependency should be added to first
crate that indicates it depends on second
crate. In this case, cargo will build contracts in the correct order and the problem would disappear. Once this is done, the contract importing path should be changed:
// Before the fix
file = "../../target/wasm32-unknown-unknown/release/contract_a.wasm"
// After the fix
file = "../../target/wasm32-unknown-unknown/release/deps/contract_a.wasm"
This is because cargo
places crate build dependencies separately from the target binaries.
Author:
Evgeniy Shishkin, Security Analyst at Veridise
Want to learn more about Veridise?
Twitter | Lens | LinkedIn | Github | Request Audit