Can We Achieve Secure and Measurable Software Using Wasm?
The Fermyon Team
wasm
webassembly
The February 2024 report from the White House, Washington, D.C. BACK TO THE BUILDING BLOCKS: A PATH TOWARD SECURE AND MEASURABLE SOFTWARE makes the case that “the challenge of eliminating entire classes of software vulnerabilities is an urgent and complex problem” and “that the technical community is well positioned to reduce computer memory safety vulnerabilities at scale by focusing on the programming language as the primary building block”.
Memory-Safe Programming Languages
The report mentions that C and C++ are not memory-safe programming languages. In C and C++, the programmer typically manages memory allocation and deallocation manually.
A “memory-safe programming language” is designed to prevent common memory-related errors, including buffer overflows and null pointer dereferences. These memory-safe languages diminish the risk of memory-related vulnerabilities.
The report mentions that the space ecosystem is not immune to memory safety vulnerabilities. However, there are several constraints in space systems with regard to language use. First, the language must allow the code to be close to the kernel so that it can tightly interact with both software and hardware; second, the language must support determinism so the timing of the outputs are consistent; and third, the language must not have – or be able to override – the “garbage collector,” a function that automatically reclaims memory allocated by the computer program that is no longer in use.
Rust
The Rust programming language is one example of a memory-safe programming language that fits the bill. Rust’s properties uniquely enable it to be a suitable replacement for C/C++ usage. Rust is a compiled programming language with “zero-cost abstractions”, which enables high-level programming that runs at optimal speeds on computers. Rust’s safety guarantee additionally extends to multi-threaded programming which is historically notoriously hard to get right. Finally, Rust does not have a runtime introducing non-deterministic pauses in execution, meaning it can run in even the smallest environment.
The Rust programming language does not have a garbage collector. Rust uses a different approach called “ownership” and “borrowing” to manage memory. The ownership system ensures that each value in Rust has a unique owner, and the ownership can be transferred or borrowed to other parts of the code. This allows Rust to enforce strict memory safety at compile-time without needing a garbage collector.
WebAssembly (Wasm)
WebAssembly (Wasm) enables developers to build complex applications that run smoothly and securely across different platforms. We will soon dive deeper into Wasm (its relationship with Rust) and the Wasm Component Model (a new specification that presents an opportunity for a polyglot future).
Wasm & Security
Wasm is a binary instruction format that allows you to run code at near-native speeds and provides performance, portability, and security benefits. Wasm runs in a sandboxed environment within the web browser or on a dedicated Wasm runtime. Wasm operates at a higher level of abstraction than the kernel (and, therefore, does not tick the first requisite property from above). However, Wasm does provide a secure and isolated sandbox execution environment. This is an equivalent qualifier. There are no side effects from the Wasm code execution. Wasm’s isolated/sandboxed approach is so secure that Wasm is commonly used to run un-trusted code on multitenant hardware. Wasm code can not access the host environment or sensitive resources or cause harm.
Wasm Is Language Agnostic
Wasm allows developers to write their code in various languages—such as Rust, Go, Python, and many others and then compile that high-level code into a common Wasm binary format that can run in any Wasm-supported environment.
If you would like to learn about which languages compile to Wasm, please feel free to visit this Wasm Language Support Matrix.
Portability
Wasm is portable and compatible across different platforms and devices. The recent launch of Wasm System Interface (WASI) 0.2 is a pivotal advancement for Wasm, showcasing its evolution from a browser-centric tool to a versatile platform for secure, cross-platform application development.
The Wasm Component Model
The Wasm Component Model packages code in a portable binary format; providing machine-readable interfaces in Wasm Interface Type (WIT) with a standardised Application Binary Interface (ABI). It enables applications and components to work together.
The White House report mentions that “the developer can use formally verified core components in their software supply chain” and that “by choosing provably secure software libraries, developers can ensure the components they are using are less likely to contain vulnerabilities”.
The Wasm Component Model promotes modularity and composability and enables code reuse across different programming languages. Its high-level design choices enhance flexibility, security, and interoperability.
The Wasm Component Model is designed with a shared-nothing architecture, eschewing global singletons and namespaces for explicit parametrization via imports, assuming no global garbage collection with a focus on explicit resource lifecycle management, and prioritizing Ahead of Time (AoT) compilation through declarative linking to enhance security, efficiency, and control in cross-component interactions.
As mentioned in this WASI 0.2 article, the WASI Subgroup recently achieved a major milestone by voting to release WASI 0.2, also called WASI Preview 2. This new standard, rooted in the Wasm Component Model, enables developers to compose components to develop larger, more complex applications. This method simplifies coding, enhances reusability and maintenance, and guarantees security, speed, and compatibility across diverse devices and systems.
With the Wasm Component Model, developers can break down applications into smaller components that can be formally verified. Writers of new software can choose these provably secure and reusable components to develop, test, and maintain larger applications. Each reusable component can be written in the best language for that task. These language-agnostic components can then be combined and orchestrated.
So how does Rust (mentioned as a memory-safe language in the report) fit into all of this Wasm talk?
Rust is often used in conjunction with Wasm due to its memory safety guarantees and ability to generate efficient Wasm code via the Rust toolchain. Rust’s strong emphasis on memory safety aligns well with the security benefits that Wasm provides. Rust’s ownership and borrowing system helps prevent common memory-related bugs, making it a popular choice for writing Wasm components.
Rust has first-class support for the Wasm Component Model via the cargo component
tool (a cargo subcommand for creating Wasm components using Rust as the component’s implementation language). Rust is proven to have excellent tooling and support for compiling code to Wasm, making it easier for developers to leverage Wasm’s benefits in their Rust projects. So, while Wasm is a language-agnostic technology, Rust is frequently used as a programming language for developing Wasm components and applications.
A Concrete Example
One of our articles, written back in November 2023, discussed how the software industry spends vast amounts of time re-implementing the exact same specifications dozens of times. The article titled It’s Time to Reboot Software Development critiques the inefficiency and time wastage in software development due to the redundant implementation of specifications across different programming languages.
With the stage set for a concrete solution, let’s explore the option of writing a Rust function, that we can compile into a reusable Wasm workload. This approach embodies the principle of “write once, run anywhere” for more efficient and consistent software development.
Whilst looking through the lens of potentially exploitable security vulnerabilities, let’s consider an integer overflow example.
An Integer Overflow Example
Integer overflow occurs when the result of an arithmetic operation exceeds the maximum value that can be represented by the data type, leading to unexpected and potentially incorrect results.
Integer overflow is not directly related to memory. Integer overflows can be security vulnerabilities if they are exploited to alter the behavior of software, potentially leading to access violations, incorrect processing, and other security issues. Further, integer overflows can indirectly impact memory if the incorrect result is used in subsequent memory operations, potentially causing memory corruption or other memory-related issues.
With all of that in mind, let’s use the Rust Programming Language book to help us write a new Rust application:
cargo new hello_cargo
cd hello_cargo
The above commands will scaffold out the following application structure:
tree .
.
├── Cargo.toml
└── src
└── main.rs
The largest value that can be represented by the i64
integer type is 9223372036854775807. So, we can go ahead and edit the main.rs
file, to deliberately create an integer overflow:
fn main() {
let mut i_64_int: i64 = 9223372036854775807;
println!("{}", i_64_int); // prints 9223372036854775807
i_64_int += 1;
println!("{}", i_64_int); // print -9223372036854775808
}
If we cargo run
the above code it will panic and stop:
cargo run
Compiling hello_cargo v0.1.0 (hello_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 0.20s
Running `target/debug/hello_cargo`
9223372036854775807
thread 'main' panicked at src/main.rs:4:5:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Interestingly though if we build using the --release
option:
cargo build --release
Compiling hello_cargo v0.1.0 (hello_cargo)
Finished release [optimized] target(s) in 0.23s
The same code will run without panic and the integer will overflow:
./target/release/hello_cargo
9223372036854775807
-9223372036854775808
In certain scenarios, integer overflow can be intentionally used to achieve specific behavior or optimizations. One example is in cryptographic algorithms, where modular arithmetic is employed. In modular arithmetic, integer overflow is desired as it wraps around the modulus value, allowing for efficient calculations. However, it’s important to note that intentionally using integer overflow requires careful consideration and understanding of the specific context and requirements of the program.
The above example showed the Rust executable being run. We can now compile that same Rust logic to the wasm32-wasi
target and get the same intentional overflow result in Wasm. First, we can create a Rust application using Spin’s http-rust
template:
spin new -t http-rust hello_wasm
Description: A Rust Wasm example
HTTP path: /...
cd hello_wasm
The above command scaffolds out the application structure, as shown below:
tree .
.
├── Cargo.toml
├── spin.toml
└── src
└── lib.rs
We can populate the lib.rs
with the same logic (i_64_int += 1
):
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;
/// A simple Spin HTTP component.
#[http_component]
fn handle_hello_spin(_req: Request) -> anyhow::Result<impl IntoResponse> {
let mut i_64_int: i64 = 9223372036854775807;
i_64_int += 1;
Ok(Response::builder()
.status(200)
.header("content-type", "text/plain")
// returns overflow -9223372036854775808
.body(i_64_int.to_string())
.build())
}
We can then build the application using the spin build
command:
spin build
And, finally, we can run the application:
spin up
Logging component stdio to ".spin/logs/"
Serving http://127.0.0.1:3000
Available Routes:
hello-wasm: http://127.0.0.1:3000 (wildcard)
Testing can be done in a browser to localhost:3000, or in a new terminal using the curl command:
curl -i localhost:3000
HTTP/1.1 200 OK
content-type: text/plain
transfer-encoding: chunked
date: Tue, 05 Mar 2024 06:36:23 GMT
-9223372036854775808
As we can see here, Wasm itself has no default trap-on-overflow instruction. As we mentioned above, in some cases i.e. when performing modular arithmetic, integer overflow is desired. This is fine!
But what if we deliberately did not want overflow to occur? Could we implement a trap-on-overflow at the component level? For example, instead of expecting trap-on-overflow from the compiler, can we create a reusable overflow-add Wasm component that can signal when overflow has occurred? One that can be shared among other Wasm applications that want to know about overflow occurrences.
What would that look like?
Would such a component be practical?
Would performance dictate just writing the trap-on-overflow natively; leaning towards not worrying about reuse?
Honestly not sure; let’s find out.
Composing a Reusable Component From the Ground Up
This section is dedicated to creating binaries that talk to each other regardless of the original programming language. This approach is pretty exciting. For this example of trap-on-overflow functionality, let’s go ahead and use the same logic as Rust’s overflowing_add
method mentioned here in the Rust standard library documentation.
Rust Example
Just to get some logic out in plain sight, let’s look at some Rust source code. If we edit our src/main.rs
from above to match the following:
fn main() {
let i_64_int: i64 = 9223372036854775807;
println!("{}", i_64_int); // prints 9223372036854775807
println!("{:?}", i_64_int.overflowing_add(1));
}
We can run this new functionality (note the i_64_int.overflowing_add(1)
). The above source code produces an output that alerts us to the overflow:
cargo run
Compiling hello_cargo v0.1.0 (hello_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 0.23s
Running `target/debug/hello_cargo`
9223372036854775807
(-9223372036854775808, true)
Wasm Example
So, how do we translate the above example to Wasm? Specifically, as a reusable component that can alert us to overflows? Firstly, it would help if we understood how the Wasm Component Model’s imports and exports work. Here is a brief explainer on that subject.
In the Wasm Component Model, imports and exports are mechanisms that allow Wasm components to interact with each other and with the host environment.
Imports are dependencies that are satisfied by the host or by the exports of other components when composed together. Imports include items such as functions that provide an implementation of various capabilities. For example, a component might import a function from the host to perform file operations.
Exports are the capabilities that a component can provide to the host or other components. They allow the component to make certain resources or functionalities available for use by other parts of the system. For example, a Wasm component might export a function that performs a specific calculation, which can then be called by other components or the host environment.
In simpler terms, imports are like requests for help, where a Wasm component asks for specific resources or functionalities from the outside world. Exports, on the other hand, are like offerings or services provided by a Wasm component, making certain resources or functionalities accessible to other parts of the system. By using imports and exports, Wasm components can collaborate and communicate with each other and with the host environment, enabling a modular and extensible architecture for building applications.
Wasm modules vs Wasm components
A Wasm “module” is designed to be a self-contained unit of deployment and execution (a .wasm
file). For example, a user can write a program in a language (i.e. Rust) compile that source code to a .wasm
module and run it. A Wasm “component”, on the other hand, extends the Wasm ecosystem beyond just modules and facilitates the sharing of higher-level Wasm functionality. With the Wasm Component Model, a user can write a function (a workload) in any of the supported Wasm languages and their new component can interoperate with other components written in other supported languages. Components facilitate the integration of code from different languages and different contributors and enable more modular and maintainable codebases.
Explain This to Me Like I Am 5 Years Old
Imagine instead of having just one toy (a module) you have a box of toys (different components). Sometimes, your box doesn’t have all the toys you want to play with. So, you ask your friend (the outside world) if you can borrow some of their toys. This asking is like “imports” - you’re getting something you need from outside your toy box.
Now, imagine you have a super cool toy that your friend wants to play with. You let your friend use your toy. This sharing is like “exports” - you’re offering something from your toy box for others to use.
When your toy box “imports” toys from your friend and “exports” toys to them, you and your friend are sharing and playing together nicely. This way, everyone gets to have more fun by using all the different toys together!
Implementing Imports and Exports
The Wasm Interface Type (WIT) is a language to describe the shape and surface of components. Consider the following world.wit
file which shapes the reusable component (by defining an interface overflowing-add
) and also provides the import and export of the overflowing-add
interface (for components targeting this world):
/// This package contains the types required to build components that handle integer overflow arithmetic.
package arithmetic:overflow;
/// An interface that represents various overflow arithmetic operations.
interface overflowing-add {
/// Represents the arguments to overflowing-add.
record arguments {
x: s32,
y: s32,
}
/// Returns a tuple including the result of addition and a boolean indicating whether overflow
/// artithmetic overflow would have occurred. If overflow would have occurred then the wrapped
/// value is returned.
overflowing-add: func(args: arguments) -> tuple<s32, bool>;
}
/// Components targeting this world export an instance that performs overflow arithmetic.
world library {
/// Exports an instance of the overflowing-add interface.
export overflowing-add;
}
/// Components targeting this world import an instance of the arithmetic interface.
world imports {
/// Import an instance of the overflowing-add interface.
import overflowing-add;
}
Next, consider the following src/lib.rs
of a Spin HTTP application that imports the library to compute the overflowing addition of 2 values that are passed to the handler within each request’s body (as a JSON value e.g. {"x": 42, "y": 2}
:
use spin_sdk::http::{Params, Request, Response, Router, IntoResponse};
use spin_sdk::http_component;
use anyhow::Result;
use serde_json::json;
// This bindings module, generated by wit-bindgen, contains the function overflowing_add, which serves as the import our component uses to compute overflow addition.
use bindings::arithmetic::overflow::overflowing_add::{overflowing_add, Arguments};
mod bindings;
/// A simple Spin HTTP component.
#[http_component]
fn serve(req: Request) -> Response {
let mut router = Router::new();
router.post("/add", handle_add);
router.handle(req)
}
fn handle_add(req: Request, _params: Params) -> Result<impl IntoResponse> {
let args: Arguments = serde_json::from_slice(req.body())?;
let (value, overflow) = overflowing_add(args);
Ok(Response::builder()
.status(200)
.header("content-type", "text/plain")
.body(json!({
"value": value,
"overflow": overflow
}).to_string())
.build())
}
If you would like to run this example:
- install Rust and Cargo, the Rust programming language and the Rust package manager (Cargo),
- install cargo-component, A
cargo
subcommand for building Wasm components according to the Component Model proposal,
- install Spin v2.3.0 or higher, a framework for building and running event-driven microservice applications with Wasm components, and
- install wasm-tools, CLI and Rust libraries for low-level manipulation of Wasm modules.
With the above prerequisites installed, please go ahead and clone the source code (as shown below):
# Clone the example
git clone https://github.com/fibonacci1729/overflow-arithmetic.git
# Change into the cloned repository
cd overflow-arithmetic
# View the layout of the structure (world.wit, library and example application)
tree .
.
├── Cargo.lock
├── Cargo.toml
├── README.md
├── example
│ ├── Cargo.toml
│ ├── build.sh
│ ├── spin.toml
│ └── src
│ ├── bindings.rs
│ └── lib.rs
├── library
│ ├── Cargo.toml
│ └── src
│ ├── bindings.rs
│ └── lib.rs
└── world.wit
5 directories, 12 files
To build and run the application, use the following spin up
command:
$ spin up --build -f example
In a separate console, make a request to the application (passing in the values to add):
curl -d '{"x": 100, "y": 200}' localhost:3000/add
The above command returns the following JSON object:
{"overflow":false,"value":300}
We can call this again; this time with amounts that will overflow:
# NOTE: the value of "x" in the following example is i32::MAX
curl -d '{"x": 2147483647, "y": 1}' localhost:3000/add
The above command returns the following JSON object:
{"overflow":true,"value":-2147483648}
Conclusion
The White House report mentions that “programmers writing lines of code do not do so without consequence,” that “choosing to build in a memory-safe programming language is an early architecture decision that can deliver significant security benefits,” and that “if every known vulnerability were to be fixed, the prevalence of undiscovered vulnerabilities across the software ecosystem would still present additional risk.”
One would think that the above excerpts from the report are likely to inspire developers to lean toward reusable components with deterministic outputs and checks, as we have above. But does this example demonstrate the flexibility of component development enough to present a solution to that problem in reality?
What more can we do to achieve secure and measurable software using Wasm in the real world?
Wasm presents unparalleled ubiquity in the software development industry. With all of the openly transparent work that has been put into making Wasm as fast, safe and reusable as possible, at the very least, Wasm deserves a lane in this race!
References: