WACon2023 CTF Write up
주변에 아는 사람들이랑 총 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> <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):
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> <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 패스워드는 같은 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를 탈취할 수 있습니다.
하지만, 사용자가 업로드한 파일을 다운로드할 경우 Response header에는 `Content-Disposition` Header 가 존재하여 브라우저가 파일을 강제로 다운로드 받는 것이 문제 입니다. 만약, `Content-Disposition` Header를 동작하지 못하게 할 수 있다면, 브라우저가 파일을 다운로드 받지 않고 rendering 해 줄 것입니다.
구글링 하던 중, 아래와 같은 글을 읽게 되었습니다. 이 글에서는 `Content-Type` 에 newline을 넣어 Response Header에 `Content-Disposition: inline` 을 넣은 것을 볼 수 있었습니다.
문제 코드에서 사용자가 업로드한 파일 이름에 대해 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 |
댓글
이 글 공유하기
다른 글
-
hacktm ctf 2023 writeup
hacktm ctf 2023 writeup
2023.02.21 -
lactf 2023 writeup
lactf 2023 writeup
2023.02.14 -
Dicectf 2023 writeup
Dicectf 2023 writeup
2023.02.08 -
DiceCTF 2022 writeup
DiceCTF 2022 writeup
2022.07.25