Lucene search

K
packetstormQualys Security AdvisoryPACKETSTORM:176931
HistoryJan 31, 2024 - 12:00 a.m.

glibc qsort() Out-Of-Bounds Read / Write

2024-01-3100:00:00
Qualys Security Advisory
packetstormsecurity.com
86
memory corruption
bound check
nontransitive comparison function
vulnerable program
glibc
patch
quality of implementation
refactoring
security advisory
transitive comparison function

8.4 High

CVSS3

Attack Vector

LOCAL

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.4 High

AI Score

Confidence

Low

4.6 Medium

CVSS2

Access Vector

LOCAL

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:L/AC:L/Au:N/C:P/I:P/A:P

`  
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 <limits.h>  
2 #include <stdlib.h>  
3 #include <sys/time.h>  
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=<optimized out>, 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=<optimized out>, signo=signo@entry=6, no_tid=no_tid@entry=0) at pthread_kill.c:44  
#1 0x00007ffff7e698a3 in __pthread_kill_internal (signo=6, threadid=<optimized out>) 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).  
  
  
`

8.4 High

CVSS3

Attack Vector

LOCAL

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.4 High

AI Score

Confidence

Low

4.6 Medium

CVSS2

Access Vector

LOCAL

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:L/AC:L/Au:N/C:P/I:P/A:P