Veridise enhances Soroban security: Breaking our Teeth on Stale Dependencies

Veridise
Veridise
Published in
7 min readJun 12, 2024

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 before second, due to the alphabetical order in which they appear to cargo

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: stale first.wasm (because during compilation it imported old second.wasm) and a fresh second.wasm.
  • After running make test, since test.rs appears inside first crate, the crate gets rebuilt. During the compilation, already updated second.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 have first.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

--

--

Veridise
Veridise

Hardening blockchain security with formal methods. We write about blockchain & zero-knowledge proof security. Contact us for industry-leading security audits.