🗂️ INDEX

글 작성자: Universe7202

주변에 아는 사람들이랑 총 4명이서 WACon2023 CTF에 참가했습니다. 결과는 15등으로 마무리 했네요. 

1. [web] 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):
    print(f'{UPLOAD_FOLDER}/{username}')
    print(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", debug=True)

 

 

flag를 획득하기 위해서는 admin 계정과 로컬에서 요청을 보내야 획득할 수 있습니다. 

@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 패스워드는 같은 web project 위치에 있는 `password.txt` 파일로 관리되고 있었습니다. 이 파일을 열람할 수 있다면, admin 계정으로 로그인할 수 있습니다. 

try:
    with open("password.txt", "r") as pw_fp:
        ADMIN_PASSWORD = pw_fp.read()
        pw_fp.close()
except:
    ADMIN_PASSWORD = "admin"

 

API 중에서 사용자가 업로드한 이미지를 확인하는 기능이 있습니다. 해당 기능에는 사용자로부터 `username`, `file` 두개의 파라미터를 받고 있습니다. 이때, 두 파라미터에 대한 검증이 없어 임의의 파일을 읽을 수 있습니다.

@app.route('/check_upload/@<username>/<file>')
def check_upload(username, file):
    print(f'{UPLOAD_FOLDER}/{username}')
    print(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)

 

따라서, `password.txt` 파일을 읽기 위해 다음과 같이 전송합니다.

curl http://host/check_upload/@../password.txt

 

위처럼 요청을 전송하면 admin의 패스워드를 획득할 수 있습니다. 

 

admin 계정을 획득했지만, flag를 획득하기 위해 SSRF 취약점을 이용해야 합니다. SSRF 가 가능한 공격 백터는 다음과 같습니다.

@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")

 

위 코드에서 requests 모듈을 이용해 SSRF 공격을 해야 합니다. 하지만 2개의 과정을 bypass 해야 하는데요.

1. `image_url` 파라미터에 확장자가 포함되어 있어야 한다.

2. `image_url` 파라미터에서 첫번째 값이 http:// 혹은 https:// 로 시작하면 안된다.

 

문제를 풀기 위해서는 `http://localhost` 이렇게 요청을 보내야 합니다. 하지만 첫번째 조건에서 파일 확장자가 없기 때문에 에러가 발생합니다. 이를 우회하기 위해 `http://localhost/#test.png` 로 전달합니다.

두번째 조건을 우회하기 위해서는 그냥 문자열 앞에 공백을 추가하면 끝입니다. ` http://localhost/#test.png`

 

최종적으로 이 문제를 풀기 위해서는 다음과 같습니다.

1. admin 패스워드 획득을 위해 `http://host/check_upload/@../password.txt` 로 요청 및 패스워드 획득

2. admin 계정으로 flag.png 라는 파일 이름으로 이미지 파일 업로드

3. SSRF 취약점으로 ` http://localhost/#test.png` 요청

4. admin 계정으로 `/check_upload/@admin/flag.png` 요청 및 flag 획득

2. [web] warmup-revenge

 

 

이 문제는 XSS를 통해 Cookie 안에 있는 flag를 획득하는 문제 입니다. 

사용자는 회원가입, 로그인, 글 작성 및 파일 업로드, 다운로드 기능을 사용할 수 있습니다. 하지만, 사용자가 입력한 값들은 html entity가 escape 되어 출력됩니다. 따라서 이러한 방법으로 XSS를 할 수 없습니다.

 

사용자가 업로드한 파일은 랜덤한 파일 이름과 확장자 없이 저장됩니다. 따라서 사용자가 업로드한 파일 이름을 알 수 없어 업로드 파일에 다이렉트로 접근할 수 없습니다.

 

코드를 분석하면서 의심되는 부분은 사용자가 파일 업로드를 포함해서 작성한 게시글에 다음과 같은 javascript 코드가 존재하는 것을 볼 수 있었습니다. 요청 URL에 `auto_download` 파라미터 값이 존재하면 2초 뒤에 download link를 `window.open()` 함수를 이용하여 팝업으로 띄우는 기능이 존재합니다. 

const urlParams = new URLSearchParams(window.location.search);
var auto_download = urlParams.get('auto_download') ? 1 : 0
if(auto_download) {
	setTimeout(download, 2000);
}

function download() {
    window.open(document.getElementById("download").href);
}

 

`window.open()` 함수 docs를 보면, Same-Origin Policy를 적용하고 있습니다. 즉, `window.open()` 함수로 열린 URL이 동일한 origin일 경우, 해당 origin에서 XSS를 통해 Cookie를 탈취할 수 있습니다.

https://developer.mozilla.org/en-US/docs/Web/API/Window/open

 

하지만, 사용자가 업로드한 파일을 다운로드할 경우 Response header에는 `Content-Disposition` Header 가 존재하여 브라우저가 파일을 강제로 다운로드 받는 것이 문제 입니다. 만약, `Content-Disposition` Header를 동작하지 못하게 할 수 있다면, 브라우저가 파일을 다운로드 받지 않고 rendering 해 줄 것입니다.

 

구글링 하던 중, 아래와 같은 글을 읽게 되었습니다. 이 글에서는 `Content-Type` 에 newline을 넣어 Response Header에 `Content-Disposition: inline` 을 넣은 것을 볼 수 있었습니다.

 

Defeating Content-Disposition

The Content-Disposition response header tells the browser to download a file rather than displaying it in the browser window. Content-Disposition: attachment; filename="filename.jpg" For example, even though this HTML outputs alert(document.domain) , becau

markitzeroday.com

 

문제 코드에서 사용자가 업로드한 파일 이름에 대해 newline에 대한 필터링이 없는 것을 볼 수 있습니다.

 

따라서 다음과 같이 filename에 `test.xml` 이후에 `\r` 를 넣고 `Content-Disposition: inline;` 를 추가하여 업로드 합니다. 

`\r` 문자는 burpsuite에서 Hex 탭에 넣을 수 있습니다.

 

위 글을 작성한 뒤, 다운로드 기능을 이용하면 파일이 다운로드 되지 않는 것을 볼 수 있습니다. 하지만, CSP 정책으로 인해 inline javascript 가 실행되지 않습니다.

 

CSP를 보면 `script-src 'self';` 로 되어 있습니다. 이를 이용하여 CSP를 우회할 수 있습니다.

Content-Security-Policy: default-src 'self'; style-src 'self' https://stackpath.bootstrapcdn.com 'unsafe-inline'; script-src 'self'; img-src data:

 

해당 문제에는 파일 업로드 기능이 있기 때문에 `alert(document.domain)` 문자열만 업로드 합니다. 이때 파일 다운로드 번호를 1번이라고 합시다.

이후, 위 파일을 불러오는 html 코드를 작성하고 저장합니다. 이 첨부파일의 번호를 2번이라고 했을 때, `/download.php?idx=1` 로 요청을 보내면 XSS가 동작하는 것을 볼 수 있습니다.

<script src="/download.php?idx=1"></script>

 

따라서 이 문제를 풀기 위해서는 Cookie를 탈취하기 위한 javascript 코드를 업로드 해야하고, 이를 load하는 html 파일을 업로드 한 뒤, bot에게 `auto_download` 파라미터를 포함한 링크를 전달하면 flag를 획득할 수 있습니다.

 

WACON2023{b1b1e2b97fcfd419db87b61459d2e267}

 

3. [misc] Web?

 

이 문제를 풀기 위해서는 eval.js 파일의 `eval()` 함수를 통해 flag를 읽어야 하는 문제 입니다.

const express = require("express");
const bodyParser = require("body-parser");
const session = require("express-session");
const child_process  = require("child_process");

const crypto = require("crypto");
const random_bytes = size => crypto.randomBytes(size).toString();
const sha256 = plain => crypto.createHash("sha256").update(plain.toString()).digest("hex");

const users = new Map([
    [],
]);

const now = () => { return Math.floor(+new Date()/1000) }
const checkoutTimes = new Map()

const app = express();

app.use(bodyParser.json());
app.use(
    session({
        cookie: { maxAge : 600000 },
        secret: random_bytes(64),
    })
);

const loginHandler = (req, res, next) => {
    if(!req.session.uid) {
        return res.redirect("/")
    }
    next();
}

app.all("/", (req, res) => {
    return res.json({ "msg" : "hello guest" });
});

app.post("/login", (req, res) => {
    const { username, password } = req.body;

    if ( typeof username !== "string" || typeof password !== "string" || username.length < 4 || password.length < 6) {
        return res.json({ msg: "invalid data" });
    }

    if (users.has(username)) {
        if (users.get(username) === sha256(password)) {
            req.session.uid = username;

            return res.redirect("/");
        } else {
            return res.json({ msg: "Invalid Password" });
        }
    } else {
        users.set(username, sha256(password));
        req.session.uid = username;
        return res.redirect("/");
    }
});

app.post("/calc", (req,res) => {
	// if(checkoutTimes.has(req.ip) && checkoutTimes.get(req.ip)+1 > now()) {
	// 	return res.json({ error: true, msg: "too fast"})
	// }
	// checkoutTimes.set(req.ip,now())

    const { expr, opt } = req.body;
    const args = ["--experimental-permission", "--allow-fs-read=/app/*"];

    const badArg = ["--print", "-p", "--input-type", "--import", "-e", "--eval", "--allow-fs-write", "--allow-child-process", "-", "-C", "--conditions"]
    
    console.log(expr)
    console.log(opt)

    if (!expr || typeof expr !== "string" ) {
        return res.json({ msg: "invalid data" });
    }

    if (opt) {
        if (!/^--[A-Za-z|,|\/|\*|\=|\-]+$/.test(opt) || badArg.includes(opt.trim())) {
            return res.json({ error: true, msg: "Invalid option" });
        }
        args.push(opt, "eval.js", btoa(expr));
    }

    args.push("eval.js", btoa(expr));

    console.log("================")
    console.log(args)
    console.log("================")
	try {
		ps = child_process.spawnSync("node", args);
        result = ps.stdout.toString().trim();
        if (result) {
            return res.type("text/plain").send(result)
        } 
        return res.type("text/plain").send("Empty");
	} catch (e) {
        console.log(e)
        return res.json({ "msg": "Nop" })
    }
});

app.listen(8001);
const fs = require("fs");
let filter = null;
try {
    filter = fs.readFileSync("config").toString();
} catch(e) {console.log(e)}

const expr = process.argv.pop();
const regex = new RegExp(filter);
if (regex.test(expr)) {
    console.log("Nop");
} else {
    console.log(eval(expr));
}
// config file

[^\+|\*|-|%|\/|\d+|0-9]

 

`--allow-fs-read` 옵션에서 `,` 를 이용하여 여러개의 경로에 `FillSystemRead` 권한을 부여할 수 있습니다.

 

예를 들어, 다음과 같이 `--allow-fs-read=/app/eval*` 옵션을 전달하면 eval.js 파일에 대해서만 File Read가 가능합니다. 이때, eval.js 파일에서는 `config` 파일을 읽지 못해 사용자가 전달한 `expr` 파라미터를 검증할 수 없어 임의의 코드를 실행할 수 있습니다.

 

따라서 `/flag*` 를 추가하여 flag를 획득할 수 있습니다.

'🚩CTF' 카테고리의 다른 글

hacktm ctf 2023 writeup  (0) 2023.02.21
lactf 2023 writeup  (0) 2023.02.14
Dicectf 2023 writeup  (0) 2023.02.08
DiceCTF 2022 writeup  (0) 2022.07.25
[WACon 2022] yet_another_baby_web  (0) 2022.06.28