硬核:如何調試 glibc

背景

國科礎石操作系統團隊在開發礎光智能操作系統的過程中,需要分析 glibc 啓動過程中的異常信息,在此過程中探索出一條快速調試 glibc 流程的方法。

由於 glibc 啓動代碼複雜,printf、ptrace 等輔助調試手段還不能正常使用,給分析過程帶來困難。本文探索的方法避免了對 printf、ptrace 的依賴。

glibc 簡介

glibc 是 Linux 系統中常用的 C 運行時庫,它是 GNU 項目的一部分,是一組函數和子例程的集合,爲 Linux 操作系統上的 C 程序提供了基本的運行時支持。

glibc 提供了 Linux 系統所需的底層功能和工具,包括內存管理、線程支持、網絡編程、文件系統訪問、數學計算、時間和日期處理、本地化支持等等。它還提供了標準的 C 庫函數,如字符串操作、輸入輸出、數據結構操作等等。

glibc 還提供了一些高級功能,例如動態內存管理、線程安全、多語言支持、安全性等等。它提供了一些重要的頭文件和宏定義,例如 stdio.h、stdlib.h、string.h、time.h 等等。

glibc 還提供了一些調試和性能分析工具,例如 gdb 調試器和 strace 系統調用跟蹤器等。

總之,glibc 是 Linux 系統中最重要的 C 運行時庫之一,提供了許多基本和高級功能,爲開發人員提供了強大的工具和支持,使得他們能夠更加輕鬆地編寫高質量、高效、可靠的 C 程序。

glibc 是什麼?

舉個簡單的例子來解釋 glibc 大概做了什麼 :

#include <stdio.h>
int sum (int a, int b) {
    return a + b;
}
int main (void) {
    int a = 35;
    int b = 24;
    printf("%d + %d = %d\n", a, b, sum(a, b));
    return 0;
}

當我們編寫一個c程序時,在 glibc 的幫助下會給我們一種錯覺 : 當我們運行編譯出來的二進制文件,操作系統直接運行到 main 函數,然後執行由提供的函數或我們自己編寫的邏輯代碼,在上述例子中,我們使用了libc提供的 "printf" 打印函數。我們自己編寫了一個求和的邏輯代碼。那麼glibc真的就是提供一些函數接口的庫麼?

其實對於操作系統而言, 它會都不 "認識"main 函數。而一個進程的執行也並非由 main 函數開始的。在鏈接時, 鏈接器會設置函數入口, 而該可執行程序入口不是 main。

[vizdl@localhost glibc_debug]# readelf -h build/crt.elf  
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - GNU
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           AArch64
  Version:                           0x1
  Entry point address:               0x400580
  Start of program headers:          64 (bytes into file)
  Start of section headers:          634584 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         6
  Size of section headers:           64 (bytes)
  Number of section headers:         28
  Section header string table index: 27

在這裏我將上述代碼編譯鏈接後, 使用 readelf -h 讀取該可執行文件的頭部信息, 可以看到 "Entry point address:  0x400580", 表明可執行程序的入口地址是 0x400580。

[vizdl@localhost glibc_debug]# readelf -s  build/crt.elf  | grep 400580
    29: 0000000000400580     0 NOTYPE  LOCAL  DEFAULT    6 $x
  2471: 0000000000400580    60 FUNC    GLOBAL HIDDEN     6 _start

我們通過 readelf -s 指令查看該二進制的符號表, 可以看到, elf 執行的第一個 "函數" 是 _start, 而不是 main。可執行文件執行到 main 函數之前,其實 glibc 偷偷加了一些代碼。這部分代碼籠統地講其實就是做了一些進程環境設置的工作, 讓編寫 c 代碼的程序員可以避免每次都要編寫重複的進程的環境設置!glibc 真切地做到了做好事不留名:) 但是今天我們提供一種方式, 讓大家都能看到 glibc 做的好事~

glibc 開發者如何調試 glibc?

在 glibc 中,一些地方調用 c 庫函數會出現問題,特別是 _start -> main 這段代碼, 由於進程環境未初始化, 導致大多數的 glibc 的函數運行的前提無法保證, 於是絕大多數 glibc 的函數無法在這段代碼內運行, 這導致對 glibc 的觀察可謂是困難重重, 如何提供一種簡單通用且可靠的調試方法一直是業界的難題。

我們在 glibc 入口函數找到了一些代碼,並調用自定義函數 dl_debug_printf 來進行調試輸出:

LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
         int argc, char **argv,
#ifdef LIBC_START_MAIN_AUXVEC_ARG
         ElfW(auxv_t) *auxvec,
#endif
         __typeof (main) init,
         void (*fini) (void),
         void (*rtld_fini) (void), void *stack_end)
{
    ...
    if (__builtin_expect (GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS, 0))
        GLRO(dl_debug_printf) ("\ninitialize program: %s\n\n", argv[0]);
    ...
}

但是 dl_debug_printf 應該怎麼用? 它依賴什麼? 有什麼限制? 要深入分析會很麻煩, 而且在使用中很大概率會因爲不夠了解其原理而導致遇到各種坑。我們何不另闢蹊徑, 自己製造出一種可靠的調試方式?

上述問題都能得以解決!

另闢蹊徑

  1. 在 glibc 中添加一個調試函數 dbg_printf, 該調試函數依賴我們 "新增" 的系統調用, 並且該系統調用僅僅通過 printk 打印的方式將傳入的參數打印到 printk 環形緩衝區中。再通過 dmesg 來取數據。

  2. 如果真正地新增系統調用, 則會導致需要重新編譯內核, 不夠通用。我們採用了 tracepoint hook 點, 依賴寄存器讀取修改的方式, 支持以驅動的方法實現一個系統調用。

本方法的要點在於:

(1) 新添加的 dbg_printf 不依賴於標準 C 庫的任何系統調用,實現了一份完全乾淨的字符串格式化方法。

(2) 實現一個內核模塊,在內核模塊中 實現一個 tracepoint hook,該 tracepoint hook 會監控 sys_enter 事件,這樣就可以攔截系統調用,而不必通過修改 Linux 源代碼的方式,來擴展新的系統調用。

我們做了什麼

該項目一共包含三個主體 : glibc, debug_printf 驅動, 一個簡單的測試程序 test.c。

glibc

我們對 glibc 添加了一個補丁, 該補丁在 make devel 時打到 glibc 源碼中。

  1. 這個補丁添加了 dbg_printf 調試函數的實現
int
__dbg_printf (const char *fmt, ...)
{
    int ret = 0;
    int len = 0;
    char buf[buffsize];
    va_list ap;
    memset(buf, 0, buffsize);
    va_start(ap, fmt);
    len = dbg_vsnprintf(buf, buffsize, fmt, ap);
    buf[len] = 0;
    va_end(ap);
    ret = syscall_intface2(__NR_dbg, (long)buf, len + 1);
    return ret;
}
#undef _IO_printf
ldbl_strong_alias (__dbg_printf, dbg_printf)
ldbl_strong_alias (__dbg_printf, _IO_dbg_printf)
  1. 這個補丁調用 dbg_printf 調試函數, 打印該進程收到的參數。
void print_args (int argc, char **argv) {
  int i;
  dbg_printf("argc : %d\n", argc);
  for (i = 0; i < argc; i++) {
    dbg_printf("argv[%d] : %s\n", i, argv[i]);
  }
}
LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
         int argc, char **argv,
#ifdef LIBC_START_MAIN_AUXVEC_ARG
         ElfW(auxv_t) *auxvec,
#endif
         __typeof (main) init,
         void (*fini) (void),
         void (*rtld_fini) (void), void *stack_end)
{
  ...
  /* Perform IREL{,A} relocations.  */
  ARCH_SETUP_IREL ();
  /* print argc and argv */
  print_args(argc, argv);
  /* The stack guard goes into the TCB, so initialize it early.  */
  ARCH_SETUP_TLS ();
  ...
}

debug_printf 驅動

利用 tracepoint sys_enter hook 點, 僞造一個不存在的系統調用。

test.c

一個普通的 c 程序, 該程序會被鏈接到我們編譯的 glibc 上, 因此我們在 glibc 上的改動 (打印參數), 會在運行該程序時執行。

#include <stdio.h>
int main (void) {
    printf("Hello, glibcdbg\n");
    return 0;
}

遇到的問題

我們在 glibc 中使用 dbg_printf 時調用 vsnprintf 與 syscall 函數時, 居然出現了堆棧錯誤, 後續將其換成了自己實現的 dbg_vsnprintf 和 syscall_intface2。

實驗環境

glibc 的編譯與鏈接存在着許多坑, 爲避免讀者再次趟坑, 我們提供了 docker 編譯環境, 避免環境問題導致實驗失敗。

  1. 推薦實驗環境

推薦使用 ubuntu 18.04 x86_64 架構環境。

vizdl@ubuntu:~/glibcdbg$ uname -a
Linux ubuntu 5.4.0-146-generic #163~18.04.1-Ubuntu SMP Mon Mar 20 15:02:59 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
  1. 準備環境依賴

該項目需要依賴基本的編譯工具

sudo apt install gcc make git -y

該項目依賴 docker, 所以第一步需要先安裝 docker(docker 需要內核版本較高, 最低內核版本 linux 3.10), 如若已安裝可跳過。

sudo curl -fsSL https://get.docker.com | bash -s docker --mirror Aliyun
  1. 拉取項目
git clone git@gitee.com:kernelsoft/glibcdbg.git
  1. 構建編譯環境 : 這步驟主要是下載 glibc 代碼, 打上我們的補丁以及構建 docker image。
make devel
  1. 編譯 : 這步驟主要是編譯驅動模塊 / 測試小程序 / glibc
make build
  1. 安裝驅動 : 該步驟僅安裝驅動模塊
make install
  1. 運行測試案例並輸出 : 運行測試小程序然後使用 dmesg 獲取我們使用 printk 輸出在內核的信息
make run
  1. 卸載驅動 : 該步驟僅卸載驅動模塊
make uninstall
  1. 清理環境 : 恢復到初始項目狀態。
make distclean
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5lphBlIdvukl5XJocKGw7Q