본문 바로가기
분류 전/CTF

[WACON2023] mosaic

by jwcs 2023. 9. 5.
728x90

https://general.wacon.world/challenges

 

WACON 2023 Prequal

 

general.wacon.world

wacon 2023 web 문제이다.

 

/

첫 페이지다.

 

/register

회원가입 페이지다. admin으로는 회원가입이 안됐다. 그 외의 것으로 회원가입을 해주자.

 

/login

로그인 페이지다.

 

/

로그인 후 첫 페이지다.

 

/upload

업로드 페이지다.

/mosaic

모자이크 페이지다.

업로드 페이지에서 업로드한 이미지 파일을 모자이크해서 출력해줄 것으로 보인다.

 

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>&nbsp;&nbsp;<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>&nbsp;&nbsp;<a href="/mosaic">mosaic</a>&nbsp;&nbsp;<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>&nbsp;&nbsp;<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>&nbsp;&nbsp;<a href="/mosaic">mosaic</a>&nbsp;&nbsp;<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

 

짜자잔

728x90
반응형

'분류 전 > 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