Some security precautions when writing CTF challenges (EN)
sam. 15 juillet 2017 Dan LousquiThese last weeks, I was working on organizing a "Capture The Flag" (CTF) contest within my company. This CTF was part of a security awareness training, and it was, in my opinion, funnier and more entertaining than a classic e-learning session or a phishing campaign.
WTF is a CTF?
For those who don't know what a CTF is, it is a set of games and challenges that you have to accomplish in order to obtain a flag. The more flag you obtain, the more points you have. Obviously, the goal is to have as much point as possible.
In my case, the CTF was created for IT people (mostly, but not only, developers), so most of the challenges required scripting skills.
For convenience purposes, many challenges are written in PHP
, enabling me to write more of them very quickly, on a
shared environment. However, the language is often irrelevant, because we did not want to test "language-specific"
skills, but "logic-specific" skills.
Example of a challenge
For instance, let's have a look at this (really easy) challenge:
chall.php
<?php
session_start();
if (!isset($_SESSION['result'])) {
$_SESSION['result']=md5(rand());
print "Please send " . $_SESSION['result'] . " to /chall.php?result={XXX} in less than 1s";
}
elseif(time()-$_SESSION['time']>1){
print "Time's up!";
session_destroy();
}
elseif($_GET['result']==$_SESSION['total']){
print "Good game ! The flag is : {QWERTY1234}";
else {
print "Try again...";
}
It is a really simple challenge, where the player is given a random string that he will have to send back to another URL very quickly. In order to do that, the best solution would be to script this.
But sometimes ...
However, the source code of a challenge is rarely given, so sometimes, players do not look in the right direction, and they might end up using tools or automated scans on the server.
Using these types of tools in challenges is considered as not "fair-play":
- At best, it is cheating (using SQLMap to exploit a SQL injection works, but what is the point doing this? This is not a blog "tutorial", it is an awareness training);
- It is sending a lot of unnecessary requests (and increase the server workload) on a shared environment containing different challenges and used by other challengers.
The worst case scenario would be to end up with availability problems for other challenges and challengers... That is not really cool!
Obviously, you cannot have a Web Application Firewall (WAF) in front of your server, because the goal is to exploit vulnerabilities, and if you block all requests containing a payload, players cannot play!
So I ended up implementing some solutions to protect my challenges.
Protections against request flooding
In order to prevent users from flooding the server with HTTP requests, I want to implement a to limit the number of queries per second.
I think 10 queries in 10 seconds is a good limit: - Overall, it is equivalant to 1 request per second; - It allows a quick burst of 10 requests, if needed.
Now, let's find a way to implement this limit. I thought about using iptable
, but the server is listening in HTTPS
and we cannot count requests if challengers do not close the TLS session.
We could also use an nginx
module, but players sharing the same IP address (if they use a corporate web proxy, they
share the same IP address) could block each other.
A last solution, the one chosen, would be to limit the amount of requests using session cookie:
<?php
if(!$_SESSION['timeframe']) {
$_SESSION['timeframe']=(int)(mktime()/10);
$_SESSION['nb']=0;
}
$cur_timeframe = (int)(mktime()/10);
if($cur_timeframe==$_SESSION['timeframe']) {
$_SESSION['nb']+=1;
}
else {
$_SESSION['timeframe']=(int)(mktime()/10);
$_SESSION['nb']=0;
}
if($_SESSION['nb'] > 10) {
$_SESSION['banned'] = mktime() + 60;
}
if($_SESSION['banned'] and $_SESSION['banned']>mktime()) {
echo "Too many request, wait ".($_SESSION['banned']-mktime())."s";
die();
}
It works with the following logic:
- If your session does not contain a timeframe
variable:
- Create a timeframe
session variable with current epoch time divided by 10
.
- Create a nb
session variable (which will contains the number of request performed in the last 10 seconds), set
to 0
.
- Create a variable cur_timeframe
with current epoch time divided by 10.
- If the session timeframe and the current time frame is the same:
- We increment the
nb
session variable. - If not:
-
We are in a different timeframe, we can reset
nb
to0
. -
If
nb > 10
: -
The player performed too many request, he should be blocked for
60s
, let's save this information inbanned
session variable. -
If
banned
is set and the block time hasn't passed yet: - Prevent the user that he's blocked, and exit the script.
Protection against multiple session
This protection against request flooding is far from perfect. It is even very simple to bypass: you just need to remove all cookie for all your request, so the server think each request is on a new session, so each request is allowed.
In order to prevent our mechanism against this flaw, we need to secure session so that players cannot create "automatically" a session. In order to do this, we will use a captcha mechanism (ex: Google reCaptcha).
We implement it like with the following code:
<?php
session_start();
if(!$_SESSION['notbot']) {
if(!$_POST['g-recaptcha-response']) {
?>
<script src='https://www.google.com/recaptcha/api.js'></script>
Beware, this website is protected against request flooding (Max. 10 requests per 10 sec.!
<form method="POST">
<div class="g-recaptcha" data-sitekey="{RECAPATCHA_API_CODE}"></div>
<input type="submit" />
</form>
<?php
die();
}
else {
$post_data = http_build_query(array(
'secret' => "{RECAPATCHA_API_SECRET}",
'response' => $_POST['g-recaptcha-response'],
'remoteip' => $_SERVER['REMOTE_ADDR']));
$opts = array('http' => array(
'method' => 'POST',
'header' => 'Content-type: application/x-www-form-urlencoded',
'content' => $post_data));
$context = stream_context_create($opts);
$response = file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $context);
$result = json_decode($response);
if (!$result->success) {
echo "You are a bot ?";
die();
}
$_SESSION['notbot']=true;
}
}
Protections against scanners
Now that we are "protected" against request spamming, let's protect the challenge against some scanner.
We cannot really "protect" against scanner, as it is always possible to mimic human behavior on the Internet. However, we can easily implement a first layer of security by analyzing the user-agent of the browser:
<?php
$agent = filter_input(INPUT_SERVER, 'HTTP_USER_AGENT');
if (strpos($agent, 'w3af') !== false ||
strpos($agent, 'dirbuster') !== false ||
strpos($agent, 'nikto') !== false ||
strpos($agent, 'fimap') !== false ||
strpos($agent, 'nessus') !== false ||
strpos($agent, 'whatweb') !== false ||
strpos($agent, 'Openvas') !== false ||
strpos($agent, 'jbrofuzz') !== false ||
strpos($agent, 'webshag') !== false ||
strpos($agent, 'sqlmap') !== false)
{
echo "Scanner detected !";
$_SESSION['banned'] = mktime() + 60;
die();
}
(The banned
session variable will be handled by the previous anti request flooding script)
Making it a library
We can than mix all those script in one script :
chall_defender.php
<?php
session_start();
$agent = filter_input(INPUT_SERVER, 'HTTP_USER_AGENT');
if (strpos($agent, 'w3af') !== false ||
strpos($agent, 'dirbuster') !== false ||
strpos($agent, 'nikto') !== false ||
strpos($agent, 'fimap') !== false ||
strpos($agent, 'nessus') !== false ||
strpos($agent, 'whatweb') !== false ||
strpos($agent, 'Openvas') !== false ||
strpos($agent, 'jbrofuzz') !== false ||
strpos($agent, 'webshag') !== false ||
strpos($agent, 'sqlmap') !== false)
{
echo "Scanner detected !";
$_SESSION['banned'] = mktime() + 60;
die();
}
if(!$_SESSION['timeframe']) {
$_SESSION['timeframe']=(int)(mktime()/10);
$_SESSION['nb']=0;
}
$cur_timeframe = (int)(mktime()/10);
if($cur_timeframe==$_SESSION['timeframe']) {
$_SESSION['nb']+=1;
}
else {
$_SESSION['timeframe']=(int)(mktime()/10);
$_SESSION['nb']=0;
}
if($_SESSION['nb'] > 10) {
$_SESSION['banned'] = mktime() + 60;
}
if($_SESSION['banned'] and $_SESSION['banned']>mktime()) {
echo "Too many request, wait ".($_SESSION['banned']-mktime())."s";
die();
}
if(!$_SESSION['notbot']) {
if(!$_POST['g-recaptcha-response']) {
?>
<script src='https://www.google.com/recaptcha/api.js'></script>
Beware, this website is protected against request flooding (Max. 10 requests per 10 sec.!
<form method="POST">
<div class="g-recaptcha" data-sitekey="{RECAPATCHA_API_CODE}"></div>
<input type="submit" />
</form>
<?php
die();
}
else {
$post_data = http_build_query(array(
'secret' => "{RECAPATCHA_API_SECRET}",
'response' => $_POST['g-recaptcha-response'],
'remoteip' => $_SERVER['REMOTE_ADDR']));
$opts = array('http' => array(
'method' => 'POST',
'header' => 'Content-type: application/x-www-form-urlencoded',
'content' => $post_data));
$context = stream_context_create($opts);
$response = file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $context);
$result = json_decode($response);
if (!$result->success) {
echo "You are a bot ?";
die();
}
$_SESSION['notbot']=true;
}
}
And integrate the script within our previous challenge:
chall.php
<?php
include("chall_defender.php");
if (!isset($_SESSION['result'])) {
$_SESSION['result']=md5(rand());
print "Please send " . $_SESSION['result'] . " to /chall.php?result={XXX} in less than 1s";
}
elseif(time()-$_SESSION['time']>1){
print "Time's up!";
session_destroy();
}
elseif($_GET['result']==$_SESSION['total']){
print "Good game ! The flag is : {QWERTY1234}";
else {
print "Try again...";
}