8 月 9 日,Solana 驗證者和用戶端團隊齊心協力解決了一個嚴重的安全漏洞。雖然這一過程是秘密進行的,但是依然是透過開源儲存庫。這個漏洞究竟有多大危害,以至於讓 Solana 團隊如此重視?

作者:Certik

封面: Photo by GuerrillaBuzz on Unsplash

1. Solana 漏洞起因

8 月 9 日,Solana 驗證者和用戶端團隊齊心協力解決了一個嚴重的安全漏洞。 Solana 驗證者 Laine 表示,這個過程始於 8 月 7 日,當時 Solana 基金會透過私人管道聯繫了知名網路營運商。此次聯繫是秘密修補漏洞策略的一部分,旨在防止漏洞被以任何方式利用。補丁透過 Anza 工程師的 GitHub 儲存庫提供,使營運商能夠獨立驗證和套用變更。

這次秘密修復的詳情可以在 GitHub 儲存庫最近一次發布的 Mainnet-beta(https://github.com/anza-xyz/agave/compare/v1.18.21...v1.18.22)中找到,唯一的改變是 rbpf SVM 虛擬機,從 8 月 9 日的 rbpf SVM 虛擬機唯一 pull(https://github.com/solana-labs/rbpf/pull/583)可以定位到漏洞所在,雖然這一過程是秘密進行的,但仍然是透過開源儲存庫,Solana 順利地過渡了這次安全危機。這個漏洞究竟有多大危害,以至於讓 Solana 團隊如此重視?

CertiK 團隊對此漏洞進行了深入分析。漏洞存在於 rbpf SVM 虛擬機器中,SVM(Solana Virtual Machine)是 Solana 區塊鏈生態系統的核心元件之一,負責執行智慧合約和去中心化應用程式。其核心原理是利用即時編譯技術實現高性能的智能合約執行。由於 Solana 的高吞吐量和低延遲特性,SVM 在 Solana 中扮演著至關重要的角色,為開發者提供了一個高效的去中心化應用開發環境,並且對 Solana 的安全性起著重要作用。

本文將會詳細分析漏洞的核心原理與影響。

2. SVM 虛擬機器存在嚴重的指令漏洞

SVM 是 Solana 區塊鏈平台的關鍵組成部分,用於提供高效、安全的執行環境,用於運行智慧合約和分散式應用程式。 SVM 的設計採用了 rbpf 字節碼解釋器(interpreter)和即時編譯器(JIT),透過全域狀態和智慧合約介面實現與區塊鏈網路的互動。

關於 SVM 虛擬機器如何載入運行 elf 智能合約可以參考上一次 CertiK 對 rbpf 的漏洞分析章節中關於 SVM 運行模式介紹。

這次漏洞的核心修補程式是 commit(https://github.com/solana-labs/rbpf/pull/583)對 rbpf SVM 虛擬機器的修復。漏洞的根源在於精心建構的`callx regs`指令會導致 rbpf SVM 虛擬機器崩潰。接下來,我們將分析`callx regs`指令如何引發如此嚴重的影響。

首先,我們需要了解 SVM 虛擬機器中`SBF`指令`callx regs`的運作模式與基礎資訊:

a. SBF 指令基本的尋址

`SBF`指令的基本結構如下圖所示,其中`program_vm_addr`是 SVM 虛擬機器中指令的起始位址。對於 SBFV1 版本的智慧合約,`program_vm_addr`計算公式為`text_section.sh_addr.saturating_add(ebpf::MM_PROGRAM_START)`。 `text_section.sh_addr`是 ELF 頭部的`text address`。在 SVM 虛擬機器中,每個`SBF`指令的大小為`ebpf::INSN_SIZE`,即 8 個位元組。下圖中的`program.len`表示 n+1 條`SBF`指令的總大小。

圖片

b. Callx regs 的運作模式

在 SVM 虛擬機器中,`callx regs`指令的運作模式如下:`target_pc`是傳入`callx`指令的暫存器值,並作為 SVM 虛擬機器中的程式計數器(PC)偏移量。在執行`callx regs`時,兩個關鍵檢查用於確保暫存器值不越界。

  1. 檢查程式起始位址:確保`target_pc`不小於程式的起始位址。 `program_vm_addr`代表`SBF`程式的起始位址。檢查條件是`program_vm_addr <= target_pc`,確保`target_pc`不低於程式的起始位址,從而避免程式跳到非法位址。
  2. 檢查程序結束位址:確保`target_pc`不超過程式的最大位址。 `program.MaxAddr`代表`SBF`指令在程式中的起始位址加上整個程式的指令大小。檢查條件是`target_pc < program.MaxAddr`,確保`target_pc`在程式的有效範圍內,避免越界存取。

如果這兩個條件都滿足,則程式會安全地跳到指定的 PC 位址。

圖片

c. 漏洞的 root cause

透過前文對`SBF`指令基本尋址和`callx regs`運行模式的了解,我們可以分析 JIT 模式下`callx regs`存在漏洞的關鍵原因。

首先先分析下在 JIT 模式中`SBF`指令尋址映射到 x86 機器碼的過程,`JitProgram`結構體包含了兩個重要成員:

  1. `pc_section`:儲存每個`SBF`指令對應到 x86 機器碼在`text_section`中的偏移位址。這個欄位提供了從指令到機器碼的映射,使得在執行`SBF`指令時可以快速找到對應的機器碼位置。
  2. `text_section`:儲存 x86 機器碼的記憶體區域。它包含了即時編譯產生的機器碼,供處理器在運行時執行。

即使在執行 x86 機器碼時,也需要根據`SBF` 指令來定址到對應的機器碼。例如,`callx target_pc`指令中,`target_pc`可以透過索引`pc_section`陣列尋址到對應的 x86 機器碼偏移。如果`target_pc`偏移的換算過程出現問題,導致從`pc_section`取得的偏移不正確,可能會導致取得的執行的 x86 機器碼不一致。

pub struct JitProgram {
    /// OS page size in bytes and the alignment of the sections
    page_size: usize,
    /// A `*const u8` pointer into the text_section for each BPF instruction
    pc_section: &'static mut [usize],
    /// The x86 machinecode
    text_section: &'static mut [u8],
}
Initialization:
pc_section: std::slice::from_raw_parts_mut(raw.cast::<usize>(), pc) ->SBF 指令偏移对应 x86 机器码偏移
write:
self.result.pc_section[self.pc] = unsafe { text_section_base.add(self.offset_in_text_section) } as usize;-> 每一次编译 SBF 指令,每一条 SBF 对应 x86 机器码地址

在`JitProgram`中初始化`pc_section`和`text_section`的流程如下:

  1. 決定頁面大小:透過`get_system_page_size()`取得系統的頁面大小,通常是記憶體管理的基本單位。
  2. `pc_loc_table_size`:`pc_loc_table_size`是`PC * 8`的大小,其中`pc`是傳入的指令數。此大小會四捨五入到頁面大小的倍數,因為`pc_section`儲存的是`text_section`中對於 x86 機器碼位址偏移,`usize`類型的位址大小在 64 位元系統中剛好是 8 位元組。
  3. `over_allocated_code_size`:`over_allocated_code_size`是`code_size`四捨五入到頁面大小的倍數。這樣做是為了確保分配足夠的記憶體來存放 x86 機器碼。
  4. 分配記憶體:透過`allocate_pages`分配的記憶體總大小是`pc_loc_table_size + over_allocated_code_size`。 `allocate_pages`傳回一個裸指針,指向分配的記憶體區域。
  5. 初始化`pc_section`:`pc_section`是一個可變切片,指向記憶體的起始部分,用來存放`pc`個 x86 機器碼位址。透過`std::slice::from_raw_parts_mut`創建,`raw.cast::<usize>()`將裸指標轉換為`*mut usize`類型,切片的長度為`pc`,每個元素的大小為 8 位元組。
  6. 初始化`text_section`:`text_section`是另一個可變切片,指向分配記憶體區域的後半部分,用於存放 x86 機器碼。它從`pc_loc_table_size`位置開始,到記憶體的末端。這透過`raw.add(pc_loc_table_size)`來確定起始位址(跳過`pc_section`儲存大小),大小為`over_allocated_code_size`。

`pc_section`用於儲存指令計數器位置表,大小為`pc * 8`,而`text_section`用於儲存 x86 機器碼,大小為`code_size`,所有記憶體分配都以頁面大小對齊。

fn new(pc: usize, code_size: usize) -> Result<Self, EbpfError> {
        let page_size = get_system_page_size();(1、确定页面大小)
        let pc_loc_table_size = round_to_page_size(pc * 8, page_size); 
(2、获取 pc_loc_table_size 值,用于 pc_section 切片大小,round_to_page_size() 函数确保四舍五入到页面大小的倍数,pc_loc_table_size 的大小需要指令数和 8 字节对齐)
        let over_allocated_code_size = round_to_page_size(code_size, page_size); 
(3、获取 over_allocated_code_size 值,用于 text_section 大小)
        unsafe {
            let raw = allocate_pages(pc_loc_table_size + over_allocated_code_size)?;
(4、分配内存,返回裸指针 raw)
            Ok(Self {
                page_size,
                pc_section: std::slice::from_raw_parts_mut(raw.cast::<usize>(), pc),
(5、初始化 pc_section)
                text_section: std::slice::from_raw_parts_mut(
                    raw.add(pc_loc_table_size),
                    over_allocated_code_size,
                ),
(6、初始化 text_section)
            })
        }
    }

`JitProgram`的每一次`compile``SBF`指令時候都會將偏移的`text_section`位址儲存到`pc_section`中,而`text_section`儲存了 x86 機器碼的偏移位址:

let text_section_base = self.result.text_section.as_ptr();(text_section_base 是一个裸指针,指向 text_section 的起始位置。)
self.result.pc_section[self.pc] = unsafe { text_section_base.add(self.offset_in_text_section) } as usize;
(目标指针(text_section_base.add(self.offset_in_text_section))被转换为 usize 类型并存储在 pc_section 的相应位置。)

在`callx regs`指令中,透過傳入的`target_pc`計算出相對位址後跳到儲存在`pc_section`中的 x86 機器碼位址。在 JIT 模式中,透過計算`target_pc - program_vm_addr`來取得相對位址。 JIT 模式下透過取得的相對位址和`self.result.pc_section.as_ptr() as i64`陣列指標位址相加可以取得`pc_section`陣列中儲存的`text_section`位址。其中`self.result.pc_section.as_ptr() as i64`取得的是`pc_section`裸指標的陣列基底位址,`pc_section`是一個`&[usize]`類型的切片,想要正確索引`pc_section`陣列的值,所取得的裸指標位址索引偏移必須是 8 位元組的整數倍。

在了解完 callx regs 的尋址方式,接著分析造成漏洞 root cause 的地方。

漏洞的根本原因在於取得相對地址的過程。 `callx regs`指令的處理流程如下:1. 取得`target_pc`的值作為絕對位址。

2. 絕對位址按照 8 位元組對齊。

3. 判斷絕對地址是否越界。

4. 取得相對地址。

5. 透過相對位址和`pc_section`陣列指標位址計算最終跳轉的 x86 機器碼位址。

圖片

漏洞的關鍵點在於第 4 步,合約中`program_vm_addr`和`target_pc`的值傳入可控,`target_pc`的值為`callx regs`的值,而`program_vm_addr`的值需要根據 ELF 格式經過精心建構並且繞過 SVM 虛擬機器對 ELF 格式的安全性檢查,就可以控制`program_vm_addr`的值。

這裡起始位址`program_vm_addr`值的建構需要注意 SVM 虛擬機器中的主要幾個檢查:

1. 這個檢查程式碼的目的是計算 ELF 檔案中入口點(`Entrypoint`)相對於文字段(`text section`)的偏移量,並檢查這個偏移量是否是指令大小`ebpf::INSN_SIZE`的整數倍,目的是確保入口點(`Entrypoint`)在 ELF 檔案的文字段(`text section`)中對齊到正確的指令邊界,由於`text_section.sh_addr`用作`program_vm_addr`的偏移,所以這裡得和入口點(`Entrypoint`)的偏移對齊:

// calculate entrypoint offset into the text section
let offset = header.e_entry.saturating_sub(text_section.sh_addr);
(这一行计算入口点 header.e_entry 和文本段基地址 text_section.sh_addr 之间的偏移量。saturating_sub 方法确保如果计算结果为负数,结果不会出现溢出,而是会返回 0。)
if offset.checked_rem(ebpf::INSN_SIZE as u64) != Some(0) {
        return Err(ElfError::InvalidEntrypoint);
}
(这一行检查偏移量 offset 是否是指令大小 ebpf::INSN_SIZE 的整数倍。checked_rem 方法用于计算偏移量对指令大小的模,并确保计算不会出现溢出。!= Some(0) 表示如果模结果不是 0(即偏移量不是指令大小的整数倍),则进入条件块。)

2. 檢查入口點`header.e_entry`是否在`.text`節的虛擬位址範圍內。如果入口點不在該範圍內,則回傳`ElfError::EntrypointOutOfBounds`錯誤。

let text_section = get_section(elf, b".text")?;
if !text_section.vm_range().contains(&header.e_entry) {
    return Err(ElfError::EntrypointOutOfBounds);
}

`target_pc`作為絕對位址在第二步中依照 8 個位元組對齊,是 8 的整數倍,`target_pc`個位數只要小於 8,執行對齊操作後將為 0,大於等於 8 將為 8,傳入正常的`program_vm_addr`與 8 位元組對齊的值將不會造成越界,只要取得到的`program_vm_addr`為並不與 8 位元組對齊且小於 8,`target_pc`減去`program_vm_addr`,可以取得到不與 8 位元組對齊的相對位址,這裡取得到的可控的相對位址範圍為(`relative address < number_of_instructions * INSN_SIZE`),相對位址將會用作索引`pc_section`數組,這裡計算方式是直接取得` self.result.pc_section.as_ptr() as i64`裸指標進行切片位址索引,未與 8 位元組對齊的相對位址將會導致`pc_section`數組基底指標引用錯誤,將會取得到一個越界位址,而越界的範圍需要小於`number_of_instructions * INSN_SIZE`,這個非法位址將會導致後續 call 跳到一個不一致的位址,假如存取到非法位址程式系統將會拋出段錯誤`Segmentation fault`,這將導致 SVM 虛擬機直接崩潰,如果通過精心構造的內存數據,可能會獲取到一個能控制的任意跳轉地址,後續甚至執行任意命令!

d. 漏洞修復

漏洞修復後的修補程式比較如下:1. 絕對位址:取得`target_pc`的值作為絕對位址。

2. 計算相對位址:先減去`program_vm_addr`來取得相對位址。這一步驟確保了後續操作能夠正確處理記憶體對齊問題。

3. 記憶體對齊:將相對位址依照 8 位元組進行記憶體對齊。

4. 越界檢查:判斷對齊後的相對位址是否越界。 5. 取得跳轉位址:最終計算出`PC`跳轉的位址。

修復漏洞的關鍵在於第一步,透過先取得相對位址並確保其正確對齊,從而避免了先前未對齊所帶來的問題。

圖片

3. SVM 漏洞 x86 程式碼偵錯與復現

在這一章節,我們將透過分析程式碼和漏洞調試來復現問題。存在漏洞的合約 POC 構造如下:

a. SBF 指令構造

假設`rax = target_pc`且`target_pc = 0x100000129`,以下是相關指令的構造,這裡的 r1 在 SVM 中為 rax:

rsh64 r1, 2        ; 将 r1 寄存器的值右移 2 位
or64 r1, 0x129     ; 将 r1 寄存器的值与 0x129 进行按位或运算
callx r1           ; 调用 r1 寄存器指定的地址

這些包含的`SBF`指令被編譯成 ELF 合約,版本為 SBFV1。 `text_section.sh_addr`透過以下計算得出:

let text_section_info = SectionInfo {
            .............
            vaddr: if sbpf_version.enable_elf_vaddr()
                && text_section.sh_addr >= ebpf::MM_PROGRAM_START
            {
                text_section.sh_addr (SBFV2)
            } else {
                text_section.sh_addr.saturating_add(ebpf::MM_PROGRAM_START) (SBFV1)
            },
            offset_range: text_section.file_range().unwrap_or_default(),
        };

透過`readelf`工具,可以查看編譯出的包含上述`SBF`指令的執行合約 ELF 檔案的頭部信息,其中`.text`段的位址為`0x121`,這裡透過正常的合約編譯出來的 ELF 結構並不能完全控制`.text`部分,需要精心修改`.text`段的`address`和`Entrypoint`的偏移,然後修復對應的 ELF 結構,才能得到能正確執行的合約。

圖片

最終的`program_vm_addr`計算如下:

text_section.sh_addr = text_section.sh_addr.saturating_add(ebpf::MM_PROGRAM_START);

在上述程式碼中,`program_vm_addr`的最終值為`0x100000121`。

b. SBF 指令構造

在 JIT 模式下,將`SBF`指令翻譯為 x86_64 組譯指令如下:

shr    rsi,0x2
mov    r10,0x33fe958d
add    r10,0xffffffffcc016b9c
or     rsi,r10

在調試器中,`rsi`計算出的`target_pc`值為`0x100000129`,這裡的`target_pc`只需要小於`number_of_instructions * INSN_SIZE`。

圖片

取得`target_pc`後,進入`call`位址檢查流程,最後得到`call_address`:

and       rax,0xfffffffffffffff8 (absolute address &= - ebpf::INSN_SIZE(8) )(绝对地址对齐)
movabs    rbp,0x100000139
cmp       rax,rbp(判断 target_pc 是否小于地址加指令总数边界)
jae       0x7ffff7e9b0da       CALL_OUTSIDE_TEXT_SEGMENT
movabs    rbp,0x100000121
cmp       rax,rbp(判断 target_pc 是否大于等于起始地址边界)
jb        0x7ffff7e9b0da       CALL_OUTSIDE_TEXT_SEGMENT
sub       rax,rbp(获取相对地址,因为 program_vm_addr 没有保证 8 字节内存对齐,这里相对地址为 7,而存储指令的内存地址是按照 8 字节来索引的)
mov       r11,rax
shr       r11,0x3(这里 r11 = rax / 8,用作后续的 CU 计算,不影响漏洞触发)
movabs    rbp,0x7ffff7e9a000(获取 pc_section 数组的基地址这里是:0x7ffff7e9a000,0x7ffff7e9a000 作为 pc_section 数组基地,这个地址数据连续保存了 3 个 SBF 指令映射到 x86 机器码的地址)
add       rax,rbp(pc_section.address + 7,0x7ffff7e9a007)
mov       rax,QWORD PTR [rax+0x0](这里将会获取越界数据,地址 0x7ffff7e9a007 对应的 8 字节数据作为后续的 call 地址,而这个 call 地址是无效数据,是个非法地址)

在調試器中取得到相對位址,`relative address = absolute address - program_vm_addr`如下:

圖片

取得`pc_section`數組的基底位址:`0x7ffff7e9a000`

圖片

`pc_section`數組的基底位址`0x7ffff7e9a000`中連續保存了 3 個`SBF`指令映射到 x86 機器碼的位址分別是:`0x7ffff7e9b6d0`、`0x7ffff7e9b6d4`、`0x7ffff7e96、`0x7ffff7e9b6d4`、`0x7ffff7e96、`0x7ffff7e9b6d4(值是`0x7ffff7e9b6d400`,這是個無效的非法位址。

圖片

最後直接`call`越界的非法記憶體位址,造成段錯誤`Segmentation fault`

圖片

c. 補丁 commit

存在漏洞的`commit`補丁如下:

圖片

4. SVM 虛擬機器指令漏洞影響

Callx 指令在智能合約中至關重要。記憶體越界常常成為底層漏洞的根源,而在 SVM 虛擬機器中,尤其是在 Solana 鏈上,這種漏洞可能導致 SVM 崩潰,使運行惡意合約的 Solana 節點無法正常使用,如果透過惡意攻擊者進行精心構造的記憶體佈局甚至會導致任意程式碼執行,篡改合約執行資料。此外,這個漏洞的生命週期可能長達 2 年以上。 Solana 對此漏洞的秘密處理非常有效,成功保護了鏈上資產和用戶利益。隨著類似 SVM 虛擬機器漏洞的減少,Solana 也將變得更加穩定。

免責聲明:作為區塊鏈資訊平台,本站所發布文章僅代表作者及來賓個人觀點,與 Web3Caff 立場無關。文章內的資訊僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。