Bài viết này nhằm mục đích ghi lại những kiến thức đã học để nhớ và hiểu rõ hơn.
Tổng quan
Lỗi của CVE này thuộc loại heap-based buffer overflow
trong sudo. Cụ thể:
- Nó cho phép người dùng bình thường lấy được root mà không cần biết mật khẩu của root.
- Những phiên bản bị ảnh hưởng:
legacy
có phiên bản của sudo từ 1.8.2 - 1.8.31p2
stable
có phiên bản của sudo từ 1.9.0 - 1.9.5p1
- Trong bài viết này, tôi tiến hành khai thác trên
Ubuntu 18.04.5 LTS
có phiên bản của sudo:
1
2
3
4
5
6
|
edisc@ubuntu:~$ sudo -V
Sudo version 1.9.5p1
Sudoers policy plugin version 1.9.5p1
Sudoers file grammar version 48
Sudoers I/O plugin version 1.9.5p1
Sudoers audit plugin version 1.9.5p1
|
Phân tích
Bức tranh sơ lược
- Khi Sudo được thực thi ở chế độ command line:
- Nếu sử dụng tùy chọn
-s
, thì Sudo's MODE_SHELL flag
sẽ được bật.
- Nếu sử dụng tùy chọn
-i
, thì Sudo's MODE_SHELL và MODE_LOGIN_SHELL flags
sẽ được bật.
- Khi thực thi, bắt đầu hàm
main()
của Sudo, hàm parse_args()
sẽ viết lại argv
(dòng 609-617)
bằng cách nối tất cả các command-line arguments
(dòng 587-595)
và thêm vào trước các kí tự đặc biệt một dấu backslashes \
. (dòng 590-591)
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
571 if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
572 char **av, *cmnd = NULL;
573 int ac = 1;
...
581 cmnd = dst = reallocarray(NULL, cmnd_size, 2);
...
587 for (av = argv; *av != NULL; av++) {
588 for (src = *av; *src != '\0'; src++) {
589 /* quote potential meta characters */
590 if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
591 *dst++ = '\\';
592 *dst++ = *src;
593 }
594 *dst++ = ' ';
595 }
...
600 ac += 2; /* -c cmnd */
...
603 av = reallocarray(NULL, ac + 1, sizeof(char *));
...
609 av[0] = (char *)user_details.shell; /* plugin may override shell */
610 if (cmnd != NULL) {
611 av[1] = "-c";
612 av[2] = cmnd;
613 }
614 av[ac] = NULL;
615
616 argv = av;
617 argc = ac;
618 }
|
- Sau đó, trong hàm
sudoers_policy_main()
, hàm set_cmnd()
nối tất cả các command-line arguments
vào heap-based buffer "user_args" (dòng) 864-871)
và bỏ qua kí tự \
dòng 866-867
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
819 if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
852 for (size = 0, av = NewArgv + 1; *av; av++)
853 size += strlen(*av) + 1;
854 if (size == 0 || (user_args = malloc(size)) == NULL) {
...
857 }
858 if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
...
864 for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
865 while (*from) {
866 if (from[0] == '\\' && !isspace((unsigned char)from[1]))
867 from++;
868 *to++ = *from++;
869 }
870 *to++ = ' ';
871 }
...
884 }
...
886 }
|
- Nếu
command-line argument
kết thúc với single backslash
:
- Tại
dòng 866
, from[0] = '\\'
và from[1]=NULL
.
- Tại
dòng 867
, from
tăng 1 đơn vị, from trỏ tới NULL - from=NULL
.
- Tại
dòng 868
, NULL
sẽ được copy vào user_args buffer
, và from
tăng 1 đơn vị, trỏ đến giá trị sau NULL
(out of the argument’s bounds).
- Vòng lặp
while
tại dòng 865-869
sẽ đọc và copy vùng dữ liệu out-of-bounds
vào user_args buffer
.
- Dễ thấy,
set_cmnd()
là nguyên nhân dẫn tới lỗ hổng heap-based buffer overflow
, bởi vì vùng dữ liệu out-of-bounds
đã được copy vào user_args buffer
, tuy nhiên nó lại không chỉ định kích thước của chúng (kích thước được tính toán tại dòng 852-853
)
- Về lí thuyết, không có command-line argument nào có thể kết thúc bằng dấu
\
bởi vì:
- Nếu
MODE_SHELL hay MODE_LOGIN_SHELL
được bật (line 858
- điều kiện cần để đến vùng code bị lỗi), thì MODE_SHELL
được bật (dòng 571
) và hàm parse_args()
sẽ thêm kí tự \
vào các kí tự đặc biệt (meta-characters) bao gồm cả \
(nó sẽ thay \
thành \\
)
- Tuy nhiên, nếu quan sát kĩ, chúng ta sẽ thấy điều kiện gọi hàm của
set_cmnd()
và parse_args()
có một chút khác biệt:
1
2
3
|
819 if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
858 if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
|
khác với
1
|
571 if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
|
- Câu hỏi đặt ra ở đây, liệu rằng ta có thể bật một trong ba mode
MODE_EDIT, MODE_CHECK, MODE_SHELL
mà không cần phải bật MODE_RUN - kích hoặc escape code
không?
- Câu trả lời hầu như không thể, bởi vì:
- Nếu ta bật
MODE_EDIT
(tùy chọn -e
, dòng 361
) hay MODE_CHECK
(tùy chọn -l
, dòng 423 và 519
), thì hàm parse_args()
sẽ xóa MODE_SHELL
từ valid_flags
(dòng 363 và 424
), trả về lỗi nếu tìm thấy invalid flag
như MODE_SHELL
(dòng 532-533
):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
358 case 'e':
...
361 mode = MODE_EDIT;
362 sudo_settings[ARG_SUDOEDIT].value = "true";
363 valid_flags = MODE_NONINTERACTIVE;
364 break;
...
416 case 'l':
...
423 mode = MODE_LIST;
424 valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
425 break;
...
518 if (argc > 0 && mode == MODE_LIST)
519 mode = MODE_CHECK;
...
532 if ((flags & valid_flags) != flags)
533 usage(1);
|
- May mắn thay, chúng vẫn tồn tại một sơ hở:
- Nếu chúng ta thực thi Sudo là
sudoedit
thay vì sudo
, parse_args()
sẽ tự động bật MODE_EDIT
(dòng 270
) nhưng không xóa các valid_flags
, và MODE_SHELL
lại nằm trong danh sách các valid_flags
này (dòng 127 và 249
):
1
2
3
4
5
6
7
8
9
10
|
127 #define DEFAULT_VALID_FLAGS (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL)
...
249 int valid_flags = DEFAULT_VALID_FLAGS;
...
267 proglen = strlen(progname);
268 if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
269 progname = "sudoedit";
270 mode = MODE_EDIT;
271 sudo_settings[ARG_SUDOEDIT].value = "true";
272 }
|
- Việc này dẫn đến: nếu chúng ta thực thi
sudoedit -s
, sau đó bật MODE_EDIT và MODE_SHELL
nhưng không bật MODE_RUN
, chúng ta sẽ tránh được escape code
và đi đến vùng code bị lỗi. Sau đó, overflow heap-based buffer “user_args” thông qua command-line argument mà kết thúc bằng dấu backslash.
1
2
3
4
5
6
7
8
|
edisc@ubuntu:~$ sudo -s '\' `perl -e 'print "A" x 65536'`
[sudo] password for edisc:
Sorry, try again.
[sudo] password for edisc:
sudo: 1 incorrect password attempt
edisc@ubuntu:~$ sudoedit -s '\' `perl -e 'print "A" x 65536'`
Segmentation fault (core dumped)
edisc@ubuntu:~$
|
- Dưới góc nhìn của kẻ tấn công, buffer overflow trong trường hợp này khá lí tưởng, bởi vì:
- Chúng ta có thể kiểm soát được kích thước của vùng nhớ mà chúng ta overflow:
user_args buffer
(dòng 852-854
)
- Chúng ta hoàn toàn độc lập trong việc kiểm soát kích thước và nội dung của vùng nhớ bị overflow (
dòng 852-853
)
- Chúng ta thậm chí có thể viết
null bytes
vào vùng nhớ bị overflow (tham số hoặc biến môi trường kết thúc bằng \
sẽ viết 1 null byte vào “user_args” dòng 866-868
)
- Ví dụ, trên amd64 Linux, chúng ta có thể cấp phát 24 bytes trong
user-args buffer
(32-heap chunk) và ghi đè những field của chunk tiếp theo với A=a\0B=b\0
, ghi đè trường fd với C=c\0D=d\0
và trường bk với E=e\0F=f\0
:
1
2
3
4
5
6
7
8
|
------------------------------------------------------------------------
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
|
fd: forward
bk: backward
là hai con trỏ trong cấu trúc heap
Trace Heap Usages
- Để hiểu được luồng thực thi của heap, chúng ta sẽ trace heap usage trên Ubuntu 18.04 từ malloc,realloc, calloc và hàm free với gdb script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
main()
|- setlocale(LC_ALL, "");
|- tzset()
|- sudo_conf_read_v1(CONF_DEBUG)
| |- setlocale("C") // lưu vị trí hiện tại đầu tiên
| |- read /etc/sudo.conf
| |- setlocale(prev_locale) // khôi phục lại locale
|- sudo_conf_read_v1(CONF_ALL) / giống với ở trên
|- get_user_info()
| |- getpwuid()
| | |- parse /etc/nsswitch.conf and create service_user structs
|- parse_args()
|- sudo_load_plugins()
| |- load sudoers.so
| |- register env hooks
|- policy_open()
| |- format_plugin_settings()
| | |- initialize sudo settings such as network_addrs (many mallocs)
| |- sudoers_policy_init()
| | |- init_defaults()
| | | |- Nhiều chuỗi ngắn gọi strdup() (kể cả def_timestampdir)
| | | |- một số chuỗi gọi dcgettext để cấp phát.
| | | |- init_envtables() // tạo ra nhiều malloc() nhỏ
| | |- init_vars()
| | |- sudo_file_parse() load và parse /etc/sudoers
|- policy_check()
| |- sudoers_policy_main()
| | |- set_cmnd()
|
Luồng thực thi từ một hàm xảy ra lỗi yêu cầu password.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
|- set_cmnd() // lỗ hổng ở đây
|- setlocale("C")
|- sudo_file_lookup() // tìm kiếm dữ liệu được phân tích từ /etc/sudoers
| |- nss_*
| | |- nss_load_library()
|- setlocale(user_locale) // khôi phục lại locale
|- check_user()
| |- check_user_interactive()
| | |- tạo tệp timestamp trong def_timestampdir
| | |- yêu cầu mật khẩu (nếu xác thực thất bại, thoát)
|
|- find_editer()
| |- call sudoer_hook_env("SUDO_EDITOR") // chỉ được gọi nếu xác thực thành công.
|
đã chạy gdbscript nhưng vẫn chưa note lại để kiểm tra xem cấu trúc có giống với tác giả chưa Đã làm được
Xác định những đối tượng có thể bị overwriting
- Với luồng thực thi trên, có một số đối tượng (objects) được cấp phát trước khi chúng ta muốn tiến hành heap overflow và được sử dụng sau đó (lí tưởng cho việc ghi đè).
nss service_user object
def_timestampdir path
compar function pointer in rbtree struct
- Con trỏ hàm có thể bị ghi đè từng phần để bypass ASLRS (dùng một đoạn code bruteforcing), tuy nhiên tham số đầu tiên lại là chuỗi rỗng.
userspects object
from parsing /etc/sudoers: có khả năng bypass xác thực trên sudo có phiên bản >= 1.8.9
nhưng phải làm giả nhiều đối tượng (fake many objects).
glibc heap with/without tcache
- Từ glibc phiên bản 2.25, tcache được thêm vào heap allocation.
- So sánh giữa tcache bins và fast bins:
- Giống:
- Cả 2 đều đánh dấu đã được sử dụng.
- Đều là LIFO (Last In, First Out).
- Có thể cấp phát lại nếu kích thước yêu cầu cấp phát trùng với kích thước của bin.
- Khác:
- Max fast bín size là 0x80, còn max tache bins size là 0x410.
- Khi kích thước yêu cầu cấp phát lớn hơn small bins (0x400), tất cả các fast bins được đưa vào unsorted bins, sau đó chúng được tập hợp lại, đưa vào bin nhỏ hơn hoặc lớn hơn. Có nhiều cấp phát yêu cầu khích thước lớn (như file buffer trong glibc) trong các chương trình chạy ở quyền sudo. Với tache bín, large chunk dược cấp phát sẽ không bị ảnh hưởng trong tcahe bins. Những free chunks với những kích thước nhất định sẽ nằm trong tache bin mãi.
Khai thác
glibc setlocale
- Khi hàm
setlocale
được gọi với chuỗi rỗng
, những biến môi trường LC_*
được sử dụng như đầu vào cho hàm _nl_find_locale
:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
115 if (cloc_name[0] == '\0')
116 {
117 /* The user decides which locale to use by setting environment
118 variables*/
119 cloc_name = getenv("LC_ALL");
120 if (!name_present(cloc_name))
121 cloc_name = getenv(_nl_category_name.str
122 + _nl_category_name_idxs[category]);
123 if (!name_present(cloc_name))
124 cloc_name = getenv("LANG");
125 if (!name_present(cloc_name))
126 cloc_name = _nl_C_name;
127 }
|
- Đoạn code trên, ta thấy ưu tiên cho việc lấy
locale name
cho mỗi danh mục là LC_ALL
, LC_<CATEGORY_NAME>
, LANG environment
. Nếu không có gì được thiết lập, special locale name "C"
được sử dụng. Nếu locale name là “C”, hàm _nl_find_locale
sẽ trả về ngay lập tức mà không đi tới heap, với những tên khác, flow sẽ như sau:
1
2
3
4
5
6
7
8
9
10
11
|
185 /*LOCALE can consist of up to four recognized parts for the XPG syntax:
186 language[_territory[.codeset]][@modifier]
187
188 Beside the first all of them are allowed to be missing. If the full specified locale is not found, the less specific one are looked for. The various part will be stripped off according to the following order:
189 (1) codeset
190 (2) normalized codeset
191 (3) territory
192 (4) modifier
*/
mask = _nl_explode_name (loc_name, &language, &modifier, &territory,
&codeset, &normalized_codeset);
|
Giá trị trả về mark
là cờ để chỉ ra sự tồn tại của các phần này trong locale name
được nhận. Sau đó hàm _nl_make_l10nflist
được gọi để kiểm tra liệu locale name
đã nhận có được tải lên hay chưa? Điều này yêu cầu malloc
để lưu đầy đủ tên thư mục. Nếu nó nằm trong danh sách/lệnh gọi chỉ để nhận, giải phóng nó rồi trả về.
1
2
3
4
5
6
7
8
9
10
11
12
|
165 /*Allocate room for the full file name. */
166 abs_filename = (char *) malloc(dirlist_len
167 + strlen(language)
168 + ((mask & XPG_TERRITORY) != 0
169 ? strlen (codeset) + 1 : 0)
170 + ((mask & XPG_CODESET) != 0
171 ? strlen(normalized_codeset) + 1 : 0)
172 + ((mask & XPG_NORM_CODESET) != 0
173 ? strlen(normalized_codeset) + 1 : 0)
174 + ((mask & XPG_MODIFIER) != 0
175 ? strlen(modifier) + 1 : 0)
176 + 1 + strlen(filename) +1);
|
- Nếu
locale name
không nằm trong danh sách được load, hàm _nl_make_l10nflist
được gọi một lần nữa để tạo ra tất cả các đường dẫn có thể (đường dẫn cơ bản là /usr/lib/locale
) từ việc kết hợp những thành phần và đặc tính của tên bằng gọi đệ quy chính đó với “mask” đã được sửa đổi. Thuật toán này sẽ tạo ra một duplicated part (malloc)
và rồi xóa nó (free)
sau khi kiểm tra xong. Càng nhiều thành phần trong locales thì sẽ có càng nhiều hàm malloc và free
được gọi.
- Sau đó, hàm
_nl_find_locale
cố gắng load locale data
từ từng đường dẫn một. Nếu tìm thấy một locale data
hợp lệ, hàm setlocale
sẽ sawpx xếp tên miền nhất định và lưu nó trong nội bộ.
- Nếu có lỗi xảy ra, hàm
setlocale
sẽ giải phóng tất cả các tên đã được lưu và mặc định sử dụng C
rồi trả về ngay lâp tức. Do đó, locale name
không thể ngẫu nhiên, ít nhất ngôn ngữ và bộ mã phải hợp lệ.
- Sau khi tất cả tên danh mục đều được
strdup()
và dữ liệu được load, LC_ALL
sẽ được tạo trong hàm new_composite_name
. Nếu tất cả các tên LC giống nhau, giá trị của nó sẽ chỉ được lấy từ tên đầu tiên, ngược lại, nếu không giống, giá trị sẽ được kết hợp lại với nhau.
Kiểm soát heap usage bằng biến môi trường.
Một số giá trị của biến môi trường giúp cho việc khai thác lỗi này:
- Thiết lập môi trường
"TZ=:"
: giảm số lần sử dụng heap trong hàm glibc tzset()
và hoàn toàn có thể tiên đoán trước.
- Thêm
";x=x"
trong mọi biến môi trường LC, luồng thực thi sẽ như sau:
- Đầu tiên, hàm
setlocale("")
sẽ cấp phát và giải phóng một cách bình thường, LC_ALL
sẽ có giá trị "...;x=x;..."
- Sau đó,
setlocale(NULL)
lấy giá trị LC_ALL
hiện tại và lưu lại.
setlocale("C")
sẽ giải phóng tất cả locale name
nhận được.
setlocale(saved_LC_ALL)
sẽ không làm gì, vì x
là tên danh mục không hợp lệ.
- Tới đây,
LC_ALL
trong glibc
là “C”
setlocale
sẽ không làm gì bởi vì LC_ALL
là C
- Kết quả chúng ta sẽ có một vùng được giải phóng với kích thước được kiểm soát bằng cách thiết lập
môi trường LC
Cấu trúc service_user
1
2
3
4
5
6
7
8
9
10
11
12
13
|
typedef struct service_user
{
/* And the link to the next entry. */
struct service_user *next;
/* Action according to result. */
lookup_actions actions[5];
/* Link to the underlying library object. */
service_library *library;
/* Collection of known functions. */
void *known;
/* Name of the service (`files', `dns', `nis', ...). */
char name[0];
} service_user;
|
-Cấu trúc này được sử dụng trong nss_load_library
của libc khá thường xuyên khi vấn đề overflow xảy ra (trace log) để load những thư viện liên kết động lên. Chúng ta có thể ghi đè trường name
và tải thư viện của chúng ta lên.
- Sau đó, chúng ta nhắm tới những thư viện mà không có đặc quyền, chạy nó với quyền root.
- Hàm như sau:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
static int
nss_load_library (service_user *ni)
{
if (ni->library == NULL)
{
static name_database default_table;
ni->library = nss_new_service (service_table ?: &default_table,
ni->name);
if (ni->library == NULL)
return -1;
}
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);
|
- Mục tiêu của hàm này là đến
ni->library->lib_handle = __libc_dlopen(shlib_name)
để tải thư viện mà chúng ta kiểm soát.
- Có hai điều mà chúng ta cần lưu ý:
- Nếu
ni->library
khác NULL, chúng ta sẽ sử dụng con trỏ trong ni->library->lib_handle
và vì ASLR
nên chúng ta không thể đoán liệu rằng con trỏ này có hợp lệ hay không. Chúng ta thiết lập ni->library=nss_new_service(...)
nếu con trỏ này NULL, sau đó chúng ta chỉ cần ghi đè cấu trúc này để lấy được tên trường và đổi nó tới những thư viện mà chúng ta kiểm soát.
- Thách thức thứ 2 là chúng ta có con trỏ
struct service_user *next
. Nếu chúng ta gây ra overflow
Ghi đè cấu trúc service_user bằng glibc tcache
Từ trace log, chúng ta thấy 2 lệnh gọi nss_load_library
. Sau đó ta kiểm tra xem service_user object
được tạo ở vị trí nào
1
2
3
4
|
LC_COLLATE LC_TIME LC_NUMERIC LC_CTYPE
--+-------------+----+------------+---+-------------+---+-------------+---
| free 0x40 | .. | free 0x40 |...| free 0x80 |...| freed 0x40 |
--+-------------+----+------------+---+-------------+---+-------------+---
|
Do đó, sẽ hướng đến servirce_user object
tại vùng nhớ LC_CTYPE
bị giải phóng và thực hiện heap overflow tại LC_NUMERIC
để ghi đè service_user object