# Pwnkit: Linux Privilege Escalation từ một thành phần của Polkit (CVE-2021-4034)
> Author: antoinenguyen_09
### 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](https://linux.die.net/man/1/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 owners** và **other** thì Linux còn 1 số [special permission](https://docs.oracle.com/cd/E19683-01/816-4883/secfile-69/index.html) 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/pkexec` là **root** 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](https://github.com/freedesktop/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](https://github.com/freedesktop/polkit/blob/539bf5dcca489534f42798a4500aca4b1a8ec8d0/src/programs/pkexec.c).

#### 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](https://www.geeksforgeeks.org/command-line-arguments-in-c-cpp/). Ví dụ ta có một chương trình tên là **main** như sau:
```c=
#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** (**ARG**ument **C**ount): 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** (**ARG**ument **V**ector): 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** (**ENV**ironment **P**ointer): 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](http://devnt.org/bien-moi-truong-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ý **argc** và **argv** 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](https://www.geeksforgeeks.org/absolute-relative-pathnames-unix) 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](https://ctf101.org/binary-exploitation/what-is-the-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 `argv` và `envp`. Dưới đây là các mà `argv` và `envp` được sắp xếp trên stack frame:

Có thể thấy các phần tử của `argv` và `envp` 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 `execdir` là `execdir/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()`](https://root.cern/doc/v606/gutils_8h.html#a6076addb372057694438ed5ad61e42da) và [implementation của hàm `g_find_program_in_path()`](https://github.com/GNOME/glib/blob/main/glib/gutils.c)). 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](https://cafedev.vn/tu-hoc-c-tim-hieu-ve-file-header-trong-c/) có tên là [unsecvars.h](https://code.woboq.org/userspace/glibc/sysdeps/generic/unsecvars.h.html) 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](https://code.woboq.org/userspace/glibc/elf/dl-support.c.html)):

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**](https://github.com/freedesktop/polkit/blob/539bf5dcca489534f42798a4500aca4b1a8ec8d0/src/programs/pkexec.c#L702):

Để ý 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](https://github.com/freedesktop/polkit/blob/539bf5dcca489534f42798a4500aca4b1a8ec8d0/src/programs/pkexec.c#L639) đến [702](https://github.com/freedesktop/polkit/blob/539bf5dcca489534f42798a4500aca4b1a8ec8d0/src/programs/pkexec.c#L702) 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](https://github.com/freedesktop/polkit/blob/539bf5dcca489534f42798a4500aca4b1a8ec8d0/src/programs/pkexec.c#L662), 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 `key` và `value` được validate như thế nào, ta đọc implementation của hàm `validate_environment_variable` tại dòng [383](https://github.com/freedesktop/polkit/blob/539bf5dcca489534f42798a4500aca4b1a8ec8d0/src/programs/pkexec.c#L383):

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](https://github.com/GNOME/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](https://stackoverflow.com/questions/9809213/what-are-a-and-so-files) (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`:
```bash=
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](https://github.com/freedesktop/polkit/commit/a2bf5c9c83b6ae46cbd5c779d3055bff81ded683) 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:
{%youtube joZmr1mMONg %}
- [Exploit code và PoC](https://github.com/antoinenguyen-09/CVE-2021-4034)