Once a satellite is launched, nobody can open it up and fix it. Whatever firmware is on board when it leaves the launch pad is what it will run until an uplink patch arrives, and an uplink patch is itself a piece of attacker-reachable input arriving over a radio link. If the code that receives that input has a memory bug, the spacecraft can be crashed, bricked, or taken over by whoever finds the bug first.
Most of that firmware is written in C, sometimes C++. Both are mature, well understood, supported by every toolchain and vendor SDK, and both make it easy to write code that reads or writes memory it doesn’t own. Rust is the first mainstream systems language that rules out most of those mistakes at compile time, without a garbage collector and without giving up the control over memory layout that embedded work depends on.
This post covers which classes of bugs Rust prevents, what those bugs actually do on a microcontroller or a spacecraft, and, just as important, which problems Rust leaves exactly where they were.
Memory bugs are most of the problem
Here are the numbers that started the industry-wide conversation:
- Microsoft reported in 2019 that roughly 70% of the vulnerabilities it assigned a CVE to each year were memory safety issues.
- Google’s Chromium team found the same proportion: around 70% of high and critical severity security bugs were memory safety problems.
- Android is the most useful data point, because it shows what happens when you change languages. As new code shifted to Rust and other memory-safe languages, memory safety vulnerabilities fell from 76% of Android’s total in 2019 to 24% in 2024. Most of the old C and C++ was never rewritten.
None of these come from embedded systems, and that matters for how you read them. A desktop browser runs on an operating system with process isolation, address space layout randomisation, non-executable memory, stack canaries and a sandbox. Most of those protections are missing on a microcontroller, so the same bug does more damage there.
This is also no longer only an engineering preference. The NSA, CISA and the White House Office of the National Cyber Director have all published guidance pushing vendors towards memory-safe languages. The ONCD’s 2024 report, Back to the Building Blocks, treats space systems as a special case: memory-safe languages there are not yet flight-proven, and the report points to memory-safe hardware and formal methods as complementary routes. Harder doesn’t mean less necessary.
Why the same bug is worse on a microcontroller
A Cortex-M class microcontroller, the kind of chip that runs most small spacecraft subsystems, reaction wheel controllers, power boards and sensor front-ends, typically has:
- One flat address space. Flash, RAM and memory-mapped peripheral registers all sit in a single physical address map. No MMU, no virtual memory, no process boundaries. A wild write can land in your variables, your stack, or a peripheral control register.
- No ASLR. The firmware is linked to fixed addresses, the same on every unit that was flashed with the same image. An exploit that works on one board works on all of them.
- Often no execute-never protection. An MPU can mark RAM as non-executable, but it’s optional hardware, and plenty of firmware never configures it.
- No stack guard by default. Stack overflow doesn’t fault. It silently overwrites whatever is placed next to the stack (more on that below).
- No supervisor that cleans up. On a desktop, a crashed process is killed and restarted by the OS. On a microcontroller, the “recovery mechanism” is usually a watchdog timer that resets the whole chip, if it was enabled and if the corruption doesn’t keep feeding it.
Satellites add a few more problems on top:
- You can’t physically access them. Recovery from a bad state depends on the very software that’s in the bad state, or on a hardware-level safe mode if the designers built one.
- Updates go out in limited windows. A low Earth orbit satellite is visible to a given ground station for a few minutes per pass. Getting a patch up can take days.
- They stay in service for a long time. A spacecraft launched today may run the same core firmware for 10 or 15 years, long after the original developers have moved on.
- They’re shared infrastructure. A spacecraft that loses attitude control or fires its thrusters at the wrong time isn’t only a lost asset. It can become debris, and debris is everybody’s problem.
Where the attacker’s bytes land
Every satellite has a telecommand path: the uplink that operators use to send commands. It’s the single most security-relevant piece of software on board, because it’s the code that reads bytes coming from outside.
Two things make this worse than it looks.
First, telecommand links have historically not been authenticated. The CCSDS standards that most missions build on do define a security layer (SDLS), but it’s optional, and many missions, especially smaller ones, have flown without it. Even where there is authentication, the code that checks it has to parse the frame first, so a memory bug in the framing or header parsing is reachable before any signature is verified.
Second, the hardware to transmit is cheap. Software-defined radios and the antennas needed to reach low Earth orbit are within reach of hobbyists, let alone a state actor.
This isn’t hypothetical. In 2023, researchers from Ruhr University Bochum and CISPA analysed the firmware of three satellites in low Earth orbit (Space Odyssey: An Experimental Software Security Analysis of Satellites, IEEE S&P 2023). None of them had ASLR, stack canaries or non-executable memory, one had no authentication on its telecommand interface, and the firmware contained numerous buffer overflows. The researchers were able to seize full control of two of the three satellites.
The Viasat KA-SAT attack on 24 February 2022, the day Russia invaded Ukraine, wasn’t a memory bug in orbit. The attackers got into the ground network through a misconfigured VPN appliance and pushed management commands that overwrote the flash memory of user modems. But it showed that satellite infrastructure is a real target in a real conflict: tens of thousands of customers across Europe lost service, along with remote control of roughly 5,800 wind turbines in Germany.
So what happens when that parser has a bug?
What Rust rules out, and what those bugs do
The guarantee Rust makes is specific: safe Rust code cannot cause undefined behaviour. Everything below follows from that guarantee. All of it applies in #![no_std] firmware with no heap and no operating system. None of it relies on a runtime or garbage collector. The checks are done by the type system and borrow checker at compile time, or by bounds checks that the compiler removes whenever it can prove they’re redundant.
Buffer overflows
The bug: writing or reading past the end of an array. It’s the oldest memory bug there is and still the most common.
Here’s the textbook version, in a shape that shows up in real telecommand handlers:
typedef struct {
uint8_t apid;
uint16_t len;
uint8_t data[];
} tc_packet_t;
void handle_tc(const tc_packet_t *pkt) {
uint8_t buf[64];
memcpy(buf, pkt->data, pkt->len); /* len comes straight from the radio */
dispatch(buf, pkt->len);
}
pkt->len is a 16-bit field the sender controls. If it says 200, memcpy writes 200 bytes into a 64-byte buffer on the stack, and the 136 extra bytes go wherever is next.
The damage: on a microcontroller with no stack canaries and no execute-never protection, an overflow that reaches the saved return address is a direct path to remote code execution: the attacker puts their own instructions in the packet and points the return address at them. In a satellite, that means arbitrary control of every subsystem the on-board computer can command. Even the “harmless” variant, an out-of-bounds read, is serious: Heartbleed was a missing length check that let anyone read up to 64 KB of server memory per request, including private keys. On a spacecraft, the equivalent leak could expose the key material used to authenticate telecommands.
What Rust does: the same handler in Rust:
const MAX_TC: usize = 64;
fn handle_tc(frame: &[u8]) -> Result<(), TcError> {
let (header, body) = frame.split_first_chunk::<3>().ok_or(TcError::Truncated)?;
let len = u16::from_be_bytes([header[1], header[2]]) as usize;
let payload = body.get(..len).ok_or(TcError::Truncated)?;
if payload.len() > MAX_TC {
return Err(TcError::TooLong);
}
let mut buf = [0u8; MAX_TC];
buf[..payload.len()].copy_from_slice(payload);
dispatch(&buf[..payload.len()])
}
The length checks are explicit here because they’re good practice, but they aren’t what makes this safe. Suppose the TooLong check were removed. buf[..payload.len()] with a length of 200 doesn’t write past the end. It panics, which is a defined, catchable event at a known place in the code, not a jump to an address the attacker picked. Any slice access (buf[i], &buf[a..b], copy_from_slice) is checked the same way. And when the compiler can prove an index is in range, as with a loop over buf.iter(), it removes the check, so the cost is much smaller than people expect.
Use-after-free and dangling pointers
The bug: memory is used after the thing that owned it has gone away. On desktops this is usually heap memory. On microcontrollers there often is no heap, but the bug still shows up, typically through DMA.
DMA lets a peripheral like a UART or radio write directly into RAM in the background while the CPU does other work. Here’s a mistake I’ve seen in more than one codebase:
void start_uart_rx(void) {
uint8_t rx[128];
dma_start(UART1_RX, rx, sizeof rx);
} /* rx is gone, but the DMA engine keeps writing into this stack address */
The function returns and its stack frame is reused by the next function call, but the DMA controller still has the old address and keeps writing incoming radio bytes into it.
The damage: it’s the worst kind of bug to debug, because the symptoms appear far from the cause. Random variables change, functions return to wrong addresses, and it happens only when data arrives at a particular moment. Remotely, it’s also an attacker-controlled write into the stack: whoever is transmitting chooses the bytes that overwrite the next function’s locals and return address.
What Rust does: ownership and lifetimes make this impossible to express in safe code. Rust’s embedded HALs model a DMA transfer as something that takes ownership of the buffer, and they require the buffer to live for 'static, meaning forever. A stack buffer doesn’t qualify, so the C version above won’t compile:
static RX_BUF: StaticCell<[u8; 128]> = StaticCell::new();
let buf: &'static mut [u8; 128] = RX_BUF.init([0; 128]);
let transfer = uart_rx.read_dma(buf, dma_ch); // buf moves into the transfer
// Any use of `buf` here is a compile error: the transfer owns it now.
let (buf, uart_rx, dma_ch) = transfer.wait(); // ownership comes back when DMA is done
The exact API shape varies between HALs, but the pattern is the same everywhere. You can’t read the buffer while the DMA engine may still be writing it, and you can’t hand it a buffer that won’t outlive the transfer. The same rules prevent double-frees, returning pointers to locals, and iterator invalidation.
Data races between interrupts and the main loop
The bug: two execution contexts touch the same memory and at least one of them writes. On a single-core microcontroller there are no threads, but there are interrupts, and an interrupt handler can preempt the main loop between any two instructions.
The damage: races like this cause intermittent failures that almost never show up on the test bench and show up in flight under real traffic. In the example, a counter drifts and a command is silently dropped. When the shared data is a buffer index instead of a counter, a race can push the index past the end of the buffer, and you’re back to an out-of-bounds write that the transmitter controls.
What Rust does: a static that’s shared between an interrupt and the main loop can’t be mutated from safe code. Writing to a static mut requires unsafe, and since the 2024 edition the compiler rejects taking references to static mut by default. Shared state has to go through a type that proves access is synchronised:
use core::cell::RefCell;
use critical_section::Mutex;
static PENDING: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));
#[interrupt]
fn USART1() {
critical_section::with(|cs| *PENDING.borrow_ref_mut(cs) += 1);
}
fn process_one() {
critical_section::with(|cs| *PENDING.borrow_ref_mut(cs) -= 1);
}
The cs token can only be obtained inside a critical section (interrupts disabled), and the Mutex won’t give you the data without it. Forget the critical section and the code doesn’t compile. Frameworks like RTIC go further: they analyse interrupt priorities at compile time and only lock where a real conflict exists. More generally, the Send and Sync traits are how Rust guarantees freedom from data races on multicore parts as well.
For a plain counter, an AtomicU32 is simpler, on cores that support atomic read-modify-write (Cortex-M3 and up).
Null pointers, uninitialised memory, and two drivers sharing a peripheral
The bug: dereferencing a null pointer, reading a variable before it’s been written, or two pieces of code that both believe they own the same hardware peripheral.
The damage: on a Cortex-M, address 0x0000_0000 is usually valid memory. It’s where the vector table lives. A null dereference doesn’t crash cleanly. It typically just reads the initial stack pointer value, and a null write (when flash is writable or remapped) can corrupt the interrupt vectors. Uninitialised stack memory leaks whatever the previous function left there, which can include key material. Two drivers reconfiguring the same SPI bus can corrupt a flash write to the memory chip holding the next firmware image.
What Rust does: there are no null references. Absence is an explicit Option<T> that the compiler forces you to handle. Every variable must be initialised before it’s read. And embedded Rust’s peripheral access crates model hardware as singletons:
let dp = pac::Peripherals::take().unwrap(); // a second call returns None
let spi = dp.SPI1; // moved: only one owner, ever
Ownership of a peripheral works like ownership of any other value. Once the flash driver owns SPI1, nothing else can touch it without being handed it explicitly.
Hardware misconfiguration
This one isn’t a memory bug in the classic sense, but it’s a big source of embedded failures, and Rust’s type system handles it well. HALs use typestates so that a pin’s or peripheral’s configuration is part of its type:
let heater = gpioa.pa5.into_push_pull_output();
heater.set_high(); // fine
let sensor = gpioa.pa6.into_analog();
sensor.set_high(); // compile error: no method `set_high` for an analog pin
Driving a pin that’s wired as an input, using a UART before its clock is enabled, or reconfiguring a pin another driver is using: these become compile errors, not field failures. On a satellite, “the heater line was accidentally left floating” can mean a battery freezing in eclipse.
Integer overflow: defined, but still your decision
Integer overflow is a useful case because Rust doesn’t fully prevent it, and it shows what memory safety actually buys you.
In C, signed overflow is undefined behaviour: the compiler may assume it never happens and optimise accordingly, which has turned harmless-looking bounds checks into no-ops. In Rust, overflow is always defined. It panics in debug builds and wraps in release builds by default. That’s safer, but wrapping arithmetic on a length field is still a bug. For firmware, turn the checks on in release too:
[profile.release]
overflow-checks = true
and use checked_add, saturating_sub and friends wherever the math depends on outside input.
Then you have to decide what should happen when the check fires. Ariane 5 Flight 501 is the classic warning. It was written in Ada, a language with safety checks. A conversion from a 64-bit float to a 16-bit integer overflowed, the check correctly raised an exception, nothing handled it, and both inertial reference units shut down within a fraction of a second of each other. The rocket broke up about 40 seconds after liftoff. The language did its job. The system had no plan for what to do when it did.
That leads to what Rust doesn’t do for you.
What Rust doesn’t fix
Memory safety removes one large class of bugs. It doesn’t make firmware secure on its own, and claiming otherwise is how teams end up overconfident. Here’s what stays your problem:
A panic is a denial of service. In the buffer overflow example, Rust turns remote code execution into a crash. That’s a huge improvement, but if an attacker can trigger the panic over and over, they can keep a spacecraft in a reboot loop. Your panic handler is part of your security design:
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
persist_crash_record(info); // to a no-init RAM section or FRAM
cortex_m::peripheral::SCB::sys_reset();
}
Pair it with a boot counter that drops into a minimal safe mode after repeated resets, and write parsers that return Err instead of indexing directly, so malformed input is rejected rather than panicking.
Stack overflow is still silent by default. On Cortex-M, the default memory layout puts the stack at the top of RAM, growing down towards your static variables. Overflow doesn’t fault. It overwrites .bss and .data, and this is one of the few ways safe embedded Rust can still corrupt memory.
Use flip-link or an MPU guard region, and measure worst-case stack depth with static analysis rather than guessing.
unsafe and FFI are where the guarantees end. Register access, vendor C libraries, legacy flight software modules and radio drivers will put some unsafe in any real firmware. Rust’s contribution is that this code is marked and contained: a few hundred audited lines behind safe interfaces, instead of the entire codebase. Keep it small, wrap it in safe abstractions, and review it like the high-risk code it is.
Logic and protocol bugs are untouched. If the telecommand interface accepts unauthenticated commands, Rust will happily parse them perfectly. Authentication, replay protection, command authorisation and key management are design problems. So is the rule that a “deorbit” command shouldn’t be one packet away from any operator with a radio.
Radiation doesn’t respect the type system. In orbit, a single event upset can flip a bit in RAM or a register, and no compile-time proof survives a cosmic ray changing a value after the fact. You still need ECC memory and scrubbing, watchdogs, redundant (TMR or lockstep) processors where it matters, and validation of critical values at runtime.
Side channels and supply chain. Timing and power analysis attacks on cryptographic code aren’t memory bugs. And cargo add makes it easy to pull dependencies into flight firmware. Pin versions, vendor them, and audit them with cargo-vet or cargo-deny.
Summary
| Bug class | Typical damage on embedded / space | Safe Rust |
|---|---|---|
| Buffer overflow (write) | Remote code execution, full spacecraft takeover | Prevented: bounds-checked, panics |
| Out-of-bounds read | Key and memory disclosure | Prevented: bounds-checked, panics |
| Use-after-free, dangling DMA buffer | Attacker-controlled memory corruption | Prevented: ownership and lifetimes |
| Data race (ISR vs main loop) | Lost commands, corrupted indices | Prevented: Send/Sync, critical sections |
| Null / uninitialised reads | Vector table corruption, data leaks | Prevented: Option, mandatory init |
| Peripheral misuse | Corrupted flash writes, wrong outputs | Largely prevented: singletons, typestates |
| Integer overflow | Wrong lengths leading to other bugs | Defined, not prevented; enable checks |
| Stack overflow | Silent static corruption | Not prevented; use flip-link or MPU |
| Panics | Denial of service, reboot loops | Your design: handler and safe mode |
| Missing auth, logic errors | Hijacked spacecraft | Not addressed |
| Radiation bit flips | Arbitrary state corruption | Not addressed: ECC, TMR, scrubbing |
Getting there without a rewrite
Nobody is going to rewrite a flight-proven C codebase in one go, and nobody should try. The Android numbers show that’s not necessary: most of the reduction came from writing new code in a memory-safe language while the old code aged in place, because most vulnerabilities live in recent code.
A practical order:
- Start with the telecommand parser. It has the largest attack surface and the most exposure, and it’s mostly pure logic: bytes in, structured commands out. It’s easy to wrap behind a C ABI and easy to fuzz with
cargo fuzzon a workstation. If you only do one thing, do this. - Write new subsystems and drivers in Rust. The embedded ecosystem is mature now: embedded-hal 1.0, async frameworks like Embassy, RTIC for hard real-time scheduling, and operating systems like Hubris and Tock designed around isolation from the start.
- Treat the C/Rust boundary as a trust boundary. Validate everything that crosses it, and keep the
unsafeglue small and reviewed. - Plan for qualification early. Safety-critical industries need qualified toolchains, and they now exist: Ferrocene is a Rust compiler qualified for ISO 26262 and IEC 61508. Space standards and agency processes are catching up more slowly. Raise the question with your quality assurance and certification people early, not after the code is written.
Rust doesn’t make firmware secure. It removes the class of bugs that has caused most exploitable vulnerabilities for decades, and it does that at compile time, before the code ever reaches a launch pad. That leaves the engineering effort for the problems no compiler can solve: authentication, fault tolerance, and deciding what a spacecraft should do when something goes wrong.
If you’re weighing how to introduce Rust into an existing embedded or flight software codebase, that’s the kind of architecture decision I help teams with as part of my consultancy work.
Sources
- Willbold, Schloegel, Vögele, Gerhardt, Holz, Abbasi. Space Odyssey: An Experimental Software Security Analysis of Satellites. IEEE Symposium on Security and Privacy, 2023. Also on ResearchGate.
- Viasat. KA-SAT Network cyber attack overview. 2022.
- Wikipedia. Viasat hack.
- Office of the National Cyber Director. Back to the Building Blocks: A Path Toward Secure and Measurable Software. The White House, February 2024.