CTF

더 해킹 챔피언십 주니어 2023(DSEC 2023) 문제 풀이 및 후기

Namchun 2023. 10. 30. 23:39

이번 10월 21일에 열린 '더 해킹 챔피언십 주니어 2023'에 참여하여 본선에 진출하였다.

 

우리 팀은 2 문제를 풀고 간신히 본선에 진출할 수 있었고, 풀었던 문제와 아쉽게 못 푼 문제를 정리하겠다.


BBBBB (crypto)

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import os

BLOCK_SIZE = 16
KEY = os.urandom(BLOCK_SIZE)
try:
    with open('flag.txt', 'rb') as f:
        FLAG = f.read()
except:
    FLAG = b'flag{test_flaggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg}'

def encrypt(data):
    cipher = AES.new(KEY, AES.MODE_ECB)
    return cipher.encrypt(pad(data, BLOCK_SIZE))

def main():
    print("[ BBBBB Server!! ]")
    
    while True:
        print("1. Encrypt")
        print("2. Bye")
        choice = input("> ")

        if choice == "1":
            user_input = bytes.fromhex(input("Plaintext: "))
            encrypted_data = encrypt(user_input + FLAG)
            print(f"Encrypted data: {encrypted_data.hex()}")

        elif choice == "2":
            break

if __name__ == "__main__":
    main()

AESECB모드에서 발생할 수 있는 취약점인 Oracle Padding Attack을 이용하여 공격을 시도하였다.

(참고 자료: https://lactea.kr/entry/ECB-설명-및-취약점)

 

아주 아주 간단하게 설명하자면

user_input = 'AAAAAAAAAAAAAAAA', flag = 'namchun{tistory}'이고

user_input + flag를 암호화하려고 할 때, 블록의 크기16이라면

['AAAAAAAAAAAAAAAA'] ['namchun{tistory}'] 이런 식으로 암호화를 수행할 것이다.

 

하지만 user_input의 길이가 15라면?

['AAAAAAAAAAAAAAAn'] ['amchun{tistory}?'] 이런 식으로 flag의 첫 글자가 앞으로 밀려와서 user_input과 같이 암호화가 수행된다.

 

따라서 블록의 크기보다 작은 길이의 input을 넣었을 때 나온 값으로 브루트포싱하여 한 글자씩 얻을 수 있을 것이다.

 

from pwn import *

p = remote('3.39.87.239', 8713)

payload = b'61'*64

def sla(msg):
    p.sendlineafter(b'1. Encrypt\n2. Bye\n> ', b'1')
    p.sendlineafter(b'Plaintext: ', msg)

flag = b''
for i in range(63):
    payload = payload[:-2]
    sla(payload)
    
    target = p.recvline()[16:-1]
    
    for ct in range(32, 127):
        test_payload = payload + flag + hex(ct)[2:].encode()
        sla(test_payload)

        encrypted_data = p.recvline()[16:-1]

        if target[:128] == encrypted_data[:128]:
            flag += hex(ct)[2:].encode()
            print(bytes.fromhex(flag.decode()))
            break

print(bytes.fromhex(flag.decode()))

flag{sometimes..._ECB_m0de_i5_vuln3rble!!_673214966bf6302c4ba3}

Hash-rain (crypto)

해당 문제는 내가 푼 것이 아닌 팀원분이 푸신 문제라 설명이 조금은 부족할 수도 있다.

#!/usr/bin/python3
import hashlib
import os
import random
import string
import signal

FLAG_FILE = "./flag.txt"
ROUNDS = 50
TIME_LIMIT = 100

def alarm_handler(signum, frame):
    print("Time's up! You didn't find the correct input in time.")
    exit(0)

def generate_random_hex_string(length):
    return ''.join(random.choice('0123456789abcdef') for _ in range(length))

def calculate_md5_hash(input_string):
    return hashlib.md5(input_string.encode()).hexdigest()

def main():
    signal.signal(signal.SIGALRM, alarm_handler)
    signal.alarm(250)
    for round_num in range(1, ROUNDS + 1):
        if round_num == ROUNDS:
            print("Congratulations! You've completed all rounds.")
            os.system("cat ./flag.txt")
            break

        print(f"Round {round_num}/{ROUNDS}")

        answer = generate_random_hex_string(6)
        print(f"Find an input whose MD5 hash ends with: {answer}")

        input_string = input("Your Input : ").strip()
        md5_hash = calculate_md5_hash(input_string)
        
        if md5_hash[-6:] == answer:
            print(f"Correct!")
            continue
        else:
            print("Nop!")
            break

if __name__ == "__main__":
    main()

 

이 문제의 취약점은 MD5 해시마지막 6자리만을 사용하여 검증한다는 점에 있다.

이로 인해 충돌의 가능성이 상당히 높아졌고, 다양한 입력값이 동일한 MD5 해시의 마지막 6자리를 가질 수 있어서 결국 브루트포스로 문제를 해결할 수 있었다.

해결 방법은 가능한 모든 7자리 16진수 문자열에 대하여 미리 MD5 해시를 계산하고, 그 결과의 마지막 6자리를 키로, 원래 문자열을 값으로 하는 딕셔너리를 생성한다.

 

(내가 이 문제를 풀면서 타임아웃 때문에 골치가 아팠었는데 해시를 미리 계산해놓는 방법이 있었다 👍)

 

import hashlib
import itertools

# 모든 경우의 수를 미리 계산
def precompute_hashes():
    chars = '0123456789abcdef'
    precomputed = {}
    
    total_combinations = len(chars) ** 7
    completed = 0
    
    for combo in itertools.product(chars, repeat=7):
        completed += 1
        percentage = (completed / total_combinations) * 100
        print(f"Progress: {percentage:.2f}%", end='\r')
        
        candidate = ''.join(combo)
        md5_hash = hashlib.md5(candidate.encode()).hexdigest()
        suffix = md5_hash[-6:]
        precomputed[suffix] = candidate

    print()  # Newline for cleaner output
    return precomputed

precomputed_hashes = precompute_hashes()

def find_answer(target_suffix):
    return precomputed_hashes.get(target_suffix, None)

# 사용 예
for i in range(50):
    target_suffix = input(f"Round {i + 1}/50\nFind an input whose MD5 hash ends with: ")
    answer = find_answer(target_suffix)
    if answer is not None:
        print(f"Your Input : {answer}")
    else:
        print("No match found!")

(팀원분의 코드)

 

못 푼 문제 - 방탈출 (misc)

이 문제는 step1과 step2로 나뉘어져 있었는데, step1에서는 10000개의 이미지 파일을 주고 OCR을 통해 제대로 된 KEY를 알아내는 방식이었다.

 

이미지는 대부분 이런 식으로 되어 있었는데, key의 형식을 몰랐었기에 우선 정규표현식을 이용하여서

"영어대소문자3글자{영어숫자특수문자18글자}"로 파일들을 걸러내었고 나온 문자열들에서 다시 특수문자의 허용범위를 줄여가며 key를 얻을 수 있었다.

 

하지만 python의 ocr 모듈은 믿을만 하지 못하였는데, 0을 O로 읽거나, 1을 l로 읽는 등 수준이 높지 못한 모습을 보여주었다. 이 때문에 key를 출력하는 것이 아닌 filepath를 출력하여 직접 key를 찾아내었다.

 

step2에서는 간단한 flask 서버 문제가 있었다.

from flask import Flask, request, redirect, render_template, session
import hashlib
import secrets

SECRET_MD5 = "8f4551d89079c8c02877be799c96e162"
app = Flask(__name__)
app.secret_key = secrets.token_urlsafe(16)
flag = open("/app/FLAG", "r")


@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        user_input = request.form.get('key')
        hashed_input = hashlib.md5(user_input.encode()).hexdigest()
        print(hashed_input)
        if hashed_input == SECRET_MD5:
            session["key"] = user_input
            return redirect('/read_file')
        else:
            return render_template('403.html', message="KEY를 찾고 오렴"), 403
    return render_template('index.html')

@app.route('/read_file', methods=['GET', 'POST'])
def read_file():
    if 'key' not in session:
        return redirect('/')
    if request.method == 'POST':
        file_path = request.form.get('file_path')
        if len(file_path) > 13:
            return render_template('403.html', message="너무 길어"), 403
        deny_list = ['app', 'flag']
        for k in deny_list:
            if k in file_path.lower():
                return render_template('403.html', message="FLAG를 설마 내가 쉽게 줄까봐?"), 403
        try:
            with open(file_path, 'r') as file:
                content = file.read()
            return content
        except Exception as e:
            return str(e)
    return render_template('file.html')

@app.route('/flag', methods=['GET'])
def last_step():
    if flag.read() == request.args.get("flag"):
        return "방 탈출 성공!"
    else:
        return "방 탈출 실패! 좀 더 노력해보자"

if __name__ == "__main__":
    app.run(host="0.0.0.0")

flag는 /app/flag에 위치해있고 입력받은 file_path에 'app'과 'flag'가 있다면 403을 뱉어낸다.

다른 문제들에서는 ../을 필터링하거나, replace를 우회하는 것만 있었지만 이것은 flag라는 문자가 들어가면 안 되는 문제였다.

 

도저히 생각이 안 나서 대회가 끝나고 웹해킹 고수 선배에게 물어보니

고민도 없이 바로 답을 알려주셨다.

 

리눅스의 File Descriptors를 이용하면 프로그램이 실행 중에 사용하는 파일에 할당된 리소스 번호를 통해 해당 파일에 접근할 수 있게 된다.

문제에서도 flag 파일을 open 해놓고 있었다;

 


은근히 난이도가 있는? 그런 대회였고 배운 것도 많은 대회였다고 생각한다. 11월 9일에 있는 본선에서도 괜찮은 성적을 거두었으면 좋겠다.

필력이 쓰레기 같지만 끝까지 봐주셔서 감사합니다.

'CTF' 카테고리의 다른 글

JBUCTF 2023 문제풀이 및 후기  (1) 2023.11.01
2022 MetaRed CTF  (0) 2022.11.13