https://general.wacon.world/challenges
wacon 2023 web 문제이다.
첫 페이지다.
회원가입 페이지다. admin으로는 회원가입이 안됐다. 그 외의 것으로 회원가입을 해주자.
로그인 페이지다.
로그인 후 첫 페이지다.
업로드 페이지다.
모자이크 페이지다.
업로드 페이지에서 업로드한 이미지 파일을 모자이크해서 출력해줄 것으로 보인다.
from flask import Flask, render_template, request, redirect, url_for, session, g, send_from_directory
import mimetypes
import requests
import imageio
import os
import sqlite3
import hashlib
import re
from shutil import copyfile, rmtree
import numpy as np
app = Flask(__name__)
app.secret_key = os.urandom(24)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1000 * 1000
DATABASE = 'mosaic.db'
UPLOAD_FOLDER = 'uploads'
MOSAIC_FOLDER = 'static/uploads'
if os.path.exists("/flag.png"):
FLAG = "/flag.png"
else:
FLAG = "/test-flag.png"
try:
with open("password.txt", "r") as pw_fp:
ADMIN_PASSWORD = pw_fp.read()
pw_fp.close()
except:
ADMIN_PASSWORD = "admin"
def apply_mosaic(image, output_path, block_size=10):
height, width, channels = image.shape
for y in range(0, height, block_size):
for x in range(0, width, block_size):
block = image[y:y+block_size, x:x+block_size]
mean_color = np.mean(block, axis=(0, 1))
image[y:y+block_size, x:x+block_size] = mean_color
imageio.imsave(output_path, image)
def hash(password):
return hashlib.md5(password.encode()).hexdigest()
def type_check(guesstype):
return guesstype in ["image/png", "image/jpeg", "image/tiff", "application/zip"]
def get_db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect(DATABASE)
return db
def init_db():
with app.app_context():
db = get_db()
db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT unique, password TEXT)")
db.execute(f"INSERT INTO users (username, password) values('admin', '{hash(ADMIN_PASSWORD)}')")
db.commit()
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()
@app.route('/', methods=['GET'])
def index():
if not session.get('logged_in'):
return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/login">login</a> <a href="/register">register</a>'''
else:
if session.get('username') == "admin" and request.remote_addr == "127.0.0.1":
copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png')
return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/upload">upload</a> <a href="/mosaic">mosaic</a> <a href="/logout">logout</a>'''
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if not re.match('^[a-zA-Z0-9]*$', username):
return "Plz use alphanumeric characters.."
cur = get_db().cursor()
cur.execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, hash(password)))
get_db().commit()
os.mkdir(f"{UPLOAD_FOLDER}/{username}")
os.mkdir(f"{MOSAIC_FOLDER}/{username}")
return redirect(url_for('login'))
return render_template("register.html")
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if not re.match('^[a-zA-Z0-9]*$', username):
return "Plz use alphanumeric characters.."
cur = get_db().cursor()
user = cur.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, hash(password))).fetchone()
if user:
session['logged_in'] = True
session['username'] = user[1]
return redirect(url_for('index'))
else:
return 'Invalid credentials. Please try again.'
return render_template("login.html")
@app.route('/logout')
def logout():
session.pop('logged_in', None)
session.pop('username', None)
return redirect(url_for('login'))
@app.route('/mosaic', methods=['GET', 'POST'])
def mosaic():
if not session.get('logged_in'):
return redirect(url_for('login'))
if request.method == 'POST':
image_url = request.form.get('image_url')
if image_url and "../" not in image_url and not image_url.startswith("/"):
guesstype = mimetypes.guess_type(image_url)[0]
ext = guesstype.split("/")[1]
mosaic_path = os.path.join(f'{MOSAIC_FOLDER}/{session["username"]}', f'{os.urandom(8).hex()}.{ext}')
filename = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', image_url)
if os.path.isfile(filename):
image = imageio.imread(filename)
elif image_url.startswith("http://") or image_url.startswith("https://"):
return "Not yet..! sry.."
else:
if type_check(guesstype):
image_data = requests.get(image_url, headers={"Cookie":request.headers.get("Cookie")}).content
image = imageio.imread(image_data)
apply_mosaic(image, mosaic_path)
return render_template("mosaic.html", mosaic_path = mosaic_path)
else:
return "Plz input image_url or Invalid image_url.."
return render_template("mosaic.html")
@app.route('/upload', methods=['GET', 'POST'])
def upload():
if not session.get('logged_in'):
return redirect(url_for('login'))
if request.method == 'POST':
if 'file' not in request.files:
return 'No file part'
file = request.files['file']
if file.filename == '':
return 'No selected file'
filename = os.path.basename(file.filename)
guesstype = mimetypes.guess_type(filename)[0]
image_path = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', filename)
if type_check(guesstype):
file.save(image_path)
return render_template("upload.html", image_path = image_path)
else:
return "Allowed file types are png, jpeg, jpg, zip, tiff.."
return render_template("upload.html")
@app.route('/check_upload/@<username>/<file>')
def check_upload(username, file):
if not session.get('logged_in'):
return redirect(url_for('login'))
if username == "admin" and session["username"] != "admin":
return "Access Denied.."
else:
return send_from_directory(f'{UPLOAD_FOLDER}/{username}', file)
if __name__ == '__main__':
init_db()
app.run(host="0.0.0.0", port="9999")
전체 코드다.
조금씩 분해해서 해석해보자.
app = Flask(__name__)
app.secret_key = os.urandom(24)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1000 * 1000
DATABASE = 'mosaic.db'
UPLOAD_FOLDER = 'uploads'
MOSAIC_FOLDER = 'static/uploads'
if os.path.exists("/flag.png"):
FLAG = "/flag.png"
else:
FLAG = "/test-flag.png"
try:
with open("password.txt", "r") as pw_fp:
ADMIN_PASSWORD = pw_fp.read()
pw_fp.close()
except:
ADMIN_PASSWORD = "admin"
기본 설정들이 이루어지고 있다.
FLAG는 최상위 폴더에 있는 flag.png인 것을 확인할 수 있다.
admin의 비밀번호는 password.txt에서 가져오는 것을 확인할 수 있다.
def apply_mosaic(image, output_path, block_size=10):
height, width, channels = image.shape
for y in range(0, height, block_size):
for x in range(0, width, block_size):
block = image[y:y+block_size, x:x+block_size]
mean_color = np.mean(block, axis=(0, 1))
image[y:y+block_size, x:x+block_size] = mean_color
imageio.imsave(output_path, image)
def hash(password):
return hashlib.md5(password.encode()).hexdigest()
def type_check(guesstype):
return guesstype in ["image/png", "image/jpeg", "image/tiff", "application/zip"]
def get_db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect(DATABASE)
return db
def init_db():
with app.app_context():
db = get_db()
db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT unique, password TEXT)")
db.execute(f"INSERT INTO users (username, password) values('admin', '{hash(ADMIN_PASSWORD)}')")
db.commit()
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()
모자이크 적용, 비밀번호 해쉬, 타입 확인, 데이터베이스 조작, 종료시 데이터베이스 조작 함수가 정의되어 있다.
@app.route('/', methods=['GET'])
def index():
if not session.get('logged_in'):
return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/login">login</a> <a href="/register">register</a>'''
else:
if session.get('username') == "admin" and request.remote_addr == "127.0.0.1":
copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png')
return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/upload">upload</a> <a href="/mosaic">mosaic</a> <a href="/logout">logout</a>'''
로그인 되었을 경우와 아닌 경우 처리에 대해 나타내주고 있다. admin으로 로그인하고 ip 주소가 127.0.0.1일 경우 위와 같은 경로에 flag를 위치시키는 것을 확인할 수 있다.
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if not re.match('^[a-zA-Z0-9]*$', username):
return "Plz use alphanumeric characters.."
cur = get_db().cursor()
cur.execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, hash(password)))
get_db().commit()
os.mkdir(f"{UPLOAD_FOLDER}/{username}")
os.mkdir(f"{MOSAIC_FOLDER}/{username}")
return redirect(url_for('login'))
return render_template("register.html")
회원가입 페이지다. 알파벳과 숫자로만 회원가입을 받고 있다. 회원가입이 완료되면 업로드 폴더를 만들어주고 있다.
참고로 admin 폴더는 도커에
RUN mkdir /app/uploads/admin
RUN mkdir /app/static/uploads/admin
이라고 나와있다.
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if not re.match('^[a-zA-Z0-9]*$', username):
return "Plz use alphanumeric characters.."
cur = get_db().cursor()
user = cur.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, hash(password))).fetchone()
if user:
session['logged_in'] = True
session['username'] = user[1]
return redirect(url_for('index'))
else:
return 'Invalid credentials. Please try again.'
return render_template("login.html")
로그인 페이지다. 시큐어 코딩이 되어있어 sqli는 어려워보인다.
@app.route('/logout')
def logout():
session.pop('logged_in', None)
session.pop('username', None)
return redirect(url_for('login'))
로그아웃 페이지다.
@app.route('/mosaic', methods=['GET', 'POST'])
def mosaic():
if not session.get('logged_in'):
return redirect(url_for('login'))
if request.method == 'POST':
image_url = request.form.get('image_url')
if image_url and "../" not in image_url and not image_url.startswith("/"):
guesstype = mimetypes.guess_type(image_url)[0]
ext = guesstype.split("/")[1]
mosaic_path = os.path.join(f'{MOSAIC_FOLDER}/{session["username"]}', f'{os.urandom(8).hex()}.{ext}')
filename = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', image_url)
if os.path.isfile(filename):
image = imageio.imread(filename)
elif image_url.startswith("http://") or image_url.startswith("https://"):
return "Not yet..! sry.."
else:
if type_check(guesstype):
image_data = requests.get(image_url, headers={"Cookie":request.headers.get("Cookie")}).content
image = imageio.imread(image_data)
apply_mosaic(image, mosaic_path)
return render_template("mosaic.html", mosaic_path = mosaic_path)
else:
return "Plz input image_url or Invalid image_url.."
return render_template("mosaic.html")
우리가 업로드한 파일을 찾아 모자이크를 적용해 랜덤한 이름과 함께 저장되는 함수이다.
@app.route('/upload', methods=['GET', 'POST'])
def upload():
if not session.get('logged_in'):
return redirect(url_for('login'))
if request.method == 'POST':
if 'file' not in request.files:
return 'No file part'
file = request.files['file']
if file.filename == '':
return 'No selected file'
filename = os.path.basename(file.filename)
guesstype = mimetypes.guess_type(filename)[0]
image_path = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', filename)
if type_check(guesstype):
file.save(image_path)
return render_template("upload.html", image_path = image_path)
else:
return "Allowed file types are png, jpeg, jpg, zip, tiff.."
업로드 함수이다.
@app.route('/check_upload/@<username>/<file>')
def check_upload(username, file):
if not session.get('logged_in'):
return redirect(url_for('login'))
if username == "admin" and session["username"] != "admin":
return "Access Denied.."
else:
return send_from_directory(f'{UPLOAD_FOLDER}/{username}', file)
/check_upload/@<username>/<file> 페이지다. 플라스크에서 괄호(<,>)를 이용해 변수를 만들 수 있다. 이 변수로 값을 전달하는 모습이다.
해당 페이지는 업로드된 파일을 확인하기 위해 만들어진 페이지로 보여진다. 모자이크가 적용되기 전의 깨끗한 이미지를 볼 수 있다. 이 페이지에서 취약점이 보인다. <username>의 입력값 검증이 이루어지지 않고 있다. 따라서 path traversal을 할 수 있을 것같다.
/check_upload/@../password.txt
이와 같이 입력해주자.
admin의 password를 얻었다. admin으로 로그인해주자.
flag.png를 불러오기 위해선
if session.get('username') == "admin" and request.remote_addr == "127.0.0.1":
copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png')
이 부분을 해결해주어야 한다. 이를 해결하기 위해 우리는 /mosiac 엔드포인트를 이용하겠다.
else:
if type_check(guesstype):
image_data = requests.get(image_url, headers={"Cookie":request.headers.get("Cookie")}).content
image = imageio.imread(image_data)
해당 코드를 이용해서 request 요청을 보내도록 유도할 수 있겠다.
HTTP://localhost:9999/?a=a.png
위의 url을 mosiac 페이지의 입력창에 넣어주자.
에러창이 뜨더라도 요청은 보내졌을 것이다. 이제 flag.png를 찾아보자.
/check_upload/@admin/flag.png
짜자잔
'분류 전 > CTF' 카테고리의 다른 글
[Sunshine] BeepBoop Blog 풀이 (0) | 2023.10.10 |
---|---|
[ASIS] hello 풀이 (2) | 2023.10.02 |
[WACON2023] mic check 풀이 (0) | 2023.09.05 |
[Wolve CTF 2023] Zombie 101 풀이 (0) | 2023.03.21 |
[b01lers CTF] warmup 풀이 (2) | 2023.03.21 |