An initial look at the application tells us that we have a flask application with the following source code:
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from random import randint
import uuid
import os
from flask import Flask, request, session, redirect, url_for
import re
from waitress import serve
app = Flask(__name__)
app.secret_key = ''.join(["{}".format(randint(0, 9)) for num in range(0, 6)])
MAIN_KEY = b"FAKE_KEY"
def gen_userid():
return str(uuid.uuid4())
def encrypt(data,MAIN_KEY):
cipher = AES.new(MAIN_KEY, AES.MODE_ECB)
cipher_text_bytes = cipher.encrypt(pad(data, 16,'pkcs7'))
cipher_text_b64 = b64encode(cipher_text_bytes)
cipher_text = cipher_text_b64.decode('ascii')
return cipher_text
def decrypt(MAIN_KEY, cipher_text):
rem0ve_b64 = b64decode(cipher_text)
cipher = AES.new(MAIN_KEY, AES.MODE_ECB)
decrypted_bytes = cipher.decrypt(rem0ve_b64)
decrypted_data = unpad(decrypted_bytes, 16, 'pkcs7').decode('ascii')
return decrypted_data
@app.route("/")
def welcome():
return '''
<div style="text-align: center;">
<h1 style="font-size: 3em; color: #333;">UNDER CONSTRUCTION</h1>
<img src="/static/img/page-under-construction.jpg" alt="Under Construction" style="width:400px; display: block; margin: 0 auto;">
</div>
<!-- HACKERS HAVE NO CHANCE THIS TIME!!! -->
'''
@app.route("/status")
def status():
if 'status' in session:
plain_text = decrypt(MAIN_KEY, session["status"])
return f'Logged in as {plain_text}'
return 'You are not logged in'
@app.route("/secret")
def get_secret():
plain_text = decrypt(MAIN_KEY, session["status"])
role = plain_text.split("::")[2]
if role == 'admin':
return os.environ['INS']
return f'You are a just a {role}. Only admins can see the secret!'
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
user_id = gen_userid()
user = re.sub(r'\W+', '', request.form['username'])
status_data = user_id + \
"::" + user + \
"::" + "guest"
cipher_text = encrypt(data=status_data.encode('ascii'),MAIN_KEY=MAIN_KEY)
session['status'] = cipher_text
return redirect(url_for('status'))
return '''
<form method="post">
<p>
<label for="username">Username:</label>
<input type="text" id="username" name="username">
</p>
<p>
<input type="submit" value="Login">
</p>
<p> * Note to myself: Password field TBD </p>
</form>
'''
@app.route('/logout')
def logout():
session.pop('status', None)
return redirect(url_for('status'))
if __name__=="__main__":
serve(app, host='0.0.0.0', port=80)
My team member, xtea418 figured out that the flask
session key is set to a random 6-digit number in the source code, so it can be bruteforced:
pip3 install flask-unsign[wordlist]
echo {0..9}{0..9}{0..9}{0..9}{0..9}{0..9} | tr ' ' '\n' > wordlist.txt
flask-unsign --unsign --cookie 'eyJzdGF0dXMiOiJhd25TaXU1WGltSDhCSkNpNFV3Zkg4L0hkd3BNL3hsYWhQOGpNc2RvMkE4bWFQNG5PalNXVFBrRlZacGgzay9tNGk2OEYzUm9aOUJkdGJ0cDZSNXdxQT09In0.aAI0-Q.saoIJ67WgELLIzQeFQmA-ltG79U' -w wordlist.txt --no-literal-eval
Success!
[*] Session decodes to: {'status': 'awnSiu5XimH8BJCi4UwfH8/HdwpM/xlahP8jMsdo2A8maP4nOjSWTPkFVZph3k/m4i68F3RoZ9Bdtbtp6R5wqA=='}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 256384 attempts
b'256321'
We now know the flask secret key and can therefore create and sign arbitrary flask sessions. However, the status
field inside the session is still encrypted with AES-ECB
using a different key that we do not know.
Since encrypted blocks in AES
are 16 bytes in length, we can figure out where the data from the session status
field lands exactly within these blocks:
Since AES-ECB
mode encrypts data into single independent blocks, we can re-order the blocks in any way we want. Since there is also no integrity checking, the re-ordered data will be decrypted without a problem. This allows us to craft a session with a status
field that places the field delimiter ::
right before a block border (Block A3
to A4
). After creating another encrypted status
field for another user with a username that ends with admin
aligned to start perfectly at the beginning of block B4
, we can then re-assemble the blocks from the two previous encrypted status
fields in a way that allows us to construct an encrypted chain of blocks that decrypts to: 4e461eba-1c43-11f0-b101-ab80c20c0721::aaaaaaaa::admin::guest
.
This will allow us to create a status block for a user that will have the admin
role.
Note: The UUID of the newly created
status
data is different than the two previously created users since parts the combined. This has no impact on this application since it doesn’t use the UUID for anything or check if a user with this UUID exists.
Finally, its time to create a solve script which will solve the challenge for us:
import requests, base64
from flask_unsign import sign, decode
def generate_raw_status(username):
s = requests.Session()
s.post("https://unchained.insomnihack.ch/login", data={"username": username})
status = decode(s.cookies["session"])["status"]
status_raw = base64.b64decode(status)
return bytearray(status_raw)
cutoff_block = generate_raw_status("aaaaaaaa")
admin_block = generate_raw_status("bbbbbbbbbbadmin")
admin_block[32:48] = cutoff_block[32:48]
reassembled_status = base64.b64encode(admin_block).decode("utf-8")
SECRET_KEY = b'256321'
reassembled_jwt = sign({"status": reassembled_status}, SECRET_KEY)
r = requests.get("https://unchained.insomnihack.ch/secret", cookies={
"session": reassembled_jwt
})
print(r.text)
Running the solve script will now get us the flag!
INS{#Com3_Br34k_th3_Cha1n_Aga1N!!!}