Google은 Pixel 10 모뎀 펌웨어에 메모리 안전한 Rust 기반 DNS 파서를 통합해 메모리 안전성 취약점의 한 범주를 완화하고, 베이스밴드 전반에 걸친 메모리 안전 코드 도입의 기반을 마련했습니다.
Google은 Pixel 기기의 보안을 지속적으로 발전시키고 있습니다. 우리는 셀룰러 베이스밴드 모뎀의 악용 방어 강화에 집중해 왔습니다. 복잡한 모뎀 펌웨어에 내재한 위험을 인식하고, Pixel 9에는 다양한 메모리 안전성 취약점에 대한 완화책이 적용되어 출시되었습니다. Pixel 10에서 Google은 이러한 선제적 보안 조치를 한층 더 발전시키고 있습니다. 이전에 다루었던 "기존 펌웨어 코드베이스에 Rust 배포하기" 논의에 이어, 이번 글에서는 구체적인 적용 사례를 공유합니다. 바로 메모리 안전한 Rust DNS(Domain Name System) 파서를 모뎀 펌웨어에 통합한 것입니다. 새로운 Rust 기반 DNS 파서는 위험도가 높은 영역에서 취약점의 한 범주 전체를 완화함으로써 보안 위험을 크게 줄이는 동시에, 다른 영역에서도 메모리 안전 코드 채택을 확대하기 위한 기반을 마련합니다.
여기에서는 이 작업을 진행하며 얻은 경험을 공유하고, 이것이 저수준 환경에서 더 많은 메모리 안전 언어 사용에 영감을 주기를 바랍니다.
최근 몇 년 동안 공격자와 보안 연구자들이 셀룰러 모뎀에 점점 더 큰 관심을 보이고 있습니다. 예를 들어, Google의 Project Zero는 인터넷을 통해 Pixel 모뎀에서 원격 코드 실행을 달성했습니다. Pixel 모뎀에는 수십 메가바이트의 실행 코드가 있습니다. 모뎀의 복잡성과 원격 공격 표면을 고려하면, 대부분이 메모리 비안전 언어로 작성된 펌웨어 코드 안에는 여전히 다른 중대한 메모리 안전성 취약점이 남아 있을 수 있습니다.
DNS 프로토콜은 일반적으로 브라우저가 웹사이트를 찾는 맥락에서 가장 잘 알려져 있습니다. 셀룰러 기술이 발전하면서 현대의 셀룰러 통신은 디지털 데이터 네트워크로 이동했고, 그 결과 착신 전환과 같은 기본적인 동작조차 DNS 서비스에 의존하게 되었습니다.
DNS는 복잡한 프로토콜이며 신뢰할 수 없는 데이터를 파싱해야 하므로, 특히 메모리 비안전 언어로 구현되었을 때 취약점으로 이어질 수 있습니다(예: CVE-2024-27227). DNS 파서를 Rust로 구현하면 메모리 비안전성과 관련된 공격 표면을 줄일 수 있다는 점에서 가치가 있습니다.
DNS는 이미 오픈소스 Rust 커뮤니티에서 어느 정도 지원되고 있습니다. 우리는 DNS를 구현하는 여러 오픈소스 crate를 평가했습니다. 이전 글에서 공유한 기준에 따라, 우리는 hickory-proto를 최적의 후보로 식별했습니다. 이 crate는 유지보수 상태가 매우 우수하고, 테스트 커버리지가 75%를 넘으며, Rust 커뮤니티에서 폭넓게 채택되고 있습니다. 이러한 보급성은 이것이 사실상의 DNS 표준 선택지이자 장기 지원 후보가 될 잠재력을 보여줍니다. 비록 hickory-proto는 처음에는 베어메탈 환경에 필요한 no_std 지원이 없었지만(이 주제는 이전 글 참조), 우리는 해당 crate와 그 의존성들에 이 지원을 추가할 수 있었습니다.
no_std 지원 추가hickory-proto에서 no_std를 활성화하는 작업은 대부분 기계적인 성격입니다. 우리는 그 과정을 이전 글에서 공유했습니다. 우리는 hickory_proto와 그 의존성들을 수정하여 no_std 지원을 가능하게 했습니다. 이 업스트림 no_std 작업은 다른 프로젝트에도 유용한 no_std URL 파서라는 결과도 낳았습니다.
위의 PR들은 기존 std 전용 crate들에 no_std 지원을 확장하는 훌륭한 예시입니다.
코드 크기는 우리가 사용할 DNS 라이브러리를 선택할 때 평가한 요소 중 하나였습니다.
코드 크기
카테고리별 Rust로 구현된, DNS 응답 수신 시 Hickory-proto를 호출하는 셈 4KB core, alloc, compiler_builtins
(재사용 가능, 일회성 비용)17KB Hickory-proto 라이브러리 및 의존성 350KB
합계 371KB
우리는 프로토타입을 빌드하고 크기 최적화 설정으로 크기를 측정했습니다. 예상할 수 있듯이 hickory_proto는 임베디드 사용을 염두에 두고 설계된 것이 아니며, 크기 최적화도 되어 있지 않습니다. Pixel 모뎀은 메모리 제약이 매우 엄격한 편은 아니기 때문에, 우리는 커뮤니티 지원과 코드 품질을 우선시했고 코드 크기 최적화는 향후 작업으로 남겨두었습니다.
하지만 추가적인 코드 크기는 다른 임베디드 시스템에서는 장애 요인이 될 수 있습니다. 이는 향후 필요한 기능만 조건부로 컴파일할 수 있도록 추가 feature flag를 도입함으로써 해결할 수 있습니다. 이러한 모듈성을 구현하는 일은 가치 있는 미래 과제가 될 것입니다.
Rust DNS 라이브러리를 빌드하기 전에, 우리는 기존 모뎀 펌웨어 코드베이스와 Rust의 통합을 검증하기 위해 기본 산술 연산, 동적 할당, 그리고 FFI를 다루는 여러 Rust 단위 테스트를 정의했습니다.
Rust 생태계에서 컴파일의 기본 선택지는 cargo이지만, 기존 빌드 시스템에 통합할 때는 어려움이 있습니다. 우리는 두 가지 선택지를 평가했습니다.
cargo를 사용해 staticlib을 빌드한 뒤, 생성된 staticlib를 링크 단계에 추가한다.rustc를 직접 사용하고, Rust 컴파일 단계를 기존 모뎀 빌드 시스템에 통합한다.선택지 1은 앞으로 더 많은 Rust 구성 요소를 추가할 경우 확장성이 떨어집니다. 여러 staticlib를 링크하면 중복 심볼 오류가 발생할 수 있기 때문입니다. 우리는 더 쉽게 확장할 수 있고 기존 빌드 시스템과 더 긴밀하게 통합할 수 있는 선택지 2를 택했습니다. 우리의 기존 C/C++ 코드베이스는 기본 빌드 시스템을 구동하기 위해 Pigweed를 사용합니다. Pigweed는 GN에 정의된 rust tools를 통해 rustc를 직접 호출하는 Rust 타깃(예시)을 지원합니다.
우리는 hickory-proto, 그 의존성들, 그리고 core, compiler_builtin, alloc를 포함한 모든 Rust crate를 rlib으로 컴파일했습니다. 그런 다음 모든 rlib crate를 extern crate 키워드로 참조하는 단일 lib.rs 파일을 가진 staticlib 타깃을 만들었습니다.
Android의 Rust Toolchain은 core, alloc, compiler_builtins의 소스 코드를 배포하며, 우리는 이를 모뎀에도 활용했습니다. 각 crate의 루트 lib.rs를 가리키는 crate_root가 있는 GN 타깃을 추가해 이들을 빌드 그래프에 포함할 수 있습니다.
Pixel 모뎀 펌웨어에는 일부 동적 메모리 할당을 지원하기 위한, 충분히 검증되고 특화된 전역 메모리 할당 시스템이 이미 있습니다. alloc 지원은 allocator의 C API에 대한 FFI 호출과 함께 GlobalAlloc을 구현함으로써 추가되었습니다.
use core::alloc::{GlobalAlloc, Layout};
extern "C" {
fn mem_malloc(size: usize, alignment: usize) -> *mut u8;
fn mem_free(ptr: *mut u8, alignment: usize);
}
struct MemAllocator;
unsafe impl GlobalAlloc for MemAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
mem_malloc(layout.size(), layout.align())
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
mem_free(ptr, layout.align());
}
}
#[global_allocator]
static ALLOCATOR: MemAllocator = MemAllocator;
Pixel 모뎀 펌웨어는 이미 전역 크래시 핸들러로서 Pigweed crash facade의 백엔드를 구현하고 있습니다. 이를 FFI를 통해 Rust panic_handler에 노출하면 Rust와 C/C++ 코드 모두에 대해 크래시 처리를 통합할 수 있습니다.
#![no_std]
use core::panic::PanicInfo;
extern "C" {
pub fn PwCrashBackend(sigature: *const i8, file_name: *const i8, line: u32);
}
#[panic_handler]
fn panic(panic_info: &PanicInfo) -> ! {
let mut filename = "";
let mut line_number: u32 = 0;
if let Some(location) = panic_info.location() {
filename = location.file();
line_number = location.line();
}
let mut cstr_buffer = [0u8; 128];
// Never writes to the last byte to make sure `cstr_buffer` is always zero
// terminated.
let (_, writer) = cstr_buffer.split_last_mut().unwrap();
for (place, ch) in writer.iter_mut().zip(filename.bytes()) {
*place = ch;
}
unsafe {
PwCrashBackend(
"Rust panic\0".as_ptr() as *const i8,
cstr_buffer.as_ptr() as *const i8,
line_number,
);
}
loop {}
}
Pixel 모뎀 펌웨어 링크 과정에는 C/C++ 코드에서 생성된 모든 오브젝트를 링크하기 위해 링커를 호출하는 단계가 있습니다. Rust로 결합된 staticlib에서 llvm-ar -x를 사용해 오브젝트 파일을 추출하고 이를 링커에 제공하면, Rust 코드는 최종 모뎀 이미지에 포함됩니다.
링크 과정에서 weak symbol로 인해 성능 문제가 발생했습니다. Rust core와 compiler-builtin의 포함으로 여러 테스트에서 예기치 않은 전력 및 성능 회귀가 발생했습니다. 분석 결과, 모뎀 펌웨어가 제공하는 memset과 memcpy의 최적화 구현이 compiler_builtin에 정의된 구현으로 의도치 않게 대체되고 있음을 확인했습니다. 이는 compiler_builtin crate와 기존 코드베이스가 모두 심볼을 weak로 정의하고 있어서, 링커가 어느 쪽이 더 약한지 판단할 방법이 없기 때문에 발생하는 것으로 보입니다. 우리는 링크 전에 한 줄짜리 셸 스크립트로 compiler_builtin crate를 제거해 이 회귀를 해결했습니다.
llvm-ar -t <rust staticlib> | grep compiler_builtins | xargs llvm-ar -d <rust staticlib>
DNS 파서의 경우, 우리는 C에서 DNS 응답 파싱 API를 선언한 뒤 Rust에서 동일한 API를 구현했습니다.
int32_t process_dns_response(uint8_t*, int32_t);
Rust 함수는 오류 코드를 나타내는 정수를 반환합니다. DNS 응답에 포함된 DNS answer들은 원래 C 구현과 결합된 인메모리 데이터 구조에 반영되어야 하므로, 이를 위해 기존 C 함수를 사용합니다. 이 기존 C 함수들은 Rust 구현에서 호출됩니다.
pub unsafe extern "C" fn process_dns_response(
dns_response: *const u8,
response_len: i32,
) -> i32 {
//... validate inputs `dns_response` and `response_len`.
// SAFETY:
// It is safe because `dns_response` is null checked above. `response_len`
// is passed in, safe as long as it is set correctly by vendor code.
match process_response(unsafe {
slice::from_raw_parts(dns_response, response_len)
}) {
Ok(()) => 0,
Err(err) => err.into(),
}
}
fn process_response(response: &[u8]) -> Result<()> {
let response = hickory_proto::op::Message::from_bytes(response)?;
let response = hickory_proto::xfer::DnsResponse::from_message(response)?;
for answer in response.answers() {
match answer.record_type() {
hickory_proto::RecordType:... => {
// SAFETY:
// It is safe because the callback function does not store
// reference of the inputs or their members.
unsafe {
callback_to_c_function(...)?;
}
}
// ... more match arms omitted.
}
}
Ok(())
}
우리의 경우 DNS 응답 파싱 함수 API는 직접 작성할 수 있을 만큼 단순했지만, 응답 처리를 위해 C 함수로 다시 호출하는 콜백 쪽은 복잡한 데이터 타입 변환이 필요했습니다. 그래서 우리는 콜백용 FFI 코드를 생성하기 위해 bindgen을 활용했습니다.
모든 기능을 비활성화하더라도 hickory-proto는 30개가 넘는 의존 crate를 도입합니다. 수동으로 빌드 규칙을 작성하면 정확성을 보장하기 어렵고, 의존성을 새 버전으로 업그레이드할 때 확장성이 크게 떨어집니다.
Fuchsia는 자사의 서드파티 Rust crate 빌드를 지원하기 위해 cargo-gnaw를 개발했습니다. Cargo-gnaw는 cargo metadata를 호출해 의존성을 해석한 다음, 이를 파싱해 GN 빌드 규칙을 생성하는 방식으로 동작합니다. 이를 통해 정확성과 유지보수 용이성을 확보할 수 있습니다.
Pixel 10 시리즈는 모뎀에 메모리 안전 언어를 통합한 최초의 Pixel 기기라는 점에서 중요한 전환점을 이룹니다.
위험한 공격 표면의 한 부분을 대체하는 것만으로도 그 자체로 가치가 있지만, 이 프로젝트는 앞으로 셀룰러 베이스밴드에 메모리 안전 파서와 코드를 더 폭넓게 통합하기 위한 기반을 마련합니다. 이를 통해 개발이 계속됨에 따라 베이스밴드의 보안 태세는 지속적으로 개선될 것입니다.
Armando Montanez, Bjorn Mellem, Boky Chen, Cheng-Yu Tsai, Dominik Maier, Erik Gilling, Ever Rosales, Hungyen Weng, Ivan Lozano, James Farrell, Jeffrey Vander Stoep, Jiacheng Lu, Jingjing Bu, Min Xu, Murphy Stein, Ray Weng, Shawn Yang, Sherk Chung, Stephan Chen, Stephen Hines께 특별한 감사를 전합니다.