디렉터리 검출 도구 (졸업작품)2024 03 25 ~ 2024 11 31

Woongmur·2025년 1월 2일

주제 선정 이유?

웹에서 발생하는 취약점을 대상으로 하는 공격들이 빈번하게 발생하고 있는데, 웹쉘을 업로드하여 실행하는 공격의 비중이 높아지고 있다고 한다.
이와 같은 공격들을 방어하기 위해서는 선제적으로 취약점을 파악하고 보안을 강화하는 방법을 강구해야한다.
그렇기 때문에 우리는 디렉터리 인덱싱과 관리자 페이지 노출과 같은 개발자의 설정 실수와 같은 문제를 신속하고 직관적으로 보여주고 해결할 수 있도록 하는 도구가 필요하다고 생각하여 이 주제를 선정하게 되었다.

어떤 부분을 맡았는가?

팀원들이 전반적인 개발을 함께 하긴 하였지만 주로 내가 한것은 디렉터리 검출 도구의 백엔드 코드를 집중적으로 개발하였다.
그리고 팀원들과의 의사소통이 적절하게 이루어지지 않아 프로젝트가 갈아엎어질뻔 했던 위험이 있어서 트러블 슈팅을 맡아서 진행하였다.

어떤 언어를 사용하였는가?

본 프로젝트에서 사용된 언어는 Python이고, Flask 웹 프레임워크를 이용하여 웹으로 가능한 디렉터리 검출 도구를 제작하였다.
기존의 직관성이 부족하여 사용자들에게 불편함을 주는 도구들을 대체하여 누구나 쉽게 취약점 점검을 할수 있도록 웹을 기반으로 쉽고 직관적인 인터페이스를 구현하는 것으로 하였다.


백엔드 코드 (app.py)

주요 기능을 하는 파이썬 백엔드 코드이다.
실행을 하면 다음과 같이 접속할 url이 나오게 된다 5000번 포트는 Flask에서 사용되는 포트이다.

검사 중 발생되는 에러 로그가 txt 파일로 저장이 된다.

import os
import aiohttp
import asyncio
from datetime import datetime
from urllib.parse import urlparse
from flask import Flask, request, render_template, send_file, jsonify
import requests
from markupsafe import Markup
-------------------------------------------------------------------------------------------------------------------------------------------------------------
os : 운영체제에서 파일 및 디렉터리를 다루는 모듈로 디렉터리 생성과 파일 경로 설정에 사용
aiohttp : 비동기 HTTP 클라이언트를 제공하는 모듈로 비동기적인 웹 요청을 보내 많은 요청을 동시에 처리
asyncio : 파이썬의 비동기 처리를 위해 사용되는 내장 모듈로 aiohttp와 함께 사용하여 비동기 작업을 효율적으로 수행
datetime : 날짜 및 시간 데이터를 다룰 때 사용되고, 로그 파일 이름 생성할때랑 테스트 시작과 종료 시간을 기록할때 사용
urllib.parse : URL을 구문 분석하여 구성요소를 분리할때 사용되고, URL의 경로를 추출하거나 변환할때 사용 
flask : 파이썬 웹 어플리케이션 프레임워크, 웹 애플리케이션과 api 구축을 도움
requests : 간단한 HTTP 요청을 동기적으로 보낼때 사용되며 URL 유효성 검사에 사용됨 
markupsafe : HTML 컨텐츠를 안전하게 렌더링 하도록 도움
-----------------------------------------------------------------------------------------------------------------------------------------------------------------

# Flask 애플리케이션 초기화
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!'

# 에러 로그를 관리하는 클래스 정의
class ErrorLogger:
    def __init__(self, log_dir):
        self.log_dir = log_dir
        self.error_log_file = None
        self.error_logs = {}

        if not os.path.exists(log_dir):
            os.makedirs(log_dir)
--------------------------------------------------------------------------------------------------------------------------------------------------------------
초기화 메서드로 에러 로그 디렉터리를 설정하고 없으면 새로 생성하는 역할
--------------------------------------------------------------------------------------------------------------------------------------------------------------
    def create_new_error_log_file(self):
        current_time = datetime.now()
        log_filename = current_time.strftime("%Y%m%d%H%M%S") + '_error_log.txt'
        log_file_path = os.path.join(self.log_dir, log_filename)
        self.error_log_file = open(log_file_path, 'a')
        print(f"Created new error log file: {log_file_path}")
----------------------------------------------------------------------------------------------------------------------------------------------------------------
현재 시간을 기반으로 새로운 에러 로그 파일 생성하는 역할 
----------------------------------------------------------------------------------------------------------------------------------------------------------------

    def close_error_log_file(self):
        if self.error_log_file:
            self.error_log_file.close()
            print("Closed error log file.")
----------------------------------------------------------------------------------------------------------------------------------------------------------------
열려있는 로그 파일을 닫음
----------------------------------------------------------------------------------------------------------------------------------------------------------------

    async def log_error(self, test_url, status, error_message):
        if self.error_log_file is None or self.error_log_file.closed:
            self.create_new_error_log_file()

        log_message = f"{error_message}\n({test_url})\n"
        if status not in self.error_logs:
            self.error_logs[status] = []
        self.error_logs[status].append(log_message)
        print(f"Error logged: {log_message}")
----------------------------------------------------------------------------------------------------------------------------------------------------------------
비동기적으로 에러 메시지를 기록하고 내부 로그데이터 구조에 저장하는 역할
----------------------------------------------------------------------------------------------------------------------------------------------------------------
    def write_error_logs(self):
        if self.error_logs:
            for status, messages in self.error_logs.items():
                self.error_log_file.write(f"\n{status}\n")
                self.error_log_file.writelines(messages)
----------------------------------------------------------------------------------------------------------------------------------------------------------------
모든 로그 데이터를 파일에 기록함.
----------------------------------------------------------------------------------------------------------------------------------------------------------------
    def reset_logs(self):
        self.error_logs = {}
        self.close_error_log_file()
        self.error_log_file = None
----------------------------------------------------------------------------------------------------------------------------------------------------------------
기존 로그 데이터를 초기화하고 파일을 닫음
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 디렉토리 트리 구조를 관리하는 클래스 정의
class DirectoryTree:
    def __init__(self):
        self.tree = {}
----------------------------------------------------------------------------------------------------------------------------------------------------------------
디렉터리 트리를 초기화 하는 함수
----------------------------------------------------------------------------------------------------------------------------------------------------------------

    def add_path(self, path, is_file=False):
        parts = path.strip('/').split('/')
        current = self.tree
        for part in parts[:-1]:
            if part not in current:
                current[part] = {}
            current = current[part]
        last_part = parts[-1]
        if is_file:
            current[last_part] = 'file'
        else:
            if last_part not in current:
                current[last_part] = {}
----------------------------------------------------------------------------------------------------------------------------------------------------------------
파일이나 디렉터리 경로를 트리에 추가하고, 마지막 부분이 파일이면 해당 노드를 file로 마킹
예를 들어 fuzz_directory에서 받아온 경로/app/file.txt 가 있으면 
[app, file.txt] 처럼 리스트로 만들고 트리가 만들어짐
----------------------------------------------------------------------------------------------------------------------------------------------------------------
    def display(self, current=None, prefix=''):
        if current is None:
            current = self.tree
        tree_str = ""
        directories = []
        files = []

        for key, subtree in current.items():
            if subtree == 'file':
                files.append(key)
            else:
                directories.append(key)

        for directory in sorted(directories):
            tree_str += f"{prefix}|___{directory}/\n"
            tree_str += self.display(current[directory], prefix + '    ')
        
        for file in sorted(files):
            tree_str += f"{prefix}|___{file}\n"
            
        return tree_str
----------------------------------------------------------------------------------------------------------------------------------------------------------------
현재 트리 구조를 문자열로 출력하여 계층구조를 커멘드 라인에 시각화
----------------------------------------------------------------------------------------------------------------------------------------------------------------
    def reset(self):
        self.tree = {}
----------------------------------------------------------------------------------------------------------------------------------------------------------------
트리를 초기화하여 다음 테스트에 이전 정보가 중복으로 출력되지 않도록 함
----------------------------------------------------------------------------------------------------------------------------------------------------------------

# ErrorLogger와 DirectoryTree 인스턴스 생성
error_logger = ErrorLogger(log_dir=os.path.join(os.path.dirname(__file__), "ERROR_LOGS"))
directory_tree = DirectoryTree()

# URL 테스트 함수
async def test_url(session, test_url, progress, semaphore, skipped_urls, checked_urls, word, retry_with_extensions=False):
    max_retries = 5
    retry_delay = 1
    headers = {
        'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0'
    }

    if test_url in skipped_urls or test_url in checked_urls:
        return

    async with semaphore:
        for attempt in range(max_retries):
            try:
                async with session.get(test_url, headers=headers) as response:
                    if response.status == 0:
                        error_message = "서버 응답 없음 (status=0)"
                        skipped_urls.add(test_url)
                        await error_logger.log_error(test_url, "ClientResponseError", error_message)
                        return
                    await handle_response(response, test_url, progress, word, retry_with_extensions)
                checked_urls.add(test_url)
                return
            except aiohttp.ClientResponseError as e:
                error_message = f"ClientResponseError: {e}"
                print(f"Response error on {test_url}: {error_message}")
                skipped_urls.add(test_url)
                await error_logger.log_error(test_url, "ClientResponseError", error_message)
                return
            except aiohttp.ClientConnectorError as e:
                error_message = f"ClientConnectorError: {e}"
                print(f"Connection error on {test_url}: {error_message}")
                skipped_urls.add(test_url)
                await error_logger.log_error(test_url, "ClientConnectorError", error_message)
                return
            except aiohttp.ClientSSLError as e:
                error_message = f"ClientSSLError: {e}"
                print(f"SSL error on {test_url}: {error_message}")
                skipped_urls.add(test_url)
                await error_logger.log_error(test_url, "ClientSSLError", error_message)
                return
            except aiohttp.ClientOSError as e:
                error_message = f"ClientOSError: {e}"
                print(f"OS error on {test_url}: {error_message}")
                skipped_urls.add(test_url)
                await error_logger.log_error(test_url, "ClientOSError", error_message)
                return
            except asyncio.TimeoutError:
                error_message = "TimeoutError"
                print(f"Timeout error on {test_url}")
                skipped_urls.add(test_url)
                await error_logger.log_error(test_url, "TimeoutError", error_message)
                return
            except Exception as e:
                error_message = f"Unexpected error: {e}"
                print(f"Unexpected error on {test_url}: {error_message}")
                skipped_urls.add(test_url)
                await error_logger.log_error(test_url, "UnexpectedError", error_message)
                return
----------------------------------------------------------------------------------------------------------------------------------------------------------------
aiohttp.ClientSession을 통헤 HTTP GET 요청을 보내서 여러 예외 상황(ClientResponseError, TimeoutError 등)에 대해 에러 메세지를 기록하고, URL이 유효할 경우 관련 경로를 DirectoryTree에 추가
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 응답 상태 코드 처리 함수
async def handle_response(response, test_url, progress, word, retry_with_extensions):
    if response.status == 200:
        # 200번대 응답인 경우 디렉토리로 간주하고 처리
        is_directory = test_url.endswith('/')
        is_file = '.' in urlparse(test_url).path.split('/')[-1] and not is_directory
        progress['vulnerable_tests'] += 1
        progress['vulnerable_words'].append((word, test_url))
        progress['existing_paths'].append(test_url)
        directory_tree.add_path(urlparse(test_url).path, is_file)
        
        # 파일로 간주된 경우에만 확장자를 붙여서 재시도
        if not is_file and not retry_with_extensions and not is_directory:
            common_extensions = ['.php', '.html', '.asp', '.aspx', '.jsp', '.txt', '.xml', '.json']
            for ext in common_extensions:
                full_url_with_ext = f"{test_url.rstrip('/')}{ext}"
                await test_url_with_extension(full_url_with_ext, word + ext, progress)
    else:
        # 200번대가 아닌 응답은 로그에 기록하고 오류로 간주
        error_message = f"{response.status} - {response.reason}"
        await error_logger.log_error(test_url, f"Status Code {response.status}", error_message)
----------------------------------------------------------------------------------------------------------------------------------------------------------------
HTTP 응답이 성공적인지 확인하고, 성공 시 해당 경로를 트리에 추가하거나 확장자를 붙여 재시도 함
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 확장자를 붙인 URL을 테스트하는 함수
async def test_url_with_extension(full_url_with_ext, word_with_ext, progress):
    async with aiohttp.ClientSession() as session:
        async with session.get(full_url_with_ext) as response:
            if response.status == 200:
                is_file = '.' in urlparse(full_url_with_ext).path.split('/')[-1]
                if is_file:
                    progress['vulnerable_tests'] += 1
                    progress['vulnerable_words'].append((word_with_ext, full_url_with_ext))
                    progress['existing_paths'].append(full_url_with_ext)
                    directory_tree.add_path(urlparse(full_url_with_ext).path, is_file)
----------------------------------------------------------------------------------------------------------------------------------------------------------------
URL에 일반적인 파일 확장자를 추가해서 다시 테스트
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 디렉토리 구조를 탐색하는 함수
async def fuzz_directory(target_url, txt_files, start_time):
	#시작 전에 트리 초기화
    directory_tree.reset()
	#탐색 진행 상황 기록하는 딕셔너리
    progress = {
        'total_tests': 0,
        'vulnerable_tests': 0,
        'vulnerable_words': [],
        'existing_paths': []
    }
    semaphore_value = 10
    max_requests = 1000
    skipped_urls = set()
    checked_urls = set()
	
	# 워드리스트의 단어수에 따라 세마포어와 요청 수를 조정(서버 과부하 방지)
    for txt_file in txt_files:
        filepath = os.path.join('./wordlist', txt_file)
        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
            lines = f.readlines()
            total_lines = len(lines)
            if total_lines > 1000000:
                semaphore_value = 7
                max_requests = 700
            else:
                semaphore_value = 10
                max_requests = 1000
	
	#동시 요청수 제한과 클라이언트 세션 설정
    semaphore = asyncio.Semaphore(semaphore_value)
    connector = aiohttp.TCPConnector(limit_per_host=semaphore_value)
    async with aiohttp.ClientSession(connector=connector, timeout=aiohttp.ClientTimeout(total=120)) as session:
        tasks = []
		#워드리스트의 단어들을 한줄씩 가져와서 테스트
        for txt_file in txt_files:
            filepath = os.path.join('./wordlist', txt_file)
            with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
                lines = f.readlines()
                for line in lines:
                    words = line.strip().split()
                    for word in words:
                        full_url = f"{target_url}/{word}"
                        if full_url not in checked_urls:
                            tasks.append(test_url(session, full_url, progress, semaphore, skipped_urls, checked_urls, word))
                        full_url_slash = f"{target_url}/{word}/"
                        if full_url_slash not in checked_urls:
                            tasks.append(test_url(session, full_url_slash, progress, semaphore, skipped_urls, checked_urls, word + '/'))
                        progress['total_tests'] += 1
                        if len(tasks) >= max_requests:
                            await asyncio.gather(*tasks)
                            tasks = []
        if tasks:
            await asyncio.gather(*tasks)

    error_logger.write_error_logs()
    error_logger.close_error_log_file()

    parsed_url = urlparse(target_url)
    domain = f"{parsed_url.scheme}://{parsed_url.netloc}"
    
	# 중복 경로 제거
    filtered_vulnerable_words = [(word, url) for word, url in progress['vulnerable_words'] if url.endswith('/') or '.' in urlparse(url).path.split('/')[-1]]

    final_filtered_words = []
    seen_paths = set()
	
	# 중복 제거된 경로를 기본으로 최종 경로 리스트 생성
    for word, url in filtered_vulnerable_words:
        path = urlparse(url).path
        base_path = path.rstrip('/').rsplit('.', 1)[0]
        if base_path in seen_paths:
            final_filtered_words = [(w, u) for w, u in final_filtered_words if not (urlparse(u).path.rstrip('/') == base_path and '/' in urlparse(u).path)]
        seen_paths.add(base_path)
        final_filtered_words.append((word, url))

	# 트리 초기화 후 add_path 메서드로 트리 구조 추가(문자열로 저장)
    directory_tree.reset()
    for _, url in final_filtered_words:
        path = urlparse(url).path
        is_file = '.' in path.split('/')[-1]
        directory_tree.add_path(path, is_file)

    tree_str = domain + '\n' + directory_tree.display()
    vulnerable_tests = len(final_filtered_words)
    progress['vulnerable_tests'] = vulnerable_tests

    print(f"\n검출된 테스트/전체 테스트 : {vulnerable_tests}/{progress['total_tests']}")
    print("디렉터리 트리 구조:")
    print(tree_str)
    
	# 보고서 생성
    report_path = generate_report(target_url, txt_files, progress['total_tests'], vulnerable_tests, final_filtered_words, start_time)
    return os.path.basename(report_path), progress['total_tests'], vulnerable_tests, final_filtered_words, tree_str
----------------------------------------------------------------------------------------------------------------------------------------------------------------
URL과 텍스트 파일 목록을 기반으로 디렉터리를 탐색하고 경로를 테스트하는 함수
비동기적으로 여러 경로를 동시에 테스트하고 각 URL에 대해 test_url을 호출, 탐색된 경로는 트리에 기록되고, 결과를 보고서에 저장
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 테스트 보고서 생성 함수
def generate_report(target_url, txt_files, total_tests, vulnerable_tests, vulnerable_words, start_time):
    end_time = datetime.now()
    timestamp = end_time.strftime("%Y%m%d_%H%M%S")
    report_name = f"report_{timestamp}.txt"
    report_content = f"테스트 시작 시각: {start_time}\n"
    report_content += f"테스트 종료 시각: {end_time}\n"
    report_content += f"테스트 대상 URL: {target_url}\n"
    report_content += f"테스트 파일 목록: {', '.join(txt_files)}\n"
    report_content += f"테스트 한 단어 수: {total_tests}\n"
    report_content += f"발견된 디렉터리 수: {vulnerable_tests}\n"
    report_content += "검출된 디렉터리 목록:\n"
    report_content += "\n".join(f"{word}" for word in vulnerable_words)

    report_path = os.path.join(os.path.dirname(__file__), "REPORT")
    if not os.path.exists(report_path):
        os.makedirs(report_path)

    report_file = os.path.join(report_path, report_name)
    with open(report_file, 'w') as f:
        f.write(report_content)

    print(f"보고서가 '{report_file}' 경로에 저장되었습니다.")
    return report_file
----------------------------------------------------------------------------------------------------------------------------------------------------------------
테스트 결과를 보고서 형식으로 저장, 시작,종료시간, 테스트 URL, 테스트 단어 수, 발견된 경로등이 포함
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# TXT 파일 목록 가져오는 함수
def get_txt_file_list():
    txt_dir = './wordlist'
    txt_files = []
    for root, dirs, files in os.walk(txt_dir):
        for file in files:
            if file.endswith(".txt"):
                relative_path = os.path.relpath(os.path.join(root, file), txt_dir)
                txt_files.append(relative_path)
    return txt_files
----------------------------------------------------------------------------------------------------------------------------------------------------------------	
지정된 ./wordlist 디렉터리에서 텍스트 파일 목록 반환
----------------------------------------------------------------------------------------------------------------------------------------------------------------

# URL 유효성 검사 함수
def validate_url(url):
    parsed_url = urlparse(url)
    
    # 프로토콜이 없는 경우 http를 붙임
    if not parsed_url.scheme:
        url = 'http://' + url
        parsed_url = urlparse(url)

    if not parsed_url.netloc:
        return False, "유효하지 않은 URL입니다. 올바른 URL을 입력해주세요."

    try:
        response = requests.head(url, timeout=5)
        if response.status_code >= 500:
            return False, "서버에서 응답을 받지 못했습니다. URL을 확인해주세요."
    except requests.RequestException:
        return False, "존재하지 않는 URL입니다."

    return True, url
----------------------------------------------------------------------------------------------------------------------------------------------------------------
URL의 형식과 유효성을 검사하는 함수, 기본적으로 http가 붙지 않으면 자동으로 추가하고 requests.head를 사용해서 서버 응답을 확인
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 디렉토리 트리를 렌더링하는 함수
def render_tree(tree):
    def render_node(name, node):
        if isinstance(node, dict):
            directories = {k: v for k, v in node.items() if isinstance(v, dict)}
            files = {k: v for k, v in node.items() if not isinstance(v, dict)}
            items = "".join([render_node(k, v) for k, v in sorted(directories.items())])
            items += "".join([render_node(k, v) for k, v in sorted(files.items())])
            return f'<li class="directory"><span>{name}</span><ul>{items}</ul></li>'
        else:
            return f'<li class="file"><span>{name}</span></li>'

    tree_html = "".join([render_node(name, node) for name, node in tree.items()])
    return Markup(f'<ul>{tree_html}</ul>')
----------------------------------------------------------------------------------------------------------------------------------------------------------------
DirectoryTree 객체의 트리를 HTML 형식으로 렌더링
노드를 재귀적으로 탐색하고, 차일과 디렉터리를 HTML 리스트 항목으로 변환
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# URL 유효성 검사를 위한 API 엔드포인트
@app.route('/validate_url', methods=['POST'])
def api_validate_url():
    data = request.json
    url = data.get('url')

    valid, message = validate_url(url)
    return jsonify({"valid": valid, "message": message})
----------------------------------------------------------------------------------------------------------------------------------------------------------------
POST 요청을 통해 URL의 유효성을 검사하고 JSON 형식의 응답을 반환하는 엔드포인트
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 메인 페이지 라우트
@app.route('/', methods=['GET', 'POST'])
def index():
    error_logger.reset_logs()
    if request.method == 'POST':
        target_url = request.form['target_url']
        txt_files = request.form.getlist('txt_file')
        start_time = datetime.now()
        result_filename, total_tests, vulnerable_tests, vulnerable_words, tree_str = asyncio.run(fuzz_directory(target_url, txt_files, start_time))
        return render_template('result.html', total_tests=total_tests, vulnerable_tests=vulnerable_tests, vulnerable_words=vulnerable_words, report_filename=result_filename, tree=directory_tree.tree, render_tree=render_tree, target_url=target_url)
    return render_template('index.html', txt_files=get_txt_file_list())
----------------------------------------------------------------------------------------------------------------------------------------------------------------
메인 페이지를 렌더링 하고, 사용자가 URL과 텍스트 파일 목록을 선택하여 테스트, 테스트가 완료되면 결과 페이지로 이동
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 보고서 다운로드 라우트
@app.route('/download/<report_filename>')
def download_report(report_filename):
    report_path = os.path.join(os.path.dirname(__file__), "REPORT", report_filename)
    return send_file(report_path, as_attachment=True)
----------------------------------------------------------------------------------------------------------------------------------------------------------------
보고서를 다운받도록 할 수 있는 엔드포인트
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# 결과 페이지 라우트
@app.route('/result/<report_filename>')
def result(report_filename):
    return render_template('result.html', report_filename=report_filename)
----------------------------------------------------------------------------------------------------------------------------------------------------------------
테스트 결과를 시각적으로 보여주는 페이지 엔드포인트
----------------------------------------------------------------------------------------------------------------------------------------------------------------
# API 라우트 - TXT 파일 목록
@app.route('/api/txt_files')
def api_txt_files():
    return jsonify(get_txt_file_list())
----------------------------------------------------------------------------------------------------------------------------------------------------------------
텍스트 파일 목록을 JSON으로 반환하는 API
----------------------------------------------------------------------------------------------------------------------------------------------------------------
if __name__ == "__main__":
    app.run(host='0.0.0.0', debug=True)

메인 페이지 (index.html)

코드를 실행하였을때 localhost:5000번 포트로 접속하면 보이는 페이지이다.

입력란에 테스트할 웹의 url을 입력하고, 사용될 워드리스트를 선택하여 테스트 시작 버튼을 누르면 시작된다.

결과가 나올때 까지 대기중의 로딩 오버레이 화면이 나오게 된다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>디렉터리 점검</title>
    <style>
        /* 로딩 오버레이 스타일 */
        .loading-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(255, 255, 255, 0.8); 
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 9999; 
            visibility: hidden; 
        }

        .loading-spinner {
            border: 16px solid #f3f3f3;
            border-top: 16px solid #3498db;
            border-radius: 50%;
            width: 120px;
            height: 120px;
            animation: spin 2s linear infinite;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        /* 로딩 텍스트 스타일 */
        .loading-text {
            margin-top: 20px;
            font-size: 20px;
            text-align: center;
        }

        .subtext {
            text-align: center;
            font-size: 14px;
            color: #666;
            margin-top: 20px; 
            margin-bottom: 20px; 
        }

        h1 {
            text-align: center;
            margin-top: 25px;
            font-size: 175px; 
            font-weight: bold;
            color: #666;
            margin-bottom: 0; 
            -webkit-text-stroke: 2px black; 
            -webkit-text-fill-color: white; 
        }

        /* 테스트 영역 스타일 */
        .test-container {
            max-width: 1100px; 
            margin: 0 auto;
            padding: 20px 0; 
            background-color: #fff;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }

        .test-container h2 {
            text-align: center;
            margin-bottom: 20px;
            color: #333;
        }

        /* 입력 폼 스타일 */
        .test-form {
            text-align: center;
        }

        .test-form label {
            display: block;
            margin-bottom: 10px;
            font-weight: bold;
            color: #666;
        }

        .test-form input[type="text"],
        .test-form select {
            width: 100%;
            padding: 10px;
            margin-bottom: 10px;
            border: 1px solid #ccc;
            border-radius: 5px;
            box-sizing: border-box;
        }

        .test-form input[type="submit"] {
            width: 140px; 
            padding: 15px; 
            font-size: 16px;
            border: none;
            border-radius: 5px;
            background-color: #007bff;
            color: #fff;
            cursor: pointer;
            transition: background-color 0.3s;
        }

        .test-form input[type="submit"]:hover {
            background-color: #0056b3;
        }

        /* 설명서 버튼 스타일 */
        .manual-btn {
            display: block;
            margin: 0 auto; 
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 5px;
            padding: 10px 20px;
            cursor: pointer;
            margin-bottom: 5px; 
        }

        .manual-btn:hover {
            background-color: #0056b3;
        }

        /* 설명서 내용 스타일 */
        .manual-content {
            margin-top: 5px; 
            margin-bottom: 20px; 
            padding: 10px;
            background-color: #f9f9f9;
            border-radius: 8px;
            display: none; 
        }

        .manual-content p {
            margin-bottom: 10px;
        }

        /* 체크박스 일렬 배치 스타일 */
        .checkbox-group {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 10px;
            margin-top: 10px;
            margin-bottom: 10px;
        }

        .checkbox-item {
            display: flex;
            align-items: center;
            justify-content: center;
            width: calc(33.33% - 10px);
            height: 90px; 
            background-color: #f9f9f9;
            border-radius: 5px;
            transition: background-color 0.3s, transform 0.3s;
            cursor: pointer;
        }

        .checkbox-item input {
            display: none;
        }

        .checkbox-item label {
            font-size: 14px;
            color: #333;
            pointer-events: none;
        }

        .checkbox-item:hover {
            background-color: #e0e0e0;
            transform: scale(1.05);
        }

        .checkbox-item.selected {
            background-color: #4caf50;
            color: white;
        }
    </style>
</head>
<body>
    <!-- 로딩 오버레이 -->
    <div class="loading-overlay" id="loadingOverlay">
        <div class="loading-spinner"></div>
        <div class="loading-text">디렉터리 분석중입니다. 다소 시간이 걸릴 수 있습니다.</div>
    </div>

    <!-- 테스트 영역 -->
    <div class="test-container">
        <form action="/" method="post" class="test-form" id="testForm">
            <h2>테스트 할 URL</h2>
            <input type="text" id="target_url" name="target_url" required><br><br>
            <label>취약점 점검 목록</label>
            <div class="checkbox-group" id="checkboxGroup">
                <!-- 텍스트 파일 목록이 여기에 동적으로 추가됩니다 -->
            </div><br>
            <div id="selectedCount">0개의 선택된 검사 항목</div><br><br>
            <input type="submit" value="테스트 시작">
        </form>
        
        <!-- 간격 조절 -->
        <div style="padding: 10px 0;"></div>
        
        <!-- 설명서 버튼 -->
        <button type="button" class="manual-btn" id="showManualBtn">설명서</button>
        <!-- 설명서 내용 -->
        <div class="manual-content" id="manualContent">
            <p>apache-user-enum-1.0.txt: Apache 웹 서버에서 사용자 이름을 탐색하기 위한 초기 워드리스트.</p>
            <p>apache-user-enum-2.0.txt: 업데이트된 워드리스트로 보다 포괄적인 탐색 가능.</p>
            <p>directory-list-1.0.txt: 웹 응용 프로그램의 기본 경로를 확인하는 데 유용함.</p>
            <p>directory-list-2.3-small.txt: 소문자 디렉터리 이름 리스트로 소문자 디렉터리 탐색에 유리.</p>
            <p>directory-list-2.3-medium.txt: 중간 규모 웹 서버에 적합한 소문자 디렉터리 리스트.</p>
            <p>directory-list-2.3-big.txt: 대규모 웹 서버에 적합한 소문자 디렉터리 리스트.</p>
            <p>directory-list-lowercase-2.3-small.txt: 소문자 디렉터리 탐색을 위한 소문자 리스트.</p>
            <p>directory-list-lowercase-2.3-medium.txt: 중간 규모 웹 서버의 소문자 디렉터리 탐색용.</p>
            <p>directory-list-lowercase-2.3-big.txt: 대규모 웹 서버의 소문자 디렉터리 탐색용.</p>
        </div>
    </div>

    <script>
        async function fetchTxtFiles() {
            const response = await fetch('/api/txt_files');
            const files = await response.json();

            const orderedFiles = [
                'apache-user-enum-1/apache-user-enum-1.0.txt',
                'apache-user-enum-2/apache-user-enum-2.0.txt',
                'directory-list-1/directory-list-1.0.txt',
                'directory-list-2.s/directory-list-2.3-small.txt',
                'directory-list-2.m/directory-list-2.3-medium.txt',
                'directory-list-2.b/directory-list-2.3-big.txt',
                'directory-list-lowercase.2s/directory-list-lowercase-2.3-small.txt',
                'directory-list-lowercase.2m/directory-list-lowercase-2.3-medium.txt',
                'directory-list-lowercase.2b/directory-list-lowercase-2.3-big.txt'
            ];

            return orderedFiles.filter(file => files.includes(file));
        }
-------------------------------------------------------------------------------------------------------------------
서버의 /api/txt_files 경로에서 텍스트 파일 목록을 가져옴
파일 목록을 가져와서 orderedFiles에서 지정한 순서대로 정렬하고 반환하도록 함
-------------------------------------------------------------------------------------------------------------------
        async function populateCheckboxes() {
            const checkboxGroup = document.getElementById('checkboxGroup');
            const files = await fetchTxtFiles();
            files.forEach(function(file) {
                const fileName = file.split('/').pop();
                const checkboxItem = document.createElement('div');
                checkboxItem.className = 'checkbox-item';
                checkboxItem.setAttribute('data-file', file);

                const checkbox = document.createElement('input');
                checkbox.type = 'checkbox';
                checkbox.id = file;
                checkbox.name = 'txt_file';
                checkbox.value = file;

                const label = document.createElement('label');
                label.setAttribute('for', file);
                label.textContent = fileName;

                checkboxItem.appendChild(checkbox);
                checkboxItem.appendChild(label);
                checkboxGroup.appendChild(checkboxItem);

                checkboxItem.addEventListener('click', function() {
                    if (checkbox.checked) {
                        checkbox.checked = false;
                    } else if (document.querySelectorAll('.checkbox-item input:checked').length < 9) {
                        checkbox.checked = true;
                    }
                    checkboxItem.classList.toggle('selected', checkbox.checked);
                    updateSelectedCount();
                });
            });
        }
-------------------------------------------------------------------------------------------------------------------
fetchTxtFiles 함수에서 가져온 파일 목록을 기반으로 체크박스를 생성하고 페이지에 추가.
각 파일에 대해 체크박스를 포함한 div 태그 생성 
클릭 이벤트 리스너를 추가하여 클릭 시 체크박스를 선택, 해제 가능 
선택된 항목의 스타일을 업데이트 (회색 -> 초록색)
-------------------------------------------------------------------------------------------------------------------
        function updateSelectedCount() {
            const checkboxes = document.querySelectorAll('.checkbox-item input:checked');
            const count = checkboxes.length;
            document.getElementById('selectedCount').textContent = `${count}개의 선택된 검사 항목`;
        }
-------------------------------------------------------------------------------------------------------------------
현재 선택된 체크박스의 개수를 세고 해당 개수를 표시 (ex: 2개의 선택된 검사 항목)
체크된 요소의 개수를 계산해서 <div id="selectedCount"> 요소의 텍스트를 업데이트
-------------------------------------------------------------------------------------------------------------------
        document.getElementById('showManualBtn').addEventListener('click', function() {
            const manualContent = document.getElementById('manualContent');
            manualContent.style.display = manualContent.style.display === 'block' ? 'none' : 'block';
        });
-------------------------------------------------------------------------------------------------------------------
설명서 버튼을 클릭할 때 설명서의 내용을 표시하거나 숨기는 기능 
클릭 이벤트를 통해서 설명서의 display 스타일을 block 또는 none으로 전환
-------------------------------------------------------------------------------------------------------------------

        document.getElementById('testForm').addEventListener('submit', async function(event) {
            event.preventDefault();
            const targetUrl = document.getElementById('target_url').value;

            const response = await fetch('/validate_url', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ url: targetUrl })
            });

            const result = await response.json();
            if (!result.valid) {
                alert(result.message);  // 유효하지 않은 URL일 때 경고창 표시
            } else {
                document.getElementById('loadingOverlay').style.visibility = 'visible';
                this.submit();  // 유효하면 폼을 제출
            }
        });
-------------------------------------------------------------------------------------------------------------------
폼이 제출되기 전에 URL의 유효성을 확인 
입력된 URL을 가져와 /validate_url 경로로 POST 요청 보내고 응답이 유효하지 않으면 경고창을 표시하고 테스트를 하지 않음 
URL이 유효한 경우 로딩 오버레이 표시하고 폼을 제출
-------------------------------------------------------------------------------------------------------------------

        window.addEventListener('DOMContentLoaded', function() {
            populateCheckboxes();
        });
-------------------------------------------------------------------------------------------------------------------
페이지가 로드된 후 populateCheckboxes 함수를 호출하여 체크박스 페이지에 채움
-------------------------------------------------------------------------------------------------------------------
    </script>
</body>
</html>

결과 페이지 (result.html)

테스트를 마치고 결과가 출력되는 페이지이다.


사진과 같이 검출된 경로와 단어들을 보여주는데 단어 뒤에 /나 확장자를 붙여 검출하는 기능도 있다.

제작을 하면서 가장 고민하고 어려웠던 부분이었던 트리 구조 생성이다.
계속 텍스트로만 보이게되고 제대로 동작하지 않아서 어떻게 해야할까 생각했는데 그때 마침 아코디언 스타일이 떠오르게 되었다.
Markup모듈을 이용하여 검출된 단어에 마킹하고 트리를 html로 받아올 수 있도록 하니까 깔끔하게 잘 나왔다.

기본적으로 결과 보고서를 다운로드 할 수 있는 기능도 있다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>테스트 결과</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f8f9fa;
            margin: 0;
            padding: 0;
        }
        .container {
            max-width: 1200px;
            margin: 50px auto;
            background-color: #fff;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }
        h1 {
            text-align: center;
            margin-bottom: 20px;
        }
        p {
            text-align: center;
            font-size: 18px;
            color: #333;
        }
        p2 {
            display: block;
            text-align: center;
            font-size: 14px;
            color: #696969;
        }
        .button-container {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 10px;
            margin-bottom: 20px;
            width: 100%;
        }
        .toggle-text {
            font-size: 20px;
            font-weight: bold;
            color: #007bff;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .toggle-text:hover {
            color: #0056b3;
        }
        .toggle-text .fa-chevron-right {
            margin-right: 5px;
            transition: transform 0.3s;
        }
        .toggle-text.active .fa-chevron-right {
            transform: rotate(90deg);
        }
        ul {
            list-style-type: none;
            padding: 0;
            margin: 20px 0;
            width: 80%; /* 아코디언 스타일의 넓이를 줄임 */
            display: none;
            margin-left: auto; /* 가운데 정렬 */
            margin-right: auto; /* 가운데 정렬 */
        }
        ul li {
            background-color: #e9ecef;
            margin: 5px 0;
            padding: 10px;
            border-radius: 5px;
            display: flex;
            justify-content: space-between;
        }
        .word {
            flex-basis: 30%;
            text-align: left;
        }
        .link {
            flex-basis: 70%;
            text-align: right;
        }
        .btn {
            padding: 10px 20px;
            font-size: 16px;
            font-weight: bold;
            color: #fff;
            background-color: #007bff;
            border: none;
            border-radius: 5px;
            text-decoration: none;
            cursor: pointer;
            transition: background-color 0.3s;
        }
        .btn:hover {
            background-color: #0056b3;
        }
        .btn-secondary {
            background-color: #6c757d;
        }
        .btn-secondary:hover {
            background-color: #5a6268;
        }
        .tree-container {
            margin-top: 20px;
            padding-left: 20px;
            word-wrap: break-word;
            width: 80%; /* 아코디언 스타일의 넓이를 줄임 */
            margin-left: auto; /* 가운데 정렬 */
            margin-right: auto; /* 가운데 정렬 */
        }
        .tree ul {
            list-style-type: none;
            padding-left: 20px;
            display: none;
            width: 100%;
        }
        .tree ul.active {
            display: block;
            animation: slide-down 0.3s ease-out;
        }
        .tree li {
            margin: 5px 0;
            cursor: pointer;
            position: relative; /* 선 연결을 위한 포지션 설정 */
        }
        .tree li.root::before, .tree li.root::after {
            content: none; /* 최상위 요소의 선 제거 */
        }
        .tree li:not(.root)::before, .tree li:not(.root)::after {
            content: '';
            position: absolute;
            left: -20px;
        }
        .tree li:not(.root)::before {
            border-left: 1px solid #666; /* 선 색깔을 더 진하게 변경 */
            bottom: 50px;
            height: 100%;
            width: 1px;
            top: 0;
        }
        .tree li:not(.root)::after {
            border-top: 1px solid #666; /* 선 색깔을 더 진하게 변경 */
            top: 20px;
            width: 20px;
            height: 20px;
        }
        .tree li:first-child::before {
            top: 0;
            height: 50%;
        }
        .tree li:last-child::before {
            height: 20px;
        }
        .tree span {
            cursor: pointer;
            display: inline-block;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            max-width: calc(100% - 20px);
            vertical-align: middle;
            font-size: 18px; /* 글씨 크기 2px 증가 */
        }
        .tree .directory span::before {
            content: "📁";
            margin-right: 5px;
            vertical-align: middle;
            font-size: 18px; /* 아이콘 크기 2px 증가 */
        }
        .tree .file span::before {
            content: "📄";
            margin-right: 5px;
            vertical-align: middle;
            font-size: 18px; /* 아이콘 크기 2px 증가 */
        }
        @keyframes slide-down {
            0% {
                opacity: 0;
                transform: translateY(-10px);
            }
            100% {
                opacity: 1;
                transform: translateY(0);
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>테스트 결과</h1>
        <p><strong>테스트 단어 수:</strong> {{ total_tests }}</p>
        <p><strong>검출된 디렉터리 수:</strong> {{ vulnerable_tests }}</p>
        <div class="button-container">
            <span class="toggle-text" onclick="toggleVulnerableWords()"><i class="fas fa-chevron-right"></i>검출된 디렉터리 목록</span>
            <ul id="vulnerableWordsList">
                {% for word, link in vulnerable_words %}
                    <li>
                        <span class="word">{{ word }}</span>
                        <a class="link" href="{{ link }}" target="_blank">{{ link }}</a>
                    </li>
                {% endfor %}
            </ul>
-------------------------------------------------------------------------------------------------------------------
검출된 디렉터리 목록을 포함하는 리스트, 각 단어마다 링크를 오른쪽에 배치해 클릭시 링크로 이동 가능
-------------------------------------------------------------------------------------------------------------------
            <span class="toggle-text" onclick="toggleTree()"><i class="fas fa-chevron-right"></i>디렉터리 트리 구조</span>
            <div class="tree-container">
                <div class="tree">
                    <ul>
                        <li class="directory root"><span>{{ target_url }}</span>
                            {{ render_tree(tree) }}
                        </li>
                    </ul>
                </div>
-------------------------------------------------------------------------------------------------------------------
파이썬 플라스크에서 테스트 하면서 만들어진 트리 구조를 받아와 HTML로 시각적으로 표시함
-------------------------------------------------------------------------------------------------------------------
            </div>
            <p2> ※ 테스트 결과로 나온 디렉터리 경로에는 버전정보, 민감한 정보등이 포함될 수 있습니다.</p2>
            <p2> ※ 서버 버전정보 숨김, 인덱스 페이지 비활성화, 민감한 정보가 포함된 페이지에 대한 접근 제한 등이 해결책이 될 수 있습니다.</p2>
            <a href="/download/{{ report_filename }}" class="btn">보고서 다운로드</a>
            <a href="/" class="btn btn-secondary">돌아가기</a>
        </div>
    </div>
    <script>
        function toggleVulnerableWords() {
            var list = document.getElementById("vulnerableWordsList");
            var toggleText = document.querySelector(".toggle-text");
            if (list.style.display === "none" || list.style.display === "") {
                list.style.display = "block";
                toggleText.classList.add("active");
            } else {
                list.style.display = "none";
                toggleText.classList.remove("active");
            }
        }
-------------------------------------------------------------------------------------------------------------------
검출된 디렉터리 목록의 표시 여부를 토글로 설정 ( 보이기/숨기기 )
id="vulnerableWordsList"인 요소를 찾아 현재 표시 상태를 확인 
표시 상태에 따라 display 스타일을 변경하여 보이거나 숨김 
클릭 시 아이콘이 회전하여 시각적 피드백 제공
-------------------------------------------------------------------------------------------------------------------

        function toggleTree() {
            var tree = document.querySelector(".tree ul");
            var toggleText = document.querySelectorAll(".toggle-text")[1];
            if (tree.classList.contains("active")) {
                tree.classList.remove("active");
                toggleText.classList.remove("active");
            } else {
                tree.classList.add("active");
                toggleText.classList.add("active");
            }
        }
-------------------------------------------------------------------------------------------------------------------
디렉터리 트리 구조의 표시 여부를 토글로 설정 
트리의 루트 요소인 <ul>을 찾아 active 클래스를 추가하거나 제거하여 표시 상태를 변경 
클릭 시 아이콘이 회전
-------------------------------------------------------------------------------------------------------------------
        document.addEventListener("DOMContentLoaded", function() {
            document.querySelectorAll(".tree .directory > span").forEach(function(span) {
                span.addEventListener("click", function() {
                    var parentLi = span.parentElement;
                    var subUl = parentLi.querySelector("ul");
                    if (subUl) {
                        if (subUl.classList.contains("active")) {
                            subUl.classList.remove("active");
                        } else {
                            subUl.classList.add("active");
                        }
                    }
                });
            });
        });
-------------------------------------------------------------------------------------------------------------------
페이지 로딩이 완료된 후 실행
트리 내 모든 디렉터리의 <span> 요소에 클릭 이벤트 리스너 추가 
클릭 시 자식 <ul> 요소의 active 클래스를 추가하거나 제거해서 하위 디렉터리의 표시상태 변경(아코디언 스타일)
-------------------------------------------------------------------------------------------------------------------
    </script>
</body>
</html>

트러블 슈팅

프로젝트를 진행하면서 몇가지 문제가 있었다.
1. 디렉터리 버스터는 url에 워드리스트의 단어를 추가하여 http get요청을 보내는데 상태코드 200이 응답으로 받아져야 해당 경로가 존재함을 알수 있다.
그러나 그 당시에 우리의 코딩 지식이 부족하여 404 상태코드를 경로 존재로 간주하게되어 취약하지 않은 아니 취약할 수 없는 index 페이지 밖에 없는 로컬 웹 조차도 엄청나게 많은 경로가 검출되었다고 결과를 도출해버렸다.
해당 문제는 프로젝트 중 파이썬 공부를 다시 하던 중 발견하여 수정하였다.
2. 팀원들과의 소통이 안되어 백엔드 코드가 매우 뒤죽박죽 엉켜있고, 함수 이름 또한 통일되어있지 않아 그에 대한 책임을 묻다 감정싸움으로 번지게 되었다.
이는 셋 중에서 그나마 깔끔했던 내 코드가 채택되어 사용하는 것으로 마무리 되었다.
3. 디렉터리 검출 후 트리 구조를 생성하는데 텍스트 이외에 출력시킬 방법이 없어서 한달 정도를 방법을 찾느라 고생했었다.
Markup 모듈로 단어를 마킹하고 html로 넘기면서 아코디언 스타일로 만드는 방법을 채택함으로써 해결되었다.


마지막

이 프로젝트를 마지막으로 대학교에서의 4년을 마무리하게 되었다.
국비 교육을 들으면서 병행하기 매우 힘들었지만 긴 시간을 할애한 만큼 골치도 많았지만 애정도 많은 내 첫 프로젝트였다.
발표까지 마무리 하고 졸업 확정이 되어 졸업예정 상태가 되었다.
코딩 실력도 많이 늘은거 같아 좋다.

산출물 링크
https://drive.google.com/file/d/1HAz6bc1eSy6di0nh771ZDHSqMk0IX2Ua/view?usp=drive_link

profile
Pentest Study & Life Blog

0개의 댓글