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:
1 | 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
1 | $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:
1 | 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
1 |
|
I noted the following HTTP headers:
1 | Server: Apache/2.4.7 (Ubuntu) |
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:
1 | $__dgi = $_GET['x']; |
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:
1 | POST /another/index.php?x=azertyuiopqsdfghjklmwxcvbn&0=dontcare&ls=system HTTP/1.1 |
Resulting into:
1 | app |
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:
1 | PHP_UPLOAD_MAX_FILESIZE=10M |
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
:
1 | POST /another/index.php?x=azertyuiopqsdfghjklmwxcvbn&0=dontcare&ls$IFS-lR$IFS/app=passthru HTTP/1.1 |
1 | /app: |
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*
:
1 |
|
Just to be curious, let's try head$IFS-100$IFS/app/admin/index*
:
1 |
|
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:
1 | $ python2 ~/CTF/tools/flask-session-cookie-manager/session_cookie_manager.py decode -c 'eyJjb3Vwb25zIjpbXSwiY3JlZGl0IjowfQ.DcaWwQ.5lqoVulqIl4jUY8h6KCmLrYBNZU' |
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
1 | from flask import Flask, Response, render_template, session, request, jsonify |
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:
1 | $ python2 |
Well so sending the following data:
1 | { |
Results into:
1 | { |
Bonus#
Of course if they were using python3 instead of python2, this couldn't happen:
1 | $ python3 |