Pwnkit: Leo thang đặc quyền từ một thành phần của Polkit (CVE-2021-4034)

Pwnkit: Leo thang đặc quyền từ một thành phần của Polkit (CVE-2021-4034)

1. Sơ lược về Polkit, pkexec và CVE-2021-4034:

Polkit là một thành phần mặc định được cài đặt trên rất nhiều bản phân phối Linux, một bộ công cụ dùng để kiểm soát và quản lý các đặc quyền trên hệ thống, gồm nhiều chương trình khác nhau như pkaction, pkcheck,...

Trong bài viết này chúng ta chỉ nói về pkexec và lỗ hổng mới được tìm ra cách đây không lâu trên pkexec là CVE-2021-4034. Lỗ hổng này đã từng giúp cho các hacker "bách chiến bách thắng" trong việc leo thang đặc quyền bởi như đã nói ở trên, Polkit được cài đặt mặc định trên rất nhiều, nếu không muốn nói là trên tất cả các bản phân phối chính của Linux như Ubuntu, Debian, Fedora, CentOS,...

Theo man page của pkexec thì pkexec cho phép một user A có thể thực thi câu lệnh với quyền của user B. User B này hoàn toàn có thể là root nếu giá trị username không được xác định:

Tuy nhiên trong man page cũng nói: pkexec allows an authorized user to execute PROGRAM as another user, có nghĩa là để pkexec có thể thực thi câu lệnh thì trước tiên phải qua bước authentication, yêu cầu user phải nhập vào password của super user (bao gồm root và các sudoer), và đây là rào cản lớn nhất dành cho các hacker:

Liệu có cách nào để không cần authen mà vẫn chạy được lệnh với quyền root không? CVE-2021-4034 sẽ trả lời câu hỏi này.

2. Dựng môi trường:

Mình sử dụng môi trường là hệ điều hành Kali Linux 2021.2 để làm PoC. Các bạn cũng có thể sử dụng Ubuntu, CentOS,... các Linux distro khác đều được, miễn là trong hệ điều hành có cài bộ công cụ Polkit.

Mặc định thì pkexec sẽ có permission như hình sau:

Từ hình trên có thể thấy file thực thi của pkexec có user chủ sở hữu là root và group chủ sở hữu là root, với permission là rwsr-xr-x tương ứng với permission number ở phía dưới là 4755. Vậy số 4 trong 4755 là gì và chữ s trong rwsr-xr-x có nghĩa là gì? Ta cần nhìn lại lý thuyết về quyền của file trong linux một chút. Bên cạnh 3 quyền cơ bản r (read), w (write) và x (executable) với 3 nhóm người dùng khác nhau là owner, group of ownersother thì Linux còn 1 số special permission khác trong đó có SUID (viết tắt cho Set user ID). Permission này thường được sử dụng trên các file thực thi của linux (ELF), cho phép file được thực thi với các đặc quyền của chủ sở hữu (owner) file đó. Số 4 trong 4755 cho ta biết /usr/bin/pkexec có SUID, và khi có SUID thì nhãn x sẽ chuyển thành nhãn s. Lại có owner của /usr/bin/pkexecroot nên bất kể ai thực thi pkexec, nó sẽ luôn chạy và chỉ chạy với các đặc quyền của user root. Đó là lí do tại sao ở phần 1 khi ta chạy pkexec id thì cần phải qua bước authentication dành cho root và sudoers. Nếu không cần authentication mà vẫn có thể chạy pkexec id đồng nghĩa là chúng ta đã leo thang đặc quyền (privilege escalation) thành công.

3. Phân tích source code:

Trước khi tìm cách leo thang đặc quyền thì ta phải nghiên cứu source code của pkexec để xem "điểm yếu" của nó nằm ở đâu. Có thể thấy source code của pkexec đã được team Freedesktop patch và commit trên github repo của Polkit vào ngày 25/1/2022, do đó mình sẽ xem code của lần commit trước đó và gần thời điểm này nhất.

a. Out-of-bounds access:

Về cơ bản cú pháp sử dụng pkexec là:

pkexec [ –user username ] PROGRAM [ ARGUMENTS …]

Nói sơ qua một chút về cách một chương trình C xử lý các command line argument. Ví dụ ta có một chương trình tên là main như sau:

#include<stdio.h>

int main(int argc,char* argv[],char* envp[])
{
    int counter;
    printf("Program Name Is: %s",argv[0]);
    if(argc==1)
        printf("\nNo Extra Command Line Argument Passed Other Than Program Name");
    if(argc>=2)
    {
        printf("\nNumber Of Arguments Passed: %d",argc);
        printf("\n----Following Are The Command Line Arguments Passed----");
        for(counter=0;counter<argc;counter++)
            printf("\nargv[%d]: %s",counter,argv[counter]);
    }
    return 0;
}

Hàm main ở đoạn trên có các argument đáng chú ý như:

  • Tham số argc (ARGument Count): là một biến int được gán bằng số lượng command-line argument được người dùng truyền vào chương trình (kể cả tên chương trình dùng khi thực thi là main cũng là 1 argument).
  • Tham số argv (ARGument Vector): là một mảng bao gồm các con trỏ kí tự (character pointer) trỏ đến tất cả các command-line argument được nhập vào. Trong đó argv[0] sẽ được dùng để chứa tên chương trình.
  • Tham số envp (ENVironment Pointer): là một mảng chứa các con trỏ đến các biến môi trường (environment variable) của chương trình. Có một biến môi trường khá là quen thuộc với chúng ta đó là PATH, và ở các bước khai thác sau ta sẽ dùng đến nó.

Ví dụ khi ta thực thi câu lệnh /main hoangnch thì argc=2 còn argv={'./main','hoangnch'}:

Quay trở lại với pkexec, ta tìm thấy một số đoạn code xử lý argcargv của pkexec (dòng 534 đến 568). Đến dòng 610, khi vòng lặp for kết thúc, vì n là một biến global (xem dòng 437) nên n=argc-1, đồng nghĩa với argv[n] sẽ trỏ đến argument cuối cùng được truyền vào pkexec. Mà argument cuối cùng của pkexec lại là đường dẫn của chương trình mà pkexec cần thực thi (ví dụ: id, whoami). Sau đó tại dòng 629, pkexec sẽ kiểm tra xem đường dẫn đó xem nó có phải là absolute path hay không. Nếu không phải thì hàm g_find_program_in_path sẽ được gọi để tìm absolute path của chương trình (dòng 632). Sau khi tìm ra thì absolute path sẽ được gán vào argv[n] tại dòng 639.

Đoạn code xử lý argument này nhìn có vẻ hết sức bình thường. Nhưng nếu có thể thực thi pkexec mà không truyền vào bất kì một argument nào, kể cả argv[0] vốn phải là pkexec thì mới có thể gọi được chương trình, thay vào đó cho argv[0]=NULL. Khi đó argc=0 vì mảng argv trống, không những vậy:

  • Tại dòng 534, do argc=0 nên vòng lặp for không được thực hiện, nhưng n vẫn được gán bằng 1.
  • Ta có argv[0]=NULL, mà NULL được dùng để đánh dấu điểm kết thúc của một mảng. Do đó tại dòng 610, argv[n] hay argv[1] sẽ là một phần tử out-of-bound của mảng argv, và được gán cho biến path.
  • Tại dòng 639, path hay argv[1] lại được gán cho biến s, và biến s lại được gán lại cho argv[n] để pkexec xử lý tiếp.

Và rốt cuộc phần tử out-of-bound của mảng argv là cái gì? Hồi sau sẽ rõ.

b. "Stack overflow":

Để biết phần tử out-of-bound của mảng argv đang nằm ở đâu và là cái gì, ta cần xem xét lại lý thuyết về stack. Trong kiến trúc máy tính nói chung, stack là một cấu trúc dữ liệu hoạt động theo nguyên lý LIFO (Last in, First out), còn trong kiến trúc x86, stack chỉ đơn giản là một vùng nhớ mà RAM dành ra cho một hàm trong chương trình để chứa các argument của nó, còn gọi là stack frame. Hàm main() trong ngôn ngữ C cũng không phải ngoại lệ, mặc định nó được RAM dành ra một stack frame để chứa các phần tử của 2 mảng argvenvp. Dưới đây là các mà argvenvp được sắp xếp trên stack frame:

Có thể thấy các phần tử của argvenvp sắp xếp ngay liền kề nhau, tức là ở ngay kế tiếp phần tử cuối cùng của mảng argv (argv[argc]) chính là phần tử bắt đầu của mảng envp (envp[0]). Ở cuối phần 1, ta đã đặt giả thuyết argv[0]=NULL đánh dấu điểm kết thúc của mảng argv tại phần tử có chỉ số là 0. Từ đó kết luận, phần tử out-of-bound của mảng argv hay argv[1] chính là envp[0] (1).

Giả sử chúng ta chạy chương trình pkexec thỏa mãn điều kiện sau:

  • argv là một mảng trống: {NULL}
  • envp bao gồm: {“somefile”, “PATH=execdir”, NULL}
  • Tạo một file thực thi trong đường dẫn execdirexecdir/somefile, đường dẫn này lại nằm ở đường dẫn hiện tại (current working directory).

Khi đó stack frame của hàm main() sẽ được biểu diễn như sau:

|---------+---------+-----+------------|---------+---------+-----+------------|
| argv[0] | argv[1] | ... | argv[argc] | envp[0] | envp[1] | ... | envp[envc] |
|----|----+----|----+-----+-----|------|----|----+----|----+-----+-----|------|
     V         V                V           V         V                V
 "program" "-option"           NULL     "somefile" "PATH=execdir"          NULL

Từ kết luận (1) cùng 3 điều kiện trên ta đi đến hệ quả:

  • Tại dòng 610, biến path sẽ được gán bằng envp[0] hay path=="somefile".
  • Vì "somefile" không bắt đầu với kí tự /, nên nó sẽ được truyền vào hàm g_find_program_in_path() tại dòng 632.
  • Hàm g_find_program_in_path() sẽ đi tìm các file thực thi có tên là "somefile", và phạm vi mà hàm này đi tìm chỉ nằm trong các đường dẫn liệt kê trong biến môi trường PATH (xem mô tả hàm g_find_program_in_path()implementation của hàm g_find_program_in_path()). Do ta đã đặt PATH=execdir và tạo một file thực thi có tên là "somefile" trong đường dẫn execdir do đó absolute path mà g_find_program_in_path() tìm ra là execdir/somefile.
  • Cuối cùng absolute path execdir/somefile sẽ được nạp trở lại vào envp[0].

Nếu nạp vào envp[0] nội dung execdir/somefile thì không có gì đáng bàn. Nhưng nếu chúng ta tạo một đường dẫn có format là <Tên biến môi trường>=. đồng thời trong đường dẫn đó ta bỏ vào một file thực thi, ví dụ tên là exploit thì sao (2)? Khi đó nạp vào envp[0] là một biến môi trường thực thụ và biến này được gán bằng một file thực thi nguy hiểm: <Tên biến môi trường>=./exploit. Không chỉ có file thực thi nguy hiểm mà ngay biến môi trường cũng có thể đem lại "nguy hiểm". Những biến môi trường "nguy hiểm" đó được gọi chung là "unsecure" environment variable và được liệt kê trong một headers file có tên là unsecvars.h thuộc thư viện chuẩn GNU C (viết tắt là glibc).

Thông thường thì các "unsecure" environment variable trên sẽ bị Linux filter trong quá trình truyền từ tiến trình cha tạo ra khi một chương trình có suid đang chạy hoặc chạy với quyền khác root sang một tiến trình con (xem cơ chế filter tại đây):

Theo kết luận (1) thì lúc này Linux chỉ coi envp[0] là một phần tử out-of-bound của mảng argv chứ không phải là phần tử đầu tiên của mảng envp, do đó giá trị được gán trong envp[0] sẽ không bị cơ chế trên của Linux coi là một environment variable nên sẽ không bị xóa (unsetenv).

Thực sự là chúng ta có thể tha hồ truyền các "unsecure" environment variable rồi ư? Khoan đã, hãy nhìn xuống dòng 702:

Để ý thấy có hàm clearenv được gọi, và không may là hàm này sẽ xóa hết tất cả các environment variable, kể cả envp[0], bằng cách gán NULL cho phần tử đầu tiên của mảng environ, và environ này chính là tham chiếu của mảng envp:

Như vậy, chúng ta phải tìm ra cách nào đó để tận dụng được envp[0] trước khi chương trình pkexec thực thi đến dòng 702. Sau khi review từ dòng 639 đến 702 thì thấy có hàm validate_environment_variable khả nghi (ở đây chúng ta chỉ xét đến các hàm sử dụng environment variable làm tham số).

Tóm tắt một chút về luồng hoạt động của chương trình ở đoạn này: vòng lặp for sẽ đi qua từng phần tử của mảng environment_variables_to_save. Mảng này chứa danh sách tên các biến môi trường mà chương trình được phép lưu vào:

Sau khi lấy giá trị của biến môi trường tại dòng 662, chúng ta đã có đầy đủ key (ứng với tên biến môi trường) và value (ứng với giá trị của biến môi trường) để truyền vào hàm validate_environment_variable nhằm mục đích validate. Để biết keyvalue được validate như thế nào, ta đọc implementation của hàm validate_environment_variable tại dòng 383:

Về cơ bản thì hàm validate_environment_variable hoạt động như sau: mỗi khi có một rule vi phạm (ví dụ như chứa các kí tự bị blacklist), thì hàm này sẽ ghi lỗi vào log bằng hàm log_message, đồng thời in ra lỗi bằng hàm g_printerr. Hàm này chính là một gadget đặc biệt quan trọng trong quá trình leo thang đặc quyền, còn nó đặc biệt cỡ nào thì ta sẽ tiếp tục phân tích ở phần tiếp theo.

c. Hàm g_printerr() và biến môi trường GCONV_PATH:

Hàm g_printerr thuộc thư viện GLib. Theo doc của thư viện GLib thì luồng hoạt động của g_printerr có thể tóm tắt bằng sơ đồ dưới đây:

Mặc định là g_printerr sẽ in thông báo lỗi ở định dạnh UTF-8. Tuy nhiên, trong trường hợp biến môi trường CHARSET khác UTF-8, ví dụ như UTF-32 chẳng hạn, g_printerr sẽ gọi đến hàm iconv_open để chuyển định dạng của output string từ UTF-8 (tương ứng với tham số fromcode của hàm) sang UTF-32 (tương ứng với tham số tocode). Để có thể thực hiện quá trình chuyển đổi định dạng này thì hàm iconv_open lại phải đi tìm và thực thi conversion descriptor, thường ở dạng shared object library (có đuối file là .so) ứng với tham số tocode. Ví dụ ở đây ta có tocode==UTF-32 thì file .so mà hàm iconv_open cần phải thực thi là UTF-32.so.

Theo như ảnh trên, các file .so được iconv_open sử dụng nằm ở đường dẫn mặc định là /usr/lib32/gconv (vì mình đang sử dụng Kali Linux). Đối với các Linux distro khác thì đường dẫn mặc định đó có thể là /usr/lib/gconv/gconv-modules. Vậy cái gì quy định đường dẫn mặc định chứa các conversion descriptor mà hàm iconv_open sử dụng? Đó chính là biến môi trường GCONV_PATH. Theo giả thuyết (2), thì GCONV_PATH không nhất thiết phải chứa đường dẫn mặc định, mà thông qua khả năng out-of-bound write có thể chèn vào biến trường này đường dẫn của một file thực thi do kẻ tấn công tự tạo. Bài toán "tìm ra điểm yếu của pkexec" đến đây là kết thúc.

4. Ý tưởng khai thác:

Các bước phân tích source code đã xong. Từ đây ý tưởng khai thác cuối cùng của ta sẽ là:

  • Biến argv thành một mảng trống chỉ có 1 phần tử NULL.
  • Đẩy vào mảng envp các giá trị như sau: {“exploit”, “PATH=GCONV_PATH=.”, “SHELL=<an arbitrary excutable file>”, “CHARSET=<something other than "UTF-8">”, NULL}.
  • Tạo một đường dẫn có tên là GCONV_PATH=..
  • Tạo một file thực thi trong đường dẫn GCONV_PATH=., giả sử nó tên là exploit, và trong đấy chứa các câu lệnh ta cần chạy với quyền root như bash chẳng hạn, để đường dẫn cuối cùng sẽ là GCONV_PATH=./exploit.

Call stack của hàm main() sau các bước trên được minh họa như sau:

Chương trình pkexec như đã phân tích ở trên sẽ viết lại envp[0] từ exploit thành GCONV_PATH=./exploit - một biến môi trường mới. Tiếp theo, tại bước validate các biến môi trường mà chúng ta cung cấp, từ envp[0] đến envp[2]. Tại envp[2], ví dụ ta đặt SHELL=/not/in/etc/shells, khi đó qua bước validate thì chương trình sẽ xác nhận đây không phải một SHELL path hợp lệ, do đó sẽ trigger hàm g_printerr() để in ra lỗi. Hàm g_printerr() sẽ nhảy đến envp[3], thấy CHARSET=NOT_UTF8, không phải là UTF-8, do đó nó lại trigger tiếp hàm iconv_open() để giúp nó chuyển dạng encoding của thông báo lỗi về dạng NOT_UTF8. Hàm iconv_open() sẽ tham chiếu đến conversion file, được quy định là sẽ nằm ở biến môi trường GCONV_PATH. Mà GCONV_PATH lại chứa đường dẫn mã khai thác mà ta đã đặt từ trước, do iconv_open() sẽ nạp vào mã khai thác vào và thực thi nó, đồng nghĩa là chúng ta đã leo root thành công.

5. Cách khắc phục:

a. Cách khắc phục tạm thời:

Trong trường hợp chưa có bản vá hoặc vì một lí nào đó không thể update được bản vá, cách khắc phục tạm thời sẽ là xóa SUID-bit của chương trình pkexec (vì SUID-bit là mặc định có khi ta cài đặt pkexec). Thay SUID-bit là 4 thành 0:

chmod 0755 / usr / bin / pkexec

b. Bản fix mới nhất:

Vào ngày 26/01/2022, team dev của Freedesktop đã commit bản vá CVE-2021-4034 cho pkexec. Cách fix khá là đơn giản, chỉ cần kiểm tra xem tham số argc của hàm main() có bé hơn 1 (đồng nghĩa với argv bị NULL) hay không. Nếu có thì kết thúc chương trình ngay lập tức.

6. Tài liệu đính kèm:

  • Video demo:

IMAGE_ALT