通過AFL++復現sudo漏洞的一次嘗試

下載源碼,源代碼下載連接:https://www.sudo.ws/
$ wget https://www.sudo.ws/dist/sudo-1.8.21.tar.gz$ tar xzf sudo-1.8.21.tar.gz
須安裝AFL++,可以使用官方docker鏡像;如果已經在本地安裝,也可直接使用。
$ docker pull aflplusplus/aflplusplus# 拉取afl++$ docker run -ti -v /location/of/your/target:/src \[-v /location/of/your/afl_src/:AFLplusplus \]aflplusplus/aflplusplus /bin/bash# 啟動docker afl# 映射代碼文件夾,如果本地沒有afl++的源代碼的話,建議也映射一份,便于后續操作
$ cd /src/sudo-1.8.21查看sudo源代碼目錄:

先不進行插樁編譯,使用原版安裝,測試一下poc是否符合預期。
$ ./configure --prefix=/src/origin_compile$ make$ make install$ cd /src/origin_compile/bin$ ./sudoedit -s '\' aaaaaaaaaaaaaaa
可以看到成功產生了一個崩潰:

我們接下來的任務就是將該崩潰用afl復現出來。
sudo程序具有SUID,普通用戶通過輸入密碼,利用sudo執行命令,獲得暫時的權力提升。對于sudo的測試,需要通過非預期的輸入,致使sudo程序產生崩潰,進而找到漏洞的利用點。
sudo的輸入有兩處,執行參數與密碼輸入,本文暫不考慮密碼輸入引發的異常。測試的場景為,非特權用戶輸入惡意構造程序執行參數,引起sudo程序崩潰。
sudo程序由root用戶和其他用戶啟動的表現是不同的。sudo的所有權是root,但卻是由普通用戶調用的。然而我們在使用afl模糊測試時,使用的是root身份,這不能完成測試的需求,雖然這并不影響本CVE的復現。因此我們需要使sudo程序即使以root身份運行,但讓其認為是普通用戶執行的。
這可以通過查看sudo調用getuid()的代碼來實現,只需將值硬編碼為1000,這是一個普通用戶的用戶ID。
這個補丁,可以通過在源代碼文件夾中搜索getuid,將getuid()和getgid()修改為1000即可。
--- ./src/sudo.c+++ ./src/sudo.c@@ -522,9 +524,9 @@ } ud->sid = getsid(0); - ud->uid = getuid();+ ud->uid = 1000; ud->euid = geteuid();- ud->gid = getgid();+ ud->gid = 1000; ud->egid = getegid();
afl原生并不支持對argv參數進行Fuzzing。afl的fuzzing模式一般是將變異得到的文件,重定向到程序,作為程序的標準輸入,然后運行被測程序,等待程序結束、崩潰或超時。
注意:afl的啟動命令中可以使用 @@ 作為占用符,但其作用并不是對占位符的位置進行fuzzing,@@占位符表示此處應有文件的輸入,且這個輸入的文件應是fuzzing得到的,由afl自動填入。
為了實現對argv的fuzzing,我們可以將/aflplusplus/utils/argv_fuzzing/argv-fuzz-inl.h復制到sudo的源代碼目錄../sudo/src下,并對sudo.c進行相應的補丁。
+++ sudo.c 2023-01-22 01:09:38.175635142 -0800--- sudo.c_org1 2023-01-22 01:06:27.035319663 -0800 @@ -14,7 +14,6 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ + #include "argv-fuzz-inl.h" #ifdef __TANDEM # include #endif @@ -134,7 +133,6 @@ int main(int argc, char *argv[], char *envp[]){+ AFL_INIT_ARGV(); int nargc, ok, status = 0; char **nargv, **env_add; char **user_info, **command_info, **argv_out, **user_env_out;
我們看一下argv-fuzz-inl.h。
#define AFL_INIT_ARGV() \ do { \ \ argv = afl_init_argv(&argc); \ \ } while (0) ······ #define MAX_CMDLINE_LEN 100000#define MAX_CMDLINE_PAR 50000 static char **afl_init_argv(int *argc) { static char in_buf[MAX_CMDLINE_LEN]; static char *ret[MAX_CMDLINE_PAR]; char *ptr = in_buf; int rc = 0; if (read(0, in_buf, MAX_CMDLINE_LEN - 2) < 0) {} while (*ptr && rc < MAX_CMDLINE_PAR) { ret[rc] = ptr; if (ret[rc][0] == 0x02 && !ret[rc][1]) ret[rc]++; rc++; while (*ptr) ptr++; ptr++; } *argc = rc; return ret;,}
argv-fuzz-inl.h定義了一個宏函數AFL_INIT_ARGV(),調用相當于執行argv = afl_init_argv(&argc);。afl_init_argv從標準輸入中讀取輸入,以'\0'表示一個參數的結束,以'\0\0'表示輸入的結束。argv作為一個指針數組的指針,該指針數組中最后一個指針應為0,其余的每一項為一個字符串指針。
'\0'作為字符串結束的標志,因此參數中'\0'后的字符沒有意義,因此以'\0'表示一個參數的結束是一個合適的操作。注意到afl_init_argv函數中,存在對0x02的判斷,編寫這個文件的作者解釋到,以單獨一個0x02作為參數表示空參數,因此將其跳過。
也就是說,生成的輸入文件,如果存在0x...000200...的話,0x02會被刪除,該參數直接變為以0x00開始且結束的空字符串。進行這個處理的原因是,默認的方法無法生成空字符串的參數,而0x02很少用,可以用它來表示空參數,影響很小。
在執行sudo程序的時,會從標準輸入中讀取密碼,來進行權限認證。我們已經通過使用標準輸入來輸入argv參數。當執行到輸入密碼時,會再次從標準輸入讀取,sudo程序會一直等待密碼輸入,因此被測程序就會因超時而被掛起,重復的掛起將會導致測試時間被嚴重拉長。
我們要明確測試的目的,普通用戶以特定的參數打開sudo,導致程序崩潰,因此權限認證不應該被通過,我們試著取消執行sudo時權限認證環節。
首先,我們需要找到權限認證的代碼片段,這里可以通過gdb調試,查看sudo運行到輸入密碼時程序的狀態。
為了避免docker用戶管理與sudo用戶管理引起的混亂,我還是建議在本機上編譯,調試。注意一定要設置好安裝目錄,防止破壞主機環境。
$ make clean$ ./configure --prefix=~/sudo_gdb_test_auth --disable-shared$ make$ make install
安裝完成后,找到對應的sudo文件,確保其用戶屬于root,并設置SUID。
$ sudo chown root:root ./sudo$ sudo chmod u+s ./sudo$ ls -l
使用非root用戶測試運行$ ./sudo ls會停留在輸入密碼處。
$ ./sudo lsPassword:(expecting input)
保留當前窗口,再開一個終端。
$ ps -ef |grep sudoroot 206957 206889 0 19:15 pts/4 00:00:00 ./sudo ls
必須使用root權限調試,$ sudo gdb attach 。如果成功的話,會斷在read處。

我們查看backtrace。顯然,可以考慮將verify_user優化掉。
pwndbg> bt#0 0x00007f1421f7dfd2 in __GI___libc_read (fd=fd@entry=5, buf=buf@entry=0x7ffd483eb217, nbytes=nbytes@entry=1) at ../sysdeps/unix/sysv/linux/read.c:26#1 0x00005622b6cac7af in read (__nbytes=1, __buf=0x7ffd483eb217, __fd=5) at /usr/include/x86_64-linux-gnu/bits/unistd.h:44#2 getln (fd=fd@entry=5, buf=buf@entry=0x5622b6d0b480 "", feedback=feedback@entry=0, bufsiz=256) at ./tgetpass.c:311#3 0x00005622b6cacbd0 in tgetpass (prompt=0x5622b6e87bb0 "Password: ", timeout=300, flags=0, callback=callback@entry=0x7ffd483ebd50) at ./tgetpass.c:178#4 0x00005622b6c9c81e in sudo_conversation (num_msgs=out>, msgs=out>, replies=0x7ffd483eb918, callback=0x7ffd483ebd50) at ./conversation.c:70#5 0x00005622b6cd8485 in auth_getpass (prompt=0x5622b6e87bb0 "Password: ", timeout=out>, type=type@entry=1, callback=callback@entry=0x7ffd483ebd50) at ./auth/sudo_auth.c:426#6 0x00005622b6cd88d6 in verify_user (pw=0x5622b6e7e158, prompt=out>, prompt@entry=0x5622b6e87bb0 "Password: ", validated=validated@entry=2, callback=callback@entry=0x7ffd483ebd50) at ./auth/sudo_auth.c:282#7 0x00005622b6cd93f1 in check_user_interactive (auth_pw=0x5622b6e7e158, mode=out>, validated=2) at ./check.c:149#8 check_user (validated=validated@entry=2, mode=out>) at ./check.c:212#9 0x00005622b6cc2af2 in sudoers_policy_main (argc=argc@entry=1, argv=argv@entry=0x7ffd483ec1a0, pwflag=pwflag@entry=0, env_add=env_add@entry=0x0, closure=closure@entry=0x7ffd483ebeb0) at ./sudoers.c:423#10 0x00005622b6cbcfc4 in sudoers_policy_check (argc=1, argv=0x7ffd483ec1a0, env_add=0x0, command_infop=0x7ffd483ebf28, argv_out=0x7ffd483ebf30, user_env_out=0x7ffd483ebf38) at ./policy.c:775#11 0x00005622b6c9ae57 in policy_check (plugin=0x5622b6d0c000 , user_env_out=0x7ffd483ebf38, argv_out=0x7ffd483ebf30, command_info=0x7ffd483ebf28, env_add=0x0, argv=0x7ffd483ec1a0, argc=1) at ./sudo.c:1149#12 main (argc=argc@entry=2, argv=argv@entry=0x7ffd483ec198, envp=0x7ffd483ec1b0) at ./sudo.c:247#13 0x00007f1421e94083 in __libc_start_main (main=0x5622b6c9aa50 , argc=2, argv=0x7ffd483ec198, init=out>, fini=out>, rtld_fini=out>, stack_end=0x7ffd483ec188) at ../csu/libc-start.c:308#14 0x00005622b6c9c6be in _start () at ./sudo.c:798
我們找到源代碼../auth/sudo_auth.c處,直接讓函數verify_user返回。

我們可以看到,sudoedit只是sudo的一個符號鏈接。
-rwsr-xr-x 1 root root 1647712 Jan 23 06:50 sudolrwxrwxrwx 1 root root 4 Jan 23 06:50 sudoedit -> sudo-rwxr-xr-x 1 root root 318384 Jan 23 06:50 sudoreplay
但在測試的過程中發現,如果打開的是sudo,即使在程序起始處將argv[0]修改為了sudoedit,程序的執行流依舊是sudo代碼片段。
$ echo -ne "sudoedit\0id\0\0" | ./sudousage: sudo -h | -K | -k | -Vusage: sudo -v [-AknS] [-g group] [-h host] [-p prompt] [-u user]usage: sudo -l [-AknS] [-g group] [-h host] [-p prompt] [-U user] [-u user] [command]usage: sudo [-AbEHknPS] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u user] [VAR=value] [-i|-s] []usage: sudo -e [-AknS] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u user] file ... $ echo "sudo\0id\0\0" | ./sudoedit usage: sudoedit [-AknS] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u user] file ...
我們可以發現main函數在開頭處調用os_init(argc, argv, envp);,而os_init被宏定義為
os_init_common,os_init_common會調用initprogname初始化程序名。
intos_init_common(int argc, char *argv[], char *envp[]){ initprogname(argc > 0 ? argv[0] : "sudo");#ifdef STATIC_SUDOERS_PLUGIN preload_static_symbols();#endif gc_init(); return 0;}
但是initprogname會優先使用__progname宏獲取程序名,而不是傳遞進來的argv[0]。
voidinitprogname(const char *name){# ifdef HAVE___PROGNAME extern const char *__progname; if (__progname != NULL && *__progname != '\0') progname = __progname; else# endif if ((progname = strrchr(name, '/')) != NULL) { progname++; } else { progname = name; } /* Check for libtool prefix and strip it if present. */ if (progname[0] == 'l' && progname[1] == 't' && progname[2] == '-' && progname[3] != '\0') progname += 3;}
因此,為了使被測程序通過argv[0]獲取程序名,我們將HAVE___PROGNAME的部分刪除。
--- ../origin_file/progname.c 2023-01-27 01:21:37.829958000 -0800+++ progname.c 2023-01-27 01:23:42.824771537 -0800@@ -59,13 +59,6 @@ void initprogname(const char *name) {-# ifdef HAVE___PROGNAME- extern const char *__progname;-- if (__progname != NULL && *__progname != '\0')- progname = __progname;- else-# endif if ((progname = strrchr(name, '/')) != NULL) { progname++; } else {
再完成上述四個補丁后,我們再次編譯得到最終文件。
重新開一個容器,將最終代碼放入/src,創建/fuzz,同時設置好主機跟蹤目錄,便于觀察fuzz情況及其進展。
$ sudo docker run -it \-v path/to/all_change:/src \-v path/to/trace_paper_fuzz:/fuzz\aflplusplus/aflplusplus /bin/bash
在docker中添加uid為1000的普通用戶
$ useradd -u 1000 aflfuzzer
使用afl-clang-fast進行插樁編譯,使用afl-gcc編譯得到的文件無法運行,相關分析我放到文末。
$ cd /src$ make clean$ CFLAGS="-g" LDFLAGS="-g" CC=afl-clang-fast ./configure --prefix=/fuzz/release --disable-shared$ make$ make install
編譯成功后,再次驗證poc能夠引起崩潰。
echo -ne "sudoedit\x00-s\x00\x5c\x00aaaaaaaaaaaaaaaaaaaaaaaaaa\x00\x00" | ./sudo# 為啥不用直接用\0,我覺得可以,但是我本機解析經常出問題,因此本文全篇都是用\xHH

經歷總總曲折,總算可以開始測試了。
$ cd /fuzz$ mkdir {input,output}$ echo -ne "sudo\x00id\x00\x00" > input/payload1$ echo -ne "sudoedit\x00id\x00\x00" > input/payload2afl-fuzz -i input/ -o output/ -D -M Master /fuzz/release/bin/sudo# 可以創建多個從fuzzer輔助測試afl-fuzz -i input/ -o output/ -D -S slave1 /fuzz/release/bin/sudo

可以發現,很快就能獲得一個崩潰。

查看崩潰,符合預期:

在進行插樁編譯的過程中,筆者一開始使用的是afl-gcc編譯,但是編譯出來的文件運行會直接崩潰。查找相關資料推薦使用llvm模式編譯。為了弄清楚afl-gcc編譯的文件失敗的原因,筆者對此做出必要探索。
使用afl-gcc編譯:
$ make clean$ CFLAGS="-g" LDFLAGS="-g" CC=afl-gcc ./configure --prefix=/src/gcc_compile --disable-shared$ make$ make install$ ./sudoedit
回顧一下插樁程序的運行過程:被測程序會在第一次執行__afl_maybe_log時進行初始化,第一次調用時共享內存指針__afl_area_ptr為空,進而調用__afl_setup初始化forkserver。
__afl_setup首先檢查__afl_setup_failure是否為空,如果不為空代表已經初始化失敗過,調用__afl_return返回,否則調用__afl_setup_first進行初始化。
__afl_setup_first會保存所有寄存器的值,然后調用getenv獲取SHM_ENV_VAR(fuzz程序保存的共享內存id)

然而跟進getenv發現,getenv又調用了__afl_maybe_log,也就是說getenv也被插樁了。

然后,本來是調用__afl_maybe_log進行初始化,但是初始化的過程又調用了__afl_maybe_log,而此時還未初始化完畢,于是又會進行初始化操作,就導致了程序執行流程的瘋狂套娃。由于執行過程中,會保留寄存器到棧上,因此棧資源被瘋狂使用,最終進程被操作系統殺掉。
為什么getenv會被插樁呢,getenv原本是c庫函數,但是在sudo源代碼中的env_hook.c中定義同名的getenv函數。
__dso_public char *getenv(const char *name){ char *val = NULL; switch (process_hooks_getenv(name, &val)) { case SUDO_HOOK_RET_STOP: return val; case SUDO_HOOK_RET_ERROR: return NULL; default: return getenv_unhooked(name); }}
而afl-gcc的插樁是通過解析編譯過程中的.s匯編文件,在需要插樁的地方,添加插樁的匯編代碼。
afl-as.h "" "/* --- AFL TRAMPOLINE (64-BIT) --- */" "" ".align 4" "" "leaq -(128+24)(%%rsp), %%rsp" "movq %%rdx, 0(%%rsp)" "movq %%rcx, 8(%%rsp)" "movq %%rax, 16(%%rsp)" "movq $0x%08x, %%rcx" "call __afl_maybe_log" "movq 16(%%rsp), %%rax" "movq 8(%%rsp), %%rcx" "movq 0(%%rsp), %%rdx" "leaq (128+24)(%%rsp), %%rsp" "" "/* --- END --- */" "";
因此afl-gcc在編譯env_hook.c時,也無可避免的對getenv進行了插樁。而使用CC=afl-clang-fast編譯,在llvm模式下對編譯中間碼IR進行插樁,就不會出現這個問題。