Qualys Security Advisory For the algorithm lovers: Nontransitive comparison functions lead to out-of-bounds read & write in glibc's qsort() ======================================================================== Contents ======================================================================== Summary Background Experiments Analysis Patch Discussion Acknowledgments Timeline CUT MY LIST IN TWO PIECES THAT'S HOW YOU START QUICK SORT -- https://twitter.com/QuinnyPig/status/1710447650112438710 ======================================================================== Summary ======================================================================== We discovered a memory corruption in the glibc's qsort() function, due to a missing bounds check. To be vulnerable, a program must call qsort() with a nontransitive comparison function (a function cmp(int a, int b) that returns (a - b), for example) and with a large number of attacker- controlled elements (to cause a malloc() failure inside qsort()). We have not tried to find such a vulnerable program in the real world. All glibc versions from at least September 1992 (glibc 1.04) to the current release (glibc 2.38) are affected, but the glibc's developers have independently discovered and patched this memory corruption in the master branch (commit b9390ba, "stdlib: Fix array bounds protection in insertion sort phase of qsort") during a recent refactoring of qsort(). About our advisory, the glibc security team issues the following statement: ------------------------------------------------------------------------ This memory corruption in the GNU C Library through the qsort function is invoked by an application passing a non-transitive comparison function, which is undefined according to POSIX and ISO C standards. As a result, we are of the opinion that the resulting CVE, if any, should be assigned to any such calling applications and subsequently fixed by passing a valid comparison function to qsort and not to glibc. We however acknowledge that this is a quality of implementation issue and we fixed this in a recent refactor of qsort. We would like to thank Qualys for sharing their findings and helping us validate our recent changes to qsort. ------------------------------------------------------------------------ ======================================================================== Background ======================================================================== While browsing through Postfix's HISTORY file, we stumbled across a puzzling entry from February 2002: ------------------------------------------------------------------------ Bugfix: make all recipient comparisons transitive, because Solaris qsort() causes SIGSEGV errors otherwise. Victor Duchovni, Morgan Stanley. File: *qmgr/qmgr_message.c. ------------------------------------------------------------------------ Segmentation faults in qsort()? Transitive comparison functions? As explained in the manual page for qsort(), "The comparison function must return an integer less than, equal to, or greater than zero if the first argument is considered to be respectively less than, equal to, or greater than the second." Of course, such a comparison function cmp() must be transitive: - if a < b (i.e., if cmp(pointer_to(a), pointer_to(b)) < 0); - and if b < c (i.e., if cmp(pointer_to(b), pointer_to(c)) < 0); - then necessarily a < c (i.e., cmp(pointer_to(a), pointer_to(c)) < 0). For example, the following comparison function (which compares integers) is transitive (and perfectly correct): ------------------------------------------------------------------------ int cmp(const void * const pa, const void * const pb) { const int a = *(const int *)pa; const int b = *(const int *)pb; if (a > b) return +1; if (a < b) return -1; return 0; } ------------------------------------------------------------------------ A shorter and more efficient version of this comparison function could simply "return (a > b) - (a < b);" and still be transitive and perfectly correct: - if a > b, it returns 1 - 0 = +1; - if a < b, it returns 0 - 1 = -1; - if a = b, it returns 0 - 0 = 0. The question, then, is: how can a comparison function be nontransitive? A comparison function cmp() is nontransitive if there exist a, b, and c such that: - a < b (because cmp(pointer_to(a), pointer_to(b)) < 0); - b < c (because cmp(pointer_to(b), pointer_to(c)) < 0); - but a >= c (because cmp(pointer_to(a), pointer_to(c)) >= 0 by mistake). Although the following comparison function seems correct at first, it is in fact nontransitive, because the subtraction in "return (a - b);" is prone to integer overflows: ------------------------------------------------------------------------ int cmp(const void * const pa, const void * const pb) { const int a = *(const int *)pa; const int b = *(const int *)pb; return (a - b); } ------------------------------------------------------------------------ For example, if a = INT_MIN, b = 0, and c = INT_MAX, then: - a < b (because cmp(pointer_to(a), pointer_to(b)) returns INT_MIN - 0, which is correctly negative); - b < c (because cmp(pointer_to(b), pointer_to(c)) returns 0 - INT_MAX, which is also correctly negative); - but a > c by mistake (because cmp(pointer_to(a), pointer_to(c)) returns INT_MIN - INT_MAX = +1, which is incorrectly positive because this subtraction overflows). Unfortunately, such nontransitive comparison functions are extremely common, as discussed in this excellent blog post from Ted Unangst: https://flak.tedunangst.com/post/subtraction-is-not-comparison and as hinted at in OpenBSD's manual page for qsort(): "It is almost always an error to use subtraction to compute the return value of the comparison function." Fortunately, when passed to a robust qsort() implementation, these nontransitive comparison functions should (at the worst) result in an incorrectly sorted array; certainly not in a memory corruption. However, the aforementioned entry from Postfix's HISTORY file suggests that not all qsort() implementations are robust. ======================================================================== Experiments ======================================================================== We therefore decided to assess the robustness of the glibc's qsort() implementation, by calling it with a nontransitive comparison function: ------------------------------------------------------------------------ 1 #include 2 #include 3 #include 4 5 static int 6 cmp(const void * const pa, const void * const pb) 7 { 8 const int a = *(const int *)pa; 9 const int b = *(const int *)pb; 10 return (a - b); 11 } 12 13 int 14 main(const int argc, const char * const argv[]) 15 { 16 if (argc != 2) return __LINE__; 17 const size_t nmemb = strtoul(argv[1], NULL, 0); 18 if (nmemb <= 0 || nmemb >= (1<<28)) return __LINE__; 19 20 int * const pcanary1 = calloc(1 + nmemb + 1, sizeof(int)); 21 if (!pcanary1) return __LINE__; 22 int * const array = pcanary1 + 1; 23 int * const pcanary2 = array + nmemb; 24 25 struct timeval tv; 26 if (gettimeofday(&tv, NULL)) return __LINE__; 27 srandom((tv.tv_sec << 16) ^ tv.tv_usec); 28 29 const int canary1 = *pcanary1 = (random() << 16) ^ random(); 30 const int canary2 = *pcanary2 = (random() << 16) ^ random(); 31 array[random() % nmemb] = INT_MIN; 32 33 qsort(array, nmemb, sizeof(int), cmp); 34 if (*pcanary1 != canary1) abort(); 35 if (*pcanary2 != canary2) abort(); 36 return 0; 37 } ------------------------------------------------------------------------ - at lines 5-11, cmp() is the nontransitive comparison function introduced in the previous "Background" section; - at lines 16-18, the number of elements to be sorted (simple integers) is read from the command line; - at lines 20-23, the array of elements to be sorted is calloc()ated, along with a canary element below this array, and a canary element above this array; - at lines 29-30, these two canary elements are randomized, and copied to the stack for later comparison; - at line 31, one random element of the array is initialized to INT_MIN (all other elements are initialized to 0 by calloc()); - at line 33, the elements of this array are sorted by qsort(); - at lines 34-35, the two canary elements (below and above the sorted array) are checked against their stack copies, and if they differ (an out-of-bounds write in qsort()), abort() is called. We chose the array elements a = INT_MIN and b = 0 because they directly exhibit the problematic behavior of this cmp() function: - a < b, because cmp(pointer_to(a), pointer_to(b)) returns INT_MIN - 0, which is correctly negative; - but b < a by mistake, because cmp(pointer_to(b), pointer_to(a)) returns 0 - INT_MIN = INT_MIN (the "Leblancian Paradox"), which is incorrectly negative (because this subtraction overflows). We then executed our test program in a loop, on Fedora 39 (which uses the latest glibc version, 2.38): ------------------------------------------------------------------------ $ while true; do n=$((RANDOM*64+RANDOM+1)); ./qsort $n; done ------------------------------------------------------------------------ Unsurprisingly, nothing happened: our program did not crash or abort(). While this loop was still running (and not crashing), we started to read the glibc's qsort() implementation; to our great surprise, we discovered that the glibc's qsort() is not, in fact, a quick sort by default, but a merge sort (in stdlib/msort.c). Most likely, merge sort was chosen over quick sort to avoid quick sort's worst-case performance, which is O(n^2); on the other hand, merge sort's worst-case performance is O(n*log(n)). But merge sort suffers from one major drawback: it does not sort in-place -- it malloc()ates a copy of the array of elements to be sorted. As a result, if this array is very large (lines 212-217), or if this malloc() fails (lines 219-229), then the glibc's qsort() falls back to a quick sort (in stdlib/qsort.c), because quick sort does sort in-place: ------------------------------------------------------------------------ 163 void 164 __qsort_r (void *b, size_t n, size_t s, __compar_d_fn_t cmp, void *arg) 165 { 166 size_t size = n * s; ... 170 /* For large object sizes use indirect sorting. */ 171 if (s > 32) 172 size = 2 * n * sizeof (void *) + s; 173 174 if (size < 1024) 175 /* The temporary array is small, so put it on the stack. */ 176 p.t = __alloca (size); 177 else 178 { ... 212 /* If the memory requirements are too high don't allocate memory. */ 213 if (size / pagesize > (size_t) phys_pages) 214 { 215 _quicksort (b, n, s, cmp, arg); 216 return; 217 } 218 219 /* It's somewhat large, so malloc it. */ 220 int save = errno; 221 tmp = malloc (size); 222 __set_errno (save); 223 if (tmp == NULL) 224 { 225 /* Couldn't get space, so use the slower algorithm 226 that doesn't need a temporary array. */ 227 _quicksort (b, n, s, cmp, arg); 228 return; 229 } 230 p.t = tmp; 231 } ... 299 } ------------------------------------------------------------------------ We therefore decided to assess the robustness of the glibc's quick sort (instead of its merge sort, which was clearly not crashing), by forcing qsort() to call _quicksort(). Locally, forcing the malloc() at line 221 to fail is very easy: we simply execute our program with a low RLIMIT_AS ("The maximum size of the process's virtual memory", man setrlimit); and this works even when executing a SUID-root program. So we executed our program in the following loop instead: ------------------------------------------------------------------------ $ while true; do n=$((RANDOM*64+RANDOM+1)); prlimit --as=$((n*4/2*3)) ./qsort $n; done Aborted (core dumped) Aborted (core dumped) Aborted (core dumped) ... ------------------------------------------------------------------------ Incredibly, we almost immediately observed crashes of our test program: calls to abort(), because one of our canary elements (below or above the sorted array) was overwritten (i.e., an out-of-bounds write in qsort()). To understand these crashes, we examined one of them in gdb: ------------------------------------------------------------------------ $ gdb prlimit (gdb) run --as=8104854 ./qsort 1350809 Starting program: /usr/bin/prlimit --as=8104854 ./qsort 1350809 ... Program received signal SIGABRT, Aborted. __pthread_kill_implementation (threadid=, signo=signo@entry=6, no_tid=no_tid@entry=0) at pthread_kill.c:44 44 return INTERNAL_SYSCALL_ERROR_P (ret) ? INTERNAL_SYSCALL_ERRNO (ret) : 0; (gdb) backtrace #0 __pthread_kill_implementation (threadid=, signo=signo@entry=6, no_tid=no_tid@entry=0) at pthread_kill.c:44 #1 0x00007ffff7e698a3 in __pthread_kill_internal (signo=6, threadid=) at pthread_kill.c:78 #2 0x00007ffff7e178ee in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26 #3 0x00007ffff7dff8ff in __GI_abort () at abort.c:79 #4 0x0000555555555334 in main (argc=2, argv=0x7fffffffe338) at qsort.c:34 (gdb) select-frame 4 (gdb) p/x canary1 $1 = 0xc6109e4c (gdb) p/x *pcanary1 $2 = 0x0 (gdb) x/xw pcanary1 - 2 0x7ffff78af008: 0x00528002 0x7ffff78af00c: 0x80000000 0x7ffff78af010: 0x00000000 0x7ffff78af014: 0xc6109e4c 0x7ffff78af018: 0x00000000 ------------------------------------------------------------------------ - at address 0x7ffff78af010 (pcanary1), the original value of the canary (0xc6109e4c) was overwritten with 0x0 -- an out-of-bounds write; - at address 0x7ffff78af00c (below pcanary1), the most significant word of an mchunk_size (heap metadata) was overwritten with 0x80000000 (INT_MIN) -- another out-of-bounds write; - at address 0x7ffff78af014 (above pcanary1), the first element of the array was overwritten with 0xc6109e4c (the original value of the canary), which was therefore read out-of-bounds beforehand (from pcanary1). ======================================================================== Analysis ======================================================================== To identify the root cause of these out-of-bounds memory accesses, we must analyze the implementation of the glibc's quick sort: ------------------------------------------------------------------------ 87 void 88 _quicksort (void *const pbase, size_t total_elems, size_t size, 89 __compar_d_fn_t cmp, void *arg) 90 { 91 char *base_ptr = (char *) pbase; ... 108 while (STACK_NOT_EMPTY) 109 { ... 193 } ... 206 char *tmp_ptr = base_ptr; ... 214 for (run_ptr = tmp_ptr + size; run_ptr <= thresh; run_ptr += size) 215 if ((*cmp) ((void *) run_ptr, (void *) tmp_ptr, arg) < 0) 216 tmp_ptr = run_ptr; 217 218 if (tmp_ptr != base_ptr) 219 SWAP (tmp_ptr, base_ptr, size); ... 223 run_ptr = base_ptr + size; 224 while ((run_ptr += size) <= end_ptr) 225 { 226 tmp_ptr = run_ptr - size; 227 while ((*cmp) ((void *) run_ptr, (void *) tmp_ptr, arg) < 0) 228 tmp_ptr -= size; ... 246 } ... 248 } ------------------------------------------------------------------------ - at lines 108-193, when quick sort's partitions become smaller than MAX_THRESH (4 elements), _quicksort() switches to a final insertion sort (at lines 206-246), which is faster than quick sort for small or mostly sorted arrays; - at lines 206-219, this insertion sort makes sure that the very first element of the array (base_ptr) is the smallest element of the array; - at lines 226-228, this first element acts as a natural barrier that prevents tmp_ptr from being decremented below the array (because if tmp_ptr reaches base_ptr, then necessarily cmp(run_ptr, tmp_ptr) >= 0 because tmp_ptr is base_ptr, the smallest element of the array); - unfortunately this does not hold true if cmp() is nontransitive, in which case cmp(run_ptr, tmp_ptr) can be < 0 even if tmp_ptr is base_ptr, so tmp_ptr can be decremented below the array, where out-of-bounds elements are read and overwritten. ======================================================================== Patch ======================================================================== To patch these out-of-bounds memory accesses in _quicksort(), a simple check "tmp_ptr > base_ptr &&" can be added in front of the cmp() call at line 227 (of course this does not magically result in a correctly sorted array if cmp() is nontransitive, but at least it does not result in a memory corruption anymore). In fact, while drafting this advisory, we discovered that such a check ("tmp_ptr != base_ptr &&") has already been added to the glibc's master branch (which will become glibc 2.39 in February 2024), by the following commit ("stdlib: Fix array bounds protection in insertion sort phase of qsort"): https://sourceware.org/git?p=glibc.git;a=commit;h=b9390ba93676c4b1e87e218af5e7e4bb596312ac Indeed, the glibc developers have recently refactored qsort() and replaced the merge sort (and its fallback to quick sort) with an introspective sort (a combination of quick sort, heap sort, and insertion sort): https://en.wikipedia.org/wiki/Introsort https://sourceware.org/pipermail/libc-alpha/2023-October/151907.html During this refactoring, the glibc developers have proactively discovered (and patched) the out-of-bounds memory accesses in the insertion sort (probably because these out-of-bounds memory accesses became directly exposed to the misbehavior of nontransitive comparison functions, instead of being safely hidden behind a malloc() failure in the merge sort). Last-minute note: in January 2024, the glibc developers have reverted this refactoring of qsort(), back to the original merge sort, plus a fallback to heap sort instead of quick sort; for more information: https://sourceware.org/pipermail/libc-alpha/2024-January/154051.html ======================================================================== Discussion ======================================================================== We have not tried to find a vulnerable program (i.e., a program that uses a nontransitive comparison function to qsort() attacker-controlled elements); however, vulnerable programs are certain to exist in the real world: - calls to qsort() are extremely common; - nontransitive comparison functions are also common; - all glibc versions from at least September 1992 (glibc 1.04, the first version that we could find online) to the current release (glibc 2.38) are affected by this memory corruption. Locally, forcing a malloc() failure in qsort() (which is necessary to reach the memory corruption) is easy: either execute the target program (e.g., a SUID-root program) with a low RLIMIT_AS, or allocate a large amount of memory with another program on the same machine. Remotely, forcing this malloc() failure is harder: either allocate a large amount of memory (e.g., a memory leak) in the network service that is being targeted, or in another network service on the same machine. ======================================================================== Acknowledgments ======================================================================== We thank the glibc security team and the linux-distros@openwall. ======================================================================== Timeline ======================================================================== 2023-12-12: We sent a draft of our advisory to the glibc security team. They immediately acknowledged receipt of our email. 2023-12-19: The glibc security team decided to not treat this memory corruption in qsort() as a vulnerability in the glibc itself, as explained in the "Summary" of our advisory. 2024-01-16: We backported commit b9390ba to all current and past stable versions of the glibc, and sent this patch and a draft of our advisory to the linux-distros@openwall (to piggyback on the glibc embargo for CVE-2023-6246). They immediately acknowledged receipt of our email. 2024-01-30: Coordinated Release Date (18:00 UTC).